diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..4bccf30 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,24 @@ +--- +name: Tests + +on: [push] + +jobs: + test_and_lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.14" + - name: Install uv + run: pip install uv + - name: Install dependencies + run: uv sync --frozen + - name: Run pytest + run: uv run pytest . + - name: Check formatting (black) + run: uv run black --check . --extend-exclude .venv + - name: Lint (ruff) + run: uv run ruff check . diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml new file mode 100644 index 0000000..3836b91 --- /dev/null +++ b/.gitea/workflows/publish.yml @@ -0,0 +1,36 @@ +name: Publish Package to Gitea + +on: + push: + tags: + - "v*" # This triggers the workflow when you push a tag like v1.0.0 + +jobs: + publish: + runs-on: ubuntu-latest # Or your specific runner label + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Install build dependencies + run: | + python -m pip install --upgrade pip + pip install build twine + + - name: Build package + run: python -m build + + - name: Publish to Gitea PyPI + env: + # These secrets must match the names you created in Gitea + TWINE_USERNAME: ${{ secrets.TWINE_PASSWORD }} + TWINE_PASSWORD: ${{ secrets.TWINE_USERNAME }} + # REPLACE THIS URL with your actual Gitea PyPI endpoint + TWINE_REPOSITORY_URL: "https://gitea.schneiderserver.site/api/packages/mathias2784/pypi" + run: | + twine upload --repository-url $TWINE_REPOSITORY_URL dist/* diff --git a/pyproject.toml b/pyproject.toml index a629de0..e906dea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,12 @@ authors = [ ] requires-python = ">=3.14" dependencies = [ + "black>=26.5.1", "pydantic>=2.13.5", + "pydantic-settings>=2.15.0", + "pytest>=9.1.1", + "ruff>=0.16.10", + "tk>=0.1.0", "twine>=7.0.0", "uvicorn>=0.54.0", ] @@ -16,6 +21,21 @@ dependencies = [ [project.scripts] utils = "utils:main" +[tool.pytest.ini_options] +# Live tests open a real GUI dialog and are intended for local manual +# testing only; they are deselected by default (e.g. in the pipeline) +# and run explicitly with: uv run pytest -m live +addopts = "-m 'not live'" +markers = [ + "live: opens a real GUI dialog, intended for local manual testing", +] + +[tool.ruff.lint] +# File modes are unreliable in this environment (/mnt/anbau mounts force 0777), +# which makes EXE002 report every file locally. Ignore it deliberately instead of +# relying on a personal global config that CI does not have. +ignore = ["EXE002"] + [build-system] requires = ["uv_build>=0.12.20,<0.13.0"] build-backend = "uv_build" diff --git a/src/utils/filedialogs.py b/src/utils/filedialogs.py new file mode 100644 index 0000000..263d918 --- /dev/null +++ b/src/utils/filedialogs.py @@ -0,0 +1,44 @@ +import tkinter +import tkinter.filedialog + + +def get_dir_path_from_dialog(**kwargs): + """Open a native directory picker and return the selected path. + + All ``kwargs`` are passed through unchanged to + :func:`tkinter.filedialog.askdirectory`. The most useful ones: + + - ``initialdir`` (str): directory the dialog opens in. + - ``title`` (str): title of the dialog window. + - ``mustexist`` (bool, default ``True``): only return existing directories. + + Returns an empty string if the user cancels the dialog. + """ + root = tkinter.Tk() + root.withdraw() + try: + return tkinter.filedialog.askdirectory(**kwargs) + finally: + root.destroy() + + +def get_file_path_from_dialog(**kwargs): + """Open a native file picker and return the selected file path. + + All ``kwargs`` are passed through unchanged to + :func:`tkinter.filedialog.askopenfilename`. The most useful ones: + + - ``initialdir`` (str): directory the dialog opens in. + - ``initialfile`` (str): file pre-selected in the dialog. + - ``filetypes`` (list of (label, pattern) tuples): restrict which files + are shown, e.g. ``[("Text files", "*.txt"), ("All files", "*")]``. + - ``title`` (str): title of the dialog window. + + Returns an empty string if the user cancels the dialog. + """ + root = tkinter.Tk() + root.withdraw() + try: + return tkinter.filedialog.askopenfilename(**kwargs) + finally: + root.destroy() diff --git a/src/utils/logging_config.py b/src/utils/logging_config.py old mode 100755 new mode 100644 index 0b8a4f4..ca1c78e --- a/src/utils/logging_config.py +++ b/src/utils/logging_config.py @@ -27,7 +27,7 @@ class LoggingSettings(BaseSettingsConfig): LOG_FILE_PATH: str = "logs/monitoring_backend.log" LOG_BACKUP_COUNT: int = Field(default=30, ge=0) - def model_post_init(self, __context: Any) -> None: + def model_post_init(self, __context: Any, /) -> None: self.setup_logging() def setup_logging(self) -> None: diff --git a/src/utils/misc.py b/src/utils/misc.py new file mode 100644 index 0000000..c40d127 --- /dev/null +++ b/src/utils/misc.py @@ -0,0 +1,8 @@ +def readlines(path: str) -> list[str]: + with open(path, "r") as fid: + lines = fid.readlines() + return lines + + +def read(path: str) -> str: + return "".join(readlines(path)) diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 0000000..e7b922d --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,31 @@ +"""Make the test suite runnable on machines without Tk system libraries. + +``utils.filedialogs`` imports tkinter at module level, and the CI runner +lacks the Tk shared libraries (``import tkinter`` fails on a missing +``libtk8.6.so``). Since pytest imports every test module at collection +time — including the deselected ``test_filedialogs_live.py`` — a minimal +stub is registered in ``sys.modules`` here, before any test module is +imported, whenever tkinter is not importable. + +The hermetic tests in ``test_filedialogs.py`` mock every tkinter +touchpoint anyway, so the stub is never actually used. +""" + +import sys +import types + +try: + import tkinter # noqa: F401 +except ImportError: + _tk_stub = types.ModuleType("tkinter") + _filedialog_stub = types.ModuleType("tkinter.filedialog") + + def _tk_unavailable(*args, **kwargs): + raise AssertionError("real tkinter was called without a display") + + _tk_stub.Tk = _tk_unavailable + _tk_stub.filedialog = _filedialog_stub + _filedialog_stub.askdirectory = _tk_unavailable + _filedialog_stub.askopenfilename = _tk_unavailable + sys.modules["tkinter"] = _tk_stub + sys.modules["tkinter.filedialog"] = _filedialog_stub diff --git a/test/data/text.txt b/test/data/text.txt new file mode 100644 index 0000000..bec0359 --- /dev/null +++ b/test/data/text.txt @@ -0,0 +1,3 @@ +This is a test file +with a second line +and a third line diff --git a/test/test_filedialogs.py b/test/test_filedialogs.py new file mode 100644 index 0000000..6c56e78 --- /dev/null +++ b/test/test_filedialogs.py @@ -0,0 +1,55 @@ +"""Hermetic tests for ``utils.filedialogs``. + +These tests never open a real dialog: every tkinter touchpoint is mocked, +so they run headless and even on machines without Tk system libraries +(e.g. the CI runner, where ``import tkinter`` fails on a missing +``libtk8.6.so``). ``conftest.py`` registers a tkinter stub in that case, +so ``utils.filedialogs`` can be imported at all. + +For tests that open a real dialog, see ``test_filedialogs_live.py`` +(marker ``live``, excluded from the default run). +""" + +from unittest import mock + +from utils.filedialogs import get_dir_path_from_dialog, get_file_path_from_dialog + + +def test_get_dir_path_from_dialog_returns_selected_path(): + with ( + mock.patch("tkinter.Tk"), + mock.patch( + "tkinter.filedialog.askdirectory", return_value="/tmp/somewhere" + ) as dialog, + ): + result = get_dir_path_from_dialog(initialdir="/tmp", title="Pick") + assert result == "/tmp/somewhere" + dialog.assert_called_once_with(initialdir="/tmp", title="Pick") + + +def test_get_dir_path_from_dialog_returns_empty_string_on_cancel(): + with ( + mock.patch("tkinter.Tk"), + mock.patch("tkinter.filedialog.askdirectory", return_value=""), + ): + assert get_dir_path_from_dialog() == "" + + +def test_get_file_path_from_dialog_returns_selected_path(): + with ( + mock.patch("tkinter.Tk"), + mock.patch( + "tkinter.filedialog.askopenfilename", return_value="/tmp/file.txt" + ) as dialog, + ): + result = get_file_path_from_dialog(filetypes=[("Text", "*.txt")]) + assert result == "/tmp/file.txt" + dialog.assert_called_once_with(filetypes=[("Text", "*.txt")]) + + +def test_get_file_path_from_dialog_returns_empty_string_on_cancel(): + with ( + mock.patch("tkinter.Tk"), + mock.patch("tkinter.filedialog.askopenfilename", return_value=""), + ): + assert get_file_path_from_dialog() == "" diff --git a/test/test_filedialogs_live.py b/test/test_filedialogs_live.py new file mode 100644 index 0000000..86fba9b --- /dev/null +++ b/test/test_filedialogs_live.py @@ -0,0 +1,30 @@ +"""Live dialog tests for ``utils.filedialogs``. + +These tests open a real OS dialog and require a working display. +They are excluded from the default (and pipeline) runs by the +``live`` marker configured in ``pyproject.toml`` and only run when +explicitly requested, e.g.:: + + uv run pytest -m live + +Choose a path in the dialog (or cancel it) — either outcome passes, +as long as the dialog returns consistently. +""" + +import os + +import pytest + +from utils.filedialogs import get_dir_path_from_dialog, get_file_path_from_dialog + + +@pytest.mark.live +def test_get_dir_path_from_dialog_live(): + result = get_dir_path_from_dialog(title="Test: pick a directory") + assert result == "" or os.path.isdir(result) + + +@pytest.mark.live +def test_get_file_path_from_dialog_live(): + result = get_file_path_from_dialog(title="Test: pick a file") + assert result == "" or os.path.isfile(result) diff --git a/test/test_logging_config.py b/test/test_logging_config.py old mode 100755 new mode 100644 index 5c3aab0..d89abfb --- a/test/test_logging_config.py +++ b/test/test_logging_config.py @@ -7,16 +7,16 @@ from pathlib import Path from unittest.mock import patch import pytest +from pydantic import ValidationError from uvicorn.logging import DefaultFormatter -from dataflex_backend_common.logging_config import LoggingSettings -from pydantic import ValidationError +from utils.logging_config import LoggingSettings SECONDS_PER_DAY = 24 * 60 * 60 @pytest.fixture -def restore_root_logger() -> Generator[None, None, None]: +def restore_root_logger() -> Generator[None]: root = logging.getLogger() old_handlers = root.handlers[:] old_level = root.level @@ -41,11 +41,13 @@ def test_sets_valid_log_level(restore_root_logger: None) -> None: def test_invalid_log_level_falls_back_to_info(restore_root_logger: None) -> None: - with patch("dataflex_backend_common.logging_config.logger.error") as log_error: + with patch("utils.logging_config.logger.error") as log_error: LoggingSettings(LOG_LEVEL="NOT_A_LEVEL", LOG_TO_FILE=False) assert logging.getLogger().level == logging.INFO - log_error.assert_called_once_with("Invalid log level %r; falling back to INFO.", "NOT_A_LEVEL") + log_error.assert_called_once_with( + "Invalid log level %r; falling back to INFO.", "NOT_A_LEVEL" + ) def test_configures_console_handler_only(restore_root_logger: None) -> None: @@ -66,7 +68,9 @@ def test_configures_file_handler_and_creates_parent_dir( handlers = logging.getLogger().handlers assert any(type(handler) is logging.StreamHandler for handler in handlers) - file_handlers = [handler for handler in handlers if isinstance(handler, logging.FileHandler)] + file_handlers = [ + handler for handler in handlers if isinstance(handler, logging.FileHandler) + ] assert len(file_handlers) == 1 assert log_file.parent.exists() assert isinstance(file_handlers[0], TimedRotatingFileHandler) @@ -77,12 +81,16 @@ def test_configures_file_handler_and_creates_parent_dir( assert file_handlers[0].encoding.lower() == "utf-8" -def test_configures_custom_log_backup_count(tmp_path: Path, restore_root_logger: None) -> None: +def test_configures_custom_log_backup_count( + tmp_path: Path, restore_root_logger: None +) -> None: log_file = tmp_path / "app.log" LoggingSettings(LOG_TO_FILE=True, LOG_FILE_PATH=str(log_file), LOG_BACKUP_COUNT=7) file_handler = next( - handler for handler in logging.getLogger().handlers if isinstance(handler, TimedRotatingFileHandler) + handler + for handler in logging.getLogger().handlers + if isinstance(handler, TimedRotatingFileHandler) ) assert file_handler.backupCount == 7 @@ -99,7 +107,9 @@ def test_writes_records_to_console_and_file( logging.getLogger("test.logging_output").info("record is written") file_handler = next( - handler for handler in logging.getLogger().handlers if isinstance(handler, TimedRotatingFileHandler) + handler + for handler in logging.getLogger().handlers + if isinstance(handler, TimedRotatingFileHandler) ) file_handler.flush() @@ -112,7 +122,9 @@ def test_rotates_active_log_file(tmp_path: Path, restore_root_logger: None) -> N LoggingSettings(LOG_FILE_PATH=str(log_file)) logger = logging.getLogger("test.logging_rotation") file_handler = next( - handler for handler in logging.getLogger().handlers if isinstance(handler, TimedRotatingFileHandler) + handler + for handler in logging.getLogger().handlers + if isinstance(handler, TimedRotatingFileHandler) ) logger.info("before rollover") @@ -137,7 +149,9 @@ def test_removes_old_rotated_files_after_backup_limit( LoggingSettings(LOG_FILE_PATH=str(log_file), LOG_BACKUP_COUNT=2) logger = logging.getLogger("test.logging_retention") file_handler = next( - handler for handler in logging.getLogger().handlers if isinstance(handler, TimedRotatingFileHandler) + handler + for handler in logging.getLogger().handlers + if isinstance(handler, TimedRotatingFileHandler) ) base_timestamp = int(time.time()) @@ -146,7 +160,8 @@ def test_removes_old_rotated_files_after_backup_limit( file_handler.flush() rollover_timestamp = base_timestamp + day * SECONDS_PER_DAY file_handler.rolloverAt = rollover_timestamp + SECONDS_PER_DAY - monkeypatch.setattr(time, "time", lambda: rollover_timestamp) + # Bind the loop variable via a default argument to avoid late binding (B023). + monkeypatch.setattr(time, "time", lambda now=rollover_timestamp: now) file_handler.doRollover() assert len(list(tmp_path.glob("app.*.log"))) == 2 @@ -162,7 +177,9 @@ def test_setup_logging_is_idempotent(tmp_path: Path, restore_root_logger: None) settings = LoggingSettings(LOG_TO_FILE=True, LOG_FILE_PATH=str(log_file)) first_file_handler = next( - handler for handler in logging.getLogger().handlers if isinstance(handler, logging.FileHandler) + handler + for handler in logging.getLogger().handlers + if isinstance(handler, logging.FileHandler) ) first_handler_count = len(logging.getLogger().handlers) diff --git a/test/test_misc.py b/test/test_misc.py new file mode 100644 index 0000000..f167e75 --- /dev/null +++ b/test/test_misc.py @@ -0,0 +1,27 @@ +from pathlib import Path + +from utils.misc import read, readlines + +CWD = Path(__file__).parent.resolve() +TEST_DATA_DIR = CWD / "data" +TEST_FILE_PATH = TEST_DATA_DIR / "text.txt" + +EXPECTED_TEST = """This is a test file +with a second line +and a third line +""" + + +def test_readlines_str(): + result = readlines(TEST_FILE_PATH) + assert result + assert isinstance(result, list) + assert all(isinstance(element, str) for element in result) + assert result[1].strip() == "with a second line" + + +def test_path_str(): + result = read(TEST_FILE_PATH) + assert result + assert isinstance(result, str) + assert result == EXPECTED_TEST diff --git a/uv.lock b/uv.lock index bfa8688..4f9b3f9 100644 --- a/uv.lock +++ b/uv.lock @@ -11,6 +11,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, ] +[[package]] +name = "black" +version = "26.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pytokens" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/37/5628dd55bf2b34257fc7603f0fe97c40e3aaf24265f416a9c85c95ca1436/black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73", size = 679439, upload-time = "2026-05-18T16:53:36.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/16/a8da8eb208c51c7f4ce74609a45d0dcc6d8a2141e45e81ee5289d1bb0d59/black-26.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e88976690a64b0af98312ca958415849cb42423423c5f2ee74af4b49a97a2168", size = 2004800, upload-time = "2026-05-18T17:05:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/11/8a/a479296a19e383b70a725882a6cf3d786540601ff03cabbaaf1cce864c5a/black-26.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32d5ea7f6c8bdfa6e648326ebca1f02b0764e2a029edc6f8dce2627e19d468c3", size = 1815576, upload-time = "2026-05-18T17:05:40.309Z" }, + { url = "https://files.pythonhosted.org/packages/81/6b/cfaf3d39f25132c156a068f6b805576c9103a84086019507c70e1911ee7d/black-26.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea8d16dc41655aa113cd64665e7219446cd7e4ff2248d7178eaa905190c86b18", size = 1877927, upload-time = "2026-05-18T17:05:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/66/76/302e313964bcff7e28df329d39f84f5270095730d85ff0acc260610a0d82/black-26.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:577f21094ea469ef92ec1adaf2c9441a226d2144d01a5be2fa823cecf6543e50", size = 1511860, upload-time = "2026-05-18T17:05:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/27/4e/a3827e35e0e567f9f9ee59e2a0ab979267dca98718f25547ca8c6733afd4/black-26.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:ed1a20af114c301a0269bf01163d51dbef72737fd65f850001e7cbe7f3c7abae", size = 1316632, upload-time = "2026-05-18T17:05:45.521Z" }, + { url = "https://files.pythonhosted.org/packages/94/51/f975cae76d44274cc2868dc9040ac5d58d464784610234455b4e7b19c6ef/black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2", size = 213693, upload-time = "2026-05-18T16:53:33.964Z" }, +] + [[package]] name = "certifi" version = "2026.7.22" @@ -154,6 +176,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, ] +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + [[package]] name = "cryptography" version = "50.0.2" @@ -240,6 +271,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "jaraco-classes" version = "3.4.0" @@ -329,6 +369,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, ] +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + [[package]] name = "nh3" version = "0.3.7" @@ -372,6 +421,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/17/c8/721b3855fe457da514fe249247d404b9b39c5d16532278f70ebaa6acf18b/platformdirs-4.12.2.tar.gz", hash = "sha256:eab5f70271a490ef74618bb314fbb86e3c7e82fa3b9c922c2ea0e0a1a155d329", size = 59353, upload-time = "2026-09-29T18:27:56.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/89/446044f33aba0348d35e433f56d12206d010a5281a1df54054d4cfb82388/platformdirs-4.12.2-py3-none-any.whl", hash = "sha256:29dbf06d96c500bc6bdbce75fb0a14d63279c93b1842f97e72a135b33e856983", size = 32457, upload-time = "2026-09-29T18:27:54.43Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -437,6 +513,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, ] +[[package]] +name = "pydantic-settings" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, +] + [[package]] name = "pygments" version = "2.21.0" @@ -446,6 +536,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, ] +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/26/2fbeedb218a787a5eea551c7532cac4e009f83d689dd2faa0d0353473f86/python_dotenv-1.2.4.tar.gz", hash = "sha256:f0d53e69935a851c0dcc78f3ab7aaccd8cabef0b92382b576b824212902873c0", size = 60824, upload-time = "2026-10-01T05:36:10Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" }, +] + +[[package]] +name = "pytokens" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, + { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, + { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, + { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, + { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, +] + [[package]] name = "pywin32-ctypes" version = "0.2.3" @@ -518,6 +652,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, ] +[[package]] +name = "ruff" +version = "0.16.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/49/23802c45f093eb14bde54b141d2b2f058edfa63a06db7beed047308cc08f/ruff-0.16.10.tar.gz", hash = "sha256:eff4728c4eaae93f0955cd264d24b2ab348e74bf59986ccf282ba6dc16b3b017", size = 4958724, upload-time = "2026-10-01T18:03:21.697Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/21/ebce22e1d90cdb2cd691026b9c6e9bec6499f0b396089481755b5d49efff/ruff-0.16.10-py3-none-linux_armv6l.whl", hash = "sha256:488b0fe3f3574210e5cf80d9f59b9e3ab17a127a8155de3f307b392589cfb511", size = 10095557, upload-time = "2026-10-01T18:02:36.072Z" }, + { url = "https://files.pythonhosted.org/packages/cb/98/a54de85876a8b2612bfa0d84c7b9abfb39c6a3354aee7800b09c1649b9e3/ruff-0.16.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e748ff95c934c4e978783b8e687bc174e7bd84e8ad24e3243e1ecfcda5e0282d", size = 10340577, upload-time = "2026-10-01T18:02:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/9f/16/1a5a4a2657effe4806110f2b907313802f1367fcdbb29e8122766e407fab/ruff-0.16.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3031a4a2e8e7b8a46f70be45f198c35a11ece509a94b80334d8d397a33c67550", size = 9774282, upload-time = "2026-10-01T18:02:41.79Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/470085af734da396e68cd80fb0a3e7c109a459716ae59588c0f6fab8a17d/ruff-0.16.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:494401c86df4c4c25f69b9419605d944467ee98c42fb6ad405ef4fa40b8fb67d", size = 9920895, upload-time = "2026-10-01T18:02:44.458Z" }, + { url = "https://files.pythonhosted.org/packages/57/de/f10cffe4f88a37ea6615ec460f01bf76f0bc1477737a35d9ee61a630a78b/ruff-0.16.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d203abc0ff2b773ee33d00ab8df0bb67046fbc7c07b119332f08b9b345cf8221", size = 9902707, upload-time = "2026-10-01T18:02:47.041Z" }, + { url = "https://files.pythonhosted.org/packages/ff/44/3fdcedf83ae60ef837dd239170e480dce606a9142cfa2db911afdf855fd9/ruff-0.16.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd83d1235a5258d318477bc5b576303974cbdef5df0c01a1bff14efcc23bd12a", size = 10619287, upload-time = "2026-10-01T18:02:49.485Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/02e76a5574002153618eafbb70e159468addc72a5d2c488e65cbb4639d2d/ruff-0.16.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc2610fb269fa56dd8a68669ae470fa6272902668c0fc2ebc3aa112b2633d5b8", size = 11339942, upload-time = "2026-10-01T18:02:52.008Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c4/cde27d47ad8d4126c587e608c67c46e7feac11763f606d261a1bc8a489e6/ruff-0.16.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e70175e29cc94c26ea296c80e470180b744b7419026898e58f520c6ab32578e", size = 10934316, upload-time = "2026-10-01T18:02:54.811Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/17234145f302a645a123e8c3bb2411ecf4669fbc350de3b0d803230a1729/ruff-0.16.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f33f43a864a8483eebd160e713336c8bab02c934feaff0a33cf5ccb41546d09a", size = 10387968, upload-time = "2026-10-01T18:02:57.497Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/2493af60240ee7644b38a4b821f5f9c3b5a4fa3178770fe1d0c685217395/ruff-0.16.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1dfc6f0088149fb6a362c1c446bcbb3fd2157b3852fe2fa68409276eab9ad9b3", size = 10537906, upload-time = "2026-10-01T18:03:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9a/f56b28f3b143bb9e518c34fb89ab191b626bca8b70dbf8af0d2e2d473572/ruff-0.16.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6553498afc35f580f036030795810b9e6bcea31604b0fd9e8d352795473042e3", size = 10012938, upload-time = "2026-10-01T18:03:03.006Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/fca37362848ea4d4d80712df13632e645e7c7cfb1bdedf140699ac7a090b/ruff-0.16.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a3b8471dea115d37f123882be852bed13403746d3a76c11de4a19ec5f9ff5a03", size = 9897945, upload-time = "2026-10-01T18:03:05.818Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c7/e0bc57664d6e0c61fd22f620af260fe9662d7e1ddb320ea7f160e76ef165/ruff-0.16.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:92e59a70bcbd9d3a5483656da906ec28edfdacfce00afd99edb8b4e9d15644be", size = 10333134, upload-time = "2026-10-01T18:03:08.25Z" }, + { url = "https://files.pythonhosted.org/packages/21/aa/5c9f3b68737c0e4a33a1db5dfab44f7b786d96ca91a7233112ddab1e6dc2/ruff-0.16.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7ae7375f803b5520dc9f546bed7e3a0acb70b91812e9bb4b19927de22f25b77d", size = 10741702, upload-time = "2026-10-01T18:03:10.775Z" }, + { url = "https://files.pythonhosted.org/packages/78/fa/0f9c2020dc316c53d983be011720b8157cc052b97e3f4dfa7db6d0f880a6/ruff-0.16.10-py3-none-win32.whl", hash = "sha256:2a12e01cb9156c10c466f63b46eaae5ecea28dfbd21b5836353ae498e7d1349a", size = 10139176, upload-time = "2026-10-01T18:03:13.267Z" }, + { url = "https://files.pythonhosted.org/packages/99/29/cfb0df9448d4d4ad48c2de029ada9ebd71baa6da983a6c77ee6c6cd0fe82/ruff-0.16.10-py3-none-win_amd64.whl", hash = "sha256:97f2015c92aa97105b0eab19eb5d224884399281cfc5da86a92db4ab5e7fb2ca", size = 10584734, upload-time = "2026-10-01T18:03:16.006Z" }, + { url = "https://files.pythonhosted.org/packages/fc/05/c16957eb287c3fc062e032619a25868d93d408a844b725bcf514f7a378ff/ruff-0.16.10-py3-none-win_arm64.whl", hash = "sha256:25a65fe998c4e6861ec079ada5826a2fc605e6cbccbe9dcd7fac1f54e791621b", size = 10366440, upload-time = "2026-10-01T18:03:19.04Z" }, +] + [[package]] name = "secretstorage" version = "3.5.0" @@ -531,6 +690,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, ] +[[package]] +name = "tk" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a0/81/742b342fd642e672fbedecde725ba44db44e800dc4c936216c3c6729885a/tk-0.1.0.tar.gz", hash = "sha256:60bc8923d5d35f67f5c6bd93d4f0c49d2048114ec077768f959aef36d4ed97f8", size = 2247, upload-time = "2019-07-08T06:51:58.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/0b/029cbdb868bb555fed99bf6540fff072d500b3f895873709f25084e85e33/tk-0.1.0-py3-none-any.whl", hash = "sha256:703a69ff0d5ba2bd2f7440582ad10160e4a6561595d33457dc6caa79b9bf4930", size = 3879, upload-time = "2019-07-08T06:51:55.175Z" }, +] + [[package]] name = "twine" version = "7.0.0" @@ -583,17 +751,27 @@ wheels = [ [[package]] name = "utils" -version = "0.1.0" +version = "0.1.1" source = { editable = "." } dependencies = [ + { name = "black" }, { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pytest" }, + { name = "ruff" }, + { name = "tk" }, { name = "twine" }, { name = "uvicorn" }, ] [package.metadata] requires-dist = [ + { name = "black", specifier = ">=26.5.1" }, { name = "pydantic", specifier = ">=2.13.5" }, + { name = "pydantic-settings", specifier = ">=2.15.0" }, + { name = "pytest", specifier = ">=9.1.1" }, + { name = "ruff", specifier = ">=0.16.10" }, + { name = "tk", specifier = ">=0.1.0" }, { name = "twine", specifier = ">=7.0.0" }, { name = "uvicorn", specifier = ">=0.54.0" }, ]