Expanded linting rules, added codespell and pip-audit, and fixed all violations.
CI / Formatting (push) Successful in 11s
CI / Linting (push) Successful in 11s
CI / Tests (push) Successful in 15s
CI / Type Checking (push) Successful in 21s
CI / Spelling (push) Successful in 12s
Dependency Audit / Dependency Audit (push) Successful in 7s

This commit is contained in:
2026-02-20 10:30:36 -05:00
parent 0f2f43a55a
commit ee73e36f8e
19 changed files with 645 additions and 189 deletions
+32
View File
@@ -0,0 +1,32 @@
name: Dependency Audit
on:
schedule:
- cron: "0 0 * * 1"
push:
branches: [master]
paths: [uv.lock]
pull_request:
paths: [uv.lock]
jobs:
audit:
name: Dependency Audit
runs-on: logaldeveloper-archlinux
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Cache uv packages
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: ~/.cache/uv
key: uv-${{ hashFiles('uv.lock') }}
- name: Install dependencies
run: uv sync
- name: Audit dependencies with pip-audit
run: uv run pip-audit --skip-editable
+21
View File
@@ -92,3 +92,24 @@ jobs:
- name: Check types with Mypy - name: Check types with Mypy
run: uv run mypy . run: uv run mypy .
spelling:
name: Spelling
runs-on: logaldeveloper-archlinux
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Cache uv packages
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: ~/.cache/uv
key: uv-${{ hashFiles('uv.lock') }}
- name: Install dependencies
run: uv sync
- name: Check spelling with codespell
run: uv run codespell
+29 -15
View File
@@ -12,10 +12,10 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
""" """Entry point for the Crabstero Discord bot.
Entry point for the Crabstero Discord bot.
Provides an argparse CLI with environment variable and systemd credential fallbacks for --token and --database-path. Provides an argparse CLI with environment variable and systemd credential
fallbacks for --token and --database-path.
""" """
import argparse import argparse
@@ -32,8 +32,7 @@ logger = logging.getLogger("crabstero")
def _read_credential(name: str) -> str | None: def _read_credential(name: str) -> str | None:
""" """Read a value from a systemd credential file.
Read a value from a systemd credential file.
Looks for a file named *name* inside the directory pointed to by the Looks for a file named *name* inside the directory pointed to by the
``CREDENTIALS_DIRECTORY`` environment variable (set automatically by ``CREDENTIALS_DIRECTORY`` environment variable (set automatically by
@@ -53,8 +52,7 @@ def _read_credential(name: str) -> str | None:
def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
""" """Parse command-line arguments with environment variable fallbacks.
Parses command-line arguments with environment variable fallbacks.
:param argv: Optional argument list (defaults to sys.argv[1:]). :param argv: Optional argument list (defaults to sys.argv[1:]).
:return: Parsed arguments namespace. :return: Parsed arguments namespace.
@@ -66,41 +64,57 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser.add_argument( parser.add_argument(
"--token", "--token",
default=os.environ.get("TOKEN") or _read_credential("token"), default=os.environ.get("TOKEN") or _read_credential("token"),
help="Discord bot token (default: TOKEN environment variable or systemd credential 'token').", help=(
"Discord bot token"
" (default: TOKEN environment variable"
" or systemd credential 'token')."
),
) )
parser.add_argument( parser.add_argument(
"--database-path", "--database-path",
"--database", "--database",
default=os.environ.get("DATABASE_PATH", "crabstero.db"), default=os.environ.get("DATABASE_PATH", "crabstero.db"),
help='Path to the SQLite database file (default: DATABASE_PATH environment variable or "crabstero.db").', help=(
"Path to the SQLite database file"
" (default: DATABASE_PATH environment"
' variable or "crabstero.db").'
),
) )
parser.add_argument( parser.add_argument(
"--ingestion-workers", "--ingestion-workers",
type=int, type=int,
default=int(os.environ.get("INGESTION_WORKERS", "4")), default=int(os.environ.get("INGESTION_WORKERS", "4")),
help="Number of concurrent ingestion workers (default: INGESTION_WORKERS environment variable or 4).", help=(
"Number of concurrent ingestion workers"
" (default: INGESTION_WORKERS environment"
" variable or 4)."
),
) )
parser.add_argument( parser.add_argument(
"--ingest-only", "--ingest-only",
action="store_true", action="store_true",
default=False, default=False,
help="Run in ingest-only mode: ingest channel history and real-time messages but never respond.", help=(
"Run in ingest-only mode: ingest channel"
" history and real-time messages but"
" never respond."
),
) )
args = parser.parse_args(argv) args = parser.parse_args(argv)
if args.token is None: if args.token is None:
parser.error( parser.error(
"a Discord bot token is required via --token, the TOKEN environment variable, or a systemd credential named 'token'" "a Discord bot token is required via --token,"
" the TOKEN environment variable,"
" or a systemd credential named 'token'"
) )
return args return args
def main() -> None: def main() -> None:
""" """Run the bot. Parse arguments, configure logging, and start the bot."""
Main entry point. Parses arguments, configures logging, and starts the bot.
"""
args = _parse_args() args = _parse_args()
logging.basicConfig( logging.basicConfig(
+14 -17
View File
@@ -12,8 +12,7 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
""" """The simple nonversation Discord bot.
The simple nonversation Discord bot.
Provides the Crabstero subclass that owns the full bot lifecycle: database connection, Provides the Crabstero subclass that owns the full bot lifecycle: database connection,
cog loading, ingestion worker pool, and graceful shutdown. cog loading, ingestion worker pool, and graceful shutdown.
@@ -35,8 +34,7 @@ logger = logging.getLogger(__name__)
class Crabstero(commands.Bot): class Crabstero(commands.Bot):
""" """Central bot subclass that owns all lifecycle state.
Central bot subclass that owns all lifecycle state.
The database is opened in setup_hook and closed in close(). Background The database is opened in setup_hook and closed in close(). Background
ingestion is handled by a bounded queue and a fixed worker pool. ingestion is handled by a bounded queue and a fixed worker pool.
@@ -49,8 +47,7 @@ class Crabstero(commands.Bot):
ingestion_workers: int = 4, ingestion_workers: int = 4,
ingest_only: bool = False, ingest_only: bool = False,
) -> None: ) -> None:
""" """Configure intents, store configuration, and prepare ingestion queue state.
Configures intents, stores configuration, and prepares ingestion queue state.
:param token: The Discord bot token. :param token: The Discord bot token.
:param database_path: The file path to the SQLite database. :param database_path: The file path to the SQLite database.
@@ -78,10 +75,11 @@ class Crabstero(commands.Bot):
self._ingestion_workers: list[asyncio.Task[None]] = [] self._ingestion_workers: list[asyncio.Task[None]] = []
self.db: Database self.db: Database
self.http.user_agent = f"DiscordBot (https://git.logal.dev/LogalDeveloper/Crabstero, {crabstero_version})" repo_url = "https://git.logal.dev/LogalDeveloper/Crabstero"
self.http.user_agent = f"DiscordBot ({repo_url}, {crabstero_version})"
async def setup_hook(self) -> None: async def setup_hook(self) -> None:
"""Opens the database, starts ingestion workers, loads all cogs, and syncs slash commands if changed.""" """Open the database, start ingestion workers, and load all cogs."""
self.db = await Database.connect(self._database_path) self.db = await Database.connect(self._database_path)
self._start_ingestion_workers() self._start_ingestion_workers()
@@ -92,7 +90,8 @@ class Crabstero(commands.Bot):
await server_events.setup(self) await server_events.setup(self)
if not self._ingest_only: if not self._ingest_only:
# Only sync slash commands if the registered commands differ from local definitions. # Only sync slash commands if the registered commands
# differ from local definitions.
local_commands = { local_commands = {
cmd.name: cmd.description cmd.name: cmd.description
for cmd in self.tree.get_commands() for cmd in self.tree.get_commands()
@@ -111,12 +110,11 @@ class Crabstero(commands.Bot):
await self.tree.sync() await self.tree.sync()
async def on_ready(self) -> None: async def on_ready(self) -> None:
"""Logs that the bot has started successfully.""" """Log that the bot has started successfully."""
logger.info("Crabstero started!") logger.info("Crabstero started!")
async def start(self, token: str = "", *, reconnect: bool = True) -> None: async def start(self, token: str = "", *, reconnect: bool = True) -> None:
""" """Start the bot using the stored token by default.
Starts the bot using the stored token by default.
:param token: Optional token override. Falls back to the stored token if empty. :param token: Optional token override. Falls back to the stored token if empty.
:param reconnect: Whether to automatically reconnect on disconnect. :param reconnect: Whether to automatically reconnect on disconnect.
@@ -124,7 +122,7 @@ class Crabstero(commands.Bot):
await super().start(token or self._token, reconnect=reconnect) await super().start(token or self._token, reconnect=reconnect)
async def close(self) -> None: async def close(self) -> None:
"""Cancels ingestion workers, closes the database, and then the bot connection.""" """Cancel ingestion workers, close the database, and then the bot connection."""
if self.is_closed(): if self.is_closed():
return return
logger.info("Shutting down Crabstero...") logger.info("Shutting down Crabstero...")
@@ -139,21 +137,20 @@ class Crabstero(commands.Bot):
def queue_channel_for_ingestion( def queue_channel_for_ingestion(
self, channel: discord.TextChannel | discord.VoiceChannel self, channel: discord.TextChannel | discord.VoiceChannel
) -> None: ) -> None:
""" """Enqueue a single channel for background message history ingestion.
Enqueues a single channel for background message history ingestion.
:param channel: The channel to enqueue. :param channel: The channel to enqueue.
""" """
self._ingestion_queue.put_nowait(channel) self._ingestion_queue.put_nowait(channel)
def _start_ingestion_workers(self) -> None: def _start_ingestion_workers(self) -> None:
"""Spawns the fixed pool of ingestion worker tasks.""" """Spawn the fixed pool of ingestion worker tasks."""
for _ in range(self._ingestion_worker_count): for _ in range(self._ingestion_worker_count):
task = asyncio.create_task(self._ingestion_worker()) task = asyncio.create_task(self._ingestion_worker())
self._ingestion_workers.append(task) self._ingestion_workers.append(task)
async def _ingestion_worker(self) -> None: async def _ingestion_worker(self) -> None:
"""Loops forever pulling channels from the ingestion queue and ingesting them.""" """Loop forever pulling channels from the ingestion queue and ingesting them."""
while True: while True:
channel = await self._ingestion_queue.get() channel = await self._ingestion_queue.get()
try: try:
+72 -58
View File
@@ -12,8 +12,7 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
""" """Async SQLite database access for Crabstero's persistent storage.
Provides async SQLite database access for all of Crabstero's persistent storage needs.
Uses aiosqlite for native async access. All methods are async def. Uses aiosqlite for native async access. All methods are async def.
""" """
@@ -52,7 +51,8 @@ CREATE TABLE IF NOT EXISTS markov_transitions (
word TEXT NOT NULL, word TEXT NOT NULL,
next_word TEXT NOT NULL next_word TEXT NOT NULL
); );
CREATE INDEX IF NOT EXISTS idx_transitions_channel_word ON markov_transitions(channel_id, word); CREATE INDEX IF NOT EXISTS idx_transitions_channel_word
ON markov_transitions(channel_id, word);
CREATE INDEX IF NOT EXISTS idx_transitions_user ON markov_transitions(user_id); CREATE INDEX IF NOT EXISTS idx_transitions_user ON markov_transitions(user_id);
-- Image URLs per channel. -- Image URLs per channel.
@@ -80,16 +80,15 @@ CREATE TABLE IF NOT EXISTS ingested_channels (
class Database: class Database:
""" """Manages all SQLite database operations for Crabstero.
Manages all SQLite database operations for Crabstero.
Uses aiosqlite for native async access. A single connection is held open for the lifetime of Uses aiosqlite for native async access. A single connection is held
the bot process with WAL mode enabled for concurrent read performance. open for the lifetime of the bot process with WAL mode enabled for
concurrent read performance.
""" """
def __init__(self, connection: aiosqlite.Connection) -> None: def __init__(self, connection: aiosqlite.Connection) -> None:
""" """Initialize the Database wrapper with an already-opened aiosqlite connection.
Initializes the Database wrapper with an already-opened aiosqlite connection.
:param connection: An open aiosqlite connection. :param connection: An open aiosqlite connection.
""" """
@@ -99,9 +98,10 @@ class Database:
@classmethod @classmethod
async def connect(cls, path: str) -> Self: async def connect(cls, path: str) -> Self:
""" """Open a SQLite database, configure it, and create the schema.
Opens a new SQLite database at the given path, configures it for performance, and creates
the schema if it does not already exist. Configure the database for performance and create the schema if it
does not already exist.
:param path: The file path to the SQLite database. :param path: The file path to the SQLite database.
:return: A new Database instance ready for use. :return: A new Database instance ready for use.
@@ -121,7 +121,7 @@ class Database:
return cls(connection) return cls(connection)
async def commit(self) -> None: async def commit(self) -> None:
"""Commits pending writes and resets the flush timer.""" """Commit pending writes and reset the flush timer."""
await self._connection.commit() await self._connection.commit()
self._pending_writes = 0 self._pending_writes = 0
if self._flush_task is not None: if self._flush_task is not None:
@@ -129,7 +129,7 @@ class Database:
self._flush_task = None self._flush_task = None
async def close(self) -> None: async def close(self) -> None:
"""Cancels the flush timer, commits any pending writes, then closes the database connection.""" """Cancel the flush timer, commit pending writes, and close the connection."""
if self._flush_task is not None: if self._flush_task is not None:
self._flush_task.cancel() self._flush_task.cancel()
with contextlib.suppress(asyncio.CancelledError): with contextlib.suppress(asyncio.CancelledError):
@@ -141,26 +141,30 @@ class Database:
await self._connection.close() await self._connection.close()
async def add_start_words_batch(self, rows: list[tuple[int, int, str]]) -> None: async def add_start_words_batch(self, rows: list[tuple[int, int, str]]) -> None:
""" """Insert a batch of starting words into the markov_start_words table.
Inserts a batch of starting words into the markov_start_words table.
:param rows: A list of (channel_id, user_id, word) tuples to insert. :param rows: A list of (channel_id, user_id, word) tuples to insert.
""" """
await self._connection.executemany( await self._connection.executemany(
"INSERT INTO markov_start_words (channel_id, user_id, word) VALUES (?, ?, ?)", "INSERT INTO markov_start_words"
" (channel_id, user_id, word)"
" VALUES (?, ?, ?)",
rows, rows,
) )
await self._maybe_commit() await self._maybe_commit()
async def get_random_start_word(self, channel_id: int) -> str | None: async def get_random_start_word(self, channel_id: int) -> str | None:
""" """Return a random starting word for a channel.
Returns a random starting word for a given channel, weighted by occurrence frequency.
Weighted by occurrence frequency.
:param channel_id: The Discord channel ID. :param channel_id: The Discord channel ID.
:return: A random starting word, or None if no starting words exist for this channel. :return: A random starting word, or None if none exist.
""" """
async with self._connection.execute( async with self._connection.execute(
"SELECT word FROM markov_start_words WHERE channel_id = ? ORDER BY RANDOM() LIMIT 1", "SELECT word FROM markov_start_words"
" WHERE channel_id = ?"
" ORDER BY RANDOM() LIMIT 1",
(channel_id,), (channel_id,),
) as cursor: ) as cursor:
row = await cursor.fetchone() row = await cursor.fetchone()
@@ -169,28 +173,32 @@ class Database:
async def add_transitions_batch( async def add_transitions_batch(
self, rows: list[tuple[int, int, str, str]] self, rows: list[tuple[int, int, str, str]]
) -> None: ) -> None:
""" """Insert a batch of word transitions into the markov_transitions table.
Inserts a batch of word transitions into the markov_transitions table.
:param rows: A list of (channel_id, user_id, word, next_word) tuples to insert. :param rows: A list of (channel_id, user_id, word, next_word)
tuples to insert.
""" """
await self._connection.executemany( await self._connection.executemany(
"INSERT INTO markov_transitions (channel_id, user_id, word, next_word) VALUES (?, ?, ?, ?)", "INSERT INTO markov_transitions"
" (channel_id, user_id, word, next_word)"
" VALUES (?, ?, ?, ?)",
rows, rows,
) )
await self._maybe_commit() await self._maybe_commit()
async def get_random_next_word(self, channel_id: int, word: str) -> str | None: async def get_random_next_word(self, channel_id: int, word: str) -> str | None:
""" """Return a random next word for a given word in a channel.
Returns a random next word for a given word in a given channel, weighted by occurrence
frequency. Weighted by occurrence frequency.
:param channel_id: The Discord channel ID. :param channel_id: The Discord channel ID.
:param word: The current word to find a transition for. :param word: The current word to find a transition for.
:return: A random next word, or None if no transitions exist. :return: A random next word, or None if no transitions exist.
""" """
async with self._connection.execute( async with self._connection.execute(
"SELECT next_word FROM markov_transitions WHERE channel_id = ? AND word = ? ORDER BY RANDOM() LIMIT 1", "SELECT next_word FROM markov_transitions"
" WHERE channel_id = ? AND word = ?"
" ORDER BY RANDOM() LIMIT 1",
(channel_id, word), (channel_id, word),
) as cursor: ) as cursor:
row = await cursor.fetchone() row = await cursor.fetchone()
@@ -199,26 +207,28 @@ class Database:
async def get_random_completing_next_word( async def get_random_completing_next_word(
self, channel_id: int, word: str self, channel_id: int, word: str
) -> str | None: ) -> str | None:
""" """Return a random sentence-ending next word for a given word in a channel.
Returns a random next word that ends a sentence for a given word in a given channel,
weighted by occurrence frequency.
A completing word is one whose last character is '.', '!', '?', or '§'. Weighted by occurrence frequency. A completing word is one whose last
character is '.', '!', '?', or '§'.
:param channel_id: The Discord channel ID. :param channel_id: The Discord channel ID.
:param word: The current word to find a completing transition for. :param word: The current word to find a completing transition for.
:return: A random completing next word, or None if no completing transitions exist. :return: A random completing next word, or None if none exist.
""" """
async with self._connection.execute( async with self._connection.execute(
"SELECT next_word FROM markov_transitions WHERE channel_id = ? AND word = ? AND SUBSTR(next_word, -1, 1) IN ('.', '!', '?', '§') ORDER BY RANDOM() LIMIT 1", "SELECT next_word FROM markov_transitions"
" WHERE channel_id = ? AND word = ?"
" AND SUBSTR(next_word, -1, 1)"
" IN ('.', '!', '?', '§')"
" ORDER BY RANDOM() LIMIT 1",
(channel_id, word), (channel_id, word),
) as cursor: ) as cursor:
row = await cursor.fetchone() row = await cursor.fetchone()
return row[0] if row else None return row[0] if row else None
async def add_image(self, channel_id: int, user_id: int, url: str) -> None: async def add_image(self, channel_id: int, user_id: int, url: str) -> None:
""" """Store an image URL for a given channel.
Stores an image URL for a given channel.
:param channel_id: The Discord channel ID. :param channel_id: The Discord channel ID.
:param user_id: The Discord user ID of the contributor. :param user_id: The Discord user ID of the contributor.
@@ -231,29 +241,31 @@ class Database:
await self._maybe_commit() await self._maybe_commit()
async def get_random_image(self, channel_id: int) -> str | None: async def get_random_image(self, channel_id: int) -> str | None:
""" """Return a random image URL for a given channel.
Returns a random image URL for a given channel.
:param channel_id: The Discord channel ID. :param channel_id: The Discord channel ID.
:return: A random image URL, or None if no images exist for this channel. :return: A random image URL, or None if none exist.
""" """
async with self._connection.execute( async with self._connection.execute(
"SELECT url FROM channel_images WHERE channel_id = ? ORDER BY RANDOM() LIMIT 1", "SELECT url FROM channel_images"
" WHERE channel_id = ?"
" ORDER BY RANDOM() LIMIT 1",
(channel_id,), (channel_id,),
) as cursor: ) as cursor:
row = await cursor.fetchone() row = await cursor.fetchone()
return row[0] if row else None return row[0] if row else None
async def set_flag(self, entity_type: str, entity_id: str, flag_name: str) -> None: async def set_flag(self, entity_type: str, entity_id: str, flag_name: str) -> None:
""" """Set a flag on a given entity. If the flag is already set, this is a no-op.
Sets a flag on a given entity. If the flag is already set, this is a no-op.
:param entity_type: The type of entity ("channel", "server", or "user"). :param entity_type: The entity type ("channel", "server", "user").
:param entity_id: The Discord ID of the entity. :param entity_id: The Discord ID of the entity.
:param flag_name: The name of the flag to set. :param flag_name: The name of the flag to set.
""" """
await self._connection.execute( await self._connection.execute(
"INSERT OR IGNORE INTO flags (entity_type, entity_id, flag_name) VALUES (?, ?, ?)", "INSERT OR IGNORE INTO flags"
" (entity_type, entity_id, flag_name)"
" VALUES (?, ?, ?)",
(entity_type, entity_id, flag_name), (entity_type, entity_id, flag_name),
) )
await self._maybe_commit() await self._maybe_commit()
@@ -261,15 +273,17 @@ class Database:
async def clear_flag( async def clear_flag(
self, entity_type: str, entity_id: str, flag_name: str self, entity_type: str, entity_id: str, flag_name: str
) -> None: ) -> None:
""" """Clear a flag on a given entity. If the flag is not set, this is a no-op.
Clears a flag on a given entity. If the flag is not set, this is a no-op.
:param entity_type: The type of entity ("channel", "server", or "user"). :param entity_type: The entity type ("channel", "server", "user").
:param entity_id: The Discord ID of the entity. :param entity_id: The Discord ID of the entity.
:param flag_name: The name of the flag to clear. :param flag_name: The name of the flag to clear.
""" """
await self._connection.execute( await self._connection.execute(
"DELETE FROM flags WHERE entity_type = ? AND entity_id = ? AND flag_name = ?", "DELETE FROM flags"
" WHERE entity_type = ?"
" AND entity_id = ?"
" AND flag_name = ?",
(entity_type, entity_id, flag_name), (entity_type, entity_id, flag_name),
) )
await self._maybe_commit() await self._maybe_commit()
@@ -277,23 +291,24 @@ class Database:
async def is_flag_set( async def is_flag_set(
self, entity_type: str, entity_id: str, flag_name: str self, entity_type: str, entity_id: str, flag_name: str
) -> bool: ) -> bool:
""" """Check whether a flag is set on a given entity.
Checks whether a flag is set on a given entity.
:param entity_type: The type of entity ("channel", "server", or "user"). :param entity_type: The entity type ("channel", "server", "user").
:param entity_id: The Discord ID of the entity. :param entity_id: The Discord ID of the entity.
:param flag_name: The name of the flag to check. :param flag_name: The name of the flag to check.
:return: True if the flag is set, False otherwise. :return: True if the flag is set, False otherwise.
""" """
async with self._connection.execute( async with self._connection.execute(
"SELECT 1 FROM flags WHERE entity_type = ? AND entity_id = ? AND flag_name = ?", "SELECT 1 FROM flags"
" WHERE entity_type = ?"
" AND entity_id = ?"
" AND flag_name = ?",
(entity_type, entity_id, flag_name), (entity_type, entity_id, flag_name),
) as cursor: ) as cursor:
return await cursor.fetchone() is not None return await cursor.fetchone() is not None
async def is_channel_ingested(self, channel_id: int) -> bool: async def is_channel_ingested(self, channel_id: int) -> bool:
""" """Check whether a channel has already been bulk-ingested.
Checks whether a channel has already been bulk-ingested.
:param channel_id: The Discord channel ID. :param channel_id: The Discord channel ID.
:return: True if the channel has been ingested, False otherwise. :return: True if the channel has been ingested, False otherwise.
@@ -305,8 +320,7 @@ class Database:
return await cursor.fetchone() is not None return await cursor.fetchone() is not None
async def mark_channel_ingested(self, channel_id: int) -> None: async def mark_channel_ingested(self, channel_id: int) -> None:
""" """Mark a channel as having been bulk-ingested.
Marks a channel as having been bulk-ingested.
:param channel_id: The Discord channel ID. :param channel_id: The Discord channel ID.
""" """
@@ -317,7 +331,7 @@ class Database:
await self._maybe_commit() await self._maybe_commit()
async def _maybe_commit(self) -> None: async def _maybe_commit(self) -> None:
"""Tracks a pending write. Commits if the threshold is reached, otherwise starts a flush timer.""" """Track a pending write and commit or start a flush timer."""
self._pending_writes += 1 self._pending_writes += 1
if self._pending_writes >= AUTO_COMMIT_WRITE_THRESHOLD: if self._pending_writes >= AUTO_COMMIT_WRITE_THRESHOLD:
await self.commit() await self.commit()
+5 -10
View File
@@ -12,8 +12,7 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
""" """Provides async convenience wrappers around the database flag methods.
Provides async convenience wrappers around the database flag methods.
Accepts discord.py objects or raw integer IDs and translates them into the Accepts discord.py objects or raw integer IDs and translates them into the
entity_type/entity_id pairs used by the database layer. entity_type/entity_id pairs used by the database layer.
@@ -45,8 +44,7 @@ class EntityType(enum.Enum):
def _entity_id(entity: discord.abc.Snowflake | int) -> str: def _entity_id(entity: discord.abc.Snowflake | int) -> str:
""" """Extract a string entity ID from a Discord object or raw integer ID.
Extracts a string entity ID from a Discord object or raw integer ID.
:param entity: A Discord entity or raw integer ID. :param entity: A Discord entity or raw integer ID.
:return: The entity ID as a string. :return: The entity ID as a string.
@@ -60,8 +58,7 @@ async def set_flag(
entity_type: EntityType, entity_type: EntityType,
flag: Flag, flag: Flag,
) -> None: ) -> None:
""" """Set a flag on a given entity.
Sets a flag on a given entity.
:param db: The database instance. :param db: The database instance.
:param entity: The Discord entity or raw integer ID to set the flag on. :param entity: The Discord entity or raw integer ID to set the flag on.
@@ -77,8 +74,7 @@ async def clear_flag(
entity_type: EntityType, entity_type: EntityType,
flag: Flag, flag: Flag,
) -> None: ) -> None:
""" """Clear a flag on a given entity.
Clears a flag on a given entity.
:param db: The database instance. :param db: The database instance.
:param entity: The Discord entity or raw integer ID to clear the flag on. :param entity: The Discord entity or raw integer ID to clear the flag on.
@@ -94,8 +90,7 @@ async def is_flag_set(
entity_type: EntityType, entity_type: EntityType,
flag: Flag, flag: Flag,
) -> bool: ) -> bool:
""" """Check whether a flag is set on a given entity.
Checks whether a flag is set on a given entity.
:param db: The database instance. :param db: The database instance.
:param entity: The Discord entity or raw integer ID to check. :param entity: The Discord entity or raw integer ID to check.
+1
View File
@@ -0,0 +1 @@
"""Listener cogs for Discord events."""
+19 -16
View File
@@ -12,8 +12,7 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
""" """Handles responding to interactions.
Handles responding to interactions.
Provides the /pingme slash command as a Cog with an app command. Provides the /pingme slash command as a Cog with an app command.
""" """
@@ -35,13 +34,10 @@ logger = logging.getLogger(__name__)
class InteractionCog(commands.Cog): class InteractionCog(commands.Cog):
""" """Cog for handling slash command interactions."""
Cog for handling slash command interactions.
"""
def __init__(self, bot: Crabstero) -> None: def __init__(self, bot: Crabstero) -> None:
""" """Create a new interaction handler cog.
Creates a new interaction handler cog.
:param bot: The bot instance. :param bot: The bot instance.
""" """
@@ -49,11 +45,13 @@ class InteractionCog(commands.Cog):
@app_commands.command( @app_commands.command(
name="pingme", name="pingme",
description="Opts into (or back out of) receiving pings for generated message which mention you.", description=(
"Opts into (or back out of) receiving pings"
" for generated message which mention you."
),
) )
async def pingme(self, interaction: discord.Interaction) -> None: async def pingme(self, interaction: discord.Interaction) -> None:
""" """Toggle the allowPings flag for the user who ran the command.
Toggles the allowPings flag for the user who ran the command.
:param interaction: The interaction event. :param interaction: The interaction event.
""" """
@@ -66,7 +64,9 @@ class InteractionCog(commands.Cog):
self.bot.db, interaction.user, EntityType.USER, Flag.ALLOW_PINGS self.bot.db, interaction.user, EntityType.USER, Flag.ALLOW_PINGS
) )
await interaction.response.send_message( await interaction.response.send_message(
"I will no longer ping you for messages which mention you. If you decide to opt back in, run `/pingme` any time.", "I will no longer ping you for messages which"
" mention you. If you decide to opt back in,"
" run `/pingme` any time.",
ephemeral=True, ephemeral=True,
) )
else: else:
@@ -74,23 +74,26 @@ class InteractionCog(commands.Cog):
self.bot.db, interaction.user, EntityType.USER, Flag.ALLOW_PINGS self.bot.db, interaction.user, EntityType.USER, Flag.ALLOW_PINGS
) )
await interaction.response.send_message( await interaction.response.send_message(
"I will now ping you for messages which mention you. If you change your mind, run `/pingme` any time.", "I will now ping you for messages which mention"
" you. If you change your mind, run"
" `/pingme` any time.",
ephemeral=True, ephemeral=True,
) )
except Exception: except Exception:
logger.exception( logger.exception(
"An exception occurred while attempting to execute slash command responder for command 'pingme'." "An exception occurred while attempting to execute"
" slash command responder for command 'pingme'."
) )
await interaction.response.send_message( await interaction.response.send_message(
"An error occurred while executing your command. Please try again later.", "An error occurred while executing your command."
" Please try again later.",
ephemeral=True, ephemeral=True,
) )
async def setup(bot: Crabstero) -> None: async def setup(bot: Crabstero) -> None:
""" """Add the InteractionCog to the bot.
Adds the InteractionCog to the bot.
:param bot: The bot instance. :param bot: The bot instance.
""" """
+7 -12
View File
@@ -12,10 +12,10 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
""" """Handles created messages.
Handles created messages.
Responds to mentions and ingests normal text messages as a Cog with an on_message listener. Responds to mentions and ingests normal text messages as a Cog with an
on_message listener.
""" """
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -30,13 +30,10 @@ if TYPE_CHECKING:
class MessageCog(commands.Cog): class MessageCog(commands.Cog):
""" """Cog for handling message creation events."""
Cog for handling message creation events.
"""
def __init__(self, bot: Crabstero) -> None: def __init__(self, bot: Crabstero) -> None:
""" """Create a new message creation handler cog.
Creates a new message creation handler cog.
:param bot: The bot instance. :param bot: The bot instance.
""" """
@@ -44,8 +41,7 @@ class MessageCog(commands.Cog):
@commands.Cog.listener() @commands.Cog.listener()
async def on_message(self, message: discord.Message) -> None: async def on_message(self, message: discord.Message) -> None:
""" """Respond to mentions and ingest normal text messages.
Responds to mentions and ingests normal text messages.
:param message: The message event. :param message: The message event.
""" """
@@ -79,8 +75,7 @@ class MessageCog(commands.Cog):
async def setup(bot: Crabstero) -> None: async def setup(bot: Crabstero) -> None:
""" """Add the MessageCog to the bot.
Adds the MessageCog to the bot.
:param bot: The bot instance. :param bot: The bot instance.
""" """
+15 -22
View File
@@ -12,11 +12,11 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
""" """Handles server-level events which trigger channel history ingestion.
Handles server-level events which trigger channel history ingestion.
All of these events share the same outcome: queuing all textable channels for message history All of these events share the same outcome: queuing all textable channels
ingestion when something changes that may grant new read permissions. for message history ingestion when something changes that may grant new
read permissions.
""" """
import contextlib import contextlib
@@ -35,13 +35,10 @@ logger = logging.getLogger(__name__)
class ServerEventsCog(commands.Cog): class ServerEventsCog(commands.Cog):
""" """Cog for handling server-level events that trigger channel history ingestion."""
Cog for handling server-level events that trigger channel history ingestion.
"""
def __init__(self, bot: Crabstero) -> None: def __init__(self, bot: Crabstero) -> None:
""" """Create a new server events handler cog.
Creates a new server events handler cog.
:param bot: The bot instance. :param bot: The bot instance.
""" """
@@ -49,9 +46,9 @@ class ServerEventsCog(commands.Cog):
@commands.Cog.listener() @commands.Cog.listener()
async def on_guild_join(self, guild: discord.Guild) -> None: async def on_guild_join(self, guild: discord.Guild) -> None:
""" """Queue all text channels for ingestion when joining a new server.
Queues all text channels for message history ingestion when joining a new server.
Also logs the join and sends an embed to the bot owner. Also log the join and send an embed to the bot owner.
:param guild: The guild that was joined. :param guild: The guild that was joined.
""" """
@@ -79,8 +76,7 @@ class ServerEventsCog(commands.Cog):
@commands.Cog.listener() @commands.Cog.listener()
async def on_guild_available(self, guild: discord.Guild) -> None: async def on_guild_available(self, guild: discord.Guild) -> None:
""" """Queue all textable channels for ingestion when a server becomes available.
Queues all textable channels for message history ingestion when a server becomes available.
:param guild: The guild that became available. :param guild: The guild that became available.
""" """
@@ -90,9 +86,9 @@ class ServerEventsCog(commands.Cog):
async def on_guild_role_update( async def on_guild_role_update(
self, before: discord.Role, after: discord.Role self, before: discord.Role, after: discord.Role
) -> None: ) -> None:
""" """Queue all text channels for ingestion when role permissions change.
Queues all text channels for message history ingestion when role permissions change,
but only if the bot is a member of the updated role. Only triggers if the bot is a member of the updated role.
:param before: The role before the update. :param before: The role before the update.
:param after: The role after the update. :param after: The role after the update.
@@ -107,9 +103,7 @@ class ServerEventsCog(commands.Cog):
async def on_guild_channel_update( async def on_guild_channel_update(
self, before: discord.abc.GuildChannel, after: discord.abc.GuildChannel self, before: discord.abc.GuildChannel, after: discord.abc.GuildChannel
) -> None: ) -> None:
""" """Queue all text channels for ingestion on permission change.
Queues all text channels for message history ingestion when channel override permissions
change.
:param before: The channel before the update. :param before: The channel before the update.
:param after: The channel after the update. :param after: The channel after the update.
@@ -121,8 +115,7 @@ class ServerEventsCog(commands.Cog):
async def setup(bot: Crabstero) -> None: async def setup(bot: Crabstero) -> None:
""" """Add the ServerEventsCog to the bot.
Adds the ServerEventsCog to the bot.
:param bot: The bot instance. :param bot: The bot instance.
""" """
+18 -18
View File
@@ -12,11 +12,11 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
""" """Markov chain sentence ingestion and generation backed by SQLite.
Ingests sentences and generates new ones using a Markov chain backed by SQLite storage.
The Markov chain stores word transitions per channel, using duplicate rows to represent frequency The Markov chain stores word transitions per channel, using duplicate rows
weight. Random selection via ORDER BY RANDOM() LIMIT 1 naturally preserves this weighting. to represent frequency weight. Random selection via ORDER BY RANDOM()
LIMIT 1 naturally preserves this weighting.
""" """
import re import re
@@ -31,9 +31,10 @@ DEFAULT_SENTENCE_END = "\u00a7"
def is_complete_sentence(sentence: str) -> bool: def is_complete_sentence(sentence: str) -> bool:
""" """Check whether a sentence ends with a valid terminator.
Checks whether a given sentence ends with a default sentence end character, a period, an
exclamation mark, or a question mark. Valid terminators are the section sign, period, exclamation mark, or
question mark.
:param sentence: The sentence to test. :param sentence: The sentence to test.
:return: True if the sentence ends with a valid terminator, False otherwise. :return: True if the sentence ends with a valid terminator, False otherwise.
@@ -45,9 +46,10 @@ def is_complete_sentence(sentence: str) -> bool:
async def ingest(db: Database, channel_id: int, user_id: int, paragraph: str) -> None: async def ingest(db: Database, channel_id: int, user_id: int, paragraph: str) -> None:
""" """Ingest a paragraph into the Markov chain for a given channel.
Ingests a string potentially containing multiple smaller sentences into the Markov chain
for a given channel. The paragraph may contain multiple sentences which are split and ingested
individually.
:param db: The database instance. :param db: The database instance.
:param channel_id: The Discord channel ID to associate with this data. :param channel_id: The Discord channel ID to associate with this data.
@@ -57,7 +59,8 @@ async def ingest(db: Database, channel_id: int, user_id: int, paragraph: str) ->
if not is_complete_sentence(paragraph): if not is_complete_sentence(paragraph):
paragraph += DEFAULT_SENTENCE_END paragraph += DEFAULT_SENTENCE_END
# Normalize whitespace, then split on sentence-ending punctuation followed by a space. # Normalize whitespace, then split on sentence-ending punctuation
# followed by a space.
normalized = re.sub(r" +", " ", paragraph.strip().replace("\n", " ")) normalized = re.sub(r" +", " ", paragraph.strip().replace("\n", " "))
sentences = re.split(r"(?<=[.!?]) ", normalized) sentences = re.split(r"(?<=[.!?]) ", normalized)
@@ -68,8 +71,7 @@ async def ingest(db: Database, channel_id: int, user_id: int, paragraph: str) ->
async def _ingest_sentence( async def _ingest_sentence(
db: Database, channel_id: int, user_id: int, sentence: str db: Database, channel_id: int, user_id: int, sentence: str
) -> None: ) -> None:
""" """Ingest a single sentence into the Markov chain for a given channel.
Ingests a string containing a single sentence into the Markov chain for a given channel.
:param db: The database instance. :param db: The database instance.
:param channel_id: The Discord channel ID to associate with this data. :param channel_id: The Discord channel ID to associate with this data.
@@ -99,14 +101,12 @@ async def _ingest_sentence(
async def generate( async def generate(
db: Database, channel_id: int, soft_limit: int = 750, hard_limit: int = 1000 db: Database, channel_id: int, soft_limit: int = 750, hard_limit: int = 1000
) -> str: ) -> str:
""" """Generate a new sentence from previously ingested words for a channel.
Generates a new sentence using words learned from previously ingested sentences for a given
channel.
:param db: The database instance. :param db: The database instance.
:param channel_id: The Discord channel ID to generate from. :param channel_id: The Discord channel ID to generate from.
:param soft_limit: The amount of characters to try and limit sentence length around. :param soft_limit: Character count to aim for when wrapping up.
:param hard_limit: The amount of characters to cut off the sentence at if it gets too long. :param hard_limit: Character count to hard-cut the sentence at.
:return: A new generated sentence. :return: A new generated sentence.
""" """
word = await db.get_random_start_word(channel_id) word = await db.get_random_start_word(channel_id)
+7 -10
View File
@@ -12,11 +12,11 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
""" """Generate Discord reply messages and ingest raw messages.
Assists with generating Discord messages in response to other users and ingesting raw messages.
Orchestrates Markov chain generation and ingestion in the context of Discord messages, handling Orchestrates Markov chain generation and ingestion in the context of
reply logic, embed generation, mention filtering, and message ingestion with flag checks. Discord messages, handling reply logic, embed generation, mention
filtering, and message ingestion with flag checks.
""" """
import re import re
@@ -39,8 +39,7 @@ _EMBED_CHANCE_THRESHOLD = 95 # Out of 100; sends an embed ~5% of the time.
async def reply_to_message(db: Database, message: discord.Message) -> None: async def reply_to_message(db: Database, message: discord.Message) -> None:
""" """Send a new message in Discord in response to a given message.
Sends a new message in Discord in response to a given message.
:param db: The database instance. :param db: The database instance.
:param message: The message prompting the response. :param message: The message prompting the response.
@@ -115,8 +114,7 @@ async def reply_to_message(db: Database, message: discord.Message) -> None:
async def ingest_message(db: Database, message: discord.Message) -> None: async def ingest_message(db: Database, message: discord.Message) -> None:
""" """Ingest a given message into its channel's Markov chain.
Ingests a given message into its channel's Markov chain.
:param db: The database instance. :param db: The database instance.
:param message: The message to ingest. :param message: The message to ingest.
@@ -148,8 +146,7 @@ async def ingest_message(db: Database, message: discord.Message) -> None:
async def _ingest_embed( async def _ingest_embed(
db: Database, channel_id: int, user_id: int, embed: discord.Embed db: Database, channel_id: int, user_id: int, embed: discord.Embed
) -> None: ) -> None:
""" """Ingest a given embed into a given channel's Markov chain.
Ingests a given embed into a given channel's Markov chain.
:param db: The database instance. :param db: The database instance.
:param channel_id: The ID of the channel to use for the Markov chain. :param channel_id: The ID of the channel to use for the Markov chain.
+1
View File
@@ -0,0 +1 @@
"""Background tasks for Crabstero."""
+9 -9
View File
@@ -12,8 +12,7 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
""" """Bulk-ingests the message history of channels.
Bulk-ingests the message history of channels.
Channels are enqueued for processing by the bot's fixed ingestion worker pool. Channels are enqueued for processing by the bot's fixed ingestion worker pool.
""" """
@@ -37,8 +36,7 @@ logger = logging.getLogger(__name__)
def queue_channels_for_ingestion(guild: discord.Guild, bot: Crabstero) -> None: def queue_channels_for_ingestion(guild: discord.Guild, bot: Crabstero) -> None:
""" """Enqueue every textable channel in a guild for message history ingestion.
Enqueues every textable channel in a guild for message history ingestion.
:param guild: The Discord guild whose channels should be ingested. :param guild: The Discord guild whose channels should be ingested.
:param bot: The bot instance. :param bot: The bot instance.
@@ -52,9 +50,10 @@ async def ingest_channel(
channel: discord.TextChannel | discord.VoiceChannel, channel: discord.TextChannel | discord.VoiceChannel,
db: Database, db: Database,
) -> None: ) -> None:
""" """Bulk-ingest the message history of a given channel.
Bulk-ingests the message history of a given channel. The task will be ended early if
permissions do not allow ingesting this channel or if it has already been ingested in the past. End early if permissions do not allow ingesting this channel or if it has
already been ingested.
:param channel: The channel to ingest. :param channel: The channel to ingest.
:param db: The database instance. :param db: The database instance.
@@ -62,7 +61,8 @@ async def ingest_channel(
try: try:
if not channel.permissions_for(channel.guild.me).read_message_history: if not channel.permissions_for(channel.guild.me).read_message_history:
logger.warning( logger.warning(
"[%s] Unable to ingest textable channel history due to lacking permissions. Ignoring.", "[%s] Unable to ingest channel history"
" due to lacking permissions. Ignoring.",
channel.id, channel.id,
) )
return return
@@ -80,7 +80,7 @@ async def ingest_channel(
await ingest_message(db, message) await ingest_message(db, message)
logger.info( logger.info(
"[%s] Ingestion of textable channel history complete. %d messages ingested.", "[%s] Ingestion of channel history complete. %d messages ingested.",
channel.id, channel.id,
count, count,
) )
+47 -2
View File
@@ -25,6 +25,8 @@ dev = [
"pytest-cov>=7.0.0", "pytest-cov>=7.0.0",
"mypy>=1.18.1", "mypy>=1.18.1",
"ruff>=0.15.1", "ruff>=0.15.1",
"pip-audit>=2.10.0",
"codespell>=2.4.1",
] ]
[tool.uv] [tool.uv]
@@ -60,20 +62,63 @@ extend-exclude = ["crabstero/_version.py"]
[tool.ruff.lint] [tool.ruff.lint]
select = [ select = [
# Core
"F", # Pyflakes "F", # Pyflakes
"E", # pycodestyle errors "E", # pycodestyle errors
"W", # pycodestyle warnings "W", # pycodestyle warnings
"N", # pep8-naming
"D", # pydocstyle
"I", # isort "I", # isort
"UP", # pyupgrade "ICN", # flake8-import-conventions
# Correctness & bugs
"B", # flake8-bugbear "B", # flake8-bugbear
"ASYNC", # flake8-async
"DTZ", # flake8-datetimez
"RSE", # flake8-raise
"RET", # flake8-return
"A", # flake8-builtins
"PIE", # flake8-pie
# Modernization & simplification
"UP", # pyupgrade
"SIM", # flake8-simplify "SIM", # flake8-simplify
"C4", # flake8-comprehensions
"FLY", # flynt (f-string conversion)
"PTH", # flake8-use-pathlib
# Performance
"PERF", # Perflint
# Security
"S", # flake8-bandit
# Code hygiene
"T10", # flake8-debugger
"T20", # flake8-print
"ERA", # eradicate
"PGH", # pygrep-hooks
"TCH", # flake8-type-checking "TCH", # flake8-type-checking
# Testing
"PT", # flake8-pytest-style
# Ruff-specific
"RUF", # Ruff-specific rules "RUF", # Ruff-specific rules
] ]
ignore = [ ignore = [
"E501", # line length enforced by the formatter; residual violations are in strings/docstrings/comments "D203", # incompatible with D211 (no blank line before class docstring)
"D213", # incompatible with D212 (summary on first line)
] ]
[tool.ruff.lint.per-file-ignores]
"crabstero/__init__.py" = ["E402"] # constants defined before imports intentionally
"crabstero/__main__.py" = ["T201"] # CLI entry point uses print() for user output
"tests/**" = ["S101", "S311"] # assert is standard for pytest; random is fine in tests
[tool.codespell]
skip = "crabstero/_version.py,uv.lock"
[tool.coverage.run] [tool.coverage.run]
source = ["crabstero"] source = ["crabstero"]
omit = [ omit = [
+1
View File
@@ -0,0 +1 @@
"""Test suite for Crabstero."""
+2
View File
@@ -11,3 +11,5 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""Shared pytest fixtures for the test suite."""
+3
View File
@@ -12,9 +12,12 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""Tests for version availability."""
import crabstero import crabstero
def test_version_is_available() -> None: def test_version_is_available() -> None:
"""Verify __version__ is a non-empty string."""
assert isinstance(crabstero.__version__, str) assert isinstance(crabstero.__version__, str)
assert crabstero.__version__ assert crabstero.__version__
Generated
+342
View File
@@ -132,6 +132,76 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f6/22/91616fe707a5c5510de2cac9b046a30defe7007ba8a0c04f9c08f27df312/audioop_lts-0.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:b492c3b040153e68b9fdaff5913305aaaba5bb433d8a7f73d5cf6a64ed3cc1dd", size = 25206, upload-time = "2025-08-05T16:43:16.444Z" }, { url = "https://files.pythonhosted.org/packages/f6/22/91616fe707a5c5510de2cac9b046a30defe7007ba8a0c04f9c08f27df312/audioop_lts-0.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:b492c3b040153e68b9fdaff5913305aaaba5bb433d8a7f73d5cf6a64ed3cc1dd", size = 25206, upload-time = "2025-08-05T16:43:16.444Z" },
] ]
[[package]]
name = "boolean-py"
version = "5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c4/cf/85379f13b76f3a69bca86b60237978af17d6aa0bc5998978c3b8cf05abb2/boolean_py-5.0.tar.gz", hash = "sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95", size = 37047, upload-time = "2025-04-03T10:39:49.734Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/ca/78d423b324b8d77900030fa59c4aa9054261ef0925631cd2501dd015b7b7/boolean_py-5.0-py3-none-any.whl", hash = "sha256:ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9", size = 26577, upload-time = "2025-04-03T10:39:48.449Z" },
]
[[package]]
name = "cachecontrol"
version = "0.14.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "msgpack" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2d/f6/c972b32d80760fb79d6b9eeb0b3010a46b89c0b23cf6329417ff7886cd22/cachecontrol-0.14.4.tar.gz", hash = "sha256:e6220afafa4c22a47dd0badb319f84475d79108100d04e26e8542ef7d3ab05a1", size = 16150, upload-time = "2025-11-14T04:32:13.138Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/79/c45f2d53efe6ada1110cf6f9fca095e4ff47a0454444aefdde6ac4789179/cachecontrol-0.14.4-py3-none-any.whl", hash = "sha256:b7ac014ff72ee199b5f8af1de29d60239954f223e948196fa3d84adaffc71d2b", size = 22247, upload-time = "2025-11-14T04:32:11.733Z" },
]
[package.optional-dependencies]
filecache = [
{ name = "filelock" },
]
[[package]]
name = "certifi"
version = "2026.1.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
{ url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
{ url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
{ url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
{ url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
{ url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
{ url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
{ url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
{ url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
{ url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
{ url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
{ url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
{ url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
{ url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
{ url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
{ url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
]
[[package]]
name = "codespell"
version = "2.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/15/e0/709453393c0ea77d007d907dd436b3ee262e28b30995ea1aa36c6ffbccaf/codespell-2.4.1.tar.gz", hash = "sha256:299fcdcb09d23e81e35a671bbe746d5ad7e8385972e65dbb833a2eaac33c01e5", size = 344740, upload-time = "2025-01-28T18:52:39.411Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/01/b394922252051e97aab231d416c86da3d8a6d781eeadcdca1082867de64e/codespell-2.4.1-py3-none-any.whl", hash = "sha256:3dadafa67df7e4a3dbf51e0d7315061b80d265f9552ebd699b3dd6834b47e425", size = 344501, upload-time = "2025-01-28T18:52:37.057Z" },
]
[[package]] [[package]]
name = "colorama" name = "colorama"
version = "0.4.6" version = "0.4.6"
@@ -190,7 +260,9 @@ dependencies = [
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "codespell" },
{ name = "mypy" }, { name = "mypy" },
{ name = "pip-audit" },
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-asyncio" }, { name = "pytest-asyncio" },
{ name = "pytest-cov" }, { name = "pytest-cov" },
@@ -205,13 +277,39 @@ requires-dist = [
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [
{ name = "codespell", specifier = ">=2.4.1" },
{ name = "mypy", specifier = ">=1.18.1" }, { name = "mypy", specifier = ">=1.18.1" },
{ name = "pip-audit", specifier = ">=2.10.0" },
{ name = "pytest", specifier = ">=9.0.2" }, { name = "pytest", specifier = ">=9.0.2" },
{ name = "pytest-asyncio", specifier = ">=1.3.0" }, { name = "pytest-asyncio", specifier = ">=1.3.0" },
{ name = "pytest-cov", specifier = ">=7.0.0" }, { name = "pytest-cov", specifier = ">=7.0.0" },
{ name = "ruff", specifier = ">=0.15.1" }, { name = "ruff", specifier = ">=0.15.1" },
] ]
[[package]]
name = "cyclonedx-python-lib"
version = "11.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "license-expression" },
{ name = "packageurl-python" },
{ name = "py-serializable" },
{ name = "sortedcontainers" },
]
sdist = { url = "https://files.pythonhosted.org/packages/89/ed/54ecfa25fc145c58bf4f98090f7b6ffe5188d0759248c57dde44427ea239/cyclonedx_python_lib-11.6.0.tar.gz", hash = "sha256:7fb85a4371fa3a203e5be577ac22b7e9a7157f8b0058b7448731474d6dea7bf0", size = 1408147, upload-time = "2025-12-02T12:28:46.446Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/1b/534ad8a5e0f9470522811a8e5a9bc5d328fb7738ba29faf357467a4ef6d0/cyclonedx_python_lib-11.6.0-py3-none-any.whl", hash = "sha256:94f4aae97db42a452134dafdddcfab9745324198201c4777ed131e64c8380759", size = 511157, upload-time = "2025-12-02T12:28:44.158Z" },
]
[[package]]
name = "defusedxml"
version = "0.7.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" },
]
[[package]] [[package]]
name = "discord-py" name = "discord-py"
version = "2.6.4" version = "2.6.4"
@@ -225,6 +323,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ca/ae/3d3a89b06f005dc5fa8618528dde519b3ba7775c365750f7932b9831ef05/discord_py-2.6.4-py3-none-any.whl", hash = "sha256:2783b7fb7f8affa26847bfc025144652c294e8fe6e0f8877c67ed895749eb227", size = 1209284, upload-time = "2025-10-08T21:45:41.679Z" }, { url = "https://files.pythonhosted.org/packages/ca/ae/3d3a89b06f005dc5fa8618528dde519b3ba7775c365750f7932b9831ef05/discord_py-2.6.4-py3-none-any.whl", hash = "sha256:2783b7fb7f8affa26847bfc025144652c294e8fe6e0f8877c67ed895749eb227", size = 1209284, upload-time = "2025-10-08T21:45:41.679Z" },
] ]
[[package]]
name = "filelock"
version = "3.24.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/73/92/a8e2479937ff39185d20dd6a851c1a63e55849e447a55e798cc2e1f49c65/filelock-3.24.3.tar.gz", hash = "sha256:011a5644dc937c22699943ebbfc46e969cdde3e171470a6e40b9533e5a72affa", size = 37935, upload-time = "2026-02-19T00:48:20.543Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/0f/5d0c71a1aefeb08efff26272149e07ab922b64f46c63363756224bd6872e/filelock-3.24.3-py3-none-any.whl", hash = "sha256:426e9a4660391f7f8a810d71b0555bce9008b0a1cc342ab1f6947d37639e002d", size = 24331, upload-time = "2026-02-19T00:48:18.465Z" },
]
[[package]] [[package]]
name = "frozenlist" name = "frozenlist"
version = "1.8.0" version = "1.8.0"
@@ -314,6 +421,65 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fc/85/69f92b2a7b3c0f88ffe107c86b952b397004b5b8ea5a81da3d9c04c04422/librt-0.7.8-cp314-cp314t-win_arm64.whl", hash = "sha256:8766ece9de08527deabcd7cb1b4f1a967a385d26e33e536d6d8913db6ef74f06", size = 40550, upload-time = "2026-01-14T12:56:01.542Z" }, { url = "https://files.pythonhosted.org/packages/fc/85/69f92b2a7b3c0f88ffe107c86b952b397004b5b8ea5a81da3d9c04c04422/librt-0.7.8-cp314-cp314t-win_arm64.whl", hash = "sha256:8766ece9de08527deabcd7cb1b4f1a967a385d26e33e536d6d8913db6ef74f06", size = 40550, upload-time = "2026-01-14T12:56:01.542Z" },
] ]
[[package]]
name = "license-expression"
version = "30.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "boolean-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/40/71/d89bb0e71b1415453980fd32315f2a037aad9f7f70f695c7cec7035feb13/license_expression-30.4.4.tar.gz", hash = "sha256:73448f0aacd8d0808895bdc4b2c8e01a8d67646e4188f887375398c761f340fd", size = 186402, upload-time = "2025-07-22T11:13:32.17Z" }
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 = "markdown-it-py"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "msgpack"
version = "1.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" },
{ url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" },
{ url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" },
{ url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" },
{ url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" },
{ url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" },
{ url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" },
{ url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" },
{ url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" },
{ url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" },
{ url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" },
{ url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" },
{ url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" },
{ url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" },
{ url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" },
{ url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" },
{ url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" },
{ url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" },
]
[[package]] [[package]]
name = "multidict" name = "multidict"
version = "6.7.1" version = "6.7.1"
@@ -389,6 +555,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
] ]
[[package]]
name = "packageurl-python"
version = "0.17.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f5/d6/3b5a4e3cfaef7a53869a26ceb034d1ff5e5c27c814ce77260a96d50ab7bb/packageurl_python-0.17.6.tar.gz", hash = "sha256:1252ce3a102372ca6f86eb968e16f9014c4ba511c5c37d95a7f023e2ca6e5c25", size = 50618, upload-time = "2025-11-24T15:20:17.998Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b1/2f/c7277b7615a93f51b5fbc1eacfc1b75e8103370e786fd8ce2abf6e5c04ab/packageurl_python-0.17.6-py3-none-any.whl", hash = "sha256:31a85c2717bc41dd818f3c62908685ff9eebcb68588213745b14a6ee9e7df7c9", size = 36776, upload-time = "2025-11-24T15:20:16.962Z" },
]
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "26.0" version = "26.0"
@@ -407,6 +582,70 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" },
] ]
[[package]]
name = "pip"
version = "26.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/48/83/0d7d4e9efe3344b8e2fe25d93be44f64b65364d3c8d7bc6dc90198d5422e/pip-26.0.1.tar.gz", hash = "sha256:c4037d8a277c89b320abe636d59f91e6d0922d08a05b60e85e53b296613346d8", size = 1812747, upload-time = "2026-02-05T02:20:18.702Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/f0/c81e05b613866b76d2d1066490adf1a3dbc4ee9d9c839961c3fc8a6997af/pip-26.0.1-py3-none-any.whl", hash = "sha256:bdb1b08f4274833d62c1aa29e20907365a2ceb950410df15fc9521bad440122b", size = 1787723, upload-time = "2026-02-05T02:20:16.416Z" },
]
[[package]]
name = "pip-api"
version = "0.0.34"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pip" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b9/f1/ee85f8c7e82bccf90a3c7aad22863cc6e20057860a1361083cd2adacb92e/pip_api-0.0.34.tar.gz", hash = "sha256:9b75e958f14c5a2614bae415f2adf7eeb54d50a2cfbe7e24fd4826471bac3625", size = 123017, upload-time = "2024-07-09T20:32:30.641Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/91/f7/ebf5003e1065fd00b4cbef53bf0a65c3d3e1b599b676d5383ccb7a8b88ba/pip_api-0.0.34-py3-none-any.whl", hash = "sha256:8b2d7d7c37f2447373aa2cf8b1f60a2f2b27a84e1e9e0294a3f6ef10eb3ba6bb", size = 120369, upload-time = "2024-07-09T20:32:29.099Z" },
]
[[package]]
name = "pip-audit"
version = "2.10.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cachecontrol", extra = ["filecache"] },
{ name = "cyclonedx-python-lib" },
{ name = "packaging" },
{ name = "pip-api" },
{ name = "pip-requirements-parser" },
{ name = "platformdirs" },
{ name = "requests" },
{ name = "rich" },
{ name = "tomli" },
{ name = "tomli-w" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bd/89/0e999b413facab81c33d118f3ac3739fd02c0622ccf7c4e82e37cebd8447/pip_audit-2.10.0.tar.gz", hash = "sha256:427ea5bf61d1d06b98b1ae29b7feacc00288a2eced52c9c58ceed5253ef6c2a4", size = 53776, upload-time = "2025-12-01T23:42:40.612Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/be/f3/4888f895c02afa085630a3a3329d1b18b998874642ad4c530e9a4d7851fe/pip_audit-2.10.0-py3-none-any.whl", hash = "sha256:16e02093872fac97580303f0848fa3ad64f7ecf600736ea7835a2b24de49613f", size = 61518, upload-time = "2025-12-01T23:42:39.193Z" },
]
[[package]]
name = "pip-requirements-parser"
version = "32.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
{ name = "pyparsing" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/2a/63b574101850e7f7b306ddbdb02cb294380d37948140eecd468fae392b54/pip-requirements-parser-32.0.1.tar.gz", hash = "sha256:b4fa3a7a0be38243123cf9d1f3518da10c51bdb165a2b2985566247f9155a7d3", size = 209359, upload-time = "2022-12-21T15:25:22.732Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/d0/d04f1d1e064ac901439699ee097f58688caadea42498ec9c4b4ad2ef84ab/pip_requirements_parser-32.0.1-py3-none-any.whl", hash = "sha256:4659bc2a667783e7a15d190f6fccf8b2486685b6dba4c19c3876314769c57526", size = 35648, upload-time = "2022-12-21T15:25:21.046Z" },
]
[[package]]
name = "platformdirs"
version = "4.9.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394, upload-time = "2026-02-16T03:56:10.574Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" },
]
[[package]] [[package]]
name = "pluggy" name = "pluggy"
version = "1.6.0" version = "1.6.0"
@@ -455,6 +694,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" },
] ]
[[package]]
name = "py-serializable"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "defusedxml" },
]
sdist = { url = "https://files.pythonhosted.org/packages/73/21/d250cfca8ff30c2e5a7447bc13861541126ce9bd4426cd5d0c9f08b5547d/py_serializable-2.1.0.tar.gz", hash = "sha256:9d5db56154a867a9b897c0163b33a793c804c80cee984116d02d49e4578fc103", size = 52368, upload-time = "2025-07-21T09:56:48.07Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/bf/7595e817906a29453ba4d99394e781b6fabe55d21f3c15d240f85dd06bb1/py_serializable-2.1.0-py3-none-any.whl", hash = "sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304", size = 23045, upload-time = "2025-07-21T09:56:46.848Z" },
]
[[package]] [[package]]
name = "pygments" name = "pygments"
version = "2.19.2" version = "2.19.2"
@@ -464,6 +715,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
] ]
[[package]]
name = "pyparsing"
version = "3.3.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" },
]
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "9.0.2" version = "9.0.2"
@@ -506,6 +766,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
] ]
[[package]]
name = "requests"
version = "2.32.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
]
[[package]]
name = "rich"
version = "14.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
]
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.15.1" version = "0.15.1"
@@ -531,6 +819,51 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/07/5bda6a85b220c64c65686bc85bd0bbb23b29c62b3a9f9433fa55f17cda93/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416", size = 10874604, upload-time = "2026-02-12T23:09:05.515Z" }, { url = "https://files.pythonhosted.org/packages/2a/07/5bda6a85b220c64c65686bc85bd0bbb23b29c62b3a9f9433fa55f17cda93/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416", size = 10874604, upload-time = "2026-02-12T23:09:05.515Z" },
] ]
[[package]]
name = "sortedcontainers"
version = "2.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" }
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 = "tomli"
version = "2.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" },
{ url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" },
{ url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" },
{ url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" },
{ url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" },
{ url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" },
{ url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" },
{ url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" },
{ url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" },
{ url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" },
{ url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" },
{ url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" },
{ url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" },
{ url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" },
{ url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" },
{ url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" },
{ url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" },
{ url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" },
{ url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" },
]
[[package]]
name = "tomli-w"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" },
]
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.15.0" version = "4.15.0"
@@ -540,6 +873,15 @@ 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" }, { 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 = "urllib3"
version = "2.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]
[[package]] [[package]]
name = "yarl" name = "yarl"
version = "1.22.0" version = "1.22.0"