From a3dc75461944c04a6ad271bcb0fecba8102c6c07 Mon Sep 17 00:00:00 2001 From: Jay Chung Date: Fri, 14 Jun 2024 15:25:38 +0800 Subject: [PATCH] init project --- .github/workflows/ci.yaml | 31 +++++++ .gitignore | 6 ++ CONTRIBUTING.md | 39 +++++++++ LICENSE.txt | 21 +++++ README.md | 17 ++++ examples/bulk.py | 72 +++++++++++++++ examples/tutorial.py | 22 +++++ pyproject.toml | 87 +++++++++++++++++++ src/fymail/__init__.py | 3 + src/fymail/error.py | 49 +++++++++++ src/fymail/fymail.py | 32 +++++++ src/fymail/provider_manager.py | 36 ++++++++ src/fymail/providers/__init__.py | 0 src/fymail/providers/base/__init__.py | 0 src/fymail/providers/base/provider_base.py | 81 +++++++++++++++++ src/fymail/providers/base/rule_base.py | 52 +++++++++++ src/fymail/providers/github/__init__.py | 6 ++ src/fymail/providers/github/github.py | 22 +++++ src/fymail/providers/github/rules/__init__.py | 0 src/fymail/providers/github/rules/commits.py | 59 +++++++++++++ src/fymail/providers/github/rules/events.py | 40 +++++++++ src/fymail/providers/github/rules/profile.py | 45 ++++++++++ src/fymail/providers/github/rules/users.py | 20 +++++ tests/__init__.py | 0 tests/integration/__init__.py | 0 tests/integration/test_github.py | 31 +++++++ 26 files changed, 771 insertions(+) create mode 100644 .github/workflows/ci.yaml create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 examples/bulk.py create mode 100644 examples/tutorial.py create mode 100644 pyproject.toml create mode 100644 src/fymail/__init__.py create mode 100644 src/fymail/error.py create mode 100644 src/fymail/fymail.py create mode 100644 src/fymail/provider_manager.py create mode 100644 src/fymail/providers/__init__.py create mode 100644 src/fymail/providers/base/__init__.py create mode 100644 src/fymail/providers/base/provider_base.py create mode 100644 src/fymail/providers/base/rule_base.py create mode 100644 src/fymail/providers/github/__init__.py create mode 100644 src/fymail/providers/github/github.py create mode 100644 src/fymail/providers/github/rules/__init__.py create mode 100644 src/fymail/providers/github/rules/commits.py create mode 100644 src/fymail/providers/github/rules/events.py create mode 100644 src/fymail/providers/github/rules/profile.py create mode 100644 src/fymail/providers/github/rules/users.py create mode 100644 tests/__init__.py create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/test_github.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..7f101d5 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + +concurrency: + group: ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Install Hatch + uses: pypa/hatch@install + - name: Run Build + run: | + hatch env create + hatch build + test: + runs-on: ubuntu-latest + steps: + - name: Install Hatch + uses: pypa/hatch@install + - name: Run Test + run: | + hatch env create test + hatch test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9163dfc --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# Editor +.idea/ +.vscode/ + +# build +dist/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2261d53 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,39 @@ +# CONTRIBUTING + +fymail use [hatch](https://hatch.pypa.io/latest/) to manage the project. Please +[install](https://hatch.pypa.io/latest/install) it before you go ahead. Run command below to install it if you +use `pip` to manage your python packages, or click [here](https://hatch.pypa.io/latest/install) for others + +```shell +pip install hatch +``` + +## Development + +### Setup Environment + +```shell +# create hatch env and install dependence +hatch env create +``` + +## Test + +create test environment only once, + +```shell +hatch env create test +``` + +and run test in the environment + +```shell +hatch test +``` + +## Release + +* Update version: `hatch version `, see [hatch version](https://hatch.pypa.io/latest/version/) + for more detail +* Build package: `hatch build`, see [hatch build](https://hatch.pypa.io/latest/build/) for more detail +* Upload package: `hatch publish`, see [hatch publish](https://hatch.pypa.io/latest/publish/) for more detail diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..db32eb0 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Jay Chung + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bbefb64 --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# FyMail + +FyMail is a shortcut for *F*ind *Y*our e*Mail*. It is a simple tool to search for email addresses in a given +account with a given provider. + +## Quick Start + +```py +fymail = FyMail() +email = await fymail.get(iden="zhongjiajie", provider="github", auth=token) +``` + +Two lines to get the email address of a user, see whole example in [tutorial.py](./examples/tutorial.py) + +## Bulk Search + +see whole example in [bulk.py](./examples/bulk.py) diff --git a/examples/bulk.py b/examples/bulk.py new file mode 100644 index 0000000..1c0b4c8 --- /dev/null +++ b/examples/bulk.py @@ -0,0 +1,72 @@ +import asyncio +import os + +import aiohttp + +import logging +from fymail import FyMail + +from aiohttp import ClientSession +import time + +logging.basicConfig() +logging.getLogger().setLevel(logging.INFO) + +# Set the environment variable ``FYMAIL_GH_TOKEN`` to your GitHub token +gh_token = os.environ.get("FYMAIL_GH_TOKEN", None) +if not gh_token: + raise ValueError("Please set the environment variable ``FYMAIL_GH_TOKEN``") + +repos = [ + "python/cpython", + "pypa/pip", +] + +session_header = { + "Authorization": f"token {gh_token}", + "X-GitHub-Api-Version": "2022-11-28", + "Accept": "application/vnd.github+json", +} + + +async def get_repo_contributors(session: ClientSession, repo: str, simple: bool = False) -> list[str]: + contributors = [] + url = f"https://api.github.com/repos/{repo}/contributors" + + page = 1 + while True: + async with session.get(url, params={"page": page}) as response: + response.raise_for_status() + data = await response.json() + if not data: + break + contributors.extend([d.get("login") for d in data if "login" in d]) + page += 1 + + # break current loop + if simple: + break + return contributors + + +async def main(): + async with aiohttp.ClientSession() as session: + session.headers.update(session_header) + + fymail = FyMail() + task_emails = [] + contributors = [] + for repo in repos: + contributors.extend(await get_repo_contributors(session, repo, simple=True)) + task_emails.extend([fymail.get(iden=c, provider="github", auth=gh_token) for c in contributors]) + + start = time.perf_counter() + emails = await asyncio.gather(*task_emails) + end = time.perf_counter() + print(list(zip(contributors, emails))) + print(f"Time taken: {end - start:.2f} seconds, " + f"for {len(repos)} repositories and {len(contributors)} github users.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/tutorial.py b/examples/tutorial.py new file mode 100644 index 0000000..b6dbea8 --- /dev/null +++ b/examples/tutorial.py @@ -0,0 +1,22 @@ +import asyncio +from fymail import FyMail +import logging +import os + +logging.basicConfig() +logging.getLogger().setLevel(logging.INFO) + +# Set the environment variable ``FYMAIL_GH_TOKEN`` to your GitHub token +gh_token = os.environ.get("FYMAIL_GH_TOKEN", None) +if not gh_token: + raise ValueError("Please set the environment variable ``FYMAIL_GH_TOKEN``") + + +async def main(): + fymail = FyMail() + email = await fymail.get(iden="zhongjiajie", provider="github", auth=gh_token) + print(email) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..42ee2bb --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,87 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "fymail" +dynamic = ["version"] +description = 'Find email for giving account in provider' +readme = "README.md" +requires-python = ">=3.8" +license = "MIT" +keywords = [ + "email", +] +authors = [ + { name = "Jay Chung", email = "zhongjiajie955@gmail.com" }, +] +classifiers = [ + # https://pypi.org/pypi?%3Aaction=list_classifiers + "Development Status :: 1 - Planning", + "Intended Audience :: Developers", + "Operating System :: Unix", + "Operating System :: POSIX", + "Operating System :: Microsoft :: Windows", + "Programming Language :: Python", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", + "Topic :: Software Development :: User Interfaces", +] +dependencies = [ + "aiohttp>=3.7.4", +] + +[project.urls] +Documentation = "https://github.com/zhongjiajie/fymail#readme" +Issues = "https://github.com/zhongjiajie/fymail/issues" +Source = "https://github.com/zhongjiajie/fymail" + +[tool.hatch.version] +path = "src/fymail/__init__.py" + +[tool.hatch.envs.test] +extra-dependencies = [ + "pytest-asyncio", +] +default-args = ["tests"] +extra-args = ["-vv"] + +[tool.hatch.envs.types] +extra-dependencies = [ + "mypy>=1.0.0", +] +[tool.hatch.envs.types.scripts] +check = "mypy --install-types --non-interactive {args:src/fymail tests}" + +[tool.hatch.build.targets.sdist] +exclude = [ + "/.github", + "/docs", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/fymail"] + +[tool.coverage.run] +source_pkgs = ["fymail"] +branch = true +parallel = true +omit = [ + "src/fymail/__about__.py", +] + +[tool.coverage.paths] +fymail = ["src/fymail", "*/fymail/src/fymail"] +tests = ["tests", "*/fymail/tests"] + +[tool.coverage.report] +exclude_lines = [ + "no cov", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", +] diff --git a/src/fymail/__init__.py b/src/fymail/__init__.py new file mode 100644 index 0000000..3ac7fdf --- /dev/null +++ b/src/fymail/__init__.py @@ -0,0 +1,3 @@ +from .fymail import FyMail + +__version__ = "0.0.1dev" \ No newline at end of file diff --git a/src/fymail/error.py b/src/fymail/error.py new file mode 100644 index 0000000..617efdd --- /dev/null +++ b/src/fymail/error.py @@ -0,0 +1,49 @@ +class FyMailError(Exception): + def __init__(self, message): + self.message = message + super().__init__(self.message) + + def __str__(self): + return f"FyMailError::{self.__class__.__name__}: {self.message}" + + +class NoProviderNameError(FyMailError): + message = "Provider name is required" + + def __init__(self): + super().__init__(self.message) + + +class NoProviderPackageError(FyMailError): + message = "Provider package path is required" + + def __init__(self): + super().__init__(self.message) + + +class NoRuleNameError(FyMailError): + message = "Rule name is required" + + def __init__(self): + super().__init__(self.message) + + +class NoRuleUrlPathError(FyMailError): + message = "Rule Path is required" + + def __init__(self): + super().__init__(self.message) + + +class NoProviderBaseUrlPathError(FyMailError): + message = "Provider base path is required" + + def __init__(self): + super().__init__(self.message) + + +class ProvideNotExistsError(FyMailError): + def __init__(self, *, provider_name): + self.message = f"Provider {provider_name} not exists" + super().__init__(self.message) + diff --git a/src/fymail/fymail.py b/src/fymail/fymail.py new file mode 100644 index 0000000..62d2783 --- /dev/null +++ b/src/fymail/fymail.py @@ -0,0 +1,32 @@ +import asyncio +import aiohttp +from functools import cached_property, cache + +from fymail.provider_manager import ProviderManger +from fymail.providers.base.provider_base import ProviderBase +from functools import cache, cached_property + + +class FyMail: + + def __init__(self): + self.pm = ProviderManger() + self.pm.register_plugin() + + async def get(self, + *, + iden: str, + provider: str, + auth: str) -> str: + provider = self.pm.get_provider(provider) + async with aiohttp.ClientSession() as session: + return await provider.get(session, auth, iden) + + async def get_bulk(self, + path: str, + provider: str, + io_type: str = None, + delimiter: str = None, + col: int = None, + ) -> str: + raise NotImplementedError diff --git a/src/fymail/provider_manager.py b/src/fymail/provider_manager.py new file mode 100644 index 0000000..ddd32f3 --- /dev/null +++ b/src/fymail/provider_manager.py @@ -0,0 +1,36 @@ +import importlib +import inspect +import pkgutil + + +from fymail.error import ProvideNotExistsError +from fymail.providers.base.provider_base import ProviderBase +import logging + +logger = logging.getLogger(__name__) + + +class ProviderManger: + providers = {} + + package_provider = "fymail.providers" + skip_modules = ["base"] + + def register_plugin(self) -> None: + package = importlib.import_module(self.package_provider) + for _, module_name, _ in pkgutil.iter_modules(package.__path__): + if module_name in self.skip_modules: + continue + + logger.debug("Trying register provider from module %s", module_name) + module = importlib.import_module(f"{self.package_provider}.{module_name}") + for name, obj in inspect.getmembers(module, inspect.isclass): + if issubclass(obj, ProviderBase): + logger.debug("Registering provider from %s", obj.__name__) + self.providers[name.lower()] = obj() + + def get_provider(self, provider: str) -> ProviderBase: + provider = provider.lower() + if provider not in self.providers: + raise ProvideNotExistsError(provider_name=provider) + return self.providers[provider] diff --git a/src/fymail/providers/__init__.py b/src/fymail/providers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/fymail/providers/base/__init__.py b/src/fymail/providers/base/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/fymail/providers/base/provider_base.py b/src/fymail/providers/base/provider_base.py new file mode 100644 index 0000000..ee3843d --- /dev/null +++ b/src/fymail/providers/base/provider_base.py @@ -0,0 +1,81 @@ +import inspect +import pkgutil +from abc import ABC, abstractmethod +from functools import cache + +from aiohttp import ClientSession + +import importlib +from fymail.error import NoProviderNameError, NoProviderBaseUrlPathError +from fymail.providers.base.rule_base import RuleBase +import logging + +logger = logging.getLogger(__name__) + + +class ProviderBaseMeta(type): + def __init__(cls, name, bases, attrs): + super().__init__(name, bases, attrs) + + if cls.__name__ == 'ProviderBase': + return + + if getattr(cls, 'base_url', None) is None: + raise NoProviderBaseUrlPathError + + if getattr(cls, 'provider_name', None) is None: + raise NoProviderNameError + + # if getattr(cls, 'package_provider', None) is None: + # raise NoProviderNameError + + +class ProviderBase(metaclass=ProviderBaseMeta): + base_url: str = None + provider_name: str = None + package_provider: str = None + rules: list[RuleBase] = [] + + def __repr__(self): + return f"" + + def name(self) -> str: + return self.provider_name + + @staticmethod + @abstractmethod + def auth_setter(session: ClientSession, + auth: str) -> None: + pass + + @abstractmethod + async def get(self, + session: ClientSession, + auth: str, + iden: str) -> str | None: + self.register_rules() + self.auth_setter(session, auth) + + for rule in self.rules: + logger.info("Trying to get %s's email with %s", iden, repr(rule)) + result = await rule.run(session, iden) + if result is not None: + logger.info("Success get %s's email %s with %s, %s", iden, result, repr(self), repr(rule)) + return result + return None + + @cache + def register_rules(self) -> None: + package = importlib.import_module(self.package_provider) + for module_info in pkgutil.walk_packages(package.__path__, f"{self.package_provider}."): + module = importlib.import_module(module_info.name) + logger.debug("Registering rules from module %s", module.__name__) + + for _, obj in inspect.getmembers(module, inspect.isclass): + if issubclass(obj, RuleBase) and obj is not RuleBase: + logger.debug("Registering rule from rule %s", obj.__name__) + self.rules.append(obj(self.base_url)) + + # make sure rule keep in manual order + self.rules.sort(key=lambda x: x.name) + diff --git a/src/fymail/providers/base/rule_base.py b/src/fymail/providers/base/rule_base.py new file mode 100644 index 0000000..c6f2e61 --- /dev/null +++ b/src/fymail/providers/base/rule_base.py @@ -0,0 +1,52 @@ +from abc import ABC, abstractmethod +from typing import Any + +from aiohttp import ClientSession, ClientResponse + +from fymail.error import NoRuleNameError, NoRuleUrlPathError + + +class RuleBaseMeta(type): + def __init__(cls, name, bases, attrs): + super().__init__(name, bases, attrs) + + if cls.__name__ == 'RuleBase': + return + + if getattr(cls, 'name', None) is None: + raise NoRuleNameError + + if getattr(cls, 'path', None) is None: + raise NoRuleUrlPathError + + +class RuleBase(metaclass=RuleBaseMeta): + name = None + path = None + headers = None + + def __init__(self, base_url: str): + self.base_url = base_url + + def __repr__(self): + return f"" + + def build_url_path(self) -> str: + return f"{self.base_url}/{self.path}" + + def build_url(self, iden: str) -> str: + return f"{self.build_url_path()}/{iden}" + + async def run(self, + session: ClientSession, + iden: str, + params: dict | None = None) -> str | None: + if self.headers: + session.headers.update(self.headers) + async with session.get(self.build_url(iden), params=params) as response: + return await self.parse(response) + + @abstractmethod + async def parse(self, + response: ClientResponse) -> str | None: + pass diff --git a/src/fymail/providers/github/__init__.py b/src/fymail/providers/github/__init__.py new file mode 100644 index 0000000..9d855e0 --- /dev/null +++ b/src/fymail/providers/github/__init__.py @@ -0,0 +1,6 @@ +# TODO find someway to avoid it +from .github import GitHub + +__all__ = [ + "GitHub" +] \ No newline at end of file diff --git a/src/fymail/providers/github/github.py b/src/fymail/providers/github/github.py new file mode 100644 index 0000000..32d1d54 --- /dev/null +++ b/src/fymail/providers/github/github.py @@ -0,0 +1,22 @@ +from aiohttp import ClientSession + +from fymail.providers.base.provider_base import ProviderBase + + +class GitHub(ProviderBase): + base_url = "https://api.github.com" + provider_name = "GitHub" + package_provider = "fymail.providers.github" + rules = [] + + def __init__(self, *args, **kwargs): + super(ProviderBase, self).__init__(*args, **kwargs) + + @staticmethod + def auth_setter(session: ClientSession, + auth: str) -> None: + session.headers.update({ + "Accept": "application/vnd.github+json", + "Authorization": f"token {auth}", + "X-GitHub-Api-Version": "2022-11-28", + }) diff --git a/src/fymail/providers/github/rules/__init__.py b/src/fymail/providers/github/rules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/fymail/providers/github/rules/commits.py b/src/fymail/providers/github/rules/commits.py new file mode 100644 index 0000000..d9de2b4 --- /dev/null +++ b/src/fymail/providers/github/rules/commits.py @@ -0,0 +1,59 @@ +from aiohttp import ClientSession, ClientResponse + +from fymail.providers.base.rule_base import RuleBase + +import logging +import asyncio + +logger = logging.getLogger(__name__) + + +class Commit(RuleBase): + """Get from user owner repository last commits' payload + + :seealso: https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#list-repositories-for-a-user + :seealso: https://docs.github.com/en/rest/commits/commits?apiVersion=2022-11-28#list-commits + """ + + name = "GH20" + path = "users" + + def build_url(self, iden: str) -> str: + return f"{self.build_url_path()}/{iden}/repos" + + async def run(self, + session: ClientSession, + iden: str, + params: dict | None = None) -> str | None: + if self.headers: + session.headers.update(self.headers) + + # we only need owner repositories and for latest page + params = {"type": "owner", "sort": "updated", "author": iden} + async with session.get(self.build_url(iden), params=params) as response: + resp: list[dict] = await response.json() + logger.debug("Get response from %s is: %s", repr(self), resp) + # fork repo cause wrong result + url_repo_commits = [f"{repo['url']}/commits" for repo in resp if repo["fork"] is False] + + if not url_repo_commits: + return None + + tasks = [session.get(url) for url in url_repo_commits] + tasks_result: list[ClientResponse] = await asyncio.gather(*tasks) + emails = [await self.parse(resp) for resp in tasks_result] + logger.debug("Get email from %s is: %s, only pick index=0 if more than one.", repr(self), emails) + return emails[0] if emails else None + + async def parse(self, resp: ClientResponse) -> str | None: + response: list[dict] = await resp.json() + logger.debug("Get response from %s is: %s", repr(self), response) + for commit in response: + + if "commit" not in commit: + continue + auther = commit["commit"]["author"] + if auther and "email" in auther and not auther["email"].endswith("users.noreply.github.com"): + return auther["email"] + return None + diff --git a/src/fymail/providers/github/rules/events.py b/src/fymail/providers/github/rules/events.py new file mode 100644 index 0000000..f0b9e85 --- /dev/null +++ b/src/fymail/providers/github/rules/events.py @@ -0,0 +1,40 @@ +from aiohttp import ClientSession, ClientResponse + +from fymail.providers.base.rule_base import RuleBase +import logging + +logger = logging.getLogger(__name__) + +class Events(RuleBase): + """Get from user push events' payload + + :seealso: https://docs.github.com/en/rest/activity/events?apiVersion=2022-11-28#list-events-for-the-authenticated-user + """ + + name = "GH30" + path = "users" + limit = 5 + + def build_url(self, iden: str) -> str: + return f"{super().build_url(iden)}/events" + + async def run(self, + session: ClientSession, + iden: str, + params: dict | None = None) -> str | None: + for page in range(1, self.limit + 1): + params = {"page": page} + return await super().run(session, iden, params) + return None + + async def parse(self, resp: ClientResponse) -> str | None: + response: list[dict] = await resp.json() + logger.debug("Get response from %s is: %s", repr(self), response) + for event in response: + # TODO some push event may merge PR to main branch, should ignore it + if event and event["type"] == 'PushEvent': + for commit in event["payload"]["commits"]: + if ("author" in commit and "email" in commit["author"] and + not commit["author"]["email"].endswith("users.noreply.github.com")): + return commit["author"]["email"] + return None \ No newline at end of file diff --git a/src/fymail/providers/github/rules/profile.py b/src/fymail/providers/github/rules/profile.py new file mode 100644 index 0000000..7061030 --- /dev/null +++ b/src/fymail/providers/github/rules/profile.py @@ -0,0 +1,45 @@ +import re + +from aiohttp import ClientSession, ClientResponse + +from fymail.providers.base.rule_base import RuleBase +import logging + + +logger = logging.getLogger(__name__) + +class Profile(RuleBase): + """Get from user's profile repository readme content + + :seealso: https://docs.github.com/en/rest/repos/contents?apiVersion=2022-11-28#get-a-repository-readme + """ + name = "GH10" + path = "repos" + + def build_url(self, iden: str) -> str: + return f"{super().build_url(iden)}/{iden}/readme" + + async def run(self, + session: ClientSession, + iden: str, + params: dict | None = None) -> str | None: + download_url = await super().run(session, iden) + if download_url is None: + return None + + email_pattern = re.compile(r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}') + + # get github profile content + async with session.get(download_url) as response: + content = await response.text() + logger.debug("Get profile content from %s is: %s", repr(self), content) + + emails = email_pattern.findall(content) + logger.debug("Get response from %s is: %s, only pick index=0 if more than one.", repr(self), emails) + return emails[0] if emails else None + + async def parse(self, resp: ClientResponse) -> str | None: + resp_json = await resp.json() + if resp_json is None or "download_url" not in resp_json: + return None + return resp_json["download_url"] \ No newline at end of file diff --git a/src/fymail/providers/github/rules/users.py b/src/fymail/providers/github/rules/users.py new file mode 100644 index 0000000..36d0a9b --- /dev/null +++ b/src/fymail/providers/github/rules/users.py @@ -0,0 +1,20 @@ +from aiohttp import ClientSession, ClientResponse + +from fymail.providers.base.rule_base import RuleBase + +import logging + +logger = logging.getLogger(__name__) + +class Users(RuleBase): + """Get from user public information + + :seealso: https://docs.github.com/en/rest/users/users?apiVersion=2022-11-28#get-a-user + """ + name = "GH01" + path = "users" + + async def parse(self, resp: ClientResponse) -> str | None: + response = await resp.json() + logger.debug("Get response from %s is: %s", repr(self), response) + return response["email"] if "email" in response else None diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_github.py b/tests/integration/test_github.py new file mode 100644 index 0000000..056d783 --- /dev/null +++ b/tests/integration/test_github.py @@ -0,0 +1,31 @@ +import pytest +from fymail import FyMail +import os +import logging + +logging.basicConfig() +logging.getLogger().setLevel(logging.INFO) + +fymail = FyMail() + +provider = "github" +token = os.environ.get("FYMAIL_GH_TOKEN", None) +if not token: + raise ValueError("Please set the environment variable ``FYMAIL_GH_TOKEN``") + + +@pytest.mark.parametrize( + "iden, rule", + [ + ("zhongjiajie", ""), + ("pnasrat", ""), + ("pfmoore", ""), + ("piwai", ""), + ] +) +@pytest.mark.asyncio +async def test_gh_rules_users(iden, rule, caplog): + with caplog.at_level(logging.INFO): + email = await fymail.get(iden=iden, provider=provider, auth=token) + assert email is not None and rule in caplog.text +