diff --git a/.github/workflows/_package-publish.yml b/.github/workflows/_package-publish.yml index 560ec54..4fb4f69 100644 --- a/.github/workflows/_package-publish.yml +++ b/.github/workflows/_package-publish.yml @@ -73,4 +73,4 @@ jobs: with: method: chat.postMessage token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }} - payload: '{"channel":"#announce-foundry","text":"🚀 Foundry Python Core ${{ github.ref_name }} released","blocks":[{"type":"section","text":{"type":"mrkdwn","text":"🚀 *Foundry Python Core* <${{ github.server_url }}/${{ github.repository }}/releases/tag/${{ github.ref_name }}|${{ github.ref_name }}> released"}},{"type":"section","text":{"type":"mrkdwn","text":"${{ env.RELEASE_NOTES_CONTENT }}"}}]}' + payload: '{"channel":"#announce-foundry","text":"🚀 aignostics-foundry-core ${{ github.ref_name }} released","blocks":[{"type":"section","text":{"type":"mrkdwn","text":"🚀 *aignostics-foundry-core* <${{ github.server_url }}/${{ github.repository }}/releases/tag/${{ github.ref_name }}|${{ github.ref_name }}> released"}},{"type":"section","text":{"type":"mrkdwn","text":"${{ env.RELEASE_NOTES_CONTENT }}"}}]}' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e5a2930..9c34064 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -84,7 +84,7 @@ repos: pass_filenames: false always_run: true verbose: true - stages: [pre-push] + stages: [pre-commit] - id: test-unit name: Test (unit) entry: bash -c 'mise run test_unit' diff --git a/ATTRIBUTIONS.md b/ATTRIBUTIONS.md index 7b0cffa..b258568 100644 --- a/ATTRIBUTIONS.md +++ b/ATTRIBUTIONS.md @@ -286,6 +286,40 @@ SOFTWARE. ``` +## annotated-doc (0.0.4) - UNKNOWN + +Document parameters, class attributes, return types, and variables inline, with Annotated. + +* URL: https://github.com/fastapi/annotated-doc +* Author(s): =?utf-8?q?Sebasti=C3=A1n_Ram=C3=ADrez?= + +### License Text + +``` +The MIT License (MIT) + +Copyright (c) 2025 Sebastián Ramírez + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +``` + ## annotated-types (0.7.0) - MIT License Reusable constraint types to use with typing.Annotated @@ -320,6 +354,39 @@ SOFTWARE. ``` +## anyio (4.13.0) - UNKNOWN + +High-level concurrency and networking framework on top of asyncio or Trio + +* URL: https://anyio.readthedocs.io/en/stable/versionhistory.html +* Author(s): Alex Grönholm + +### License Text + +``` +The MIT License (MIT) + +Copyright (c) 2018 Alex Grönholm + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +``` + ## argcomplete (3.6.3) - Apache Software License Bash tab completion for argparse @@ -2989,6 +3056,40 @@ execnet: rapid multi-Python deployment ``` +## fastapi (0.135.2) - UNKNOWN + +FastAPI framework, high performance, easy to learn, fast to code, ready for production + +* URL: https://github.com/fastapi/fastapi +* Author(s): =?utf-8?q?Sebasti=C3=A1n_Ram=C3=ADrez?= + +### License Text + +``` +The MIT License (MIT) + +Copyright (c) 2018 Sebastián Ramírez + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +``` + ## filelock (3.25.2) - MIT License A platform independent file lock. @@ -3763,6 +3864,13 @@ license-expression is a comprehensive utility library to parse, compare, simplif ``` +## loguru (0.7.3) - MIT License + +Python logging made (stupidly) simple + +* URL: https://github.com/Delgan/loguru +* Author(s): Delgan + ## lxml (6.0.2) - BSD-3-Clause Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API. @@ -4913,9 +5021,9 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -## psutil (7.2.2) - BSD-3-Clause +## psutil (6.1.1) - BSD License -Cross-platform lib for process and system monitoring. +Cross-platform lib for process and system monitoring in Python. * URL: https://github.com/giampaolo/psutil * Author(s): Giampaolo Rodola @@ -7171,6 +7279,47 @@ limitations under the License. ``` +## starlette (1.0.0) - UNKNOWN + +The little ASGI library that shines. + +* URL: https://github.com/Kludex/starlette +* Author(s): Tom Christie +* Maintainer(s): Marcelo Trylesinski + +### License Text + +``` +Copyright © 2018, [Encode OSS Ltd](https://www.encode.io/). +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +``` + ## tabledata (1.3.4) - MIT License tabledata is a Python library to represent tabular data. Used for pytablewriter/pytablereader/SimpleSQLite/etc. diff --git a/README.md b/README.md index e94d3f0..a5759dc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 🏭 Foundry Python Core -[![License](https://img.shields.io/badge/license-Proprietary-A41831?labelColor=414042)](https://github.com/aignostics/foundry-python-core/blob/main/LICENSE) +[![License](https://img.shields.io/badge/license-MIT-blue)](https://github.com/aignostics/foundry-python-core/blob/main/LICENSE) [![CI](https://github.com/aignostics/foundry-python-core/actions/workflows/ci-cd.yml/badge.svg)](https://github.com/aignostics/foundry-python-core/actions/workflows/ci-cd.yml) [![Quality Gate](https://sonarcloud.io/api/project_badges/measure?project=aignostics_foundry-python-core&metric=alert_status&token=a2fcb508f6d22af0c9d0a38728a7f5ee22d5b2ab)](https://sonarcloud.io/summary/new_code?id=aignostics_foundry-python-core) [![Security](https://sonarcloud.io/api/project_badges/measure?project=aignostics_foundry-python-core&metric=security_rating&token=a2fcb508f6d22af0c9d0a38728a7f5ee22d5b2ab)](https://sonarcloud.io/summary/new_code?id=aignostics_foundry-python-core) diff --git a/pyproject.toml b/pyproject.toml index 085e1c1..1a11ca6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,10 @@ classifiers = [ requires-python = ">=3.11, <3.15" dependencies = [ + "fastapi>=0.110,<1", + "loguru>=0.7,<1", + "platformdirs>=4,<5", + "psutil>=6", "pydantic>=2,<3", "pydantic-settings>=2,<3", "rich>=14,<15", diff --git a/src/aignostics_foundry_core/AGENTS.md b/src/aignostics_foundry_core/AGENTS.md index a25c8c2..2519312 100644 --- a/src/aignostics_foundry_core/AGENTS.md +++ b/src/aignostics_foundry_core/AGENTS.md @@ -8,6 +8,10 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei | Module | Purpose | Description | |--------|---------|-------------| +| **models** | Shared output format enum | `OutputFormat` StrEnum with `YAML` and `JSON` values for use in CLI and API responses | +| **process** | Current process introspection | `ProcessInfo`, `ParentProcessInfo` Pydantic models and `get_process_info()` for runtime process metadata; `SUBPROCESS_CREATION_FLAGS` for subprocess creation | +| **api.exceptions** | API exception hierarchy and FastAPI handlers | `ApiException` (500), `NotFoundException` (404), `AccessDeniedException` (401); `api_exception_handler`, `unhandled_exception_handler`, `validation_exception_handler` for FastAPI registration | +| **log** | Configurable loguru logging initialisation | `logging_initialize(project_name, version, env_file, filter_func)`, `LogSettings` (env-prefix configurable), `InterceptHandler` for stdlib-to-loguru bridging | | **console** | Themed terminal output | Module-level `console` object (Rich `Console`) with colour theme and `_get_console()` factory | | **di** | Dependency injection | `locate_subclasses`, `locate_implementations`, `load_modules`, `discover_plugin_packages`, `clear_caches`, `PLUGIN_ENTRY_POINT_GROUP` for plugin and subclass discovery | | **health** | Service health checks | `Health` model and `HealthStatus` enum for tree-structured health status | @@ -17,6 +21,58 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei +### api.exceptions + +**API exception hierarchy and FastAPI exception handlers** + +- **Purpose**: Provides standardised HTTP exceptions and matching FastAPI exception handlers so all API errors return a consistent `{"success": false, "error": {"code": …, "message": …}}` envelope +- **Key Features**: + - `ApiException(Exception)` — base API error; class-level `status_code = 500`, `message = "Unhandled API exception"`; both overridable via constructor kwargs + - `NotFoundException(ApiException)` — `status_code = 404` + - `AccessDeniedException(ApiException)` — `status_code = 401` + - `api_exception_handler(request, exc)` — maps `ApiException` to `JSONResponse` with `success: False` and structured error body + - `unhandled_exception_handler(request, exc)` — catches any `Exception`, logs at CRITICAL via loguru, returns 500 + - `validation_exception_handler(request, exc)` — handles Pydantic `ValidationError` / FastAPI `RequestValidationError`; calls `.errors()` if available, returns 422 +- **Location**: `aignostics_foundry_core/api/exceptions.py` +- **Dependencies**: `fastapi>=0.110,<1` (mandatory); `loguru` (used lazily inside `unhandled_exception_handler`) +- **Import**: `from aignostics_foundry_core.api.exceptions import ApiException, NotFoundException, AccessDeniedException, api_exception_handler, unhandled_exception_handler, validation_exception_handler` + +### log + +**Configurable loguru logging initialisation** + +- **Purpose**: Bootstraps loguru as the primary logging framework, optionally redirecting stdlib `logging` via `InterceptHandler`. All project-specific constants are passed as parameters rather than hard-coded. +- **Key Features**: + - `InterceptHandler(logging.Handler)` — redirects stdlib log records to loguru, preserving original module/function/line metadata + - `LogSettings(BaseSettings)` — reads from `FOUNDRY_LOG_*` env vars by default; override prefix and env file via constructor kwargs (e.g. `LogSettings(_env_prefix="BRIDGE_LOG_", _env_file=".env")`). Fields: `level`, `stderr_enabled`, `file_enabled`, `file_name`, `redirect_logging` + - `logging_initialize(project_name, version, env_file, filter_func)` — removes all existing loguru handlers, then adds stderr/file handlers per settings; embeds `project_name` and `version` in loguru `extra`; installs `InterceptHandler` for stdlib redirect; suppresses psycopg pool noise +- **Location**: `aignostics_foundry_core/log.py` +- **Dependencies**: `loguru>=0.7,<1`, `platformdirs>=4,<5` (mandatory) +- **Import**: `from aignostics_foundry_core.log import logging_initialize, LogSettings, InterceptHandler` + +### models + +**Shared output format enum for CLI and API responses** + +- **Purpose**: Provides a single source of truth for supported output formats across all Foundry components +- **Key Features**: + - `OutputFormat(StrEnum)` — `YAML = "yaml"`, `JSON = "json"`; each member is a plain `str` subtype, usable wherever a string format identifier is expected +- **Location**: `aignostics_foundry_core/models.py` +- **Dependencies**: Python stdlib only (`enum.StrEnum`, Python ≥ 3.11) + +### process + +**Current process introspection** + +- **Purpose**: Provides runtime process metadata for observability, diagnostics, and user-agent generation +- **Key Features**: + - `ParentProcessInfo(BaseModel)` — `name` and `pid` of the parent process + - `ProcessInfo(BaseModel)` — `project_root`, `pid`, `parent`, and `cmdline` of the current process + - `get_process_info()` — returns a `ProcessInfo` for the running process (uses `psutil` lazily) + - `SUBPROCESS_CREATION_FLAGS` — platform-safe creation flags for `subprocess` calls (suppresses console window on Windows) +- **Location**: `aignostics_foundry_core/process.py` +- **Dependencies**: `psutil>=6` (mandatory) + ### console **Themed Rich console for structured terminal output** diff --git a/src/aignostics_foundry_core/api/__init__.py b/src/aignostics_foundry_core/api/__init__.py new file mode 100644 index 0000000..659b6f6 --- /dev/null +++ b/src/aignostics_foundry_core/api/__init__.py @@ -0,0 +1,23 @@ +"""API sub-package for aignostics_foundry_core. + +Sub-modules: +- exceptions: ApiException hierarchy and FastAPI exception handlers +""" + +from .exceptions import ( + AccessDeniedException, + ApiException, + NotFoundException, + api_exception_handler, + unhandled_exception_handler, + validation_exception_handler, +) + +__all__ = [ + "AccessDeniedException", + "ApiException", + "NotFoundException", + "api_exception_handler", + "unhandled_exception_handler", + "validation_exception_handler", +] diff --git a/src/aignostics_foundry_core/api/exceptions.py b/src/aignostics_foundry_core/api/exceptions.py new file mode 100644 index 0000000..685a162 --- /dev/null +++ b/src/aignostics_foundry_core/api/exceptions.py @@ -0,0 +1,137 @@ +"""API exception classes and handlers. + +This module provides: +- ApiException: Base exception for API errors +- NotFoundException: 404 Not Found exception +- AccessDeniedException: 401 Unauthorized exception +- Exception handlers for FastAPI +""" + +from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable + +if TYPE_CHECKING: + from fastapi import Request + from fastapi.responses import JSONResponse + + +@runtime_checkable +class _HasErrors(Protocol): + """Protocol for exceptions that expose a structured errors() method.""" + + def errors(self) -> list[dict[str, Any]]: ... + + +class ApiException(Exception): # noqa: N818 + """Base exception for API errors.""" + + status_code = 500 + message = "Unhandled API exception" + + def __init__(self, message: str | None = None, status_code: int | None = None) -> None: + """Initialize API exception. + + Args: + message: Optional error message override. + status_code: Optional status code override. + """ + if message is not None: + self.message = message + if status_code is not None: + self.status_code = status_code + super().__init__(self.message) + + +class NotFoundException(ApiException): + """Exception for 404 Not Found errors.""" + + status_code = 404 + message = "Not found" + + +class AccessDeniedException(ApiException): + """Exception for 401 Unauthorized errors. + + Note: HTTP 401 indicates that a request lacks valid authentication credentials. + In the HTTP specification, it should have been called "Unauthenticated" to avoid confusion. + For "Access denied" where the user is authenticated but lacks permission, use HTTP 403 Forbidden. + See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401 + """ + + status_code = 401 + message = "Access denied" + + +def api_exception_handler(_: "Request", exc: ApiException) -> "JSONResponse": + """Handle ApiException by returning a standardized JSON error response. + + Args: + _: The FastAPI request object (unused). + exc: The ApiException to handle. + + Returns: + JSONResponse with error details. + """ + from fastapi.responses import JSONResponse # noqa: PLC0415 + + return JSONResponse( + status_code=exc.status_code, + content={ + "success": False, + "error": { + "code": exc.status_code, + "message": exc.message, + }, + }, + ) + + +def unhandled_exception_handler(_: "Request", exc: Exception) -> "JSONResponse": + """Handle unhandled exceptions by logging and returning a generic error. + + Args: + _: The FastAPI request object (unused). + exc: The unhandled exception. + + Returns: + JSONResponse with generic server error. + """ + from fastapi.responses import JSONResponse # noqa: PLC0415 + from loguru import logger # noqa: PLC0415 + + logger.critical(f"Unhandled api exception {exc!r}", extra={"exception": f"{exc!r}"}) + return JSONResponse( + status_code=500, + content={ + "success": False, + "error": {"code": 500, "message": "Server Error"}, + }, + ) + + +def validation_exception_handler(_: "Request", exc: Exception) -> "JSONResponse": + """Handle validation exceptions by returning detailed error information. + + Args: + _: The FastAPI request object (unused). + exc: The validation exception (Pydantic ValidationError or FastAPI RequestValidationError). + + Returns: + JSONResponse with validation error details. + """ + from fastapi.responses import JSONResponse # noqa: PLC0415 + + # Both ValidationError and RequestValidationError have errors() method + if isinstance(exc, _HasErrors): + errors: object = exc.errors() + else: + errors = str(exc) + return JSONResponse( + status_code=422, + content={ + "success": False, + "error": { + "code": 422, + "message": f"Validation error: {errors}", + }, + }, + ) diff --git a/src/aignostics_foundry_core/log.py b/src/aignostics_foundry_core/log.py new file mode 100644 index 0000000..9226195 --- /dev/null +++ b/src/aignostics_foundry_core/log.py @@ -0,0 +1,221 @@ +"""Logging configuration and utilities. + +This module configures loguru as the primary logging framework and redirects +standard library logging to loguru via InterceptHandler. + +Special logger configurations: +- psycopg.pool: Set to WARNING level to suppress verbose INFO logs from + connection pool operations (getconn/putconn). Only non-nominal pool + messages (warnings and errors) are logged. +""" + +import contextlib +import logging +import os +import sys +from pathlib import Path +from typing import TYPE_CHECKING, Literal + +import platformdirs +from loguru import logger +from pydantic import Field, ValidationInfo, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + +if TYPE_CHECKING: + from collections.abc import Callable + + from loguru import Record + +_DEFAULT_PROJECT = "foundry" + + +def _validate_file_name(file_name: str | None) -> str | None: + """Validate the file_name is valid and the file writeable. + + - Checks file_name does not yet exist or is a file + - If not yet existing, checks it can be created + - If existing file, checks file is writeable + + Args: + file_name: The file name of the log file + + Returns: + str | None: The validated file name + + Raises: + ValueError: If file name is not valid or the file not writeable + """ + if file_name is None: + return file_name + + file_path = Path(file_name) + if file_path.exists(): + if file_path.is_dir(): + message = f"File name {file_path.absolute()} exists but is a directory" + raise ValueError(message) + if not os.access(file_path, os.W_OK): + message = f"File {file_path.absolute()} is not writable" + raise ValueError(message) + else: + try: + file_path.touch(exist_ok=True) + except OSError as e: + message = f"File {file_path.absolute()} cannot be created: {e}" + raise ValueError(message) from e + + with contextlib.suppress(OSError): # Parallel execution e.g. in tests can create race + file_path.unlink() + + return file_name + + +class InterceptHandler(logging.Handler): + """Stdlib logging handler that redirects all records to loguru.""" + + def emit(self, record: logging.LogRecord) -> None: # noqa: PLR6301 + """Emit a log record by forwarding it to loguru. + + Args: + record: The stdlib logging record to forward. + """ + # Ignore Sentry-related log messages + if "sentry.io" in record.getMessage(): + return + + try: + level: str | int = logger.level(record.levelname).name + except ValueError: + level = "DEBUG" + + # Patch the record to use the original logger name, function, and line from standard logging + def patcher(record_dict: "Record") -> None: + record_dict["module"] = record.module + record_dict["extra"] = record.__dict__.get("extra", {}) + if record.processName and record.process: + record_dict["process"].id = record.process + record_dict["process"].name = record.processName + if record.threadName and record.thread: + record_dict["thread"].id = record.thread + record_dict["thread"].name = record.threadName + if record.taskName: + record_dict["extra"]["logging.taskName"] = record.taskName + record_dict["name"] = record.name + record_dict["function"] = record.funcName + record_dict["line"] = record.lineno + record_dict["file"].path = record.pathname + record_dict["file"].name = record.filename + + # Don't use depth parameter - let it use the patched function/line info instead + # Use contextlib.suppress to handle Loguru re-entrancy issues in async contexts + # Silently ignore re-entrancy errors - the message was already logged + # by another handler or will be logged when the lock is released + with contextlib.suppress(RuntimeError): + logger.patch(patcher).opt(exception=record.exc_info).log(level, record.getMessage()) + + +class LogSettings(BaseSettings): + """Settings for configuring logging behaviour. + + Reads from environment variables with the ``FOUNDRY_LOG_`` prefix by + default. Callers can supply a project-specific prefix or env file at + instantiation time using Pydantic Settings v2 constructor kwargs:: + + settings = LogSettings(_env_prefix="BRIDGE_LOG_", _env_file=".env") + """ + + model_config = SettingsConfigDict( + env_prefix="FOUNDRY_LOG_", + extra="ignore", + env_file_encoding="utf-8", + ) + + level: Literal["CRITICAL", "ERROR", "WARNING", "SUCCESS", "INFO", "DEBUG", "TRACE"] = Field( + default="INFO", + description="Log level, see https://loguru.readthedocs.io/en/stable/api/logger.html", + ) + stderr_enabled: bool = Field(default=True, description="Enable logging to stderr") + file_enabled: bool = Field(default=False, description="Enable logging to file") + file_name: str = Field( + default=platformdirs.user_data_dir(_DEFAULT_PROJECT) + f"/{_DEFAULT_PROJECT}.log", + description="Name of the log file", + ) + redirect_logging: bool = Field(default=True, description="Redirect standard logging to loguru") + + @field_validator("file_name") + @classmethod + def validate_file_name_when_enabled(cls, file_name: str, info: ValidationInfo) -> str: + """Validate file_name only when file_enabled is True. + + Args: + file_name: The file name to validate. + info: Validation info containing other field values. + + Returns: + str: The validated file name. + """ + if info.data.get("file_enabled", False): + _validate_file_name(file_name) + return file_name + + +def logging_initialize( + project_name: str = _DEFAULT_PROJECT, + version: str = "dev", + env_file: str | None = None, + filter_func: "Callable[[Record], bool] | None" = None, +) -> None: + """Initialize logging configuration. + + Removes all existing loguru handlers, then adds stderr and/or file + handlers based on settings read from environment variables with the + ``{project_name.upper()}_LOG_`` prefix. + + Args: + project_name: Application / project name used as the env-var prefix + (e.g. ``"bridge"`` → ``BRIDGE_LOG_*``) and embedded in log + record extras. + version: Application version string embedded in log record extras. + env_file: Optional path to an ``.env`` file for settings overrides. + filter_func: Optional loguru filter callable; receives a ``Record`` + and returns ``True`` to keep the message, ``False`` to drop it. + """ + settings = LogSettings(_env_prefix=f"{project_name.upper()}_LOG_", _env_file=env_file) # pyright: ignore[reportCallIssue] + + logger.remove() # Remove all default loggers + + logger.configure( + extra={ + "project_name": project_name, + "version": version, + "K_SERVICE": os.getenv("K_SERVICE", ""), + } + ) + + log_format = ( + "{time:YYYY-MM-DD HH:mm:ss} | " + "{process: <6} | " + "{level: <8} | " + "{name}:{function}:{line} | " + "{message} | " + "{extra}" + ) + + if settings.stderr_enabled: + logger.add(sys.stderr, level=settings.level, format=log_format, filter=filter_func) + + if settings.file_enabled: + logger.add(settings.file_name, level=settings.level, format=log_format, filter=filter_func) + + if settings.redirect_logging: + logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True) + + # Suppress psycopg connection logs to prevent Loguru deadlock issues. + # The psycopg library logs during connection setup, and when these logs are + # intercepted by Loguru's InterceptHandler while Loguru's lock is already held, + # it causes a re-entrant lock error ("Could not acquire internal lock"). + # Also suppresses verbose getconn/putconn spam from the pool logger. + logging.getLogger("psycopg").setLevel(logging.WARNING) + logging.getLogger("psycopg.pool").setLevel(logging.WARNING) + logger.trace("psycopg and psycopg.pool loggers set to WARNING to prevent deadlock and reduce noise") + + logger.trace("Logging initialized with level: {}", settings.level) diff --git a/src/aignostics_foundry_core/models.py b/src/aignostics_foundry_core/models.py new file mode 100644 index 0000000..c95d0d4 --- /dev/null +++ b/src/aignostics_foundry_core/models.py @@ -0,0 +1,15 @@ +"""Common models reusable across Foundry components.""" + +from enum import StrEnum + + +class OutputFormat(StrEnum): + """Supported output formats for CLI and API responses. + + Usage: + format = OutputFormat.YAML + print(f"Using {format} format") + """ + + YAML = "yaml" + JSON = "json" diff --git a/src/aignostics_foundry_core/process.py b/src/aignostics_foundry_core/process.py new file mode 100644 index 0000000..9163098 --- /dev/null +++ b/src/aignostics_foundry_core/process.py @@ -0,0 +1,45 @@ +"""Process related utilities.""" + +import subprocess +from pathlib import Path + +import psutil +from pydantic import BaseModel + +SUBPROCESS_CREATION_FLAGS = getattr(subprocess, "CREATE_NO_WINDOW", 0) + + +class ParentProcessInfo(BaseModel): + """Information about a parent process.""" + + name: str | None = None + pid: int | None = None + + +class ProcessInfo(BaseModel): + """Information about the current process.""" + + project_root: str + pid: int + parent: ParentProcessInfo + cmdline: list[str] + + +def get_process_info() -> ProcessInfo: + """Get information about the current process and its parent. + + Returns: + ProcessInfo: Object containing process information. + """ + current_process = psutil.Process() + parent = current_process.parent() + + return ProcessInfo( + project_root=str(Path(__file__).parent.parent.parent), + pid=current_process.pid, + parent=ParentProcessInfo( + name=parent.name() if parent else None, + pid=parent.pid if parent else None, + ), + cmdline=current_process.cmdline(), + ) diff --git a/tests/aignostics_foundry_core/api/__init__.py b/tests/aignostics_foundry_core/api/__init__.py new file mode 100644 index 0000000..6b58dce --- /dev/null +++ b/tests/aignostics_foundry_core/api/__init__.py @@ -0,0 +1 @@ +"""Tests for aignostics_foundry_core.api sub-package.""" diff --git a/tests/aignostics_foundry_core/api/exceptions_test.py b/tests/aignostics_foundry_core/api/exceptions_test.py new file mode 100644 index 0000000..d695fa6 --- /dev/null +++ b/tests/aignostics_foundry_core/api/exceptions_test.py @@ -0,0 +1,147 @@ +"""Tests for aignostics_foundry_core.api.exceptions.""" + +import pytest +from fastapi.responses import JSONResponse + +from aignostics_foundry_core.api.exceptions import ( + AccessDeniedException, + ApiException, + NotFoundException, + api_exception_handler, + unhandled_exception_handler, + validation_exception_handler, +) + +DEFAULT_STATUS_CODE = 500 +NOT_FOUND_STATUS_CODE = 404 +ACCESS_DENIED_STATUS_CODE = 401 +VALIDATION_STATUS_CODE = 422 +SUCCESS_KEY = "success" +ERROR_KEY = "error" + + +class TestApiException: + """Tests for ApiException base class.""" + + @pytest.mark.unit + def test_api_exception_defaults(self) -> None: + """ApiException has status_code 500 and a non-empty default message.""" + exc = ApiException() + assert exc.status_code == DEFAULT_STATUS_CODE + assert exc.message + + @pytest.mark.unit + def test_api_exception_message_override(self) -> None: + """Custom message parameter is reflected in .message and str().""" + custom_error = "custom error" + exc = ApiException(message=custom_error) + assert exc.message == custom_error + assert str(exc) == custom_error + + @pytest.mark.unit + def test_api_exception_status_code_override(self) -> None: + """Custom status_code parameter is reflected in .status_code.""" + exc = ApiException(status_code=418) + assert exc.status_code == 418 + + @pytest.mark.unit + def test_not_found_exception_status_code(self) -> None: + """NotFoundException has status_code 404.""" + exc = NotFoundException() + assert exc.status_code == NOT_FOUND_STATUS_CODE + + @pytest.mark.unit + def test_access_denied_exception_status_code(self) -> None: + """AccessDeniedException has status_code 401.""" + exc = AccessDeniedException() + assert exc.status_code == ACCESS_DENIED_STATUS_CODE + + @pytest.mark.unit + def test_not_found_is_api_exception(self) -> None: + """NotFoundException is a subclass of ApiException.""" + assert isinstance(NotFoundException(), ApiException) + + @pytest.mark.unit + def test_access_denied_is_api_exception(self) -> None: + """AccessDeniedException is a subclass of ApiException.""" + assert isinstance(AccessDeniedException(), ApiException) + + +class TestApiExceptionHandler: + """Tests for the api_exception_handler function.""" + + @pytest.mark.unit + def test_api_exception_handler_returns_structured_json(self) -> None: + """Handler returns JSONResponse with success: False and correct code.""" + exc = ApiException(message="something went wrong", status_code=500) + response: JSONResponse = api_exception_handler(None, exc) # type: ignore[arg-type] + + assert isinstance(response, JSONResponse) + assert response.status_code == DEFAULT_STATUS_CODE + + import json + + body = json.loads(bytes(response.body)) + assert body[SUCCESS_KEY] is False + assert body[ERROR_KEY]["code"] == DEFAULT_STATUS_CODE + assert body[ERROR_KEY]["message"] == "something went wrong" + + @pytest.mark.unit + def test_api_exception_handler_propagates_status_code(self) -> None: + """Handler uses the exception's status_code, not a hard-coded value.""" + exc = NotFoundException() + response: JSONResponse = api_exception_handler(None, exc) # type: ignore[arg-type] + assert response.status_code == NOT_FOUND_STATUS_CODE + + +class TestUnhandledExceptionHandler: + """Tests for the unhandled_exception_handler function.""" + + @pytest.mark.unit + def test_unhandled_exception_handler_returns_500(self) -> None: + """Unhandled exception handler always returns status code 500.""" + response: JSONResponse = unhandled_exception_handler(None, RuntimeError("boom")) # type: ignore[arg-type] + + assert isinstance(response, JSONResponse) + assert response.status_code == DEFAULT_STATUS_CODE + + import json + + body = json.loads(bytes(response.body)) + assert body[SUCCESS_KEY] is False + assert body[ERROR_KEY]["code"] == DEFAULT_STATUS_CODE + + +class TestValidationExceptionHandler: + """Tests for the validation_exception_handler function.""" + + @pytest.mark.unit + def test_validation_exception_handler_returns_422(self) -> None: + """Validation exception handler returns status code 422.""" + # Use FastAPI's RequestValidationError to exercise the errors() path + from fastapi.exceptions import RequestValidationError + from pydantic_core import InitErrorDetails, PydanticCustomError + + error = PydanticCustomError("value_error", "bad value") + exc = RequestValidationError(errors=[InitErrorDetails(type=error, loc=("field",), input="bad")]) + response: JSONResponse = validation_exception_handler(None, exc) # type: ignore[arg-type] + + assert isinstance(response, JSONResponse) + assert response.status_code == VALIDATION_STATUS_CODE + + import json + + body = json.loads(bytes(response.body)) + assert body[SUCCESS_KEY] is False + assert body[ERROR_KEY]["code"] == VALIDATION_STATUS_CODE + + @pytest.mark.unit + def test_validation_exception_handler_plain_exception_returns_422(self) -> None: + """Validation handler falls back to str(exc) when errors() is absent.""" + response: JSONResponse = validation_exception_handler(None, ValueError("no errors method")) # type: ignore[arg-type] + assert response.status_code == VALIDATION_STATUS_CODE + + import json + + body = json.loads(bytes(response.body)) + assert body[SUCCESS_KEY] is False diff --git a/tests/aignostics_foundry_core/log_test.py b/tests/aignostics_foundry_core/log_test.py new file mode 100644 index 0000000..a4b2453 --- /dev/null +++ b/tests/aignostics_foundry_core/log_test.py @@ -0,0 +1,114 @@ +"""Tests for aignostics_foundry_core.log.""" + +import logging as stdlib_logging +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from aignostics_foundry_core.log import InterceptHandler, LogSettings, logging_initialize + +_PROJECT = "testfoundry" +_VERSION = "0.0.1" +_MARKER_MESSAGE = "log_test_unique_marker_4f2a" +_STDLIB_MESSAGE = "stdlib_redirect_unique_marker_9b3c" +_FILE_HANDLER_MARKER = "file_handler_unique_marker_7e9b" +_FILTER_MARKER = "filter_func_unique_marker_3d5f" +_REPLACE_MARKER = "replace_handlers_unique_marker_8c1a" +_SENTRY_MARKER = "sentry.io unique drop marker 2f4e" + + +@pytest.mark.sequential +@pytest.mark.unit +class TestLoggingInitialize: + """Behavioural tests for logging_initialize().""" + + def test_logging_initialize_adds_stderr_handler(self, capsys: pytest.CaptureFixture[str]) -> None: + """After initialization with defaults, a log message appears on stderr.""" + logging_initialize(_PROJECT, _VERSION) + from loguru import logger + + logger.info(_MARKER_MESSAGE) + captured = capsys.readouterr() + assert _MARKER_MESSAGE in captured.err + + def test_logging_initialize_skips_stderr_when_disabled( + self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + ) -> None: + """When stderr is disabled via env var, no output is written to stderr.""" + monkeypatch.setenv(f"{_PROJECT.upper()}_LOG_STDERR_ENABLED", "false") + logging_initialize(_PROJECT, _VERSION) + from loguru import logger + + logger.info(_MARKER_MESSAGE) + captured = capsys.readouterr() + assert _MARKER_MESSAGE not in captured.err + + def test_intercept_handler_redirects_stdlib_log(self, capsys: pytest.CaptureFixture[str]) -> None: + """After initialization, stdlib logging messages are forwarded to loguru (and thus stderr).""" + logging_initialize(_PROJECT, _VERSION) + stdlib_logging.getLogger("test.intercept").warning(_STDLIB_MESSAGE) + captured = capsys.readouterr() + assert _STDLIB_MESSAGE in captured.err + + def test_logging_initialize_file_handler_writes_to_file( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + """File handler writes log output to the configured file when file_enabled.""" + log_file = tmp_path / "test.log" + monkeypatch.setenv(f"{_PROJECT.upper()}_LOG_FILE_ENABLED", "true") + monkeypatch.setenv(f"{_PROJECT.upper()}_LOG_FILE_NAME", str(log_file)) + logging_initialize(_PROJECT, _VERSION) + from loguru import logger + + logger.info(_FILE_HANDLER_MARKER) + logger.remove() # Close/flush the file sink before reading + assert _FILE_HANDLER_MARKER in log_file.read_text() + + def test_logging_initialize_filter_func_is_applied(self, capsys: pytest.CaptureFixture[str]) -> None: + """A filter_func returning False suppresses all output from the handler.""" + logging_initialize(_PROJECT, _VERSION, filter_func=lambda _: False) + from loguru import logger + + logger.info(_FILTER_MARKER) + assert _FILTER_MARKER not in capsys.readouterr().err + + def test_logging_initialize_replaces_handlers_on_repeated_calls(self, capsys: pytest.CaptureFixture[str]) -> None: + """Repeated calls replace existing handlers rather than accumulating them.""" + logging_initialize(_PROJECT, _VERSION) + logging_initialize(_PROJECT, _VERSION) + capsys.readouterr() # Drain any buffered output from initialization + from loguru import logger + + logger.info(_REPLACE_MARKER) + assert capsys.readouterr().err.count(_REPLACE_MARKER) == 1 + + def test_intercept_handler_drops_sentry_messages(self, capsys: pytest.CaptureFixture[str]) -> None: + """InterceptHandler silently drops stdlib log messages containing 'sentry.io'.""" + logging_initialize(_PROJECT, _VERSION) + stdlib_logging.getLogger("test.sentry").warning(_SENTRY_MARKER) + assert _SENTRY_MARKER not in capsys.readouterr().err + + +@pytest.mark.unit +class TestLogSettings: + """Behavioural tests for LogSettings validation.""" + + def test_log_settings_file_name_validation_rejects_directory(self, tmp_path: Path) -> None: + """Passing an existing directory as file_name raises ValidationError when file_enabled.""" + with pytest.raises(ValidationError): + LogSettings(file_enabled=True, file_name=str(tmp_path)) # pyright: ignore[reportCallIssue] + + def test_log_settings_file_validation_skipped_when_file_disabled(self, tmp_path: Path) -> None: + """Directory path as file_name is accepted without error when file_enabled is False.""" + settings = LogSettings(file_enabled=False, file_name=str(tmp_path)) # pyright: ignore[reportCallIssue] + assert settings.file_enabled is False + + +@pytest.mark.unit +class TestInterceptHandler: + """Behavioural tests for InterceptHandler.""" + + def test_intercept_handler_is_logging_handler(self) -> None: + """InterceptHandler is a subclass of stdlib logging.Handler.""" + assert issubclass(InterceptHandler, stdlib_logging.Handler) diff --git a/tests/aignostics_foundry_core/models_test.py b/tests/aignostics_foundry_core/models_test.py new file mode 100644 index 0000000..f9d6700 --- /dev/null +++ b/tests/aignostics_foundry_core/models_test.py @@ -0,0 +1,26 @@ +"""Tests for OutputFormat enum.""" + +import pytest + +from aignostics_foundry_core.models import OutputFormat + + +class TestOutputFormat: + """Tests for the OutputFormat StrEnum.""" + + @pytest.mark.unit + def test_output_format_yaml_value(self) -> None: + """OutputFormat.YAML has the string value 'yaml'.""" + assert OutputFormat.YAML == "yaml" + + @pytest.mark.unit + def test_output_format_json_value(self) -> None: + """OutputFormat.JSON has the string value 'json'.""" + assert OutputFormat.JSON == "json" + + @pytest.mark.unit + def test_output_format_is_str(self) -> None: + """Every OutputFormat member is usable as a plain str.""" + for member in OutputFormat: + assert isinstance(member, str) + assert member == member.value diff --git a/tests/aignostics_foundry_core/process_test.py b/tests/aignostics_foundry_core/process_test.py new file mode 100644 index 0000000..24e373e --- /dev/null +++ b/tests/aignostics_foundry_core/process_test.py @@ -0,0 +1,33 @@ +"""Tests for aignostics_foundry_core.process module.""" + +import os +from pathlib import Path + +import pytest + +from aignostics_foundry_core.process import get_process_info + + +@pytest.mark.unit +def test_get_process_info_returns_current_process() -> None: + """get_process_info() returns info for the currently running process.""" + info = get_process_info() + assert info.pid == os.getpid() + + +@pytest.mark.unit +def test_process_info_has_parent() -> None: + """ProcessInfo.parent has a non-empty name and positive pid.""" + info = get_process_info() + assert info.parent.name is not None + assert len(info.parent.name) > 0 + assert info.parent.pid is not None + assert info.parent.pid > 0 + + +@pytest.mark.unit +def test_process_info_project_root_is_directory() -> None: + """ProcessInfo.project_root is an existing directory.""" + info = get_process_info() + project_root = Path(info.project_root) + assert project_root.is_dir() diff --git a/uv.lock b/uv.lock index 83b44d2..0853426 100644 --- a/uv.lock +++ b/uv.lock @@ -10,6 +10,10 @@ name = "aignostics-foundry-core" version = "0.0.0" source = { editable = "." } dependencies = [ + { name = "fastapi" }, + { name = "loguru" }, + { name = "platformdirs" }, + { name = "psutil" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "rich" }, @@ -46,6 +50,10 @@ dev = [ [package.metadata] requires-dist = [ + { name = "fastapi", specifier = ">=0.110,<1" }, + { name = "loguru", specifier = ">=0.7,<1" }, + { name = "platformdirs", specifier = ">=4,<5" }, + { name = "psutil", specifier = ">=6" }, { name = "pydantic", specifier = ">=2,<3" }, { name = "pydantic-settings", specifier = ">=2,<3" }, { name = "rich", specifier = ">=14,<15" }, @@ -80,6 +88,15 @@ dev = [ { name = "watchdog", specifier = ">=6.0.0" }, ] +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -89,6 +106,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + [[package]] name = "argcomplete" version = "3.6.3" @@ -561,6 +591,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, ] +[[package]] +name = "fastapi" +version = "0.135.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/73/5903c4b13beae98618d64eb9870c3fac4f605523dd0312ca5c80dadbd5b9/fastapi-0.135.2.tar.gz", hash = "sha256:88a832095359755527b7f63bb4c6bc9edb8329a026189eed83d6c1afcf419d56", size = 395833, upload-time = "2026-03-23T14:12:41.697Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/ea/18f6d0457f9efb2fc6fa594857f92810cadb03024975726db6546b3d6fcf/fastapi-0.135.2-py3-none-any.whl", hash = "sha256:0af0447d541867e8db2a6a25c23a8c4bd80e2394ac5529bd87501bbb9e240ca5", size = 117407, upload-time = "2026-03-23T14:12:43.284Z" }, +] + [[package]] name = "filelock" version = "3.25.2" @@ -709,6 +755,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/40/791891d4c0c4dab4c5e187c17261cedc26285fd41541577f900470a45a4d/license_expression-30.4.4-py3-none-any.whl", hash = "sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4", size = 120615, upload-time = "2025-07-22T11:13:31.217Z" }, ] +[[package]] +name = "loguru" +version = "0.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, +] + [[package]] name = "lxml" version = "6.0.2" @@ -1149,30 +1208,17 @@ wheels = [ [[package]] name = "psutil" -version = "7.2.2" +version = "6.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, - { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, - { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, - { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, - { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, - { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, - { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, - { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, - { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, - { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, - { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, - { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, - { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, - { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, - { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, - { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, - { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, - { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, - { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, - { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/1f/5a/07871137bb752428aa4b659f910b399ba6f291156bdea939be3e96cae7cb/psutil-6.1.1.tar.gz", hash = "sha256:cf8496728c18f2d0b45198f06895be52f36611711746b7f30c464b422b50e2f5", size = 508502, upload-time = "2024-12-19T18:21:20.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/99/ca79d302be46f7bdd8321089762dd4476ee725fce16fc2b2e1dbba8cac17/psutil-6.1.1-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:fc0ed7fe2231a444fc219b9c42d0376e0a9a1a72f16c5cfa0f68d19f1a0663e8", size = 247511, upload-time = "2024-12-19T18:21:45.163Z" }, + { url = "https://files.pythonhosted.org/packages/0b/6b/73dbde0dd38f3782905d4587049b9be64d76671042fdcaf60e2430c6796d/psutil-6.1.1-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:0bdd4eab935276290ad3cb718e9809412895ca6b5b334f5a9111ee6d9aff9377", size = 248985, upload-time = "2024-12-19T18:21:49.254Z" }, + { url = "https://files.pythonhosted.org/packages/17/38/c319d31a1d3f88c5b79c68b3116c129e5133f1822157dd6da34043e32ed6/psutil-6.1.1-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b6e06c20c05fe95a3d7302d74e7097756d4ba1247975ad6905441ae1b5b66003", size = 284488, upload-time = "2024-12-19T18:21:51.638Z" }, + { url = "https://files.pythonhosted.org/packages/9c/39/0f88a830a1c8a3aba27fededc642da37613c57cbff143412e3536f89784f/psutil-6.1.1-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97f7cb9921fbec4904f522d972f0c0e1f4fabbdd4e0287813b21215074a0f160", size = 287477, upload-time = "2024-12-19T18:21:55.306Z" }, + { url = "https://files.pythonhosted.org/packages/47/da/99f4345d4ddf2845cb5b5bd0d93d554e84542d116934fde07a0c50bd4e9f/psutil-6.1.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:33431e84fee02bc84ea36d9e2c4a6d395d479c9dd9bba2376c1f6ee8f3a4e0b3", size = 289017, upload-time = "2024-12-19T18:21:57.875Z" }, + { url = "https://files.pythonhosted.org/packages/38/53/bd755c2896f4461fd4f36fa6a6dcb66a88a9e4b9fd4e5b66a77cf9d4a584/psutil-6.1.1-cp37-abi3-win32.whl", hash = "sha256:eaa912e0b11848c4d9279a93d7e2783df352b082f40111e078388701fd479e53", size = 250602, upload-time = "2024-12-19T18:22:08.808Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d7/7831438e6c3ebbfa6e01a927127a6cb42ad3ab844247f3c5b96bea25d73d/psutil-6.1.1-cp37-abi3-win_amd64.whl", hash = "sha256:f35cfccb065fff93529d2afb4a2e89e363fe63ca1e4a5da22b603a85833c2649", size = 254444, upload-time = "2024-12-19T18:22:11.335Z" }, ] [[package]] @@ -1870,6 +1916,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, ] +[[package]] +name = "starlette" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, +] + [[package]] name = "tabledata" version = "1.3.4" @@ -2121,6 +2180,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e2/cc/e097523dd85c9cf5d354f78310927f1656c422bd7b2613b2db3e3f9a0f2c/webcolors-25.10.0-py3-none-any.whl", hash = "sha256:032c727334856fc0b968f63daa252a1ac93d33db2f5267756623c210e57a4f1d", size = 14905, upload-time = "2025-10-31T07:51:01.778Z" }, ] +[[package]] +name = "win32-setctime" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, +] + [[package]] name = "wrapt" version = "2.1.2"