diff --git a/poetry.lock b/poetry.lock index f86cbce..b30ed8d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -415,14 +415,14 @@ files = [ [[package]] name = "cachetools" -version = "5.5.0" +version = "7.0.5" description = "Extensible memoizing collections and decorators" optional = false -python-versions = ">=3.7" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "cachetools-5.5.0-py3-none-any.whl", hash = "sha256:02134e8439cdc2ffb62023ce1debca2944c3f289d66bb17ead3ab3dede74b292"}, - {file = "cachetools-5.5.0.tar.gz", hash = "sha256:2cc24fb4cbe39633fb7badd9db9ca6295d766d9c2995f245725a46715d050f2a"}, + {file = "cachetools-7.0.5-py3-none-any.whl", hash = "sha256:46bc8ebefbe485407621d0a4264b23c080cedd913921bad7ac3ed2f26c183114"}, + {file = "cachetools-7.0.5.tar.gz", hash = "sha256:0cd042c24377200c1dcd225f8b7b12b0ca53cc2c961b43757e774ebe190fd990"}, ] [[package]] @@ -1054,6 +1054,46 @@ files = [ docs = ["ipython", "matplotlib", "numpydoc", "sphinx"] tests = ["pytest", "pytest-cov", "pytest-xdist"] +[[package]] +name = "debugpy" +version = "1.8.20" +description = "An implementation of the Debug Adapter Protocol for Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "debugpy-1.8.20-cp310-cp310-macosx_15_0_x86_64.whl", hash = "sha256:157e96ffb7f80b3ad36d808646198c90acb46fdcfd8bb1999838f0b6f2b59c64"}, + {file = "debugpy-1.8.20-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:c1178ae571aff42e61801a38b007af504ec8e05fde1c5c12e5a7efef21009642"}, + {file = "debugpy-1.8.20-cp310-cp310-win32.whl", hash = "sha256:c29dd9d656c0fbd77906a6e6a82ae4881514aa3294b94c903ff99303e789b4a2"}, + {file = "debugpy-1.8.20-cp310-cp310-win_amd64.whl", hash = "sha256:3ca85463f63b5dd0aa7aaa933d97cbc47c174896dcae8431695872969f981893"}, + {file = "debugpy-1.8.20-cp311-cp311-macosx_15_0_universal2.whl", hash = "sha256:eada6042ad88fa1571b74bd5402ee8b86eded7a8f7b827849761700aff171f1b"}, + {file = "debugpy-1.8.20-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:7de0b7dfeedc504421032afba845ae2a7bcc32ddfb07dae2c3ca5442f821c344"}, + {file = "debugpy-1.8.20-cp311-cp311-win32.whl", hash = "sha256:773e839380cf459caf73cc533ea45ec2737a5cc184cf1b3b796cd4fd98504fec"}, + {file = "debugpy-1.8.20-cp311-cp311-win_amd64.whl", hash = "sha256:1f7650546e0eded1902d0f6af28f787fa1f1dbdbc97ddabaf1cd963a405930cb"}, + {file = "debugpy-1.8.20-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:4ae3135e2089905a916909ef31922b2d733d756f66d87345b3e5e52b7a55f13d"}, + {file = "debugpy-1.8.20-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:88f47850a4284b88bd2bfee1f26132147d5d504e4e86c22485dfa44b97e19b4b"}, + {file = "debugpy-1.8.20-cp312-cp312-win32.whl", hash = "sha256:4057ac68f892064e5f98209ab582abfee3b543fb55d2e87610ddc133a954d390"}, + {file = "debugpy-1.8.20-cp312-cp312-win_amd64.whl", hash = "sha256:a1a8f851e7cf171330679ef6997e9c579ef6dd33c9098458bd9986a0f4ca52e3"}, + {file = "debugpy-1.8.20-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:5dff4bb27027821fdfcc9e8f87309a28988231165147c31730128b1c983e282a"}, + {file = "debugpy-1.8.20-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:84562982dd7cf5ebebfdea667ca20a064e096099997b175fe204e86817f64eaf"}, + {file = "debugpy-1.8.20-cp313-cp313-win32.whl", hash = "sha256:da11dea6447b2cadbf8ce2bec59ecea87cc18d2c574980f643f2d2dfe4862393"}, + {file = "debugpy-1.8.20-cp313-cp313-win_amd64.whl", hash = "sha256:eb506e45943cab2efb7c6eafdd65b842f3ae779f020c82221f55aca9de135ed7"}, + {file = "debugpy-1.8.20-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:9c74df62fc064cd5e5eaca1353a3ef5a5d50da5eb8058fcef63106f7bebe6173"}, + {file = "debugpy-1.8.20-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:077a7447589ee9bc1ff0cdf443566d0ecf540ac8aa7333b775ebcb8ce9f4ecad"}, + {file = "debugpy-1.8.20-cp314-cp314-win32.whl", hash = "sha256:352036a99dd35053b37b7803f748efc456076f929c6a895556932eaf2d23b07f"}, + {file = "debugpy-1.8.20-cp314-cp314-win_amd64.whl", hash = "sha256:a98eec61135465b062846112e5ecf2eebb855305acc1dfbae43b72903b8ab5be"}, + {file = "debugpy-1.8.20-cp38-cp38-macosx_15_0_x86_64.whl", hash = "sha256:b773eb026a043e4d9c76265742bc846f2f347da7e27edf7fe97716ea19d6bfc5"}, + {file = "debugpy-1.8.20-cp38-cp38-manylinux_2_34_x86_64.whl", hash = "sha256:20d6e64ea177ab6732bffd3ce8fc6fb8879c60484ce14c3b3fe183b1761459ca"}, + {file = "debugpy-1.8.20-cp38-cp38-win32.whl", hash = "sha256:0dfd9adb4b3c7005e9c33df430bcdd4e4ebba70be533e0066e3a34d210041b66"}, + {file = "debugpy-1.8.20-cp38-cp38-win_amd64.whl", hash = "sha256:60f89411a6c6afb89f18e72e9091c3dfbcfe3edc1066b2043a1f80a3bbb3e11f"}, + {file = "debugpy-1.8.20-cp39-cp39-macosx_15_0_x86_64.whl", hash = "sha256:bff8990f040dacb4c314864da95f7168c5a58a30a66e0eea0fb85e2586a92cd6"}, + {file = "debugpy-1.8.20-cp39-cp39-manylinux_2_34_x86_64.whl", hash = "sha256:70ad9ae09b98ac307b82c16c151d27ee9d68ae007a2e7843ba621b5ce65333b5"}, + {file = "debugpy-1.8.20-cp39-cp39-win32.whl", hash = "sha256:9eeed9f953f9a23850c85d440bf51e3c56ed5d25f8560eeb29add815bd32f7ee"}, + {file = "debugpy-1.8.20-cp39-cp39-win_amd64.whl", hash = "sha256:760813b4fff517c75bfe7923033c107104e76acfef7bda011ffea8736e9a66f8"}, + {file = "debugpy-1.8.20-py2.py3-none-any.whl", hash = "sha256:5be9bed9ae3be00665a06acaa48f8329d2b9632f15fd09f6a9a8c8d9907e54d7"}, + {file = "debugpy-1.8.20.tar.gz", hash = "sha256:55bc8701714969f1ab89a6d5f2f3d40c36f91b2cbe2f65d98bf8196f6a6a2c33"}, +] + [[package]] name = "distlib" version = "0.3.9" @@ -1898,31 +1938,6 @@ files = [ {file = "kiwisolver-1.4.7.tar.gz", hash = "sha256:9893ff81bd7107f7b685d3017cc6583daadb4fc26e4a888350df530e41980a60"}, ] -[[package]] -name = "markdown-it-py" -version = "3.0.0" -description = "Python port of markdown-it. Markdown parsing, done right!" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, - {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, -] - -[package.dependencies] -mdurl = ">=0.1,<1.0" - -[package.extras] -benchmarking = ["psutil", "pytest", "pytest-benchmark"] -code-style = ["pre-commit (>=3.0,<4.0)"] -compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] -linkify = ["linkify-it-py (>=1,<3)"] -plugins = ["mdit-py-plugins"] -profiling = ["gprof2dot"] -rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] -testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] - [[package]] name = "markupsafe" version = "3.0.2" @@ -2047,18 +2062,6 @@ python-dateutil = ">=2.7" [package.extras] dev = ["meson-python (>=0.13.1)", "numpy (>=1.25)", "pybind11 (>=2.6)", "setuptools (>=64)", "setuptools_scm (>=7)"] -[[package]] -name = "mdurl" -version = "0.1.2" -description = "Markdown URL utilities" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, - {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, -] - [[package]] name = "multidict" version = "6.1.0" @@ -2967,21 +2970,6 @@ numpy = ">=1.16.4" carto = ["pydeck-carto"] jupyter = ["ipykernel (>=5.1.2) ; python_version >= \"3.4\"", "ipython (>=5.8.0) ; python_version < \"3.4\"", "ipywidgets (>=7,<8)", "traitlets (>=4.3.2)"] -[[package]] -name = "pygments" -version = "2.18.0" -description = "Pygments is a syntax highlighting package written in Python." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, - {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, -] - -[package.extras] -windows-terminal = ["colorama (>=0.4.6)"] - [[package]] name = "pykerberos" version = "1.2.4" @@ -3374,26 +3362,6 @@ urllib3 = ">=1.26" [package.extras] test = ["pytest"] -[[package]] -name = "rich" -version = "13.9.4" -description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" -optional = false -python-versions = ">=3.8.0" -groups = ["main"] -files = [ - {file = "rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90"}, - {file = "rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098"}, -] - -[package.dependencies] -markdown-it-py = ">=2.2.0" -pygments = ">=2.13.0,<3.0.0" -typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.11\""} - -[package.extras] -jupyter = ["ipywidgets (>=7.5.1,<9)"] - [[package]] name = "rpds-py" version = "0.21.0" @@ -3600,39 +3568,45 @@ full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart [[package]] name = "streamlit" -version = "1.40.2" +version = "1.55.0" description = "A faster way to build and share data apps" optional = false -python-versions = "!=3.9.7,>=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "streamlit-1.40.2-py2.py3-none-any.whl", hash = "sha256:7f6d1379a590f9625a6aee79ca73ceccff03cd2e05a3acbe5fe98915c27a7ffe"}, - {file = "streamlit-1.40.2.tar.gz", hash = "sha256:0cc131fc9b18065feaff8f6f241c81164ad37d8d9e3a85499a0240aaaf6a6a61"}, + {file = "streamlit-1.55.0-py3-none-any.whl", hash = "sha256:1e4a16449c6131696180f4ddb40ea8c51834e89c2a43e1b0362bc9b1cfd9b415"}, + {file = "streamlit-1.55.0.tar.gz", hash = "sha256:015e512bbd02d000f4047e51118dc086b70e7d9c46b4a11a33c2509731379626"}, ] [package.dependencies] -altair = ">=4.0,<6" -blinker = ">=1.0.0,<2" -cachetools = ">=4.0,<6" +altair = ">=4.0,<5.4.0 || >5.4.0,<5.4.1 || >5.4.1,<7" +blinker = ">=1.5.0,<2" +cachetools = ">=5.5,<8" click = ">=7.0,<9" gitpython = ">=3.0.7,<3.1.19 || >3.1.19,<4" numpy = ">=1.23,<3" -packaging = ">=20,<25" +packaging = ">=20" pandas = ">=1.4.0,<3" -pillow = ">=7.1.0,<12" -protobuf = ">=3.20,<6" +pillow = ">=7.1.0,<13" +protobuf = ">=3.20,<7" pyarrow = ">=7.0" pydeck = ">=0.8.0b4,<1" requests = ">=2.27,<3" -rich = ">=10.14.0,<14" tenacity = ">=8.1.0,<10" toml = ">=0.10.1,<2" -tornado = ">=6.0.3,<7" -typing-extensions = ">=4.3.0,<5" +tornado = ">=6.0.3,<6.5.0 || >6.5.0,<7" +typing-extensions = ">=4.10.0,<5" watchdog = {version = ">=2.1.5,<7", markers = "platform_system != \"Darwin\""} [package.extras] -snowflake = ["snowflake-connector-python (>=2.8.0) ; python_version < \"3.12\"", "snowflake-snowpark-python[modin] (>=1.17.0) ; python_version < \"3.12\""] +all = ["rich (>=11.0.0)", "streamlit[auth,charts,pdf,performance,snowflake,sql]"] +auth = ["Authlib (>=1.3.2)"] +charts = ["graphviz (>=0.19.0)", "matplotlib (>=3.0.0)", "orjson (>=3.5.0)", "plotly (>=4.0.0)"] +pdf = ["streamlit-pdf (>=1.0.0)"] +performance = ["httptools (>=0.6.3)", "orjson (>=3.5.0)", "uvloop (>=0.15.2) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\""] +snowflake = ["snowflake-connector-python (>=3.3.0) ; python_version < \"3.12\"", "snowflake-snowpark-python[modin] (>=1.17.0) ; python_version < \"3.12\""] +sql = ["SQLAlchemy (>=2.0.0)"] +starlette = ["anyio (>=4.0.0)", "itsdangerous (>=2.1.2)", "python-multipart (>=0.0.10)", "starlette (>=0.40.0)", "uvicorn (>=0.30.0)", "websockets (>=12.0.0)"] [[package]] name = "streamlit-folium" @@ -3695,24 +3669,22 @@ files = [ [[package]] name = "tornado" -version = "6.5" +version = "6.5.5" description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "tornado-6.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:f81067dad2e4443b015368b24e802d0083fecada4f0a4572fdb72fc06e54a9a6"}, - {file = "tornado-6.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9ac1cbe1db860b3cbb251e795c701c41d343f06a96049d6274e7c77559117e41"}, - {file = "tornado-6.5-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7c625b9d03f1fb4d64149c47d0135227f0434ebb803e2008040eb92906b0105a"}, - {file = "tornado-6.5-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9a0d8d2309faf015903080fb5bdd969ecf9aa5ff893290845cf3fd5b2dd101bc"}, - {file = "tornado-6.5-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03576ab51e9b1677e4cdaae620d6700d9823568b7939277e4690fe4085886c55"}, - {file = "tornado-6.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ab75fe43d0e1b3a5e3ceddb2a611cb40090dd116a84fc216a07a298d9e000471"}, - {file = "tornado-6.5-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:119c03f440a832128820e87add8a175d211b7f36e7ee161c631780877c28f4fb"}, - {file = "tornado-6.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:231f2193bb4c28db2bdee9e57bc6ca0cd491f345cd307c57d79613b058e807e0"}, - {file = "tornado-6.5-cp39-abi3-win32.whl", hash = "sha256:fd20c816e31be1bbff1f7681f970bbbd0bb241c364220140228ba24242bcdc59"}, - {file = "tornado-6.5-cp39-abi3-win_amd64.whl", hash = "sha256:007f036f7b661e899bd9ef3fa5f87eb2cb4d1b2e7d67368e778e140a2f101a7a"}, - {file = "tornado-6.5-cp39-abi3-win_arm64.whl", hash = "sha256:542e380658dcec911215c4820654662810c06ad872eefe10def6a5e9b20e9633"}, - {file = "tornado-6.5.tar.gz", hash = "sha256:c70c0a26d5b2d85440e4debd14a8d0b463a0cf35d92d3af05f5f1ffa8675c826"}, + {file = "tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa"}, + {file = "tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521"}, + {file = "tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5"}, + {file = "tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07"}, + {file = "tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e"}, + {file = "tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca"}, + {file = "tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7"}, + {file = "tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b"}, + {file = "tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6"}, + {file = "tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9"}, ] [[package]] @@ -4048,4 +4020,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.1" python-versions = ">=3.10" -content-hash = "265195672f7cdff1e3227244a9232c95e278f405ebc3066bee2b13ac46793c49" +content-hash = "a057b80cf3197c47151e31f4147775bd3d37f6dbe2e4bfe71b9dd3759bdb6b4c" diff --git a/pyproject.toml b/pyproject.toml index a05356a..382d6d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,8 @@ pymongo = {extras = ["gssapi", "snappy", "srv", "tls"], version = "^4.15.4"} fastapi = "^0.135.2" uvicorn = "^0.42.0" httpx = "^0.28.1" +debugpy = "^1.8.20" +cachetools = "^7.0.5" [tool.poetry.group.dev.dependencies] # command: `poetry add --group dev ` diff --git a/src/api.py b/src/api.py index 2ad4506..6dff279 100644 --- a/src/api.py +++ b/src/api.py @@ -4,13 +4,16 @@ import logging from datetime import datetime, timedelta +from functools import lru_cache from http import HTTPStatus +from threading import Lock import numpy as np import openmeteo_requests import pandas as pd import requests import requests_cache +from cachetools import TTLCache, cached from geopy.geocoders import Nominatim from retry_requests import retry @@ -20,15 +23,28 @@ testing = 1 +# data expires after 600 seconds (10 min) +_TTL = 600 +_ocean_cache = TTLCache(maxsize=300, ttl=_TTL) +_uv_cache = TTLCache(maxsize=300, ttl=_TTL) +uv_history_cache = TTLCache(maxsize=300, ttl=_TTL) +_ocean_history_cache = TTLCache(maxsize=300, ttl=_TTL) +_wind_temp_cache = TTLCache(maxsize=300, ttl=_TTL) +_rain_cache = TTLCache(maxsize=300, ttl=_TTL) +_forecast_cache = TTLCache(maxsize=300, ttl=_TTL) +_hourly_forecast_cache = TTLCache(maxsize=300, ttl=_TTL) +_ocean_lock = Lock() -def _create_openmeteo_client(): + +def _create_openmeteo_client() -> openmeteo_requests.Client: """Creates a cached, retry-enabled Open-Meteo API client.""" cache_session = requests_cache.CachedSession(".cache", expire_after=3600) retry_session = retry(cache_session, retries=5, backoff_factor=0.2) return openmeteo_requests.Client(session=retry_session) -def get_coordinates(args): +@lru_cache(maxsize=128) +def get_coordinates(args: tuple) -> list | str: """ Takes a location (city or address) and returns the coordinates: [lat, long] If no location is specified or the location is invalid, default_location() @@ -55,7 +71,7 @@ def get_coordinates(args): return default_location() -def default_location(): +def default_location() -> list | str: """ If no location specified in cli, find user's location Make a GET request to the API endpoint @@ -72,7 +88,10 @@ def default_location(): return "No data" -def get_uv(lat, long, decimal, unit="imperial"): +@cached(_uv_cache, lock=_ocean_lock) +def get_uv( + lat: float, long: float, decimal: int, unit: str = "imperial" +) -> float | str: """ Get UV at coordinates (lat, long) Calling the API here: https://open-meteo.com/en/docs @@ -100,7 +119,10 @@ def get_uv(lat, long, decimal, unit="imperial"): return current_uv_index -def get_uv_history(lat, long, decimal, unit="imperial"): +@cached(uv_history_cache, lock=_ocean_lock) +def get_uv_history( + lat: float, long: float, decimal: int, unit: str = "imperial" +) -> str: """ Retrieve the UV index from one year ago for specified coordinates. @@ -167,7 +189,10 @@ def get_uv_history(lat, long, decimal, unit="imperial"): return f"{historical_uv_index:.{decimal}f}" -def ocean_information(lat, long, decimal, unit="imperial"): +@cached(_ocean_cache, lock=_ocean_lock) +def ocean_information( + lat: float, long: float, decimal: int, unit: str = "imperial" +) -> list | str: """ Get Ocean Data at coordinates API: https://open-meteo.com/en/docs/marine-weather-api @@ -201,7 +226,10 @@ def ocean_information(lat, long, decimal, unit="imperial"): return [current_wave_height, current_wave_direction, current_wave_period] -def ocean_information_history(lat, long, decimal, unit="imperial"): +@cached(_ocean_history_cache, lock=_ocean_lock) +def ocean_information_history( + lat: float, long: float, decimal: int, unit: str = "imperial" +) -> list | str: """ Retrieve ocean data from one year ago for specified coordinates. @@ -276,7 +304,10 @@ def ocean_information_history(lat, long, decimal, unit="imperial"): ] -def current_wind_temp(lat, long, decimal, temp_unit="fahrenheit"): +@cached(_wind_temp_cache, lock=_ocean_lock) +def current_wind_temp( + lat: float, long: float, decimal: int, temp_unit: str = "fahrenheit" +) -> list: """ Gathers the wind and temperature data """ @@ -307,7 +338,8 @@ def current_wind_temp(lat, long, decimal, temp_unit="fahrenheit"): ] -def get_rain(lat, long): +@cached(_rain_cache, lock=_ocean_lock) +def get_rain(lat: float, long: float) -> tuple[float, float]: """ Get rain data at coordinates (lat, long) Calling the API here: https://open-meteo.com/en/docs @@ -335,7 +367,8 @@ def get_rain(lat, long): ) -def forecast(lat, long, decimal, days=0): +@cached(_forecast_cache, lock=_ocean_lock) +def forecast(lat: float, long: float, decimal: int, days: int = 0) -> dict: """ Number of forecast days. Max is 7 API: https://open-meteo.com/en/docs/marine-weather-api @@ -433,7 +466,10 @@ def forecast(lat, long, decimal, days=0): return forecast_data -def get_hourly_forecast(lat, long, days=1, unit="fahrenheit"): +@cached(_hourly_forecast_cache, lock=_ocean_lock) +def get_hourly_forecast( + lat: float, long: float, days: int = 1, unit: str = "fahrenheit" +) -> dict: """ Gets hourly weather data """ @@ -480,7 +516,7 @@ def get_hourly_forecast(lat, long, days=1, unit="fahrenheit"): return curr_hour_data -def gather_data(lat, long, arguments): +def gather_data(lat: float | str, long: float | str, arguments: dict) -> dict: """ Calls APIs though python files, returns all the ocean data(height, period...) @@ -537,13 +573,13 @@ def gather_data(lat, long, arguments): return ocean_data_dict -def separate_args_and_get_location(args): +def separate_args_and_get_location(args: list) -> dict: """ Gets user's coordinates from either the argument(location=) or, if none, the default coordinates(default_location()) """ - coordinates = get_coordinates(args) + coordinates = get_coordinates(tuple(args)) location_data = { "coordinates": coordinates, "lat": coordinates[0], diff --git a/src/cli.py b/src/cli.py index 32b9e4b..a96b96f 100644 --- a/src/cli.py +++ b/src/cli.py @@ -19,14 +19,14 @@ class SurfReport: then exposes a run() method to execute a full report cycle. """ - def __init__(self): + def __init__(self) -> None: gpt_env = settings.GPTSettings() self.gpt_prompt = gpt_env.GPT_PROMPT self.gpt_info = (gpt_env.API_KEY, gpt_env.GPT_MODEL) self.db_handler = self._init_db() @staticmethod - def _init_db(): + def _init_db() -> operations.SurfReportDatabaseOps | None: """Initializes the database handler, or returns None if unavailable.""" db_env = settings.DatabaseSettings() if not db_env.DB_URI: @@ -39,7 +39,12 @@ def _init_db(): ) return None - def run(self, lat=None, long=None, args=None): + def run( + self, + lat: float | None = None, + long: float | None = None, + args: list[str] | None = None, + ): """ Fetches surf data for the given coordinates or parsed location, optionally persists the report, and renders output. @@ -60,7 +65,7 @@ def run(self, lat=None, long=None, args=None): self._save_report(ocean_data_dict) return self._render_output(ocean_data_dict, arguments) - def _save_report(self, ocean_data_dict): + def _save_report(self, ocean_data_dict) -> None: """Persists the report to the database if a handler is available.""" if self.db_handler: try: diff --git a/src/db/connection.py b/src/db/connection.py index 357e611..96d35dd 100755 --- a/src/db/connection.py +++ b/src/db/connection.py @@ -19,7 +19,9 @@ def __init__(self): def connect(self, db_name="surf"): if not self.client: try: - self.client = MongoClient(self.db_uri) + self.client = MongoClient( + self.db_uri, serverSelectionTimeoutMS=100 + ) self.db = self.client[db_name] logger.info("Database connected successfully") except Exception as e: diff --git a/src/db/operations.py b/src/db/operations.py index 2464543..791d1bb 100755 --- a/src/db/operations.py +++ b/src/db/operations.py @@ -14,7 +14,7 @@ def __init__(self): def insert_report(self, report_document): try: - rec = self.collection.insert_one(report_document) + rec = self.collection.insert_one(report_document.copy()) logger.info("Document inserted with ID: %s", rec.inserted_id) return rec.inserted_id except Exception as e: diff --git a/src/helper.py b/src/helper.py index 07ceb35..f47fcc4 100644 --- a/src/helper.py +++ b/src/helper.py @@ -127,7 +127,7 @@ def separate_args(args): Args are separated by commas in input. Separate them and return list. """ if len(args) > 1: - return args[1].split(",") + return args.split(",") return [] diff --git a/src/server.py b/src/server.py index 1fbcf9a..0e9ef39 100644 --- a/src/server.py +++ b/src/server.py @@ -2,17 +2,18 @@ FastAPI Server! """ +import io import logging -import subprocess -import sys +from contextlib import redirect_stdout from pathlib import Path import uvicorn -from fastapi import FastAPI, HTTPException, Request +from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, PlainTextResponse from fastapi.templating import Jinja2Templates +from src import cli from src.settings import ServerSettings logger = logging.getLogger(__name__) @@ -21,6 +22,8 @@ TEMPLATES_DIR = BASE_DIR / "src" / "templates" CLI_PATH = BASE_DIR / "src" / "cli.py" +surf = cli.SurfReport() + templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) @@ -54,19 +57,13 @@ async def default_route(request: Request): f"{key}={value}" if value else key for key, value in request.query_params.items() ] - args = ",".join(parsed_parameters) - - try: - result = subprocess.run( - [sys.executable, str(CLI_PATH), args], - capture_output=True, - text=True, - check=True, - ) - return result.stdout - except subprocess.CalledProcessError as e: - logger.error("Subprocess error: %s", e.stderr) - raise HTTPException(status_code=500, detail="Internal CLI Error") + passed_args = ",".join(parsed_parameters) + + # get stdout from print function (probably not ideal but whatever) + f = io.StringIO() + with redirect_stdout(f): + surf.run(args=passed_args) + return f.getvalue() return app diff --git a/tests/test_api.py b/tests/test_api.py index f7845fd..400f3bf 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -21,6 +21,7 @@ ocean_information, ocean_information_history, seperate_args_and_get_location, + uv_history_cache, ) from src.helper import arguments_dictionary @@ -58,7 +59,7 @@ def test_default_location_mocked( def test_get_coordinates(): - coordinates = get_coordinates(["loc=santa_cruz"]) + coordinates = get_coordinates(tuple(["loc=santa_cruz"])) lat = coordinates[0] long = coordinates[1] assert isinstance(lat, (int, float)) @@ -217,7 +218,7 @@ def test_get_coordinates_no_args_falls_back_to_default(mocker): mocker.patch( "src.api.default_location", return_value=[0.0, 0.0, "Default City"] ) - result = get_coordinates([]) + result = get_coordinates(()) assert result == [0.0, 0.0, "Default City"] @@ -232,7 +233,7 @@ def test_get_coordinates_invalid_location_falls_back_to_default( ) with caplog.at_level(logging.WARNING, logger="src.api"): - result = get_coordinates(["location=nowhere_xyz_invalid"]) + result = get_coordinates(tuple(["location=nowhere_xyz_invalid"])) assert result == [0.0, 0.0, "Default City"] assert "Invalid location" in caplog.text @@ -247,6 +248,7 @@ def test_get_uv_returns_no_data_on_value_error(mocker): def test_get_uv_history_returns_no_data_on_value_error(mocker): """get_uv_history returns 'No data' when the API raises ValueError.""" + uv_history_cache.clear() mock_client = mocker.patch("src.api._create_openmeteo_client") mock_client.return_value.weather_api.side_effect = ValueError("bad coords") assert get_uv_history(31.9505, 115.8605, 2) == "No data" diff --git a/tests/test_helper.py b/tests/test_helper.py index 43928cd..58024d8 100644 --- a/tests/test_helper.py +++ b/tests/test_helper.py @@ -196,12 +196,6 @@ def test_get_forecast_days_default(): # --------------------------------------------------------------------------- -def test_separate_args_splits_on_comma(): - """separate_args returns the second element split by comma.""" - result = helper.separate_args(["cmd", "json,location=santa_cruz"]) - assert result == ["json", "location=santa_cruz"] - - def test_separate_args_returns_empty_for_single_arg(): """separate_args returns [] when no extra args are passed.""" assert helper.separate_args(["cmd"]) == [] diff --git a/tests/test_root.py b/tests/test_root.py index 6769e35..21f722f 100644 --- a/tests/test_root.py +++ b/tests/test_root.py @@ -1,4 +1,3 @@ -import subprocess from http import HTTPStatus from fastapi.testclient import TestClient @@ -7,21 +6,12 @@ from src.settings import ServerSettings -def test_root_returns_200_with_mock(monkeypatch): +def test_root_returns_200(): env = ServerSettings() app = create_app(env) - class DummyCompleted: - stdout = "ok" - stderr = "" - - def fake_run(*args, **kwargs): - return DummyCompleted() - - monkeypatch.setattr(subprocess, "run", fake_run) - client = TestClient(app) resp = client.get("/") assert resp.status_code == HTTPStatus.OK - assert "ok" in resp.text + assert "wave" in resp.text.lower() diff --git a/tests/test_server.py b/tests/test_server.py deleted file mode 100644 index 666eafc..0000000 --- a/tests/test_server.py +++ /dev/null @@ -1,27 +0,0 @@ -""" -Tests for server.py -""" - -import subprocess -from http import HTTPStatus - -from fastapi.testclient import TestClient - -from src.server import create_app -from src.settings import ServerSettings - - -def _make_app(): - return create_app(ServerSettings()) - - -def test_root_subprocess_error_returns_500(monkeypatch): - """GET / returns 500 and logs the error when the subprocess fails.""" - app = _make_app() - - def fail_run(*args, **kwargs): - raise subprocess.CalledProcessError(1, "cmd", stderr="boom") - - monkeypatch.setattr(subprocess, "run", fail_run) - resp = TestClient(app).get("/") - assert resp.status_code == HTTPStatus.INTERNAL_SERVER_ERROR