From 9141b183b15c8aa82af8df4f700bfa1af43734f1 Mon Sep 17 00:00:00 2001 From: Billie Thompson Date: Tue, 4 Jul 2023 15:54:19 +0200 Subject: [PATCH] refactor: Make calls to datasource async Co-authored-by: Adam Gardner --- poetry.lock | 20 +++++++++++++++++++- pyproject.toml | 1 + src/cli/__init__.py | 0 src/cli/async_helper.py | 12 ++++++++++++ src/cli/parsers.py | 5 +++++ src/main.py | 27 +++++++++++++++++++-------- src/repos/carbon_intensity.py | 14 +++++++------- tests/cli/test_async_helper.py | 16 ++++++++++++++++ tests/repos/test_carbon_intensity.py | 8 ++++++-- 9 files changed, 85 insertions(+), 18 deletions(-) create mode 100644 src/cli/__init__.py create mode 100644 src/cli/async_helper.py create mode 100644 src/cli/parsers.py create mode 100644 tests/cli/test_async_helper.py diff --git a/poetry.lock b/poetry.lock index 772ae13..f2615a1 100644 --- a/poetry.lock +++ b/poetry.lock @@ -859,6 +859,24 @@ pluggy = ">=0.12,<2.0" [package.extras] testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +[[package]] +name = "pytest-asyncio" +version = "0.21.0" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-asyncio-0.21.0.tar.gz", hash = "sha256:2b38a496aef56f56b0e87557ec313e11e1ab9276fc3863f6a7be0f1d0e415e1b"}, + {file = "pytest_asyncio-0.21.0-py3-none-any.whl", hash = "sha256:f2b3366b7cd501a4056858bd39349d5af19742aed2d81660b7998b6341c7eb9c"}, +] + +[package.dependencies] +pytest = ">=7.0.0" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"] + [[package]] name = "requests" version = "2.31.0" @@ -1027,4 +1045,4 @@ requests = ">=2.20.0,<3.0.0" [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "58aa840cfa8cb77005d136494f2107876a22e1f203e499ff7a68c738875371be" +content-hash = "39088ccc089ee76684e6c1a81a9410a54cc04f47f7ecf42949f4169aaae8362b" diff --git a/pyproject.toml b/pyproject.toml index 2dab6f4..f12168d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ ruff = "^0.0.276" mypy = "^1.4.0" pytest = "^7.3.2" wiremock = "^2.5.0" +pytest-asyncio = "^0.21.0" [build-system] requires = ["poetry-core"] diff --git a/src/cli/__init__.py b/src/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/cli/async_helper.py b/src/cli/async_helper.py new file mode 100644 index 0000000..a41ca89 --- /dev/null +++ b/src/cli/async_helper.py @@ -0,0 +1,12 @@ +import asyncio +from typing import Callable, Coroutine, ParamSpec, TypeVar + +P = ParamSpec("P") +R = TypeVar("R") + + +def async_to_sync(f: Callable[P, Coroutine[None, None, R]]) -> Callable[P, R]: + def inner(*args: P.args, **kwargs: P.kwargs) -> R: + return asyncio.run(f(*args, **kwargs)) + + return inner diff --git a/src/cli/parsers.py b/src/cli/parsers.py new file mode 100644 index 0000000..7ad9523 --- /dev/null +++ b/src/cli/parsers.py @@ -0,0 +1,5 @@ +from httpx import URL + + +def parse_url(url: str) -> URL: + return URL(url) diff --git a/src/main.py b/src/main.py index 65b193e..a4675ec 100644 --- a/src/main.py +++ b/src/main.py @@ -3,6 +3,9 @@ from httpx import URL +from src.cli.async_helper import async_to_sync +from src.cli.parsers import parse_url + UK_CARBON_INTENSITY_API_BASE_URL: URL = URL("https://api.carbonintensity.org.uk") FILE_FOR_INTENSITY_READING: str = ".carbon_intensity" @@ -21,10 +24,6 @@ class DataSource(StrEnum): UK_CARBON_INTENSITY = "uk-carbon-intensity" -def parse_url(url: str) -> URL: - return URL(url) - - def main( max_carbon_intensity: Annotated[ int, @@ -56,11 +55,25 @@ def main( parser=parse_url, ), ] = UK_CARBON_INTENSITY_API_BASE_URL, +) -> None: + carbon_intensity( + data_source, + from_file_carbon_intensity_file_path, + max_carbon_intensity, + uk_carbon_intensity_api_base_url, + ) + + +@async_to_sync +async def carbon_intensity( + data_source: DataSource, + from_file_carbon_intensity_file_path: Path, + max_carbon_intensity: int, + uk_carbon_intensity_api_base_url: URL, ) -> None: intensity_repo: CarbonIntensityRepo = FromFileCarbonIntensityRepo( from_file_carbon_intensity_file_path ) - match data_source: case DataSource.FILE: intensity_repo = intensity_repo @@ -68,11 +81,9 @@ def main( intensity_repo = UkCarbonIntensityApiRepo( base_url=uk_carbon_intensity_api_base_url ) - - if intensity_repo.get_carbon_intensity() > max_carbon_intensity: + if await intensity_repo.get_carbon_intensity() > max_carbon_intensity: typer.echo("Carbon levels exceed threshold, skipping.") raise typer.Exit(1) - typer.echo("Carbon levels under threshold, proceeding.") diff --git a/src/repos/carbon_intensity.py b/src/repos/carbon_intensity.py index 7609a56..ef864d5 100644 --- a/src/repos/carbon_intensity.py +++ b/src/repos/carbon_intensity.py @@ -1,12 +1,12 @@ from pathlib import Path from typing import Protocol -from httpx import URL, Client +from httpx import URL, AsyncClient from pydantic import BaseModel, field_validator class CarbonIntensityRepo(Protocol): - def get_carbon_intensity(self) -> int: + async def get_carbon_intensity(self) -> int: ... @@ -14,7 +14,7 @@ class InMemoryCarbonIntensityRepo(object): def __init__(self, carbon_intensity: int) -> None: self._carbon_intensity = carbon_intensity - def get_carbon_intensity(self) -> int: + async def get_carbon_intensity(self) -> int: return self._carbon_intensity @@ -22,7 +22,7 @@ class FromFileCarbonIntensityRepo(object): def __init__(self, file_path: Path) -> None: self._file_path = file_path - def get_carbon_intensity(self) -> int: + async def get_carbon_intensity(self) -> int: return int(self._file_path.read_text(encoding="utf8")) @@ -49,10 +49,10 @@ def ensure_data_is_not_empty( class UkCarbonIntensityApiRepo: def __init__(self, base_url: URL): - self._client = Client(base_url=base_url, http2=True) + self._client = AsyncClient(base_url=base_url, http2=True) - def get_carbon_intensity(self) -> int: - response = self._client.get("/intensity") + async def get_carbon_intensity(self) -> int: + response = await self._client.get("/intensity") response.raise_for_status() parsed_reponse = UkCarbonIntensityResponse.model_validate_json(response.content) diff --git a/tests/cli/test_async_helper.py b/tests/cli/test_async_helper.py new file mode 100644 index 0000000..31cf872 --- /dev/null +++ b/tests/cli/test_async_helper.py @@ -0,0 +1,16 @@ +from typing import ParamSpec, TypeVar + +from src.cli.async_helper import async_to_sync + +P = ParamSpec("P") +R = TypeVar("R") + + +def test_can_run_async_code_with_annotations() -> None: + @async_to_sync + async def async_func() -> str: + return "hello" + + result = async_func() + + assert result == "hello" diff --git a/tests/repos/test_carbon_intensity.py b/tests/repos/test_carbon_intensity.py index 626a737..98fea93 100644 --- a/tests/repos/test_carbon_intensity.py +++ b/tests/repos/test_carbon_intensity.py @@ -82,10 +82,14 @@ def carbon_intensity_repo( def expected_carbon_intensity(self) -> int: return 7 - def test_gives_me_a_global_carbon_intensity_repo( + @pytest.mark.asyncio + async def test_gives_me_a_global_carbon_intensity_repo( self, carbon_intensity_repo: CarbonIntensityRepo, expected_carbon_intensity: int ) -> None: - assert carbon_intensity_repo.get_carbon_intensity() == expected_carbon_intensity + assert ( + await carbon_intensity_repo.get_carbon_intensity() + == expected_carbon_intensity + ) def test_no_uk_carbon_intensity_data_raises() -> None: