From 7690b089906746e6607bbe8bebed086aa7d3c9c9 Mon Sep 17 00:00:00 2001 From: Alex Povel Date: Sun, 5 Feb 2023 19:36:18 +0100 Subject: [PATCH] docs: Document and clear up web {client,server} and other code --- ancv/__main__.py | 6 ++- ancv/exceptions.py | 4 ++ ancv/reflection.py | 2 +- ancv/visualization/translations.py | 7 ++++ ancv/web/client.py | 30 +++++++++++++++ ancv/web/server.py | 59 ++++++++++++++++++++++++++---- tests/test_timing.py | 1 + tests/web/conftest.py | 4 +- tests/web/test_server.py | 2 +- 9 files changed, 102 insertions(+), 13 deletions(-) diff --git a/ancv/__main__.py b/ancv/__main__.py index 3aaa6d7..2da9156 100644 --- a/ancv/__main__.py +++ b/ancv/__main__.py @@ -37,11 +37,13 @@ def api( # Not specifying a token works just as well, but has a much lower request # ceiling: token=os.environ.get("GH_TOKEN"), - homepage=os.environ.get("HOMEPAGE", METADATA.home_page or "NO HOMEPAGE SET"), + terminal_landing_page=os.environ.get( + "HOMEPAGE", METADATA.home_page or "NO HOMEPAGE SET" + ), # When visiting this endpoint in a browser, we want to redirect to the homepage. # That page cannot be this same path under the same hostname again, else we get # a loop. - landing_page=os.environ.get( + browser_landing_page=os.environ.get( "LANDING_PAGE", METADATA.project_url[0] if METADATA.project_url else "https://github.com/", ), diff --git a/ancv/exceptions.py b/ancv/exceptions.py index b21c86f..daa067d 100644 --- a/ancv/exceptions.py +++ b/ancv/exceptions.py @@ -1,6 +1,10 @@ class ResumeLookupError(LookupError): + """Raised when a user's resume cannot be found, is malformed, ...""" + pass class ResumeConfigError(ValueError): + """Raised when a resume config is invalid, e.g. missing required fields.""" + pass diff --git a/ancv/reflection.py b/ancv/reflection.py index 2119266..0fce2eb 100644 --- a/ancv/reflection.py +++ b/ancv/reflection.py @@ -7,7 +7,7 @@ class Metadata(BaseModel): - """Python package metadata. + """Modeling Python package metadata. Modelled after the Python core metadata specification: https://packaging.python.org/en/latest/specifications/core-metadata/ . diff --git a/ancv/visualization/translations.py b/ancv/visualization/translations.py index 7d8d55f..f5f4db3 100644 --- a/ancv/visualization/translations.py +++ b/ancv/visualization/translations.py @@ -2,6 +2,13 @@ class Translation(BaseModel): + """Modelling a translation for a resume section or field. + + These are simple, hard-coded translations. Special grammatical cases, singular vs. + plural, etc. are not handled and need to be handled identically across all languages + (which might end up not working...). + """ + grade: str awarded_by: str issued_by: str diff --git a/ancv/web/client.py b/ancv/web/client.py index 6a51b02..823ec5e 100644 --- a/ancv/web/client.py +++ b/ancv/web/client.py @@ -26,6 +26,36 @@ async def get_resume( filename: str = "resume.json", size_limit: int = 1 * SIPrefix.MEGA, ) -> ResumeSchema: + """Fetch a user's resume from their GitHub gists. + + Searches through all of the user's gists for a file with a given name. Checks for + various bad states: + + - User... + - doesn't exist. + - has no gists. + - has no gists with the given filename. + - File... + - is too large. + - is not valid JSON. + - is not valid against the resume schema. + + There are others that are probably not covered (hard to test). + + Sections of the code are timed for performance analysis. + + Args: + user: The GitHub username to fetch the resume from. + session: The `aiohttp.ClientSession` to use for the request. + github: The API object to use for the request. + stopwatch: The `Stopwatch` to use for timing. + filename: The name of the file to look for in the user's gists. + size_limit: The maximum size of the file to look for in the user's gists. + + Returns: + The parsed resume. + """ + log = LOGGER.bind(user=user, session=session) stopwatch("Fetching Gists") diff --git a/ancv/web/server.py b/ancv/web/server.py index 467e916..873c751 100644 --- a/ancv/web/server.py +++ b/ancv/web/server.py @@ -27,6 +27,8 @@ def is_terminal_client(user_agent: str) -> bool: + """Determines if a user agent string indicates a terminal client.""" + terminal_clients = [ "curl", "wget", @@ -41,29 +43,51 @@ def is_terminal_client(user_agent: str) -> bool: @dataclass class ServerContext: + """Context for the server.""" + host: Optional[str] port: Optional[int] path: Optional[str] class Runnable(ABC): + """A server object that can be `run`, enabling different server implementations.""" + @abstractmethod def run(self, context: ServerContext) -> None: ... class APIHandler(Runnable): + """A runnable server for handling dynamic API requests. + + This is the core application server powering the API. It is responsible for handling + requests for the resume of a given user, and returning the appropriate response. It + queries the live GitHub API. + """ + def __init__( self, requester: str, token: Optional[str], - homepage: str, - landing_page: str, + terminal_landing_page: str, + browser_landing_page: str, ) -> None: + """Initializes the handler. + + Args: + requester: The user agent to use for the GitHub API requests. + token: The token to use for the GitHub API requests. + terminal_landing_page: URL to "redirect" to for requests to the root from a + *terminal* client. + browser_landing_page: URL to redirect to for requests to the root from a + *browser* client. + """ + self.requester = requester self.token = token - self.homepage = homepage - self.landing_page = landing_page + self.terminal_landing_page = terminal_landing_page + self.browser_landing_page = browser_landing_page LOGGER.debug("Instantiating web application.") self.app = web.Application() @@ -129,17 +153,25 @@ async def app_context(self, app: web.Application) -> AsyncGenerator[None, None]: log.info("App context teardown done.") async def root(self, request: web.Request) -> web.Response: + """The root endpoint, redirecting to the landing page.""" + user_agent = request.headers.get("User-Agent", "") if is_terminal_client(user_agent): - return web.Response(text=f"Visit {self.homepage} to get started.\n") + return web.Response( + text=f"Visit {self.terminal_landing_page} to get started.\n" + ) - raise web.HTTPFound(self.landing_page) # Redirect + raise web.HTTPFound(self.browser_landing_page) # Redirect async def showcase(self, request: web.Request) -> web.Response: + """The showcase endpoint, returning a static resume.""" + return web.Response(text=_SHOWCASE_RESUME) async def username(self, request: web.Request) -> web.Response: + """The username endpoint, returning a dynamic resume from a user's gists.""" + stopwatch = Stopwatch() stopwatch(segment="Initialize Request") @@ -187,7 +219,15 @@ async def username(self, request: web.Request) -> web.Response: class FileHandler(Runnable): + """A handler serving a rendered, static template loaded from a file at startup.""" + def __init__(self, file: Path) -> None: + """Initializes the handler. + + Args: + file: The (JSON Resume) file to load the template from. + """ + self.template = Template.from_file(file) self.rendered = self.template.render() @@ -202,12 +242,17 @@ def run(self, context: ServerContext) -> None: web.run_app(self.app, host=context.host, port=context.port, path=context.path) async def root(self, request: web.Request) -> web.Response: + """The root and *only* endpoint, returning the rendered template.""" + LOGGER.debug("Serving rendered template.", request=request) return web.Response(text=self.rendered) def server_timing_header(timings: dict[str, timedelta]) -> str: - """https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Server-Timing""" + """From a mapping of names to `timedelta`s, return a `Server-Timing` header value. + + See also: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Server-Timing + """ # For controlling `timedelta` conversion precision, see: # https://docs.python.org/3/library/datetime.html#datetime.timedelta.total_seconds diff --git a/tests/test_timing.py b/tests/test_timing.py index 9fd0277..c30537c 100644 --- a/tests/test_timing.py +++ b/tests/test_timing.py @@ -25,6 +25,7 @@ def sleep(seconds: float) -> None: while time.time() <= (now + seconds): time.sleep(0.001) + @pytest.mark.flaky(reruns=3) def test_stopwatch_basics() -> None: stopwatch = Stopwatch() diff --git a/tests/web/conftest.py b/tests/web/conftest.py index 4e916d4..7dcd872 100644 --- a/tests/web/conftest.py +++ b/tests/web/conftest.py @@ -11,8 +11,8 @@ def api_client_app() -> Application: return APIHandler( requester=f"{METADATA.name}-PYTEST-REQUESTER", token=GH_TOKEN, - homepage=f"{METADATA.name}-PYTEST-HOMEPAGE", - landing_page=f"{METADATA.name}-PYTEST-LANDING_PAGE", + terminal_landing_page=f"{METADATA.name}-PYTEST-HOMEPAGE", + browser_landing_page=f"{METADATA.name}-PYTEST-LANDING_PAGE", ).app diff --git a/tests/web/test_server.py b/tests/web/test_server.py index 161a6de..24095c6 100644 --- a/tests/web/test_server.py +++ b/tests/web/test_server.py @@ -293,4 +293,4 @@ def test_server_timing_header( def test_exact_showcase_output(showcase_output: str) -> None: - assert (_SHOWCASE_RESUME == showcase_output) + assert _SHOWCASE_RESUME == showcase_output