From cf52436b3392db7aeb4f442f0d6fe48bfec8fcb9 Mon Sep 17 00:00:00 2001 From: andrewgryan Date: Tue, 26 Nov 2024 11:21:45 +0000 Subject: [PATCH] add usability methods to make templating easier --- pdm.lock | 192 +++++++++++++++++++++++++++++++++++++++- pyproject.toml | 9 ++ src/detaf/cloud.py | 3 +- src/detaf/lib.py | 65 +++----------- src/detaf/phenomenon.py | 4 + src/detaf/visibility.py | 20 +++++ src/detaf/wind.py | 32 +++++++ src/detaf/wx.py | 15 +++- tests/test_interface.py | 6 +- tests/test_template.py | 45 ++++++++++ 10 files changed, 328 insertions(+), 63 deletions(-) create mode 100644 src/detaf/phenomenon.py create mode 100644 src/detaf/visibility.py create mode 100644 src/detaf/wind.py create mode 100644 tests/test_template.py diff --git a/pdm.lock b/pdm.lock index decbcf7..76de8c2 100644 --- a/pdm.lock +++ b/pdm.lock @@ -2,10 +2,36 @@ # It is not intended for manual editing. [metadata] -groups = ["default", "lint", "test"] +groups = ["default", "lint", "test", "example"] strategy = ["inherit_metadata"] lock_version = "4.4.2" -content_hash = "sha256:bbe8674f209f6eb3560792db6b53b4bc582bff64a6b355bae5489087e7b7fadf" +content_hash = "sha256:819fbff184499aef39d22d594cd83dc35bbce675161c82f984fef4fc324003e2" + +[[package]] +name = "annotated-types" +version = "0.7.0" +requires_python = ">=3.8" +summary = "Reusable constraint types to use with typing.Annotated" +groups = ["example"] +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + +[[package]] +name = "anyio" +version = "4.6.2.post1" +requires_python = ">=3.9" +summary = "High level compatibility layer for multiple asynchronous event loop implementations" +groups = ["example"] +dependencies = [ + "idna>=2.8", + "sniffio>=1.1", +] +files = [ + {file = "anyio-4.6.2.post1-py3-none-any.whl", hash = "sha256:6d170c36fba3bdd840c73d3868c1e777e33676a69c3a72cf0a0d5d6d8009b61d"}, + {file = "anyio-4.6.2.post1.tar.gz", hash = "sha256:4c8bc31ccdb51c7f7bd251f51c609e038d63e34219b44aa86e47576389880b4c"}, +] [[package]] name = "attrs" @@ -18,18 +44,59 @@ files = [ {file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"}, ] +[[package]] +name = "click" +version = "8.1.7" +requires_python = ">=3.7" +summary = "Composable command line interface toolkit" +groups = ["example"] +dependencies = [ + "colorama; platform_system == \"Windows\"", +] +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + [[package]] name = "colorama" version = "0.4.6" requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" summary = "Cross-platform colored terminal text." -groups = ["test"] -marker = "sys_platform == \"win32\"" +groups = ["example", "test"] +marker = "sys_platform == \"win32\" or platform_system == \"Windows\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "fastapi" +version = "0.115.5" +requires_python = ">=3.8" +summary = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" +groups = ["example"] +dependencies = [ + "pydantic!=1.8,!=1.8.1,!=2.0.0,!=2.0.1,!=2.1.0,<3.0.0,>=1.7.4", + "starlette<0.42.0,>=0.40.0", + "typing-extensions>=4.8.0", +] +files = [ + {file = "fastapi-0.115.5-py3-none-any.whl", hash = "sha256:596b95adbe1474da47049e802f9a65ab2ffa9c2b07e7efee70eb8a66c9f2f796"}, + {file = "fastapi-0.115.5.tar.gz", hash = "sha256:0e7a4d0dc0d01c68df21887cce0945e72d3c48b9f4f79dfe7a7d53aa08fbb289"}, +] + +[[package]] +name = "h11" +version = "0.14.0" +requires_python = ">=3.7" +summary = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +groups = ["example"] +files = [ + {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, + {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, +] + [[package]] name = "hypothesis" version = "6.118.7" @@ -45,6 +112,17 @@ files = [ {file = "hypothesis-6.118.7.tar.gz", hash = "sha256:604328f5d766a056182f54b4826f9b2d5f664f42bff68fd81b4d9d6c44b2398b"}, ] +[[package]] +name = "idna" +version = "3.10" +requires_python = ">=3.6" +summary = "Internationalized Domain Names in Applications (IDNA)" +groups = ["example"] +files = [ + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, +] + [[package]] name = "iniconfig" version = "2.0.0" @@ -56,6 +134,31 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] +[[package]] +name = "jinja2" +version = "3.1.4" +requires_python = ">=3.7" +summary = "A very fast and expressive template engine." +groups = ["example", "test"] +dependencies = [ + "MarkupSafe>=2.0", +] +files = [ + {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"}, + {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, +] + +[[package]] +name = "markupsafe" +version = "3.0.2" +requires_python = ">=3.9" +summary = "Safely add untrusted strings to HTML/XML markup." +groups = ["example", "test"] +files = [ + {file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"}, + {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, +] + [[package]] name = "packaging" version = "24.1" @@ -78,6 +181,36 @@ files = [ {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, ] +[[package]] +name = "pydantic" +version = "2.10.1" +requires_python = ">=3.8" +summary = "Data validation using Python type hints" +groups = ["example"] +dependencies = [ + "annotated-types>=0.6.0", + "pydantic-core==2.27.1", + "typing-extensions>=4.12.2", +] +files = [ + {file = "pydantic-2.10.1-py3-none-any.whl", hash = "sha256:a8d20db84de64cf4a7d59e899c2caf0fe9d660c7cfc482528e7020d7dd189a7e"}, + {file = "pydantic-2.10.1.tar.gz", hash = "sha256:a4daca2dc0aa429555e0656d6bf94873a7dc5f54ee42b1f5873d666fb3f35560"}, +] + +[[package]] +name = "pydantic-core" +version = "2.27.1" +requires_python = ">=3.8" +summary = "Core functionality for Pydantic validation and serialization" +groups = ["example"] +dependencies = [ + "typing-extensions!=4.7.0,>=4.6.0", +] +files = [ + {file = "pydantic_core-2.27.1-cp311-none-win_amd64.whl", hash = "sha256:3af385b0cee8df3746c3f406f38bcbfdc9041b5c2d5ce3e5fc6637256e60bbc5"}, + {file = "pydantic_core-2.27.1.tar.gz", hash = "sha256:62a763352879b84aa31058fc931884055fd75089cccbd9d58bb6afd01141b235"}, +] + [[package]] name = "pytest" version = "8.3.3" @@ -136,6 +269,17 @@ files = [ {file = "ruff-0.7.3.tar.gz", hash = "sha256:e1d1ba2e40b6e71a61b063354d04be669ab0d39c352461f3d789cac68b54a313"}, ] +[[package]] +name = "sniffio" +version = "1.3.1" +requires_python = ">=3.7" +summary = "Sniff out which async library your code is running under" +groups = ["example"] +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + [[package]] name = "sortedcontainers" version = "2.4.0" @@ -146,6 +290,46 @@ files = [ {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, ] +[[package]] +name = "starlette" +version = "0.41.3" +requires_python = ">=3.8" +summary = "The little ASGI library that shines." +groups = ["example"] +dependencies = [ + "anyio<5,>=3.4.0", +] +files = [ + {file = "starlette-0.41.3-py3-none-any.whl", hash = "sha256:44cedb2b7c77a9de33a8b74b2b90e9f50d11fcf25d8270ea525ad71a25374ff7"}, + {file = "starlette-0.41.3.tar.gz", hash = "sha256:0e4ab3d16522a255be6b28260b938eae2482f98ce5cc934cb08dce8dc3ba5835"}, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +requires_python = ">=3.8" +summary = "Backported and Experimental Type Hints for Python 3.8+" +groups = ["example"] +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + +[[package]] +name = "uvicorn" +version = "0.32.1" +requires_python = ">=3.8" +summary = "The lightning-fast ASGI server." +groups = ["example"] +dependencies = [ + "click>=7.0", + "h11>=0.8", +] +files = [ + {file = "uvicorn-0.32.1-py3-none-any.whl", hash = "sha256:82ad92fd58da0d12af7482ecdb5f2470a04c9c9a53ced65b9bbb4a205377602e"}, + {file = "uvicorn-0.32.1.tar.gz", hash = "sha256:ee9519c246a72b1c084cea8d3b44ed6026e78a4a309cbedae9c37e4cb9fbb175"}, +] + [[package]] name = "watchdog" version = "6.0.0" diff --git a/pyproject.toml b/pyproject.toml index 3966e19..5498697 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ test = [ "pytest>=8.3.3", "hypothesis>=6.118.7", "pytest-watcher>=0.4.3", + "jinja2>=3.1.4", ] [build-system] requires = ["pdm-backend"] @@ -28,8 +29,16 @@ distribution = true test = "pytest" fmt = "ruff format" watch = "ptw ." +example.cmd = "uvicorn --port 8888 server:app" +example.working_dir = "example" + [tool.pdm.dev-dependencies] lint = [ "ruff>=0.7.3", ] +example = [ + "fastapi>=0.115.5", + "jinja2>=3.1.4", + "uvicorn>=0.32.1", +] diff --git a/src/detaf/cloud.py b/src/detaf/cloud.py index 3d30a42..99534ba 100644 --- a/src/detaf/cloud.py +++ b/src/detaf/cloud.py @@ -1,5 +1,6 @@ from dataclasses import dataclass from enum import Enum +from detaf.phenomenon import Phenomenon class CloudDescription(str, Enum): @@ -18,7 +19,7 @@ class Type(str, Enum): @dataclass -class Cloud: +class Cloud(Phenomenon): description: CloudDescription height: int | None = None type: Type | None = None diff --git a/src/detaf/lib.py b/src/detaf/lib.py index 6a9c83d..49c5198 100644 --- a/src/detaf/lib.py +++ b/src/detaf/lib.py @@ -2,10 +2,14 @@ from dataclasses import dataclass, field from enum import Enum from collections import namedtuple -from detaf import wx, temperature +from detaf import temperature +from detaf.phenomenon import Phenomenon from detaf.cloud import Cloud from detaf.temperature import Temperature +from detaf.wind import Wind +from detaf.visibility import Visibility from detaf import wx as weather +from detaf.wx import Weather __all__ = [ "Change", @@ -22,6 +26,7 @@ "Temperature", "Visibility", "weather", + "Weather", "WeatherCondition", "Wind", ] @@ -62,52 +67,6 @@ class Modification(str, Enum): dayhour = namedtuple("dayhour", "day hour") -@dataclass -class Visibility: - distance: int - - def taf_encode(self): - return f"{self.distance}" - - @staticmethod - def taf_decode(token: str): - pattern = re.compile(r"[0-9]{4}") - if len(token) == 4 and pattern.match(token): - return Visibility(int(token)) - else: - return None - - -@dataclass -class Wind: - direction: int - speed: int - gust: int | None = None - - def taf_encode(self): - if self.gust: - return f"{self.direction:03}{self.speed:02}G{self.gust:02}KT" - else: - return f"{self.direction:03}{self.speed:02}KT" - - @staticmethod - def taf_decode(token: str): - if token.endswith("KT"): - if "G" in token: - gust = int(token[6:8]) - else: - gust = None - direction = None - try: - direction = int(token[:3]) - except ValueError: - direction = token[:3] - assert direction == "VRB", "must be either VRB or 3-digit number" - return Wind(direction, int(token[3:5]), gust) - else: - return None # Explicit is better than implicit - - class NSW(str, Enum): NO_SIGNIFICANT_WEATHER = "NSW" @@ -122,9 +81,6 @@ def taf_decode(token: str): return None -Phenomenon = Visibility | Wind | Cloud - - @dataclass class WeatherCondition: period: period = None @@ -153,6 +109,9 @@ class Format(str, Enum): # METAR = "METAR" # SPECI = "SPECI" + def taf_encode(self): + return self.value + @dataclass class TAF: @@ -308,7 +267,7 @@ def parse_phenomenon(tokens, cursor=0): parse_decoder(Wind.taf_decode), parse_decoder(Cloud.taf_decode), parse_decoder(NSW.taf_decode), - parse_decoder(wx.parse), + parse_decoder(Weather.taf_decode), parse_decoder(Temperature.taf_decode), ]: phenomenon, cursor = parser(tokens, cursor) @@ -337,7 +296,9 @@ def peek(tokens, cursor): def encode(item) -> str: - if isinstance(item, issue): + if hasattr(item, "taf_encode"): + return item.taf_encode() + elif isinstance(item, issue): return encode_issue_time(item) elif isinstance(item, period): return encode_period(item) diff --git a/src/detaf/phenomenon.py b/src/detaf/phenomenon.py new file mode 100644 index 0000000..7a065f4 --- /dev/null +++ b/src/detaf/phenomenon.py @@ -0,0 +1,4 @@ +class Phenomenon: + @property + def category(self): + return self.__class__.__name__.lower() diff --git a/src/detaf/visibility.py b/src/detaf/visibility.py new file mode 100644 index 0000000..e740c8f --- /dev/null +++ b/src/detaf/visibility.py @@ -0,0 +1,20 @@ +import re +from dataclasses import dataclass +from detaf.phenomenon import Phenomenon + + + +@dataclass +class Visibility(Phenomenon): + distance: int + + def taf_encode(self): + return f"{self.distance}" + + @staticmethod + def taf_decode(token: str): + pattern = re.compile(r"[0-9]{4}") + if len(token) == 4 and pattern.match(token): + return Visibility(int(token)) + else: + return None diff --git a/src/detaf/wind.py b/src/detaf/wind.py new file mode 100644 index 0000000..dfb485b --- /dev/null +++ b/src/detaf/wind.py @@ -0,0 +1,32 @@ +from dataclasses import dataclass +from detaf.phenomenon import Phenomenon + + +@dataclass +class Wind(Phenomenon): + direction: int | str + speed: int + gust: int | None = None + + def taf_encode(self): + if self.gust: + return f"{self.direction:03}{self.speed:02}G{self.gust:02}KT" + else: + return f"{self.direction:03}{self.speed:02}KT" + + @staticmethod + def taf_decode(token: str): + if token.endswith("KT"): + if "G" in token: + gust = int(token[6:8]) + else: + gust = None + direction = None + try: + direction = int(token[:3]) + except ValueError: + direction = token[:3] + assert direction == "VRB", "must be either VRB or 3-digit number" + return Wind(direction, int(token[3:5]), gust) + else: + return None # Explicit is better than implicit diff --git a/src/detaf/wx.py b/src/detaf/wx.py index b7d196c..358d7c6 100644 --- a/src/detaf/wx.py +++ b/src/detaf/wx.py @@ -1,5 +1,6 @@ from dataclasses import dataclass from enum import Enum +from detaf.phenomenon import Phenomenon class Proximity(str, Enum): @@ -54,7 +55,7 @@ class Other(str, Enum): @dataclass -class Wx: +class Weather(Phenomenon): proximity: Proximity | None = None intensity: Intensity | None = None descriptor: Descriptor | None = None @@ -62,6 +63,10 @@ class Wx: obscuration: Obscuration | None = None other: Other | None = None + @property + def category(self): + return "weather" + def taf_encode(self): parts = [] if self.proximity: @@ -78,8 +83,12 @@ def taf_encode(self): parts.append(self.other.value) return "".join(parts) + @classmethod + def taf_decode(cls, token: str): + return parse(token) + -def parse(token: str) -> Wx | None: +def parse(token: str) -> Weather | None: index = 0 # Proximity @@ -131,7 +140,7 @@ def parse(token: str) -> Wx | None: break if (precipitation is not None) or (obscuration is not None) or (other is not None): - return Wx( + return Weather( proximity=proximity, intensity=intensity, descriptor=descriptor, diff --git a/tests/test_interface.py b/tests/test_interface.py index 6d9f2ee..ddedc04 100644 --- a/tests/test_interface.py +++ b/tests/test_interface.py @@ -58,7 +58,7 @@ def test_integration_eigw(): phenomena=[ detaf.Wind(140, 10), detaf.Visibility(4000), - wx.Wx(intensity="-", precipitation="DZ"), + detaf.Weather(intensity="-", precipitation="DZ"), detaf.Cloud("BKN", 700), ], ), @@ -102,7 +102,7 @@ def test_integration_eigw(): probability=30, phenomena=[ detaf.Visibility(4000), - wx.Wx(intensity="-", precipitation="DZ"), + detaf.Weather(intensity="-", precipitation="DZ"), detaf.Cloud("BKN", 800), ], ), @@ -231,7 +231,7 @@ def test_parse_visibility(bulletin, expected): def test_parse_wx(): - assert wx.decode("-DZ") == wx.Wx( + assert wx.decode("-DZ") == wx.Weather( intensity=wx.Intensity.LIGHT, precipitation=wx.Precipitation.DRIZZLE ) diff --git a/tests/test_template.py b/tests/test_template.py new file mode 100644 index 0000000..fa97201 --- /dev/null +++ b/tests/test_template.py @@ -0,0 +1,45 @@ +import detaf +from jinja2 import Environment + + +def test_html_templating(): + report = "TAF ABCD 000000Z 0000/0000 +RA CAVOK VRB01KT 0001/0002 4000" + env = Environment() + env.filters["encode"] = detaf.encode + template = env.from_string(''' +
+ {{ taf.format | encode }} + {{ taf.icao_identifier }} + {{ taf.issue_time | encode }} + {%- for condition in taf.weather_conditions %} + {{ condition.period | encode }} + {%- for phenomenon in condition.phenomena %} + {% if phenomenon.category == "visibility" -%} + {{ phenomenon | encode }} + {%- elif phenomenon.category == "weather" -%} + {{ phenomenon | encode }} + {%- elif phenomenon.category == "wind" -%} + {{ phenomenon | encode }} + {%- elif phenomenon.category == "cloud" -%} + {{ phenomenon | encode }} + {%- else -%} + {{ phenomenon | encode }} + {%- endif %} + {%- endfor %} + {%- endfor %} +
+ ''') + expect = ''' +
+ TAF + ABCD + 000000Z + 0000/0000 + +RA + CAVOK + VRB01KT + 0001/0002 + 4000 +
+ ''' + assert template.render(taf=detaf.decode(report)) == expect