From 7b117e5e9572d070223944b367f045972499e621 Mon Sep 17 00:00:00 2001 From: Dimosthenis Schizas Date: Wed, 15 Apr 2026 12:30:53 +0300 Subject: [PATCH 1/3] Migrate project to Pydantic v2 --- pyproject.toml | 5 +- src/core/config.py | 211 ++++++++++++++++++++--------------------- src/core/constants.py | 46 ++++----- src/webhooks/server.py | 2 +- src/webhooks/types.py | 8 +- uv.lock | 119 +++++++++++++++++++---- 6 files changed, 236 insertions(+), 155 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 99fe3b8..abba6e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,8 @@ dependencies = [ "aiodns>=3.0.0,<4.0.0", "arrow>=1.2.3,<2.0.0", "py-cord>=2.4.1,<3.0.0", - "pydantic[dotenv]>=1.10.7,<2.0.0", + "pydantic>=2.13.0,<3.0.0", + "pydantic-settings>=2.11.0,<3.0.0", "taskipy>=1.10.4,<2.0.0", "sqlalchemy[asyncio]>=2.0.9,<3.0.0", "bcrypt>=4.0.1,<5.0.0", @@ -34,6 +35,7 @@ dependencies = [ "audioop-lts>=0.2.2,<0.3.0", "aiohttp>=3.13.4", "urllib3>=2.6.3", + "python-dotenv>=1.0,<2.0.0", ] [dependency-groups] @@ -52,7 +54,6 @@ dev = [ "pre-commit>=3.2,<5.0.0", "pytest>=7.1.2,<8.0.0", "pytest-asyncio>=0.21,<0.22.0", - "python-dotenv>=1.0,<2.0.0", "ipython>=8.12.0,<9.0.0", "ipdb>=0.13.13,<0.14.0", "aioresponses>=0.7.4,<0.8.0", diff --git a/src/core/config.py b/src/core/config.py index 8c69337..4748098 100644 --- a/src/core/config.py +++ b/src/core/config.py @@ -1,9 +1,9 @@ import os import re from pathlib import Path -from typing import Optional -from pydantic import BaseSettings, validator +from pydantic import Field, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict # AcademyCertificates is removed; cert mappings are now in the dynamic_role DB table. @@ -11,29 +11,28 @@ class Bot(BaseSettings): """The API settings.""" + model_config = SettingsConfigDict(env_file=".env", env_prefix="BOT_", extra="ignore") + NAME: str = "Hackster" TOKEN: str ENVIRONMENT: str = "development" - @validator("TOKEN") - def check_token_format(cls, v: str) -> str: + @field_validator("TOKEN") + @classmethod + def check_token_format(cls, value: str) -> str: """Validate discord tokens format.""" pattern = re.compile(r".{26}\..{6}\..{38}") assert pattern.fullmatch( - v + value ), f"Discord token must follow >> {pattern.pattern} << pattern." - return v - - class Config: - """The Pydantic settings configuration.""" - - env_file = ".env" - env_prefix = "BOT_" + return value class Database(BaseSettings): """The database settings.""" + model_config = SettingsConfigDict(env_file=".env", env_prefix="MYSQL_", extra="ignore") + HOST: str = "localhost" PORT: int = 3306 DATABASE: str = "bot" @@ -49,16 +48,12 @@ def assemble_db_connection(self) -> str: ) return connection_string - class Config: - """The Pydantic settings configuration.""" - - env_file = ".env" - env_prefix = "MYSQL_" - class Channels(BaseSettings): """Channel ids.""" + model_config = SettingsConfigDict(env_file=".env", env_prefix="CHANNEL_", extra="ignore") + DEVLOG: int = 0 SR_MOD: int VERIFY_LOGS: int @@ -68,23 +63,24 @@ class Channels(BaseSettings): UNVERIFIED_BOT_COMMANDS: int = 0 HOW_TO_VERIFY: int = 0 - @validator( - "DEVLOG", "SR_MOD", "VERIFY_LOGS", "BOT_COMMANDS", "SPOILER", "BOT_LOGS", - "UNVERIFIED_BOT_COMMANDS", "HOW_TO_VERIFY", + @field_validator( + "DEVLOG", + "SR_MOD", + "VERIFY_LOGS", + "BOT_COMMANDS", + "SPOILER", + "BOT_LOGS", + "UNVERIFIED_BOT_COMMANDS", + "HOW_TO_VERIFY", ) - def check_ids_format(cls, v: list[int]) -> list[int]: + @classmethod + def check_ids_format(cls, value: int) -> int: """Validate discord ids format.""" - if not v: - return v - - assert len(str(v)) > 17, "Discord ids must have a length of 19." - return v - - class Config: - """The Pydantic settings configuration.""" + if not value: + return value - env_file = ".env" - env_prefix = "CHANNEL_" + assert len(str(value)) > 17, "Discord ids must have a length of 19." + return value class Roles(BaseSettings): @@ -93,7 +89,10 @@ class Roles(BaseSettings): Core roles (required): used in decorators at import time for permission checks. Dynamic roles (optional): managed via DB, kept here as fallback during transition. """ - # ── Core roles (required, used in decorators) ──────────────────── + + model_config = SettingsConfigDict(env_file=".env", env_prefix="ROLE_", extra="ignore") + + # Core roles (required, used in decorators) VERIFIED: int COMMUNITY_MANAGER: int COMMUNITY_TEAM: int @@ -106,78 +105,79 @@ class Roles(BaseSettings): MUTED: int ACADEMY_USER: int - # ── Dynamic roles (optional, DB-backed, env var fallback) ──────── - # Ranks - OMNISCIENT: Optional[int] = None - GURU: Optional[int] = None - ELITE_HACKER: Optional[int] = None - PRO_HACKER: Optional[int] = None - HACKER: Optional[int] = None - SCRIPT_KIDDIE: Optional[int] = None - NOOB: Optional[int] = None - # Subscriptions - VIP: Optional[int] = None - VIP_PLUS: Optional[int] = None - SILVER_ANNUAL: Optional[int] = None - GOLD_ANNUAL: Optional[int] = None - # Content Creation - CHALLENGE_CREATOR: Optional[int] = None - BOX_CREATOR: Optional[int] = None - SHERLOCK_CREATOR: Optional[int] = None - # Positions - RANK_ONE: Optional[int] = None - RANK_TEN: Optional[int] = None - # Season Tiers - SEASON_HOLO: Optional[int] = None - SEASON_PLATINUM: Optional[int] = None - SEASON_RUBY: Optional[int] = None - SEASON_SILVER: Optional[int] = None - SEASON_BRONZE: Optional[int] = None - # Academy Certs - ACADEMY_CWES: Optional[int] = None - ACADEMY_CPTS: Optional[int] = None - ACADEMY_CDSA: Optional[int] = None - ACADEMY_CWEE: Optional[int] = None - ACADEMY_CAPE: Optional[int] = None - ACADEMY_CJCA: Optional[int] = None - ACADEMY_CWPE: Optional[int] = None - # Joinable roles - UNICTF2022: Optional[int] = None - BIZCTF2022: Optional[int] = None - NOAH_GANG: Optional[int] = None - BUDDY_GANG: Optional[int] = None - RED_TEAM: Optional[int] = None - BLUE_TEAM: Optional[int] = None - - @validator("VERIFIED", "COMMUNITY_MANAGER", "COMMUNITY_TEAM", "ADMINISTRATOR", - "SR_MODERATOR", "MODERATOR", "JR_MODERATOR", "HTB_STAFF", "HTB_SUPPORT", - "MUTED", "ACADEMY_USER", pre=True, each_item=True) + # Dynamic roles (optional, DB-backed, env var fallback) + OMNISCIENT: int | None = None + GURU: int | None = None + ELITE_HACKER: int | None = None + PRO_HACKER: int | None = None + HACKER: int | None = None + SCRIPT_KIDDIE: int | None = None + NOOB: int | None = None + VIP: int | None = None + VIP_PLUS: int | None = None + SILVER_ANNUAL: int | None = None + GOLD_ANNUAL: int | None = None + CHALLENGE_CREATOR: int | None = None + BOX_CREATOR: int | None = None + SHERLOCK_CREATOR: int | None = None + RANK_ONE: int | None = None + RANK_TEN: int | None = None + SEASON_HOLO: int | None = None + SEASON_PLATINUM: int | None = None + SEASON_RUBY: int | None = None + SEASON_SILVER: int | None = None + SEASON_BRONZE: int | None = None + ACADEMY_CWES: int | None = None + ACADEMY_CPTS: int | None = None + ACADEMY_CDSA: int | None = None + ACADEMY_CWEE: int | None = None + ACADEMY_CAPE: int | None = None + ACADEMY_CJCA: int | None = None + ACADEMY_CWPE: int | None = None + UNICTF2022: int | None = None + BIZCTF2022: int | None = None + NOAH_GANG: int | None = None + BUDDY_GANG: int | None = None + RED_TEAM: int | None = None + BLUE_TEAM: int | None = None + + @field_validator( + "VERIFIED", + "COMMUNITY_MANAGER", + "COMMUNITY_TEAM", + "ADMINISTRATOR", + "SR_MODERATOR", + "MODERATOR", + "JR_MODERATOR", + "HTB_STAFF", + "HTB_SUPPORT", + "MUTED", + "ACADEMY_USER", + mode="before", + ) + @classmethod def check_length(cls, value: str | int) -> str | int: value_str = str(value) if not 17 <= len(value_str) <= 20: raise ValueError("Each role ID must be between 18 & 19 characters long") return value - class Config: - """The Pydantic settings configuration.""" - - env_file = ".env" - env_prefix = "ROLE_" - class Global(BaseSettings): """The app settings.""" - bot: Bot = None - database: Database = None - channels: Channels = None - roles: Roles = None + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + + bot: Bot | None = None + database: Database | None = None + channels: Channels | None = None + roles: Roles | None = None HTB_API_KEY: str - role_groups: dict[str, list[int | str]] = {} + role_groups: dict[str, list[int | str]] = Field(default_factory=dict) guild_ids: list[int] - dev_guild_ids: list[int] = [] + dev_guild_ids: list[int] = Field(default_factory=list) SENTRY_DSN: str | None = None LOG_LEVEL: str | int = "INFO" @@ -195,22 +195,26 @@ class Global(BaseSettings): SLACK_FEEDBACK_WEBHOOK: str = "" JIRA_WEBHOOK: str = "" - ROOT: Path = None + ROOT: Path | None = None VERSION: str = "unknown" SEASON_ID: int = 0 - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - @validator("guild_ids", "dev_guild_ids", pre=True, each_item=True) - def check_ids_format(cls, v: int | str) -> int: + @field_validator("guild_ids", "dev_guild_ids", mode="before") + @classmethod + def check_ids_format(cls, value: list[int | str] | int | str) -> list[int | str] | int: """Validate Discord snowflakes and accept string-form IDs from env sources.""" - if isinstance(v, int): - discord_id = v - elif isinstance(v, str) and v.isdigit(): - discord_id = int(v) + if isinstance(value, list): + return [cls._validate_discord_id(item) for item in value] + return cls._validate_discord_id(value) + + @staticmethod + def _validate_discord_id(value: int | str) -> int: + if isinstance(value, int): + discord_id = value + elif isinstance(value, str) and value.isdigit(): + discord_id = int(value) else: raise ValueError("Discord IDs must be base-10 integer snowflakes.") @@ -222,11 +226,6 @@ def check_ids_format(cls, v: int | str) -> int: # Helper methods (get_post_or_rank, get_season, get_cert, get_academy_cert_role) # have been moved to RoleManager (src/services/role_manager.py). - class Config: - """The Pydantic settings configuration.""" - - env_file = ".env" - def load_settings(env_file: str | None = None): global_settings = Global(_env_file=env_file) diff --git a/src/core/constants.py b/src/core/constants.py index d5551eb..3e8c0bf 100644 --- a/src/core/constants.py +++ b/src/core/constants.py @@ -4,39 +4,39 @@ class Colours(BaseModel): """Colour codes.""" - blue = 0x0279FD - bright_green = 0x01D277 - dark_green = 0x1F8B4C - gold = 0xE6C200 - grass_green = 0x66FF00 - orange = 0xE67E22 - pink = 0xCF84E0 - purple = 0xB734EB - python_blue = 0x4B8BBE - python_yellow = 0xFFD43B - red = 0xFF0000 - soft_green = 0x68C290 - soft_orange = 0xF9CB54 - soft_red = 0xCD6D6D - yellow = 0xF8E500 + blue: int = 0x0279FD + bright_green: int = 0x01D277 + dark_green: int = 0x1F8B4C + gold: int = 0xE6C200 + grass_green: int = 0x66FF00 + orange: int = 0xE67E22 + pink: int = 0xCF84E0 + purple: int = 0xB734EB + python_blue: int = 0x4B8BBE + python_yellow: int = 0xFFD43B + red: int = 0xFF0000 + soft_green: int = 0x68C290 + soft_orange: int = 0xF9CB54 + soft_red: int = 0xCD6D6D + yellow: int = 0xF8E500 class Emojis(BaseModel): """Emoji codes.""" - arrow_left = "\u2B05" # ⬅ - arrow_right = "\u27A1" # ➡ - lock = "\U0001F512" # 🔒 - partying_face = "\U0001F973" # 🥳 - track_next = "\u23ED" # ⏭ - track_previous = "\u23EE" # ⏮ + arrow_left: str = "\u2B05" # ⬅ + arrow_right: str = "\u27A1" # ➡ + lock: str = "\U0001F512" # 🔒 + partying_face: str = "\U0001F973" # 🥳 + track_next: str = "\u23ED" # ⏭ + track_previous: str = "\u23EE" # ⏮ class Pagination(BaseModel): """Pagination default settings.""" - max_size = 500 - timeout = 300 # In seconds + max_size: int = 500 + timeout: int = 300 # In seconds class Constants(BaseModel): diff --git a/src/webhooks/server.py b/src/webhooks/server.py index 1c4d9e3..7dfd78e 100644 --- a/src/webhooks/server.py +++ b/src/webhooks/server.py @@ -66,7 +66,7 @@ async def webhook_handler(request: Request) -> Dict[str, Any]: raise HTTPException(status_code=401, detail="Unauthorized") try: - body = WebhookBody.validate(json.loads(body)) + body = WebhookBody.model_validate(json.loads(body)) except ValidationError as e: logger.warning("Invalid webhook request: %s", e.errors()) raise HTTPException(status_code=400, detail="Invalid webhook request body") diff --git a/src/webhooks/types.py b/src/webhooks/types.py index 2fabb3b..e260412 100644 --- a/src/webhooks/types.py +++ b/src/webhooks/types.py @@ -1,6 +1,6 @@ from enum import Enum -from pydantic import BaseModel, ConfigDict, Extra, Field +from pydantic import BaseModel, ConfigDict, Field class WebhookEvent(Enum): @@ -25,9 +25,9 @@ class Platform(Enum): class WebhookBody(BaseModel): - model_config = ConfigDict(extra=Extra.allow) + model_config = ConfigDict(extra="allow") platform: Platform event: WebhookEvent - properties: dict = Field(default_factory=dict) - traits: dict = Field(default_factory=dict) + properties: dict[str, object] = Field(default_factory=dict) + traits: dict[str, object] = Field(default_factory=dict) diff --git a/uv.lock b/uv.lock index 13f29f1..d8ddb3d 100644 --- a/uv.lock +++ b/uv.lock @@ -143,6 +143,15 @@ 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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +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.12.1" @@ -755,8 +764,10 @@ dependencies = [ { name = "hypercorn" }, { name = "prometheus-client" }, { name = "py-cord" }, - { name = "pydantic", extra = ["dotenv"] }, + { name = "pydantic" }, + { name = "pydantic-settings" }, { name = "pymysql" }, + { name = "python-dotenv" }, { name = "sentry-sdk", extra = ["sqlalchemy"] }, { name = "slack-sdk" }, { name = "sqlalchemy", extra = ["asyncio"] }, @@ -785,7 +796,6 @@ dev = [ { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-mock" }, - { name = "python-dotenv" }, ] [package.metadata] @@ -801,8 +811,10 @@ requires-dist = [ { name = "hypercorn", specifier = ">=0.17.3,<0.18.0" }, { name = "prometheus-client", specifier = ">=0.16.0,<1.0.0" }, { name = "py-cord", specifier = ">=2.4.1,<3.0.0" }, - { name = "pydantic", extras = ["dotenv"], specifier = ">=1.10.7,<2.0.0" }, + { name = "pydantic", specifier = ">=2.13.0,<3.0.0" }, + { name = "pydantic-settings", specifier = ">=2.11.0,<3.0.0" }, { name = "pymysql", specifier = ">=1.1.1,<2.0.0" }, + { name = "python-dotenv", specifier = ">=1.0,<2.0.0" }, { name = "sentry-sdk", extras = ["sqlalchemy"], specifier = ">=2.8.0,<3.0.0" }, { name = "slack-sdk", specifier = ">=3.27.1,<4.0.0" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.9,<3.0.0" }, @@ -831,7 +843,6 @@ dev = [ { name = "pytest", specifier = ">=7.1.2,<8.0.0" }, { name = "pytest-asyncio", specifier = ">=0.21,<0.22.0" }, { name = "pytest-mock", specifier = ">=3.10.0,<4.0.0" }, - { name = "python-dotenv", specifier = ">=1.0,<2.0.0" }, ] [[package]] @@ -1423,29 +1434,87 @@ wheels = [ [[package]] name = "pydantic" -version = "1.10.26" +version = "2.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, { name = "typing-extensions" }, + { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7b/da/fd89f987a376c807cd81ea0eff4589aade783bbb702637b4734ef2c743a2/pydantic-1.10.26.tar.gz", hash = "sha256:8c6aa39b494c5af092e690127c283d84f363ac36017106a9e66cb33a22ac412e", size = 357906, upload-time = "2025-12-18T15:47:46.557Z" } +sdist = { url = "https://files.pythonhosted.org/packages/84/6b/69fd5c7194b21ebde0f8637e2a4ddc766ada29d472bfa6a5ca533d79549a/pydantic-2.13.0.tar.gz", hash = "sha256:b89b575b6e670ebf6e7448c01b41b244f471edd276cd0b6fe02e7e7aca320070", size = 843468, upload-time = "2026-04-13T10:51:35.571Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/28/b9/17a5a5a421c23ac27486b977724a42c9d5f8b7f0f4aab054251066223900/pydantic-1.10.26-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1ae7913bb40a96c87e3d3f6fe4e918ef53bf181583de4e71824360a9b11aef1c", size = 2494599, upload-time = "2025-12-18T15:47:00.209Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8e/6e3bd4241076cf227b443d7577245dd5d181ecf40b3182fcb908bc8c197d/pydantic-1.10.26-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8154c13f58d4de5d3a856bb6c909c7370f41fb876a5952a503af6b975265f4ba", size = 2254391, upload-time = "2025-12-18T15:47:02.268Z" }, - { url = "https://files.pythonhosted.org/packages/a8/30/a1c4092eda2145ecbead6c92db489b223e101e1ba0da82576d0cf73dd422/pydantic-1.10.26-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f8af0507bf6118b054a9765fb2e402f18a8b70c964f420d95b525eb711122d62", size = 2609445, upload-time = "2025-12-18T15:47:04.909Z" }, - { url = "https://files.pythonhosted.org/packages/3a/2a/0491f1729ee4b7b6bc859ec22f69752f0c09bee1b66ac6f5f701136f34c3/pydantic-1.10.26-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dcb5a7318fb43189fde6af6f21ac7149c4bcbcfffc54bc87b5becddc46084847", size = 2732124, upload-time = "2025-12-18T15:47:07.464Z" }, - { url = "https://files.pythonhosted.org/packages/2a/56/b59f3b2f84e1df2b04ae768a1bb04d9f0288ff71b67cdcbb07683757b2c0/pydantic-1.10.26-cp313-cp313-win_amd64.whl", hash = "sha256:71cde228bc0600cf8619f0ee62db050d1880dcc477eba0e90b23011b4ee0f314", size = 1939888, upload-time = "2025-12-18T15:47:09.618Z" }, - { url = "https://files.pythonhosted.org/packages/d2/8b/0c3dc02d4b97790b0f199bf933f677c14e7be4a8d21307c5f2daae06aa41/pydantic-1.10.26-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6b40730cc81d53d515dc0b8bb5c9b43fadb9bed46de4a3c03bd95e8571616dba", size = 2502689, upload-time = "2025-12-18T15:47:12.308Z" }, - { url = "https://files.pythonhosted.org/packages/d4/9d/d31aeea45542b2ae4b09ecba92b88aaba696b801c31919811aa979a1242d/pydantic-1.10.26-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c3bbb9c0eecdf599e4db9b372fa9cc55be12e80a0d9c6d307950a39050cb0e37", size = 2269494, upload-time = "2025-12-18T15:47:14.53Z" }, - { url = "https://files.pythonhosted.org/packages/78/c1/3a4d069593283ca4dd0006039ba33644e21e432cddc09da706ac50441610/pydantic-1.10.26-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc2e3fe7bc4993626ef6b6fa855defafa1d6f8996aa1caef2deb83c5ac4d043a", size = 2620047, upload-time = "2025-12-18T15:47:17.089Z" }, - { url = "https://files.pythonhosted.org/packages/e0/0e/340c3d29197d99c15ab04093d43bb9c9d0fd17c2a34b80cb9d36ed732b09/pydantic-1.10.26-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:36d9e46b588aaeb1dcd2409fa4c467fe0b331f3cc9f227b03a7a00643704e962", size = 2747625, upload-time = "2025-12-18T15:47:19.21Z" }, - { url = "https://files.pythonhosted.org/packages/1e/58/f12ab3727339b172c830b32151919456b67787cdfe8808b2568b322fb15c/pydantic-1.10.26-cp314-cp314-win_amd64.whl", hash = "sha256:81ce3c8616d12a7be31b4aadfd3434f78f6b44b75adbfaec2fe1ad4f7f999b8c", size = 1976436, upload-time = "2025-12-18T15:47:21.384Z" }, - { url = "https://files.pythonhosted.org/packages/1f/98/556e82f00b98486def0b8af85da95e69d2be7e367cf2431408e108bc3095/pydantic-1.10.26-py3-none-any.whl", hash = "sha256:c43ad70dc3ce7787543d563792426a16fd7895e14be4b194b5665e36459dd917", size = 166975, upload-time = "2025-12-18T15:47:44.927Z" }, + { url = "https://files.pythonhosted.org/packages/01/d7/c3a52c61f5b7be648e919005820fbac33028c6149994cd64453f49951c17/pydantic-2.13.0-py3-none-any.whl", hash = "sha256:ab0078b90da5f3e2fd2e71e3d9b457ddcb35d0350854fbda93b451e28d56baaf", size = 471872, upload-time = "2026-04-13T10:51:33.343Z" }, ] -[package.optional-dependencies] -dotenv = [ +[[package]] +name = "pydantic-core" +version = "2.46.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/0a/9414cddf82eda3976b14048cc0fa8f5b5d1aecb0b22e1dcd2dbfe0e139b1/pydantic_core-2.46.0.tar.gz", hash = "sha256:82d2498c96be47b47e903e1378d1d0f770097ec56ea953322f39936a7cf34977", size = 471441, upload-time = "2026-04-13T09:06:33.813Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/05/ab3b0742bad1d51822f1af0c4232208408902bdcfc47601f3b812e09e6c2/pydantic_core-2.46.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:a05900c37264c070c683c650cbca8f83d7cbb549719e645fcd81a24592eac788", size = 2116814, upload-time = "2026-04-13T09:04:12.41Z" }, + { url = "https://files.pythonhosted.org/packages/98/08/30b43d9569d69094a0899a199711c43aa58fce6ce80f6a8f7693673eb995/pydantic_core-2.46.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de8e482fd4f1e3f36c50c6aac46d044462615d8f12cfafc6bebeaa0909eea22", size = 1951867, upload-time = "2026-04-13T09:04:02.364Z" }, + { url = "https://files.pythonhosted.org/packages/db/a0/bf9a1ba34537c2ed3872a48195291138fdec8fe26c4009776f00d63cf0c8/pydantic_core-2.46.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c525ecf8a4cdf198327b65030a7d081867ad8e60acb01a7214fff95cf9832d47", size = 1977040, upload-time = "2026-04-13T09:06:16.088Z" }, + { url = "https://files.pythonhosted.org/packages/71/70/0ba03c20e1e118219fc18c5417b008b7e880f0e3fb38560ec4465984d471/pydantic_core-2.46.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f14581aeb12e61542ce73b9bfef2bca5439d65d9ab3efe1a4d8e346b61838f9b", size = 2055284, upload-time = "2026-04-13T09:05:25.125Z" }, + { url = "https://files.pythonhosted.org/packages/58/cf/1e320acefbde7fb7158a9e5def55e0adf9a4634636098ce28dc6b978e0d3/pydantic_core-2.46.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c108067f2f7e190d0dbd81247d789ec41f9ea50ccd9265a3a46710796ac60530", size = 2238896, upload-time = "2026-04-13T09:05:01.345Z" }, + { url = "https://files.pythonhosted.org/packages/df/f5/ea8ba209756abe9eba891bb0ef3772b4c59a894eb9ad86cd5bd0dd4e3e52/pydantic_core-2.46.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ac10967e9a7bb1b96697374513f9a1a90a59e2fb41566b5e00ee45392beac59", size = 2314353, upload-time = "2026-04-13T09:06:07.942Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f8/5885350203b72e96438eee7f94de0d8f0442f4627237ca8ef75de34db1cd/pydantic_core-2.46.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7897078fe8a13b73623c0955dfb2b3d2c9acb7177aac25144758c9e5a5265aaa", size = 2098522, upload-time = "2026-04-13T09:04:23.239Z" }, + { url = "https://files.pythonhosted.org/packages/bf/88/5930b0e828e371db5a556dd3189565417ddc3d8316bb001058168aadcf5f/pydantic_core-2.46.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:e69ce405510a419a082a78faed65bb4249cfb51232293cc675645c12f7379bf7", size = 2168757, upload-time = "2026-04-13T09:07:12.46Z" }, + { url = "https://files.pythonhosted.org/packages/da/75/63d563d3035a0548e721c38b5b69fd5626fdd51da0f09ff4467503915b82/pydantic_core-2.46.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fd28d13eea0d8cf351dc1fe274b5070cc8e1cca2644381dee5f99de629e77cf3", size = 2202518, upload-time = "2026-04-13T09:05:44.418Z" }, + { url = "https://files.pythonhosted.org/packages/a7/53/1958eacbfddc41aadf5ae86dd85041bf054b675f34a2fa76385935f96070/pydantic_core-2.46.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:ee1547a6b8243e73dd10f585555e5a263395e55ce6dea618a078570a1e889aef", size = 2190148, upload-time = "2026-04-13T09:06:56.151Z" }, + { url = "https://files.pythonhosted.org/packages/c7/17/098cc6d3595e4623186f2bc6604a6195eb182e126702a90517236391e9ce/pydantic_core-2.46.0-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:c3dc68dcf62db22a18ddfc3ad4960038f72b75908edc48ae014d7ac8b391d57a", size = 2342925, upload-time = "2026-04-13T09:04:17.286Z" }, + { url = "https://files.pythonhosted.org/packages/71/a7/abdb924620b1ac535c690b36ad5b8871f376104090f8842c08625cecf1d3/pydantic_core-2.46.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:004a2081c881abfcc6854a4623da6a09090a0d7c1398a6ae7133ca1256cee70b", size = 2383167, upload-time = "2026-04-13T09:04:52.643Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c9/2ddd10f50e4b7350d2574629a0f53d8d4eb6573f9c19a6b43e6b1487a31d/pydantic_core-2.46.0-cp313-cp313-win32.whl", hash = "sha256:59d24ec8d5eaabad93097525a69d0f00f2667cb353eb6cda578b1cfff203ceef", size = 1965660, upload-time = "2026-04-13T09:06:05.877Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e7/1efc38ed6f2680c032bcefa0e3ebd496a8c77e92dfdb86b07d0f2fc632b1/pydantic_core-2.46.0-cp313-cp313-win_amd64.whl", hash = "sha256:71186dad5ac325c64d68fe0e654e15fd79802e7cc42bc6f0ff822d5ad8b1ab25", size = 2069563, upload-time = "2026-04-13T09:07:14.738Z" }, + { url = "https://files.pythonhosted.org/packages/c3/1e/a325b4989e742bf7e72ed35fa124bc611fd76539c9f8cd2a9a7854473533/pydantic_core-2.46.0-cp313-cp313-win_arm64.whl", hash = "sha256:8e4503f3213f723842c9a3b53955c88a9cfbd0b288cbd1c1ae933aebeec4a1b4", size = 2034966, upload-time = "2026-04-13T09:04:21.629Z" }, + { url = "https://files.pythonhosted.org/packages/36/3b/914891d384cdbf9a6f464eb13713baa22ea1e453d4da80fb7da522079370/pydantic_core-2.46.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:4fc801c290342350ffc82d77872054a934b2e24163727263362170c1db5416ca", size = 2113349, upload-time = "2026-04-13T09:04:59.407Z" }, + { url = "https://files.pythonhosted.org/packages/35/95/3a0c6f65e231709fb3463e32943c69d10285cb50203a2130a4732053a06d/pydantic_core-2.46.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0a36f2cc88170cc177930afcc633a8c15907ea68b59ac16bd180c2999d714940", size = 1949170, upload-time = "2026-04-13T09:06:09.935Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/d845c36a608469fe7bee226edeff0984c33dbfe7aecd755b0e7ab5a275c4/pydantic_core-2.46.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a3912e0c568a1f99d4d6d3e41def40179d61424c0ca1c8c87c4877d7f6fd7fb", size = 1977914, upload-time = "2026-04-13T09:04:56.16Z" }, + { url = "https://files.pythonhosted.org/packages/08/6f/f2e7a7f85931fb31671f5378d1c7fc70606e4b36d59b1b48e1bd1ef5d916/pydantic_core-2.46.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3534c3415ed1a19ab23096b628916a827f7858ec8db49ad5d7d1e44dc13c0d7b", size = 2050538, upload-time = "2026-04-13T09:05:06.789Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/f4aa7181dd9a16dd9059a99fc48fdab0c2aab68307283a5c04cf56de68c4/pydantic_core-2.46.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21067396fc285609323a4db2f63a87570044abe0acddfcca8b135fc7948e3db7", size = 2236294, upload-time = "2026-04-13T09:07:03.2Z" }, + { url = "https://files.pythonhosted.org/packages/24/c1/6a5042fc32765c87101b500f394702890af04239c318b6002cfd627b710d/pydantic_core-2.46.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2afd85b7be186e2fe7cdbb09a3d964bcc2042f65bbcc64ad800b3c7915032655", size = 2312954, upload-time = "2026-04-13T09:06:11.919Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e4/566101a561492ce8454f0844ca29c3b675a6b3a7b3ff577db85ed05c8c50/pydantic_core-2.46.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67e2c2e171b78db8154da602de72ffdc473c6ee51de8a9d80c0f1cd4051abfc7", size = 2102533, upload-time = "2026-04-13T09:06:58.664Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ac/adc11ee1646a5c4dd9abb09a00e7909e6dc25beddc0b1310ca734bb9b48e/pydantic_core-2.46.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c16ae1f3170267b1a37e16dba5c297bdf60c8b5657b147909ca8774ce7366644", size = 2169447, upload-time = "2026-04-13T09:04:11.143Z" }, + { url = "https://files.pythonhosted.org/packages/26/73/408e686b45b82d28ac19e8229e07282254dbee6a5d24c5c7cf3cf3716613/pydantic_core-2.46.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:133b69e1c1ba34d3702eed73f19f7f966928f9aa16663b55c2ebce0893cca42e", size = 2200672, upload-time = "2026-04-13T09:03:54.056Z" }, + { url = "https://files.pythonhosted.org/packages/0a/3b/807d5b035ec891b57b9079ce881f48263936c37bd0d154a056e7fd152afb/pydantic_core-2.46.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:15ed8e5bde505133d96b41702f31f06829c46b05488211a5b1c7877e11de5eb5", size = 2188293, upload-time = "2026-04-13T09:07:07.614Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ed/719b307516285099d1196c52769fdbe676fd677da007b9c349ae70b7226d/pydantic_core-2.46.0-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:8cfc29a1c66a7f0fcb36262e92f353dd0b9c4061d558fceb022e698a801cb8ae", size = 2335023, upload-time = "2026-04-13T09:04:05.176Z" }, + { url = "https://files.pythonhosted.org/packages/8d/90/8718e4ae98c4e8a7325afdc079be82be1e131d7a47cb6c098844a9531ffe/pydantic_core-2.46.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e1155708540f13845bf68d5ac511a55c76cfe2e057ed12b4bf3adac1581fc5c2", size = 2377155, upload-time = "2026-04-13T09:06:18.081Z" }, + { url = "https://files.pythonhosted.org/packages/dd/dc/7172789283b963f81da2fc92b186e22de55687019079f71c4d570822502b/pydantic_core-2.46.0-cp314-cp314-win32.whl", hash = "sha256:de5635a48df6b2eef161d10ea1bc2626153197333662ba4cd700ee7ec1aba7f5", size = 1963078, upload-time = "2026-04-13T09:05:30.615Z" }, + { url = "https://files.pythonhosted.org/packages/e0/69/03a7ea4b6264def3a44eabf577528bcec2f49468c5698b2044dea54dc07e/pydantic_core-2.46.0-cp314-cp314-win_amd64.whl", hash = "sha256:f07a5af60c5e7cf53dd1ff734228bd72d0dc9938e64a75b5bb308ca350d9681e", size = 2068439, upload-time = "2026-04-13T09:04:57.729Z" }, + { url = "https://files.pythonhosted.org/packages/f5/eb/1c3afcfdee2ab6634b802ab0a0f1966df4c8b630028ec56a1cb0a710dc58/pydantic_core-2.46.0-cp314-cp314-win_arm64.whl", hash = "sha256:e7a77eca3c7d5108ff509db20aae6f80d47c7ed7516d8b96c387aacc42f3ce0f", size = 2026470, upload-time = "2026-04-13T09:05:08.654Z" }, + { url = "https://files.pythonhosted.org/packages/5c/30/1177dde61b200785c4739665e3aa03a9d4b2c25d2d0408b07d585e633965/pydantic_core-2.46.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:5e7cdd4398bee1aaeafe049ac366b0f887451d9ae418fd8785219c13fea2f928", size = 2107447, upload-time = "2026-04-13T09:05:46.314Z" }, + { url = "https://files.pythonhosted.org/packages/b1/60/4e0f61f99bdabbbc309d364a2791e1ba31e778a4935bc43391a7bdec0744/pydantic_core-2.46.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5c2c92d82808e27cef3f7ab3ed63d657d0c755e0dbe5b8a58342e37bdf09bd2e", size = 1926927, upload-time = "2026-04-13T09:06:20.371Z" }, + { url = "https://files.pythonhosted.org/packages/1d/d0/67f89a8269152c1d6eaa81f04e75a507372ebd8ca7382855a065222caa80/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bab80af91cd7014b45d1089303b5f844a9d91d7da60eabf3d5f9694b32a6655", size = 1966613, upload-time = "2026-04-13T09:07:05.389Z" }, + { url = "https://files.pythonhosted.org/packages/cd/07/8dfdc3edc78f29a80fb31f366c50203ec904cff6a4c923599bf50ac0d0ff/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1e49ffdb714bc990f00b39d1ad1d683033875b5af15582f60c1f34ad3eeccfaa", size = 2032902, upload-time = "2026-04-13T09:06:42.47Z" }, + { url = "https://files.pythonhosted.org/packages/b0/2a/111c5e8fe24f99c46bcad7d3a82a8f6dbc738066e2c72c04c71f827d8c78/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ca877240e8dbdeef3a66f751dc41e5a74893767d510c22a22fc5c0199844f0ce", size = 2244456, upload-time = "2026-04-13T09:05:36.484Z" }, + { url = "https://files.pythonhosted.org/packages/6b/7c/cfc5d11c15a63ece26e148572c77cfbb2c7f08d315a7b63ef0fe0711d753/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87e6843f89ecd2f596d7294e33196c61343186255b9880c4f1b725fde8b0e20d", size = 2294535, upload-time = "2026-04-13T09:06:01.689Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2c/f0d744e3dab7bd026a3f4670a97a295157cff923a2666d30a15a70a7e3d0/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e20bc5add1dd9bc3b9a3600d40632e679376569098345500799a6ad7c5d46c72", size = 2104621, upload-time = "2026-04-13T09:04:34.388Z" }, + { url = "https://files.pythonhosted.org/packages/a7/64/e7cc4698dc024264d214b51d5a47a2404221b12060dd537d76f831b2120a/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:ee6ff79a5f0289d64a9d6696a3ce1f98f925b803dd538335a118231e26d6d827", size = 2130718, upload-time = "2026-04-13T09:04:26.23Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a8/224e655fec21f7d4441438ad2ecaccb33b5a3876ce7bb2098c74a49efc14/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:52d35cfb58c26323101c7065508d7bb69bb56338cda9ea47a7b32be581af055d", size = 2180738, upload-time = "2026-04-13T09:05:50.253Z" }, + { url = "https://files.pythonhosted.org/packages/32/7b/b3025618ed4c4e4cbaa9882731c19625db6669896b621760ea95bc1125ef/pydantic_core-2.46.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d14cc5a6f260fa78e124061eebc5769af6534fc837e9a62a47f09a2c341fa4ea", size = 2171222, upload-time = "2026-04-13T09:07:29.929Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e3/68170aa1d891920af09c1f2f34df61dc5ff3a746400027155523e3400e89/pydantic_core-2.46.0-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:4f7ff859d663b6635f6307a10803d07f0d09487e16c3d36b1744af51dbf948b2", size = 2320040, upload-time = "2026-04-13T09:06:35.732Z" }, + { url = "https://files.pythonhosted.org/packages/67/1b/5e65807001b84972476300c1f49aea2b4971b7e9fffb5c2654877dadd274/pydantic_core-2.46.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:8ef749be6ed0d69dba31902aaa8255a9bb269ae50c93888c4df242d8bb7acd9e", size = 2377062, upload-time = "2026-04-13T09:07:39.945Z" }, + { url = "https://files.pythonhosted.org/packages/75/03/48caa9dd5f28f7662bd52bff454d9a451f6b7e5e4af95e289e5e170749c9/pydantic_core-2.46.0-cp314-cp314t-win32.whl", hash = "sha256:d93ca72870133f86360e4bb0c78cd4e6ba2a0f9f3738a6486909ffc031463b32", size = 1951028, upload-time = "2026-04-13T09:04:20.224Z" }, + { url = "https://files.pythonhosted.org/packages/87/ed/e97ff55fe28c0e6e3cba641d622b15e071370b70e5f07c496b07b65db7c9/pydantic_core-2.46.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6ebb2668afd657e2127cb40f2ceb627dd78e74e9dfde14d9bf6cdd532a29ff59", size = 2048519, upload-time = "2026-04-13T09:05:10.464Z" }, + { url = "https://files.pythonhosted.org/packages/b6/51/e0db8267a287994546925f252e329eeae4121b1e77e76353418da5a3adf0/pydantic_core-2.46.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4864f5bbb7993845baf9209bae1669a8a76769296a018cb569ebda9dcb4241f5", size = 2026791, upload-time = "2026-04-13T09:04:37.724Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, ] [[package]] @@ -1789,6 +1858,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "tzdata" version = "2025.3" From 6b009f59a08ed982d97f16b191bbd2f6a8aa7c6c Mon Sep 17 00:00:00 2001 From: Dimosthenis Schizas Date: Wed, 15 Apr 2026 13:43:50 +0300 Subject: [PATCH 2/3] Harden Pydantic v2 settings and constants --- src/core/config.py | 190 +++++++++++++++++++++--------------------- src/core/constants.py | 23 ++--- 2 files changed, 109 insertions(+), 104 deletions(-) diff --git a/src/core/config.py b/src/core/config.py index 4748098..210b78a 100644 --- a/src/core/config.py +++ b/src/core/config.py @@ -2,16 +2,36 @@ import re from pathlib import Path -from pydantic import Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict -# AcademyCertificates is removed; cert mappings are now in the dynamic_role DB table. +def _validate_discord_snowflake(value: int | str) -> int: + if isinstance(value, int): + discord_id = value + elif isinstance(value, str) and value.isdigit(): + discord_id = int(value) + else: + raise ValueError("Discord IDs must be base-10 integer snowflakes.") -class Bot(BaseSettings): - """The API settings.""" + if not 0 < discord_id < 2**64: + raise ValueError("Discord IDs must be positive unsigned 64-bit snowflakes.") - model_config = SettingsConfigDict(env_file=".env", env_prefix="BOT_", extra="ignore") + return discord_id + + +def _validate_non_zero_channel_id(value: int) -> int: + if not value: + return value + if len(str(value)) <= 17: + raise ValueError("Discord IDs must have a length of 19.") + return value + + +class BotSettings(BaseModel): + """Discord bot settings.""" + + model_config = ConfigDict(extra="forbid") NAME: str = "Hackster" TOKEN: str @@ -20,18 +40,17 @@ class Bot(BaseSettings): @field_validator("TOKEN") @classmethod def check_token_format(cls, value: str) -> str: - """Validate discord tokens format.""" + """Validate Discord token format.""" pattern = re.compile(r".{26}\..{6}\..{38}") - assert pattern.fullmatch( - value - ), f"Discord token must follow >> {pattern.pattern} << pattern." + if not pattern.fullmatch(value): + raise ValueError(f"Discord token must follow >> {pattern.pattern} << pattern.") return value -class Database(BaseSettings): - """The database settings.""" +class DatabaseSettings(BaseModel): + """Database settings.""" - model_config = SettingsConfigDict(env_file=".env", env_prefix="MYSQL_", extra="ignore") + model_config = ConfigDict(extra="forbid") HOST: str = "localhost" PORT: int = 3306 @@ -39,20 +58,19 @@ class Database(BaseSettings): USER: str = "bot" PASSWORD: str = "" CHARSET: str = "utf8mb4" + ASYNC: bool | None = None def assemble_db_connection(self) -> str: - connection_string = ( + return ( f"mariadb+asyncmy://{self.USER}:{self.PASSWORD}@{self.HOST}:{self.PORT}/" - f"{self.DATABASE}?charset=" - f"{self.CHARSET}" + f"{self.DATABASE}?charset={self.CHARSET}" ) - return connection_string -class Channels(BaseSettings): - """Channel ids.""" +class ChannelsSettings(BaseModel): + """Channel IDs.""" - model_config = SettingsConfigDict(env_file=".env", env_prefix="CHANNEL_", extra="ignore") + model_config = ConfigDict(extra="forbid") DEVLOG: int = 0 SR_MOD: int @@ -75,24 +93,19 @@ class Channels(BaseSettings): ) @classmethod def check_ids_format(cls, value: int) -> int: - """Validate discord ids format.""" - if not value: - return value - - assert len(str(value)) > 17, "Discord ids must have a length of 19." - return value + """Validate Discord IDs format.""" + return _validate_non_zero_channel_id(value) -class Roles(BaseSettings): - """The roles settings. +class RolesSettings(BaseModel): + """Role settings. Core roles (required): used in decorators at import time for permission checks. Dynamic roles (optional): managed via DB, kept here as fallback during transition. """ - model_config = SettingsConfigDict(env_file=".env", env_prefix="ROLE_", extra="ignore") + model_config = ConfigDict(extra="forbid") - # Core roles (required, used in decorators) VERIFIED: int COMMUNITY_MANAGER: int COMMUNITY_TEAM: int @@ -105,7 +118,6 @@ class Roles(BaseSettings): MUTED: int ACADEMY_USER: int - # Dynamic roles (optional, DB-backed, env var fallback) OMNISCIENT: int | None = None GURU: int | None = None ELITE_HACKER: int | None = None @@ -120,8 +132,15 @@ class Roles(BaseSettings): CHALLENGE_CREATOR: int | None = None BOX_CREATOR: int | None = None SHERLOCK_CREATOR: int | None = None + APRIL_ROLE_1: int | None = None + APRIL_ROLE_2: int | None = None + RANK_FIVE: int | None = None + RANK_TWENTY_FIVE: int | None = None + RANK_FIFTY: int | None = None + RANK_HUNDRED: int | None = None RANK_ONE: int | None = None RANK_TEN: int | None = None + ACADEMY_CBBH: int | None = None SEASON_HOLO: int | None = None SEASON_PLATINUM: int | None = None SEASON_RUBY: int | None = None @@ -164,28 +183,31 @@ def check_length(cls, value: str | int) -> str | int: class Global(BaseSettings): - """The app settings.""" - - model_config = SettingsConfigDict(env_file=".env", extra="ignore") + """Application settings.""" - bot: Bot | None = None - database: Database | None = None - channels: Channels | None = None - roles: Roles | None = None - HTB_API_KEY: str + model_config = SettingsConfigDict( + env_file=".env", + env_nested_delimiter="__", + extra="forbid", + populate_by_name=True, + ) - role_groups: dict[str, list[int | str]] = Field(default_factory=dict) + bot: BotSettings = Field(validation_alias="BOT") + database: DatabaseSettings = Field(validation_alias="DATABASE") + channels: ChannelsSettings = Field(validation_alias="CHANNEL") + roles: RolesSettings = Field(validation_alias="ROLE") + HTB_API_KEY: str guild_ids: list[int] dev_guild_ids: list[int] = Field(default_factory=list) + APRIL_FLAG_1: str = "" + APRIL_FLAG_2: str = "" SENTRY_DSN: str | None = None LOG_LEVEL: str | int = "INFO" DEBUG: bool = False HTB_URL: str = "https://labs.hackthebox.com" - API_URL: str = f"{HTB_URL}/api" - API_V4_URL: str = f"{API_URL}/v4" HTB_API_SECRET: str | None = None START_WEBHOOK_SERVER: bool = False @@ -193,12 +215,12 @@ class Global(BaseSettings): WEBHOOK_TOKEN: str = "" SLACK_FEEDBACK_WEBHOOK: str = "" + SLACK_WEBHOOK: str = "" JIRA_WEBHOOK: str = "" + JIRA_SPOILER_WEBHOOK: str = "" ROOT: Path | None = None - VERSION: str = "unknown" - SEASON_ID: int = 0 @field_validator("guild_ids", "dev_guild_ids", mode="before") @@ -206,55 +228,35 @@ class Global(BaseSettings): def check_ids_format(cls, value: list[int | str] | int | str) -> list[int | str] | int: """Validate Discord snowflakes and accept string-form IDs from env sources.""" if isinstance(value, list): - return [cls._validate_discord_id(item) for item in value] - return cls._validate_discord_id(value) - - @staticmethod - def _validate_discord_id(value: int | str) -> int: - if isinstance(value, int): - discord_id = value - elif isinstance(value, str) and value.isdigit(): - discord_id = int(value) - else: - raise ValueError("Discord IDs must be base-10 integer snowflakes.") - - if not 0 < discord_id < 2**64: - raise ValueError("Discord IDs must be positive unsigned 64-bit snowflakes.") - - return discord_id - - # Helper methods (get_post_or_rank, get_season, get_cert, get_academy_cert_role) - # have been moved to RoleManager (src/services/role_manager.py). - - -def load_settings(env_file: str | None = None): - global_settings = Global(_env_file=env_file) - global_settings.bot = Bot(_env_file=env_file) - global_settings.database = Database(_env_file=env_file) - global_settings.channels = Channels(_env_file=env_file) - global_settings.roles = Roles(_env_file=env_file) - - # Core role groups (used in decorators at import time for permission checks). - # Dynamic role groups (ALL_RANKS, ALL_SEASON_RANKS, etc.) are now served by - # RoleManager.get_group_ids() from the database. - global_settings.role_groups = { - "ALL_ADMINS": [ - global_settings.roles.ADMINISTRATOR, - global_settings.roles.COMMUNITY_MANAGER, - ], - "ALL_SR_MODS": [global_settings.roles.SR_MODERATOR], - "ALL_MODS": [ - global_settings.roles.SR_MODERATOR, - global_settings.roles.MODERATOR, - global_settings.roles.JR_MODERATOR, - ], - "ALL_HTB_STAFF": [global_settings.roles.HTB_STAFF], - "ALL_HTB_SUPPORT": [global_settings.roles.HTB_SUPPORT], - } - - return global_settings - - -settings = load_settings( - os.environ.get("ENV_PATH") if os.environ.get("BOT_ENVIRONMENT") else ".test.env" + return [_validate_discord_snowflake(item) for item in value] + return _validate_discord_snowflake(value) + + @property + def API_URL(self) -> str: + return f"{self.HTB_URL}/api" + + @property + def API_V4_URL(self) -> str: + return f"{self.API_URL}/v4" + + @property + def role_groups(self) -> dict[str, list[int]]: + return { + "ALL_ADMINS": [ + self.roles.ADMINISTRATOR, + self.roles.COMMUNITY_MANAGER, + ], + "ALL_SR_MODS": [self.roles.SR_MODERATOR], + "ALL_MODS": [ + self.roles.SR_MODERATOR, + self.roles.MODERATOR, + self.roles.JR_MODERATOR, + ], + "ALL_HTB_STAFF": [self.roles.HTB_STAFF], + "ALL_HTB_SUPPORT": [self.roles.HTB_SUPPORT], + } + + +settings = Global( + _env_file=os.environ.get("ENV_PATH") if os.environ.get("BOT_ENVIRONMENT") else ".test.env" ) diff --git a/src/core/constants.py b/src/core/constants.py index 3e8c0bf..ae3f4b4 100644 --- a/src/core/constants.py +++ b/src/core/constants.py @@ -1,7 +1,8 @@ -from pydantic import BaseModel +from dataclasses import dataclass, field -class Colours(BaseModel): +@dataclass(frozen=True, slots=True) +class Colours: """Colour codes.""" blue: int = 0x0279FD @@ -21,7 +22,8 @@ class Colours(BaseModel): yellow: int = 0xF8E500 -class Emojis(BaseModel): +@dataclass(frozen=True, slots=True) +class Emojis: """Emoji codes.""" arrow_left: str = "\u2B05" # ⬅ @@ -32,20 +34,21 @@ class Emojis(BaseModel): track_previous: str = "\u23EE" # ⏮ -class Pagination(BaseModel): +@dataclass(frozen=True, slots=True) +class Pagination: """Pagination default settings.""" max_size: int = 500 timeout: int = 300 # In seconds -class Constants(BaseModel): - """The app constants.""" - - colours: Colours = Colours() - emojis: Emojis = Emojis() - pagination: Pagination = Pagination() +@dataclass(frozen=True, slots=True) +class Constants: + """Application constants.""" + colours: Colours = field(default_factory=Colours) + emojis: Emojis = field(default_factory=Emojis) + pagination: Pagination = field(default_factory=Pagination) low_latency: int = 200 high_latency: int = 400 From d4992730bb37809e3e1a3bf6d8a72043044e48db Mon Sep 17 00:00:00 2001 From: Dimosthenis Schizas Date: Wed, 15 Apr 2026 15:36:27 +0300 Subject: [PATCH 3/3] Update tests for nested Pydantic settings envs --- .test.env | 141 +++++++++++++++++----------------- tests/plugins/env_vars.py | 2 +- tests/src/core/test_config.py | 55 ++++++++++++- 3 files changed, 122 insertions(+), 76 deletions(-) diff --git a/.test.env b/.test.env index 953a105..fc7b72e 100644 --- a/.test.env +++ b/.test.env @@ -1,7 +1,7 @@ # Bot -BOT_ENVIRONMENT=example -BOT_NAME=Hackster -BOT_TOKEN=y1IY5cNYbiv0EdMDdMDjmv9qDL.lyIJQb.Okk2vH1mRyPagTbQOGnsFBgwq17ZJEi2xN4KV6 +BOT__ENVIRONMENT=example +BOT__NAME=Hackster +BOT__TOKEN=y1IY5cNYbiv0EdMDdMDjmv9qDL.lyIJQb.Okk2vH1mRyPagTbQOGnsFBgwq17ZJEi2xN4KV6 GUILD_IDS=["6455184161011276950"] DEV_GUILD_IDS=["6455184161011276950"] @@ -9,12 +9,11 @@ LOG_LEVEL=DEBUG DEBUG=True # Database -MYSQL_HOST=localhost -MYSQL_PORT=3306 -MYSQL_DATABASE=bot_dev -MYSQL_USER=bot -MYSQL_PASSWORD=not_a_password -MYSQL_ASYNC=True +DATABASE__HOST=localhost +DATABASE__PORT=3306 +DATABASE__DATABASE=bot_dev +DATABASE__USER=bot +DATABASE__PASSWORD=not_a_password # HTB HTB_API_SECRET=9BR5vXIXhcaasZGXujkEDvqxFyinPA @@ -25,73 +24,73 @@ WEBHOOK_TOKEN=JrvjfD0E-KV91-Bt8Y-ZLlg-uTEMk8SZXYvc SENTRY_DSN= # Channels -CHANNEL_SR_MOD=1127695218900993410 -CHANNEL_VERIFY_LOGS=1012769518828339331 -CHANNEL_BOT_COMMANDS=1276953350848588101 -CHANNEL_SPOILER=2769521890099371011 -CHANNEL_BOT_LOGS=1105517088266788925 -CHANNEL_UNVERIFIED_BOT_COMMANDS=1430556712313688225 -CHANNEL_HOW_TO_VERIFY=1432333413980835840 +CHANNEL__SR_MOD=1127695218900993410 +CHANNEL__VERIFY_LOGS=1012769518828339331 +CHANNEL__BOT_COMMANDS=1276953350848588101 +CHANNEL__SPOILER=2769521890099371011 +CHANNEL__BOT_LOGS=1105517088266788925 +CHANNEL__UNVERIFIED_BOT_COMMANDS=1430556712313688225 +CHANNEL__HOW_TO_VERIFY=1432333413980835840 # Roles -ROLE_VERIFIED=1333333333333333337 - -ROLE_BIZCTF2022=7629466241011276950 -ROLE_NOAH_GANG=6706800691011276950 -ROLE_BUDDY_GANG=6706800681011276950 -ROLE_RED_TEAM=6706800701011276950 -ROLE_BLUE_TEAM=6706800711011276950 - -ROLE_COMMUNITY_MANAGER=7839345151011276950 -ROLE_COMMUNITY_TEAM=845823057817153850 -ROLE_ADMINISTRATOR=7839345141011276950 -ROLE_SR_MODERATOR=7629466271011276950 -ROLE_MODERATOR=7629466261011276950 -ROLE_JR_MODERATOR=7629466221011276950 -ROLE_HTB_STAFF=7629466201011276950 -ROLE_HTB_SUPPORT=6455184211011276950 -ROLE_MUTED=7419955651011276950 - -ROLE_OMNISCIENT=8377361011276950695 -ROLE_GURU=8377351011276950695 -ROLE_ELITE_HACKER=8377341011276950695 -ROLE_PRO_HACKER=8377331011276950695 -ROLE_HACKER=8377321011276950695 -ROLE_SCRIPT_KIDDIE=8377311011276950695 -ROLE_NOOB=8377301011276950695 - -ROLE_VIP=9583772810112769506 -ROLE_VIP_PLUS=9583772910112769506 -ROLE_SILVER_ANNUAL = 1432409954337296384 -ROLE_GOLD_ANNUAL = 1432410047782064232 - -ROLE_CHALLENGE_CREATOR=8215461011276950716 -ROLE_BOX_CREATOR=8215471011276950716 -ROLE_SHERLOCK_CREATOR=1384037506349011044 - -ROLE_RANK_ONE=7419955631011276950 -ROLE_RANK_TEN=7419955611011276950 - -ROLE_ACADEMY_USER=8087599101014249251 -ROLE_ACADEMY_CWES=7168215511011276950 -ROLE_ACADEMY_CPTS=1795774641027354363 -ROLE_ACADEMY_CDSA=1157697238949167235 -ROLE_ACADEMY_CWEE=1257697240949167235 -ROLE_ACADEMY_CAPE=1318971191586979861 -ROLE_ACADEMY_CJCA=1400475445455224902 -ROLE_ACADEMY_CWPE=1466407288758599821 - -ROLE_UNICTF2022=6148613121047893215 - -ROLE_SEASON_HOLO=1099033418995597373 -ROLE_SEASON_PLATINUM=1099048578166554784 -ROLE_SEASON_RUBY=1099049568148127774 -ROLE_SEASON_SILVER=1099056313952125019 -ROLE_SEASON_BRONZE=1099056442281042022 +ROLE__VERIFIED=1333333333333333337 + +ROLE__BIZCTF2022=7629466241011276950 +ROLE__NOAH_GANG=6706800691011276950 +ROLE__BUDDY_GANG=6706800681011276950 +ROLE__RED_TEAM=6706800701011276950 +ROLE__BLUE_TEAM=6706800711011276950 + +ROLE__COMMUNITY_MANAGER=7839345151011276950 +ROLE__COMMUNITY_TEAM=845823057817153850 +ROLE__ADMINISTRATOR=7839345141011276950 +ROLE__SR_MODERATOR=7629466271011276950 +ROLE__MODERATOR=7629466261011276950 +ROLE__JR_MODERATOR=7629466221011276950 +ROLE__HTB_STAFF=7629466201011276950 +ROLE__HTB_SUPPORT=6455184211011276950 +ROLE__MUTED=7419955651011276950 + +ROLE__OMNISCIENT=8377361011276950695 +ROLE__GURU=8377351011276950695 +ROLE__ELITE_HACKER=8377341011276950695 +ROLE__PRO_HACKER=8377331011276950695 +ROLE__HACKER=8377321011276950695 +ROLE__SCRIPT_KIDDIE=8377311011276950695 +ROLE__NOOB=8377301011276950695 + +ROLE__VIP=9583772810112769506 +ROLE__VIP_PLUS=9583772910112769506 +ROLE__SILVER_ANNUAL=1432409954337296384 +ROLE__GOLD_ANNUAL=1432410047782064232 + +ROLE__CHALLENGE_CREATOR=8215461011276950716 +ROLE__BOX_CREATOR=8215471011276950716 +ROLE__SHERLOCK_CREATOR=1384037506349011044 + +ROLE__RANK_ONE=7419955631011276950 +ROLE__RANK_TEN=7419955611011276950 + +ROLE__ACADEMY_USER=8087599101014249251 +ROLE__ACADEMY_CWES=7168215511011276950 +ROLE__ACADEMY_CPTS=1795774641027354363 +ROLE__ACADEMY_CDSA=1157697238949167235 +ROLE__ACADEMY_CWEE=1257697240949167235 +ROLE__ACADEMY_CAPE=1318971191586979861 +ROLE__ACADEMY_CJCA=1400475445455224902 +ROLE__ACADEMY_CWPE=1466407288758599821 + +ROLE__UNICTF2022=6148613121047893215 + +ROLE__SEASON_HOLO=1099033418995597373 +ROLE__SEASON_PLATINUM=1099048578166554784 +ROLE__SEASON_RUBY=1099049568148127774 +ROLE__SEASON_SILVER=1099056313952125019 +ROLE__SEASON_BRONZE=1099056442281042022 # Season ID, Updated at regular interval -CURRENT_SEASON_ID=1 +SEASON_ID=1 #V4 Bearer Token HTB_API_KEY=CHANGE_ME diff --git a/tests/plugins/env_vars.py b/tests/plugins/env_vars.py index 0eb365f..2ddefa2 100644 --- a/tests/plugins/env_vars.py +++ b/tests/plugins/env_vars.py @@ -5,5 +5,5 @@ @pytest.hookimpl(tryfirst=True) def pytest_load_initial_conftests(): - os.environ["BOT_TOKEN"] = "ODk3MTVyNDOb50MDAxODE0NTC4.YWRgYg.hqWNRybjyk1j2h3h42vEoc8feoNqR0ubBCYwxo" + os.environ["BOT__TOKEN"] = "ODk3MTVyNDOb50MDAxODE0NTC4.YWRgYg.hqWNRybjyk1j2h3h42vEoc8feoNqR0ubBCYwxo" os.environ["GUILD_IDS"] = "[7764771731239076051,8312163095926538351]" diff --git a/tests/src/core/test_config.py b/tests/src/core/test_config.py index 6996ea3..0260bac 100644 --- a/tests/src/core/test_config.py +++ b/tests/src/core/test_config.py @@ -7,22 +7,65 @@ class TestConfig(unittest.TestCase): + @staticmethod + def minimal_settings(**overrides): + payload = { + "bot": { + "TOKEN": "ODk3MTVyNDOb50MDAxODE0NTC4.YWRgYg.hqWNRybjyk1j2h3h42vEoc8feoNqR0ubBCYwxo", + }, + "database": { + "HOST": "localhost", + "PORT": 3306, + "DATABASE": "bot", + "USER": "bot", + "PASSWORD": "secret", + }, + "channels": { + "SR_MOD": 1127695218900993410, + "VERIFY_LOGS": 1012769518828339331, + "BOT_COMMANDS": 1276953350848588101, + "SPOILER": 2769521890099371011, + "BOT_LOGS": 1105517088266788925, + }, + "roles": { + "VERIFIED": 1333333333333333337, + "COMMUNITY_MANAGER": 7839345151011276950, + "COMMUNITY_TEAM": 845823057817153850, + "ADMINISTRATOR": 7839345141011276950, + "SR_MODERATOR": 7629466271011276950, + "MODERATOR": 7629466261011276950, + "JR_MODERATOR": 7629466221011276950, + "HTB_STAFF": 7629466201011276950, + "HTB_SUPPORT": 6455184211011276950, + "MUTED": 7419955651011276950, + "ACADEMY_USER": 8087599101014249251, + }, + "HTB_API_KEY": "test", + "guild_ids": [6455184161011276950], + "dev_guild_ids": [7764771731239076051], + } + payload.update(overrides) + return Global(**payload) + def test_guild_ids_accept_string_snowflakes(self): """Test that guild IDs can be provided as digit strings and are coerced to ints.""" - config = Global(HTB_API_KEY="test", guild_ids=["6455184161011276950"], dev_guild_ids=["7764771731239076051"]) + config = self.minimal_settings( + guild_ids=["6455184161011276950"], + dev_guild_ids=["7764771731239076051"], + ) self.assertEqual(config.guild_ids, [6455184161011276950]) self.assertEqual(config.dev_guild_ids, [7764771731239076051]) def test_guild_ids_reject_non_snowflakes(self): """Test that guild IDs must be positive base-10 unsigned 64-bit snowflakes.""" with self.assertRaises(ValidationError): - Global(HTB_API_KEY="test", guild_ids=["not-a-snowflake"]) + self.minimal_settings(guild_ids=["not-a-snowflake"]) with self.assertRaises(ValidationError): - Global(HTB_API_KEY="test", guild_ids=[0]) + self.minimal_settings(guild_ids=[0]) with self.assertRaises(ValidationError): - Global(HTB_API_KEY="test", guild_ids=[2**64]) + self.minimal_settings(guild_ids=[2**64]) def test_core_roles_required(self): """Test that core roles are still loaded from env vars.""" @@ -53,3 +96,7 @@ def test_dynamic_roles_are_optional(self): self.assertTrue(hasattr(settings.roles, "OMNISCIENT")) self.assertTrue(hasattr(settings.roles, "VIP")) self.assertTrue(hasattr(settings.roles, "BOX_CREATOR")) + + def test_season_id_loads_from_nested_env_config(self): + """Test that SEASON_ID is loaded under the new nested env contract.""" + self.assertEqual(settings.SEASON_ID, 1)