From 66e5428ce397306c1d9bde9dec2a70db579fed72 Mon Sep 17 00:00:00 2001 From: Imogen Date: Thu, 2 Oct 2025 13:13:59 +0200 Subject: [PATCH 1/8] update linting --- algobattle/battle.py | 52 ++++---- algobattle/cli.py | 198 ++++++++++++++++--------------- algobattle/match.py | 53 ++++----- algobattle/program.py | 98 ++++++++------- algobattle/templates/__init__.py | 10 +- algobattle/types.py | 90 +++++++------- algobattle/util.py | 27 ++--- pyproject.toml | 106 ++++++++++++++++- tests/test_battles.py | 11 +- 9 files changed, 359 insertions(+), 286 deletions(-) diff --git a/algobattle/battle.py b/algobattle/battle.py index af22a341..71a8be3a 100644 --- a/algobattle/battle.py +++ b/algobattle/battle.py @@ -3,10 +3,11 @@ This module contains the :class:`Battle` class, which speciefies how each type of battle is fought and scored, some basic battle types, and related classed. """ +from abc import abstractmethod +from collections.abc import Iterable from dataclasses import dataclass, field from enum import StrEnum from importlib.metadata import entry_points -from abc import abstractmethod from inspect import isclass from itertools import count from pathlib import Path @@ -16,7 +17,6 @@ Annotated, Any, ClassVar, - Iterable, Literal, ParamSpec, Protocol, @@ -26,9 +26,8 @@ Unpack, overload, ) -from typing_extensions import TypedDict -from annotated_types import Ge +from annotated_types import Ge from pydantic import ( ConfigDict, Field, @@ -39,11 +38,10 @@ ValidatorFunctionWrapHandler, ) from pydantic_core import CoreSchema -from pydantic_core.core_schema import ( - tagged_union_schema, - with_info_wrap_validator_function, -) +from pydantic_core.core_schema import tagged_union_schema, with_info_wrap_validator_function +from typing_extensions import TypedDict +from algobattle.problem import InstanceModel, Problem, SolutionModel from algobattle.program import ( Generator, GeneratorResult, @@ -53,15 +51,7 @@ Solver, SolverResult, ) -from algobattle.problem import InstanceModel, Problem, SolutionModel -from algobattle.util import ( - Encodable, - EncodableModel, - ExceptionInfo, - BaseModel, - Role, -) - +from algobattle.util import BaseModel, Encodable, EncodableModel, ExceptionInfo, Role _BattleConfig: TypeAlias = Any """Type alias used to generate correct typings when subclassing :class:`Battle`. @@ -73,7 +63,7 @@ """ T = TypeVar("T") P = ParamSpec("P") -Type = type +_type = type class ProgramLogConfigTime(StrEnum): @@ -91,7 +81,7 @@ class ProgramLogConfigLocation(StrEnum): inline = "inline" -class ProgramLogConfigView(Protocol): # noqa: D101 +class ProgramLogConfigView(Protocol): when: ProgramLogConfigTime = ProgramLogConfigTime.error output: ProgramLogConfigLocation = ProgramLogConfigLocation.inline @@ -100,7 +90,7 @@ class ProgramRunInfo(BaseModel): """Data about a program's execution.""" runtime: float = 0 - overriden: RunConfigOverride = Field(default_factory=dict) + overriden: RunConfigOverride = Field(default_factory=RunConfigOverride) error: ExceptionInfo | None = None battle_data: SerializeAsAny[EncodableModel] | None = None instance: SerializeAsAny[InstanceModel] | None = None @@ -177,7 +167,7 @@ def end_fight(self) -> None: """Informs the ui that the fight has finished running and has been added to the battle's `.fight_results`.""" -class RunKwargs(TypedDict, total=False): +class RunKwargs(TypedDict, total=False, closed=True): """The keyword arguments used by the FightHandler.run family of functions.""" timeout_generator: float | None @@ -394,7 +384,7 @@ class Config(BaseModel): """Type of battle that will be used.""" @classmethod - def __get_pydantic_core_schema__(cls, source: Type, handler: GetCoreSchemaHandler) -> CoreSchema: + def __get_pydantic_core_schema__(cls, source: _type, handler: GetCoreSchemaHandler) -> CoreSchema: # there's two bugs we need to catch: # 1. this function is called during the pydantic BaseModel metaclass's __new__, so the BattleConfig class # won't be ready at that point and be missing in the namespace @@ -446,7 +436,7 @@ def check_installed(val: object, handler: ValidatorFunctionWrapHandler, info: Va installed = ", ".join(b.name() for b in Battle._battle_types.values()) raise ValueError( f"The specified battle type '{passed}' is not installed. Installed types are: {installed}" - ) + ) from e return with_info_wrap_validator_function(check_installed, subclass_schema) @@ -459,7 +449,7 @@ class FallbackConfig(Config): if TYPE_CHECKING: # to hint that we're gonna fill this with arbitrary data belonging to some supposed battle type - def __getattr__(self, __attr: str) -> Any: + def __getattr__(self, attr: str, /) -> Any: ... class UiData(BaseModel): @@ -560,7 +550,7 @@ class Config(Battle.Config): exit after that many failures, or `"unlimited"` to never exit early. """ - class UiData(Battle.UiData): # noqa: D106 + class UiData(Battle.UiData): reached: list[int] cap: int note: str @@ -620,7 +610,7 @@ def score(self, config: Config) -> float: return 0 if len(self.results) == 0 else sum(self.results) / len(self.results) @staticmethod - def format_score(score: float) -> str: # noqa: D102 + def format_score(score: float) -> str: return str(int(score)) @@ -637,7 +627,7 @@ class Config(Battle.Config): num_fights: int = 10 """Number of iterations in each round.""" - class UiData(Battle.UiData): # noqa: D106 + class UiData(Battle.UiData): round: int async def run_battle(self, fight: FightHandler, config: Config, min_size: int, ui: BattleUi) -> None: @@ -659,7 +649,7 @@ def score(self, config: Config) -> float: return sum(f.score for f in self.fights) / len(self.fights) @staticmethod - def format_score(score: float) -> str: # noqa: D102 + def format_score(score: float) -> str: return format(score, ".0%") @@ -681,7 +671,7 @@ class Fight: gen_sols: set[Role] sol_sols: set[Role] - def encode(self, target: Path, role: Role) -> None: # noqa: D102 + def encode(self, target: Path, role: Role) -> None: target.mkdir() for i, fight in enumerate(self.history): fight_dir = target / str(i) @@ -724,7 +714,7 @@ class Config(Battle.Config): solver_solutions: set[Role] = {Role.solver} """Who to show the solver's solutions to.""" - class UiData(Battle.UiData): # noqa: D106 + class UiData(Battle.UiData): round: int async def run_battle(self, fight: FightHandler, config: Config, min_size: int, ui: BattleUi) -> None: @@ -760,5 +750,5 @@ def score(self, config: Config) -> float: return total / quotient @staticmethod - def format_score(score: float) -> str: # noqa: D102 + def format_score(score: float) -> str: return format(score, ".0%") diff --git a/algobattle/cli.py b/algobattle/cli.py index c81071a2..78abfff3 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -2,68 +2,69 @@ Provides a command line interface to start matches and observe them. See `battle --help` for further options. """ + +import operator +import shutil +import sys +from collections.abc import Iterable from enum import StrEnum from functools import cached_property -import operator +from importlib.metadata import version as pkg_version from os import environ from pathlib import Path from random import choice from shutil import rmtree from subprocess import PIPE, Popen -import sys -from typing import Annotated, Any, ClassVar, Iterable, Literal, Optional, Self, cast -from typing_extensions import override -from importlib.metadata import version as pkg_version +from typing import Annotated, Any, ClassVar, Literal, Self, cast from zipfile import ZipFile -import shutil from anyio import run as run_async_fn from click import Choice from click.core import Parameter from pydantic import Field, TypeAdapter, ValidationError -from typer import Typer, Argument, Option, Abort, get_app_dir, launch -from rich.console import Group, RenderableType, Console +from rich.columns import Columns +from rich.console import Console, Group, RenderableType from rich.live import Live -from rich.table import Table, Column +from rich.padding import Padding +from rich.panel import Panel from rich.progress import ( - Progress, - TextColumn, - SpinnerColumn, BarColumn, MofNCompleteColumn, - TimeElapsedColumn, + Progress, ProgressColumn, + SpinnerColumn, Task, + TextColumn, + TimeElapsedColumn, ) -from rich.panel import Panel +from rich.prompt import Confirm, Prompt +from rich.rule import Rule +from rich.table import Column, Table from rich.text import Text -from rich.columns import Columns -from rich.prompt import Prompt, Confirm from rich.theme import Theme -from rich.rule import Rule -from rich.padding import Padding from rich.traceback import Traceback -from tomlkit import TOMLDocument, comment, parse as parse_toml, dumps as dumps_toml, table, nl as toml_newline +from tomlkit import TOMLDocument, comment, dumps as dumps_toml, nl as toml_newline, parse as parse_toml, table from tomlkit.exceptions import ParseError from tomlkit.items import Table as TomlTable +from typer import Abort, Argument, Option, Typer, get_app_dir, launch +from typing_extensions import override from algobattle.battle import Battle -from algobattle.match import AlgobattleConfig, EmptyUi, Match, MatchConfig, MatchupStr, TeamInfo, Ui, ProjectConfig +from algobattle.match import AlgobattleConfig, EmptyUi, Match, MatchConfig, MatchupStr, ProjectConfig, TeamInfo, Ui from algobattle.problem import Instance, Problem, Solution from algobattle.program import Generator, Matchup, Solver +from algobattle.templates import Language, PartialTemplateArgs, TemplateArgs, write_problem_template, write_templates from algobattle.util import ( + BaseModel, BuildError, DockerNotRunning, EncodableModel, ExceptionInfo, Role, RunningTimer, - BaseModel, TempDir, timestamp, ) -from algobattle.templates import Language, PartialTemplateArgs, TemplateArgs, write_problem_template, write_templates - __all__ = ("app",) @@ -75,16 +76,14 @@ app = Typer(pretty_exceptions_show_locals=True, help=help_message) packager = Typer(help="Subcommands to package problems and programs into `.algo` files.") app.add_typer(packager, name="package") -theme = Theme( - { - "success": "green", - "warning": "orange3", - "error": "red", - "attention": "magenta2", - "heading": "blue", - "info": "dim cyan", - } -) +theme = Theme({ + "success": "green", + "warning": "orange3", + "error": "red", + "attention": "magenta2", + "heading": "blue", + "info": "dim cyan", +}) console = Console(theme=theme) @@ -101,7 +100,7 @@ class _General(BaseModel): class CliConfig(BaseModel): - general: _General = Field(default_factory=dict, validate_default=True) + general: _General = Field(default_factory=_General) default_project_table: ProjectConfig | None = Field(default=None) _doc: TOMLDocument @@ -138,8 +137,7 @@ def save(self) -> None: @property def default_project_doc(self) -> TomlTable | None: """The default exec config for each problem.""" - exec: Any = self._doc.get("default_project_table", None) - return exec + return self._doc.get("default_project_table", None) @cached_property def install_cmd(self) -> list[str]: @@ -167,6 +165,7 @@ def install_cmd(self) -> list[str]: @app.command("run") def run_match( + *, path: Annotated[ Path, Argument(exists=True, help="Path to either a config file or a directory containing one.") ] = Path(), @@ -184,27 +183,25 @@ def run_match( save = False except KeyboardInterrupt: console.print("[error]Stopping match execution") - finally: - try: - if config.project.points > 0 and result.active_teams: - points = result.calculate_points() - leaderboard = Table( - Column("Team", justify="center"), - Column("Points", justify="right"), - title="[heading]Leaderboard", - ) - for team, pts in sorted(points.items(), key=operator.itemgetter(1)): - leaderboard.add_row(team, f"{pts:.1f}") - console.print(Padding(leaderboard, (1, 0, 0, 0))) - - if save: - out_path = config.project.results.joinpath(f"match-{timestamp()}.json") - config.project.results.mkdir(parents=True, exist_ok=True) - out_path.write_text(result.format(error_detail=config.project.error_detail)) - console.print("Saved match result to ", out_path) - return result - except KeyboardInterrupt: - raise Abort + try: + if config.project.points > 0 and result.active_teams: + points = result.calculate_points() + leaderboard = Table( + Column("Team", justify="center"), + Column("Points", justify="right"), + title="[heading]Leaderboard", + ) + for team, pts in sorted(points.items(), key=operator.itemgetter(1)): + leaderboard.add_row(team, f"{pts:.1f}") + console.print(Padding(leaderboard, (1, 0, 0, 0))) + if save: + out_path = config.project.results.joinpath(f"match-{timestamp()}.json") + config.project.results.mkdir(parents=True, exist_ok=True) + out_path.write_text(result.format(error_detail=config.project.error_detail)) + console.print("Saved match result to ", out_path) + except KeyboardInterrupt as e: + raise Abort from e + return result def _init_program(target: Path, lang: Language, args: PartialTemplateArgs, role: Role) -> None: @@ -239,11 +236,9 @@ def get_metavar(self, param: Parameter) -> str: @app.command(epilog=f"Supported languages are: {', '.join(Language)}.") def init( - target: Annotated[ - Optional[Path], Argument(file_okay=False, writable=True, help="The folder to initialize.") - ] = None, + target: Annotated[Path | None, Argument(file_okay=False, writable=True, help="The folder to initialize.")] = None, problem_: Annotated[ - Optional[str], + str | None, Option( "--problem", "-p", @@ -251,15 +246,15 @@ def init( ), ] = None, language: Annotated[ - Optional[Language], + Language | None, Option("--language", "-l", help="The language to use for the programs.", click_type=ClickLanguage()), ] = None, generator: Annotated[ - Optional[Language], + Language | None, Option("--generator", "-g", help="The language to use for the generator.", click_type=ClickLanguage()), ] = None, solver: Annotated[ - Optional[Language], + Language | None, Option("--solver", "-s", help="The language to use for the solver.", click_type=ClickLanguage()), ] = None, schemas: Annotated[bool, Option(help="Whether to also save the problem's IO schemas.")] = False, @@ -282,10 +277,28 @@ def init( if language: generator = solver = language config = CliConfig.load() - team_name = config.general.team_name or choice( - ("Dogs", "Cats", "Otters", "Red Pandas", "Crows", "Rats", "Cockatoos", "Dingos", "Penguins", "Kiwis", "Orcas") - + ("Bearded Dragons", "Macaws", "Wombats", "Wallabies", "Owls", "Seals", "Octopuses", "Frogs", "Jellyfish") - ) + team_name = config.general.team_name or choice(( + "Dogs", + "Cats", + "Otters", + "Red Pandas", + "Crows", + "Rats", + "Cockatoos", + "Dingos", + "Penguins", + "Kiwis", + "Orcas", + "Bearded Dragons", + "Macaws", + "Wombats", + "Wallabies", + "Owls", + "Seals", + "Octopuses", + "Frogs", + "Jellyfish", + )) if new: # create a new problem if problem_ is None: @@ -304,12 +317,12 @@ def init( target = Path() try: parsed_config = AlgobattleConfig.from_file(target, relativize_paths=False) - except FileNotFoundError: + except FileNotFoundError as e: console.print("[error]You must use a problem spec file or target a directory with an existing config.") - raise Abort + raise Abort from e except ValueError as e: console.print("[error]The Algobattle config file is not formatted properly\n", e) - raise Abort + raise Abort from e console.print("Using existing project data") if len(parsed_config.teams) == 1: team_name = next(iter(parsed_config.teams.keys())) @@ -323,9 +336,8 @@ def init( elif (problem := Path(problem_)).is_file(): # use a problem spec file with TempDir() as unpack_dir: - with console.status("Extracting problem data"): - with ZipFile(problem) as problem_zip: - problem_zip.extractall(unpack_dir) + with console.status("Extracting problem data"), ZipFile(problem) as problem_zip: + problem_zip.extractall(unpack_dir) parsed_config = AlgobattleConfig.from_file(unpack_dir, relativize_paths=False) if target is None: @@ -333,7 +345,7 @@ def init( target.mkdir(parents=True, exist_ok=True) problem_data = list(unpack_dir.iterdir()) - if any(((target / path.name).exists() for path in problem_data)): + if any(target.joinpath(path.name).exists() for path in problem_data): copy_problem_data = Confirm.ask( "[attention]The target directory already contains an algobattle project, " "do you want to replace it?", @@ -363,9 +375,10 @@ def init( problem_name = parsed_config.match.problem if deps := parsed_config.problem.dependencies: cmd = config.install_cmd - with console.status(f"Installing {problem_name}'s dependencies"), Popen( - cmd + deps, env=environ.copy(), stdout=PIPE, stderr=PIPE, text=True - ) as installer: + with ( + console.status(f"Installing {problem_name}'s dependencies"), + Popen(cmd + deps, env=environ.copy(), stdout=PIPE, stderr=PIPE, text=True) as installer, + ): assert installer.stdout is not None assert installer.stderr is not None for line in installer.stdout: @@ -506,7 +519,7 @@ async def sol_builder() -> Solver: @app.command() def test( project: Annotated[Path, Argument(help="The project folder to use.")] = Path(), - size: Annotated[Optional[int], Option(help="The size of instance the generator will be asked to create.")] = None, + size: Annotated[int | None, Option(help="The size of instance the generator will be asked to create.")] = None, ) -> Literal["success", "error"]: """Tests whether the programs install successfully and run on dummy instances without crashing.""" if not (project.is_file() or project.joinpath("algobattle.toml").is_file()): @@ -542,10 +555,10 @@ def config() -> None: def package_problem( project: Annotated[Path, Argument(exists=True, resolve_path=True, help="Path to the project directory.")] = Path(), description: Annotated[ - Optional[Path], Option(exists=True, dir_okay=False, help="Path to a problem description file.") + Path | None, Option(exists=True, dir_okay=False, help="Path to a problem description file.") ] = None, out: Annotated[ - Optional[Path], Option("--out", "-o", dir_okay=False, file_okay=False, help="Location of the output.") + Path | None, Option("--out", "-o", dir_okay=False, file_okay=False, help="Location of the output.") ] = None, ) -> None: """Packages problem data into an `.algo` file.""" @@ -571,22 +584,23 @@ def package_problem( parsed_config = AlgobattleConfig.from_file(config) except (ValidationError, ParseError) as e: console.print(f"[error]Improperly formatted config file[/]\nError: {e}") - raise Abort + raise Abort from e problem_name = parsed_config.match.problem try: with console.status("Loading problem"): - parsed_config.loaded_problem + # we need to access the property so that it gets loaded + parsed_config.loaded_problem # noqa: B018 except ValueError as e: console.print(f"[error]Couldn't load the problem file[/]\nError: {e}") - raise Abort + raise Abort from e except RuntimeError as e: error = e.__cause__ if error is None: console.print(f"[error]Couldn't load the problem file[/]\nError: {e}") - raise Abort + raise Abort from e trace = Traceback.from_exception(error.__class__, error, error.__traceback__) console.print("[error]Couldn't execute the problem file[/]\nError:", trace) - raise Abort + raise Abort from e if "project" in config_doc: config_doc.remove("project") @@ -613,7 +627,7 @@ def package_problem( def package_programs( project: Annotated[Path, Argument(help="The project folder to use.")] = Path(), team: Annotated[ - Optional[str], + str | None, Option( help="Name of team whose programs should be packaged. If None are specified, every team's are packaged." ), @@ -789,9 +803,6 @@ def __init__(self, match: Match, config: AlgobattleConfig) -> None: self.config = config super().__init__(None, refresh_per_second=10, transient=True, console=console) - def __enter__(self) -> Self: - return cast(Self, super().__enter__()) - def _update_renderable(self) -> None: if self.build is None: renderable = Group(self.display_match(self.match, self.config.match), *self.battle_panels.values()) @@ -809,10 +820,7 @@ def display_match(match: Match, config: MatchConfig) -> RenderableType: title="[heading]Match overview", ) for matchup, battle in match.battles.items(): - if battle.runtime_error is None: - res = battle.format_score(battle.score(config.battle)) - else: - res = ":warning:" + res = battle.format_score(battle.score(config.battle)) if battle.runtime_error is None else ":warning:" table.add_row(matchup.generator, matchup.solver, res) return Padding(table, pad=(1, 0, 0, 0)) @@ -868,7 +876,7 @@ def end_fight(self, matchup: Matchup) -> None: fights = battle.fights[-1:-6:-1] panel = self.battle_panels[matchup] table = panel._fights_table() - for i, fight in zip(range(len(battle.fights), len(battle.fights) - len(fights), -1), fights): + for i, fight in zip(range(len(battle.fights), len(battle.fights) - len(fights), -1), fights, strict=True): if fight.generator.error: info = f"[error]Generator failed[/]: {fight.generator.error.message}" elif fight.solver and fight.solver.error: diff --git a/algobattle/match.py b/algobattle/match.py index dd6c12bc..9e0fff9f 100644 --- a/algobattle/match.py +++ b/algobattle/match.py @@ -1,14 +1,16 @@ """Module defining how a match is run.""" +import tomllib +from collections.abc import Iterable from dataclasses import dataclass from datetime import datetime, timedelta from functools import cached_property from itertools import combinations from pathlib import Path -import tomllib -from typing import Annotated, Any, Iterable, Literal, Protocol, ClassVar, Self, TypeAlias, TypeVar, cast -from typing_extensions import override -from typing_extensions import TypedDict +from typing import Annotated, Any, ClassVar, Literal, Protocol, Self, TypeAlias, TypeVar, cast +from anyio import CapacityLimiter, create_task_group +from anyio.to_thread import current_default_thread_limiter +from docker.types import LogConfig, Ulimit from pydantic import ( AfterValidator, ByteSize, @@ -24,27 +26,20 @@ from pydantic.types import PathType from pydantic_core import CoreSchema from pydantic_core.core_schema import no_info_after_validator_function, union_schema -from anyio import create_task_group, CapacityLimiter -from anyio.to_thread import current_default_thread_limiter -from docker.types import LogConfig, Ulimit +from typing_extensions import TypedDict, override from algobattle.battle import ( Battle, + BattleUi, FightHandler, FightUi, - BattleUi, Iterated, ProgramLogConfigLocation, ProgramLogConfigTime, ) -from algobattle.program import ProgramConfigView, ProgramUi, Matchup, TeamHandler, BuildUi from algobattle.problem import Problem -from algobattle.util import ( - ExceptionInfo, - Role, - RunningTimer, - BaseModel, -) +from algobattle.program import BuildUi, Matchup, ProgramConfigView, ProgramUi, TeamHandler +from algobattle.util import BaseModel, ExceptionInfo, Role, RunningTimer @dataclass(frozen=True) @@ -159,7 +154,7 @@ def calculate_points(self) -> dict[str, float]: other team did against them. """ total_points_per_team = self.config.project.points - points = {team: 0.0 for team in self.active_teams + list(self.excluded_teams)} + points = dict.fromkeys(self.active_teams + list(self.excluded_teams), 0.0) if len(self.active_teams) == 0: return points if len(self.active_teams) == 1: @@ -290,7 +285,7 @@ def __enter__(self) -> Self: """Starts displaying the Ui.""" return self - def __exit__(self, *args: Any) -> None: + def __exit__(self, *args: object) -> None: """Stops the Ui.""" return @@ -303,23 +298,23 @@ class BattleObserver(BattleUi, FightUi, ProgramUi): matchup: Matchup @override - def update_battle_data(self, data: Battle.UiData) -> None: # noqa: D102 + def update_battle_data(self, data: Battle.UiData) -> None: self.ui.update_battle_data(self.matchup, data) @override - def start_fight(self, max_size: int) -> None: # noqa: D102 + def start_fight(self, max_size: int) -> None: self.ui.start_fight(self.matchup, max_size) @override - def end_fight(self) -> None: # noqa: D102 + def end_fight(self) -> None: self.ui.end_fight(self.matchup) @override - def start_program(self, role: Role, timeout: float | None) -> None: # noqa: D102 + def start_program(self, role: Role, timeout: float | None) -> None: self.ui.start_program(self.matchup, role, RunningTimer(datetime.now(), timeout)) @override - def stop_program(self, role: Role, runtime: float) -> None: # noqa: D102 + def stop_program(self, role: Role, runtime: float) -> None: self.ui.end_program(self.matchup, role, runtime) @@ -344,7 +339,7 @@ def convert(val: float | timedelta) -> float: def parse_none(value: Any) -> Any | None: """Used as a validator to parse false-y values into Python None objects.""" - return None if not value else value + return value or None T = TypeVar("T") @@ -372,7 +367,7 @@ def _relativize_file(path: Path, info: ValidationInfo) -> Path: class _Adapter: """Turns a docker library config class into a pydantic parseable one.""" - _Args: ClassVar[type[TypedDict]] + _Args: ClassVar[type] @classmethod def _construct(cls, kwargs: dict[str, Any]) -> Self: @@ -383,13 +378,13 @@ def __get_pydantic_core_schema__(cls, source: type, handler: GetCoreSchemaHandle return no_info_after_validator_function(cls._construct, handler(cls._Args)) -class PydanticLogConfig(LogConfig, _Adapter): # noqa: D101 +class PydanticLogConfig(LogConfig, _Adapter): class _Args(TypedDict): type: str conifg: dict[Any, Any] -class PydanticUlimit(Ulimit, _Adapter): # noqa: D101 +class PydanticUlimit(Ulimit, _Adapter): class _Args(TypedDict): name: str soft: int @@ -666,10 +661,10 @@ class AlgobattleConfig(BaseModel): # funky defaults to force their validation with context info present teams: TeamInfos = Field(default_factory=dict) - project: ProjectConfig = Field(default_factory=dict, validate_default=True) + project: ProjectConfig = Field(default_factory=ProjectConfig) match: MatchConfig docker: DockerConfig = DockerConfig() - problem: DynamicProblemConfig = Field(default_factory=dict, validate_default=True) + problem: DynamicProblemConfig = Field(default_factory=DynamicProblemConfig) model_config = ConfigDict(revalidate_instances="always") @@ -696,7 +691,7 @@ def from_file(cls, file: Path, *, ignore_uninstalled: bool = False, relativize_p try: config_dict = tomllib.loads(file.read_text()) except tomllib.TOMLDecodeError as e: - raise ValueError(f"The config file at {file} is not a properly formatted TOML file!\n{e}") + raise ValueError(f"The config file at {file} is not a properly formatted TOML file!\n{e}") from e context: dict[str, Any] = {"ignore_uninstalled": ignore_uninstalled} if relativize_paths: context["base_path"] = file.parent diff --git a/algobattle/program.py b/algobattle/program.py index 5d3b3e67..52dcc0a3 100644 --- a/algobattle/program.py +++ b/algobattle/program.py @@ -1,30 +1,33 @@ """Module providing an interface to interact with the teams' programs.""" + +import json from abc import ABC, abstractmethod +from collections.abc import Generator as PyGenerator, Iterable, Iterator, Mapping from contextlib import contextmanager +from dataclasses import dataclass, field from itertools import combinations from os import environ from pathlib import Path from tarfile import TarFile, is_tarfile from tempfile import TemporaryDirectory from timeit import default_timer -from types import EllipsisType -from typing import Any, ClassVar, Iterable, Iterator, Mapping, Protocol, Self, TypeVar, cast, Generator as PyGenerator -from typing_extensions import TypedDict +from types import EllipsisType, TracebackType +from typing import Any, ClassVar, Protocol, Self, TypeVar, cast from uuid import uuid4 -import json -from dataclasses import dataclass, field from zipfile import ZipFile, is_zipfile +from anyio import run as run_async +from anyio.to_thread import run_sync from docker import DockerClient from docker.errors import APIError, BuildError as DockerBuildError, DockerException, ImageNotFound -from docker.models.images import Image as DockerImage from docker.models.containers import Container as DockerContainer +from docker.models.images import Image as DockerImage from docker.types import Mount -from requests import Timeout, ConnectionError -from anyio import run as run_async -from anyio.to_thread import run_sync +from requests import ConnectionError, Timeout +from typing_extensions import TypedDict from urllib3.exceptions import ReadTimeoutError +from algobattle.problem import Instance, Problem, Solution from algobattle.util import ( BuildError, DockerError, @@ -34,12 +37,10 @@ ExceptionInfo, ExecutionError, ExecutionTimeout, + Role, TempDir, ValidationError, - Role, ) -from algobattle.problem import Problem, Instance, Solution - _client_var: DockerClient | None = None @@ -55,8 +56,8 @@ def client() -> DockerClient: _client_var = DockerClient.from_env() else: _client_var.ping() - except (DockerException, APIError): - raise DockerNotRunning + except (DockerException, APIError) as e: + raise DockerNotRunning from e return _client_var @@ -156,7 +157,7 @@ def mounts(self) -> list[Mount]: def __enter__(self) -> Self: return self - def __exit__(self, exc: Any, val: Any, tb: Any): + def __exit__(self, exc: type[BaseException] | None, val: BaseException | None, tb: TracebackType | None): self._input.__exit__(exc, val, tb) self._output.__exit__(exc, val, tb) @@ -256,7 +257,7 @@ async def build( normalized = team_name.lower().replace(" ", "_") name = f"algobattle_{normalized}_{cls.role.name}" try: - old_image = cast(DockerImage, client().images.get(name)) + old_image = client().images.get(name) except ImageNotFound: old_image = None else: @@ -292,7 +293,7 @@ async def build( problem=problem, config=config, ) - used_size = cast(dict[str, Any], image.attrs).get("Size", 0) + used_size = image.attrs.get("Size", 0) if config.max_program_size is not None and used_size > config.max_program_size: try: self.remove() @@ -357,36 +358,33 @@ async def _run_inner( ui: ProgramUi | None, ) -> tuple[float, Encodable | None]: """Encodes the metadata, runs the docker container, and decodes battle metadata.""" - with open(io.input / "info.json", "w+") as f: - json.dump( + io.input.joinpath("info.json").write_text( + json.dumps( { "max_size": max_size, "timeout": specs.timeout, "space": specs.space, "cpus": specs.cpus, }, - f, ) + ) if battle_input is not None: try: battle_input.encode(io.input / "battle_data", self.role) except Exception as e: - raise EncodingError("Battle data couldn't be encoded.", detail=str(e)) + raise EncodingError("Battle data couldn't be encoded.", detail=str(e)) from e runtime = 0 try: - container = cast( - DockerContainer, - client().containers.create( - image=self.id, - name=f"algobattle_{uuid4().hex[:8]}", - mem_limit=specs.space, - nano_cpus=specs.cpus * 1_000_000_000, - detach=True, - mounts=io.mounts if io else None, - cpuset_cpus=set_cpus, - **self.config.run_kwargs, - ), + container = client().containers.create( + image=self.id, + name=f"algobattle_{uuid4().hex[:8]}", + mem_limit=specs.space, + nano_cpus=specs.cpus * 1_000_000_000, + detach=True, + mounts=io.mounts if io else None, + cpuset_cpus=set_cpus, + **self.config.run_kwargs, ) if ui is not None: @@ -394,7 +392,7 @@ async def _run_inner( try: runtime = await run_sync(self._run_daemon_call, container, specs.timeout, cancellable=True) except ExecutionError as e: - raise _WrappedException(e, e.runtime) + raise _WrappedException(e, e.runtime) from e finally: container.remove(force=True) if ui is not None: @@ -402,13 +400,13 @@ async def _run_inner( except APIError as e: raise _WrappedException( DockerError("Docker APIError thrown while running container.", detail=str(e)), runtime - ) + ) from e if battle_output: try: decoded_battle_output = battle_output.decode(io.output / "battle_data", max_size, self.role) except Exception as e: - raise _WrappedException(e, runtime) + raise _WrappedException(e, runtime) from e else: decoded_battle_output = None return runtime, decoded_battle_output @@ -445,7 +443,7 @@ def _run_daemon_call(self, container: DockerContainer, timeout: float | None = N if len(e.args) != 1 or not isinstance(e.args[0], ReadTimeoutError): raise if self.config.strict_timeouts: - raise ExecutionTimeout("The docker container exceeded the time limit.", runtime=elapsed_time) + raise ExecutionTimeout("The docker container exceeded the time limit.", runtime=elapsed_time) from e return elapsed_time def remove(self) -> None: @@ -468,7 +466,7 @@ def remove(self) -> None: def __enter__(self): return self - def __exit__(self, _type: Any, _value: Any, _traceback: Any): + def __exit__(self, *args: object): if self.config.cleanup_images: self.remove() @@ -513,9 +511,7 @@ async def run( exception_info = None with ProgramIO() as io: try: - with open(io.input / "max_size.txt", "w+") as f: - f.write(str(max_size)) - + io.input.joinpath("max_size.txt").write_text(str(max_size)) runtime, battle_data = await self._run_inner( io=io, max_size=max_size, @@ -531,15 +527,17 @@ async def run( except EncodingError: raise except Exception as e: - raise EncodingError("Unknown error thrown while decoding the problem instance.", detail=str(e)) + raise EncodingError( + "Unknown error thrown while decoding the problem instance.", detail=str(e) + ) from e try: instance.validate_instance() except ValidationError: raise except Exception as e: - raise ValidationError("Unknown error thrown during instance validation.", detail=str(e)) + raise ValidationError("Unknown error thrown during instance validation.", detail=str(e)) from e if instance.size > max_size: - raise ValidationError( + raise ValidationError( # noqa: TRY301 "Instance is too large.", detail=f"Generated: {instance.size}, maximum: {max_size}" ) if self.problem.with_solution: @@ -550,13 +548,13 @@ async def run( except EncodingError: raise except Exception as e: - raise EncodingError("Unknown error thrown while decoding the solution.", detail=str(e)) + raise EncodingError("Unknown error thrown while decoding the solution.", detail=str(e)) from e try: solution.validate_solution(instance, Role.generator) except ValidationError: raise except Exception as e: - raise ValidationError("Unknown error thrown during solution validation.", detail=str(e)) + raise ValidationError("Unknown error thrown during solution validation.", detail=str(e)) from e except _WrappedException as e: runtime = e.runtime @@ -637,13 +635,13 @@ async def run( except EncodingError: raise except Exception as e: - raise EncodingError("Unexpected error thrown while decoding the solution.", detail=str(e)) + raise EncodingError("Unexpected error thrown while decoding the solution.", detail=str(e)) from e try: solution.validate_solution(instance, Role.solver) except ValidationError: raise except Exception as e: - raise ValidationError("Unexpected error during solution validation.", detail=str(e)) + raise ValidationError("Unexpected error during solution validation.", detail=str(e)) from e except _WrappedException as e: runtime = e.runtime @@ -758,7 +756,7 @@ def __enter__(self) -> Self: self.solver.__enter__() return self - def __exit__(self, *args: Any): + def __exit__(self, *args: object): self.generator.__exit__(*args) self.solver.__exit__(*args) @@ -832,7 +830,7 @@ def __enter__(self) -> Self: team.__enter__() return self - def __exit__(self, *args: Any): + def __exit__(self, *args: object): for team in self.active: team.__exit__(*args) diff --git a/algobattle/templates/__init__.py b/algobattle/templates/__init__.py index 60dae855..6427672f 100644 --- a/algobattle/templates/__init__.py +++ b/algobattle/templates/__init__.py @@ -4,8 +4,9 @@ from functools import cached_property from pathlib import Path from typing import Literal -from typing_extensions import TypedDict + from jinja2 import Environment, PackageLoader, Template +from typing_extensions import TypedDict class Language(StrEnum): @@ -66,10 +67,9 @@ def write_templates(target: Path, lang: Language, args: TemplateArgs) -> None: formatted_path = Path(Template(name).render(template_args)) if formatted_path.suffix == ".jinja": formatted_path = formatted_path.with_suffix("") - - (target / formatted_path).parent.mkdir(parents=True, exist_ok=True) - with open(target / formatted_path, "w+") as file: - file.write(formatted) + full_path = target / formatted_path + full_path.parent.mkdir(parents=True, exist_ok=True) + full_path.write_text(formatted) problem_env = Environment( diff --git a/algobattle/types.py b/algobattle/types.py index 1df1cd6c..b1380a3b 100644 --- a/algobattle/types.py +++ b/algobattle/types.py @@ -1,20 +1,13 @@ """Utility types used to easily define Problems.""" +from collections.abc import Collection, Iterator from dataclasses import dataclass from functools import cache, cached_property +from itertools import pairwise from sys import float_info -from typing import ( - Annotated, - Any, - ClassVar, - Collection, - Iterator, - Literal, - TypeVar, - Generic, - TypedDict, - overload, -) +from typing import Annotated, Any, ClassVar, Generic, Literal, TypedDict, TypeVar, overload + import annotated_types as at +import pydantic._internal._validators as validators from annotated_types import ( BaseMetadata, GroupedMetadata, @@ -25,52 +18,49 @@ SupportsLt, SupportsMod, ) -from itertools import pairwise - from pydantic import GetCoreSchemaHandler, GetJsonSchemaHandler from pydantic.json_schema import JsonSchemaValue -import pydantic._internal._validators as validators from pydantic_core import CoreSchema, PydanticKnownError from pydantic_core.core_schema import no_info_after_validator_function from algobattle.problem import ( - InstanceModel, - SolutionModel, AttributeReference, AttributeReferenceValidator, + InstanceModel, InstanceRef, + SolutionModel, ) from algobattle.util import BaseModel, Role, ValidationError __all__ = ( - "u64", - "i64", - "u32", - "i32", - "u16", - "i16", - "Gt", + "AlgobattleContext", + "DirectedGraph", + "Edge", + "EdgeLen", + "EdgeWeights", "Ge", - "Lt", - "Le", + "Gt", "Interval", - "MultipleOf", - "MinLen", - "MaxLen", + "LaxComp", + "Le", "Len", - "UniqueItems", + "Lt", + "MaxLen", + "MinLen", + "MultipleOf", + "Path", "SizeIndex", "SizeLen", - "DirectedGraph", "UndirectedGraph", - "Edge", - "Path", - "EdgeLen", - "EdgeWeights", + "UniqueItems", "VertexWeights", - "AlgobattleContext", - "LaxComp", + "i16", + "i32", + "i64", "lax_comp", + "u16", + "u32", + "u64", ) @@ -228,7 +218,7 @@ class Interval(GroupedMetadata): lt: SupportsLt | AttributeReference | None = None le: SupportsLe | AttributeReference | None = None - def __iter__(self) -> Iterator[BaseMetadata | AttributeReferenceValidator]: # type: ignore + def __iter__(self) -> Iterator[BaseMetadata | AttributeReferenceValidator]: """Unpack an Interval into zero or more single-bounds.""" if self.gt is not None: yield Gt(self.gt) @@ -315,7 +305,7 @@ class Len(GroupedMetadata): min_length: Annotated[int, Ge(0)] | AttributeReference = 0 max_length: Annotated[int, Ge(0)] | AttributeReference | None = None - def __iter__(self) -> Iterator[BaseMetadata | AttributeReferenceValidator]: # type: ignore + def __iter__(self) -> Iterator[BaseMetadata | AttributeReferenceValidator]: """Unpack a Len into one or more single-bounds.""" if self.min_length != 0: yield MinLen(self.min_length) @@ -376,12 +366,12 @@ def validator(val: Any, attr: Any) -> Any: return AttributeReferenceValidator(validator, attribute) @classmethod - def __class_getitem__(cls, __key: AttributeReference) -> type[int]: + def __class_getitem__(cls, key: AttributeReference, /) -> Annotated: def validator(val: Any, attr: Any) -> Any: validators.less_than_validator(val, len(attr)) return val - return Annotated[int, at.Ge(0), AttributeReferenceValidator(validator, __key)] + return Annotated[int, at.Ge(0), AttributeReferenceValidator(validator, key)] # * Algobattle specific types @@ -453,9 +443,9 @@ def neighbors(self, vertex: Vertex, direction: Literal["all", "outgoing", "incom """The neighbors of a vertex.""" res = set[Vertex]() if direction in {"all", "outgoing"}: - res |= set(v for (u, v) in self.edges if u == vertex) + res |= {v for (u, v) in self.edges if u == vertex} if direction in {"all", "incoming"}: - res |= set(v for (v, u) in self.edges if u == vertex) + res |= {v for (v, u) in self.edges if u == vertex} return res @@ -482,14 +472,14 @@ def edge_set(self) -> set[tuple[Vertex, Vertex]]: Normalized to contain every edge in both directions. """ - return set(self.edges) | set((v, u) for (u, v) in self.edges) + return set(self.edges) | {(v, u) for (u, v) in self.edges} @cache def neighbors(self, vertex: Vertex, direction: Literal["all", "outgoing", "incoming"] = "all") -> set[Vertex]: """The neighbors of a vertex.""" # more efficient specialization - return set(v for (u, v) in self.edge_set if u == vertex) + return {v for (u, v) in self.edge_set if u == vertex} class EdgeLen: @@ -523,7 +513,7 @@ class EdgeWeights(DirectedGraph, BaseModel, Generic[Weight]): @cached_property def edges_with_weights(self) -> Iterator[tuple[tuple[Vertex, Vertex], Weight]]: """Iterate over all edges and their weights.""" - return zip(self.edges, self.edge_weights) + return zip(self.edges, self.edge_weights, strict=True) @cache def weight(self, edge: Edge | tuple[Vertex, Vertex]) -> Weight: @@ -534,14 +524,14 @@ def weight(self, edge: Edge | tuple[Vertex, Vertex]) -> Weight: if isinstance(edge, tuple): try: edge = self.edges.index(edge) - except ValueError: + except ValueError as e: if isinstance(self, UndirectedGraph): try: edge = self.edges.index((edge[1], edge[0])) - except ValueError: - raise KeyError + except ValueError as e: + raise KeyError from e else: - raise KeyError + raise KeyError from e return self.edge_weights[edge] diff --git a/algobattle/util.py b/algobattle/util.py index dd45d221..08b24a48 100644 --- a/algobattle/util.py +++ b/algobattle/util.py @@ -2,24 +2,20 @@ In particular, the base classes :class:`BaseModel`, :class:`Encodable`, :class:`EncodableModel`, and exception classes. """ +import json +import sys from abc import ABC, abstractmethod from dataclasses import dataclass from datetime import datetime from enum import StrEnum from importlib.util import module_from_spec, spec_from_file_location -import json from pathlib import Path -import sys from tempfile import TemporaryDirectory from traceback import format_exception from types import ModuleType -from typing import Any, LiteralString, TypeVar, Self +from typing import Any, LiteralString, Self, TypeVar -from pydantic import ( - ConfigDict, - BaseModel as PydandticBaseModel, - ValidationError as PydanticValidationError, -) +from pydantic import BaseModel as PydandticBaseModel, ConfigDict, ValidationError as PydanticValidationError class Role(StrEnum): @@ -103,20 +99,19 @@ def _decode(model_cls: type[ModelT], source: Path, **context: Any) -> ModelT: if not source.with_suffix(".json").is_file(): raise EncodingError("The json file does not exist.") try: - with open(source.with_suffix(".json"), "r") as f: - return model_cls.model_validate_json(f.read(), context=context) + text = source.with_suffix(".json").read_text() + return model_cls.model_validate_json(text, context=context) except PydanticValidationError as e: - raise EncodingError("Json data does not fit the schema.", detail=str(e)) + raise EncodingError("Json data does not fit the schema.", detail=str(e)) from e except Exception as e: - raise EncodingError("Unknown error while decoding the data.", detail=str(e)) + raise EncodingError("Unknown error while decoding the data.", detail=str(e)) from e def encode(self, target: Path, role: Role) -> None: """Uses pydantic to create a json representation of the object at the targeted file.""" try: - with open(target.with_suffix(".json"), "w") as f: - f.write(self.model_dump_json()) + target.with_suffix(".json").write_text(self.model_dump_json()) except Exception as e: - raise EncodingError("Unkown error while encoding the data.", detail=str(e)) + raise EncodingError("Unkown error while encoding the data.", detail=str(e)) from e @classmethod def io_schema(cls) -> str: @@ -258,6 +253,6 @@ def import_file_as_module(path: Path, name: str) -> ModuleType: module = module_from_spec(spec) sys.modules[spec.name] = module spec.loader.exec_module(module) - return module except Exception as e: raise RuntimeError from e + return module diff --git a/pyproject.toml b/pyproject.toml index 46e445db..39977556 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,8 +8,12 @@ version = "4.3.2" description = "The Algobattle lab course package." readme = "README.md" requires-python = ">=3.11" -license = {text = "MIT"} -authors = [{name = "Imogen Hergeth"}, {name = "Jan Dreier"}, {name = "Henri Lotze"}] +license = { text = "MIT" } +authors = [ + { name = "Imogen Hergeth" }, + { name = "Jan Dreier" }, + { name = "Henri Lotze" }, +] classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: Console", @@ -48,8 +52,98 @@ dev = [ [project.scripts] algobattle = "algobattle.cli:app" -[tool.pyright] -diagnosticMode = "workspace" - -[tool.black] +[tool.ruff] +preview = true line-length = 120 +exclude = ["docs"] + +[tool.ruff.lint] +select = [ + "F", + "E4", + "E7", + "E9", + "I", + "UP", + "YTT", + "ASYNC", + "BLE", + "FBT", + "B", + "C4", + "DTZ", + "ISC", + "G", + "PIE", + "PYI", + "SLOT", + "SIM", + "PTH", + "TRY", + "FLY", + "PERF", + "FURB", + "RUF", +] +ignore = [ + "TRY003", + "PIE790", + "FBT001", + "FBT002", + "B027", + "FBT003", + "RUF001", + "RUF036", + "BLE001", + "DTZ005", + "ASYNC109", +] + +[tool.ruff.lint.isort] +split-on-trailing-comma = false +combine-as-imports = true + +[tool.pyright] +exclude = ["docs"] +typeCheckingMode = "standard" +disableBytesTypePromotions = true +strictDictionaryInference = true +strictListInference = true +strictSetInference = true +deprecateTypingAliases = true +enableExperimentalFeatures = true +reportAssertAlwaysTrue = "information" +reportUnusedExpression = "information" +reportConstantRedefinition = "warning" +reportDeprecated = "information" +reportDuplicateImport = "information" +reportIncompleteStub = "warning" +reportInconsistentConstructor = "error" +reportInvalidStubStatement = "warning" +reportMatchNotExhaustive = "warning" +reportMissingParameterType = "warning" +reportMissingTypeArgument = "none" +reportPrivateUsage = "none" +reportTypeCommentUsage = "information" +reportUnknownArgumentType = "none" +reportUnknownLambdaType = "none" +reportUnknownMemberType = "none" +reportUnknownParameterType = "warning" +reportUnknownVariableType = "none" +reportUnnecessaryCast = "information" +reportUnnecessaryIsInstance = "information" +reportUnnecessaryComparison = "information" +reportUnnecessaryContains = "information" +reportUnusedCoroutine = "warning" +reportUntypedFunctionDecorator = "warning" +reportUntypedClassDecorator = "warning" +reportUntypedBaseClass = "warning" +reportUntypedNamedTuple = "warning" +reportCallInDefaultInitializer = "none" +reportImportCycles = "warning" +reportPropertyTypeMismatch = "error" +reportShadowedImports = "warning" +reportUnnecessaryTypeIgnoreComment = "information" +reportUninitializedInstanceVariable = "warning" +reportFunctionMemberAccess = "warning" +reportOverlappingOverload = "warning" diff --git a/tests/test_battles.py b/tests/test_battles.py index 93115ffd..a4a7e64f 100644 --- a/tests/test_battles.py +++ b/tests/test_battles.py @@ -1,9 +1,11 @@ """Tests for the Battle types.""" + +from collections.abc import Iterable from enum import Enum from itertools import chain, cycle from pathlib import Path from types import EllipsisType -from typing import Any, Iterable, TypeVar, Unpack, cast +from typing import Any, TypeVar, Unpack, cast from unittest import IsolatedAsyncioTestCase, TestCase, main from uuid import uuid4 @@ -13,7 +15,6 @@ from algobattle.util import Encodable, ExceptionInfo, Role, TempDir from tests.testsproblem.problem import TestInstance, TestSolution - T = TypeVar("T") @@ -148,7 +149,7 @@ async def _expect_fights( results = chain(results, always(fail)) handler = TestHandler(battle, results) await battle.run_battle(handler, Iterated.Config(rounds=1, maximum_size=1000), 1, self.ui) - fought_sizes = list(f.max_size for f in battle.fights) + fought_sizes = [f.max_size for f in battle.fights] if not total: fought_sizes = fought_sizes[: len(sizes)] self.assertEqual(sizes, fought_sizes) @@ -271,7 +272,9 @@ async def test_fights_tracked(self) -> None: handler = await self.run_battle(gen_res, sol_res) self.assertEqual(len(handler.data), 3) for i, history in enumerate(handler.data): - self.assertEqual(history, [FightHistory.Fight(1, g, s) for (g, s) in zip(gen_res[:i], sol_res[:i])]) + self.assertEqual( + history, [FightHistory.Fight(1, g, s) for (g, s) in zip(gen_res[:i], sol_res[:i], strict=True)] + ) async def test_sol_none(self) -> None: gen_res = [self.gen_res() for _ in range(3)] From 5e86dc1d9143529b36c6a0264aeee353f11a83b9 Mon Sep 17 00:00:00 2001 From: Imogen Date: Thu, 2 Oct 2025 13:14:56 +0200 Subject: [PATCH 2/8] update click API usage to new version --- algobattle/cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/algobattle/cli.py b/algobattle/cli.py index 78abfff3..e8257eb6 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -20,7 +20,7 @@ from anyio import run as run_async_fn from click import Choice -from click.core import Parameter +from click.core import Context, Parameter from pydantic import Field, TypeAdapter, ValidationError from rich.columns import Columns from rich.console import Console, Group, RenderableType @@ -230,7 +230,7 @@ class ClickLanguage(Choice): def __init__(self, case_sensitive: bool = True) -> None: super().__init__([lang.value for lang in Language], case_sensitive) - def get_metavar(self, param: Parameter) -> str: + def get_metavar(self, param: Parameter, ctx: Context) -> str: return "LANGUAGE" From fd26f49618000bf12946f163180da92d5845e255 Mon Sep 17 00:00:00 2001 From: Imogen Date: Thu, 2 Oct 2025 13:23:09 +0200 Subject: [PATCH 3/8] fix pydantic model validation default assignment --- algobattle/battle.py | 2 +- algobattle/cli.py | 2 +- algobattle/match.py | 12 +++++++++--- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/algobattle/battle.py b/algobattle/battle.py index 71a8be3a..69ec8dcb 100644 --- a/algobattle/battle.py +++ b/algobattle/battle.py @@ -90,7 +90,7 @@ class ProgramRunInfo(BaseModel): """Data about a program's execution.""" runtime: float = 0 - overriden: RunConfigOverride = Field(default_factory=RunConfigOverride) + overriden: RunConfigOverride = Field(default_factory=RunConfigOverride, validate_default=True) error: ExceptionInfo | None = None battle_data: SerializeAsAny[EncodableModel] | None = None instance: SerializeAsAny[InstanceModel] | None = None diff --git a/algobattle/cli.py b/algobattle/cli.py index e8257eb6..7829878b 100644 --- a/algobattle/cli.py +++ b/algobattle/cli.py @@ -100,7 +100,7 @@ class _General(BaseModel): class CliConfig(BaseModel): - general: _General = Field(default_factory=_General) + general: _General = Field(default_factory=_General, validate_default=True) default_project_table: ProjectConfig | None = Field(default=None) _doc: TOMLDocument diff --git a/algobattle/match.py b/algobattle/match.py index 9e0fff9f..82d1d427 100644 --- a/algobattle/match.py +++ b/algobattle/match.py @@ -1,4 +1,5 @@ """Module defining how a match is run.""" + import tomllib from collections.abc import Iterable from dataclasses import dataclass @@ -583,7 +584,7 @@ class MatchConfig(BaseModel): problem: str """The problem this match is over.""" - build_timeout: WithNone[TimeDeltaFloat] = 600 + build_timeout: WithNone[TimeDeltaFloat] = 600.0 """Timeout for building each docker image.""" max_program_size: WithNone[ByteSizeInt] = 4_000_000_000 """Maximum size a built program image is allowed to be.""" @@ -656,15 +657,20 @@ class TeamInfo(BaseModel): TeamInfos: TypeAlias = dict[str, TeamInfo] +def _empty_default() -> Any: + """Helper to make a structure that gets parsed to a default pydantic struct while passing type checking.""" + return {} + + class AlgobattleConfig(BaseModel): """Base that contains all config options and can be parsed from config files.""" # funky defaults to force their validation with context info present teams: TeamInfos = Field(default_factory=dict) - project: ProjectConfig = Field(default_factory=ProjectConfig) + project: ProjectConfig = Field(default_factory=_empty_default, validate_default=True) match: MatchConfig docker: DockerConfig = DockerConfig() - problem: DynamicProblemConfig = Field(default_factory=DynamicProblemConfig) + problem: DynamicProblemConfig = Field(default_factory=_empty_default, validate_default=True) model_config = ConfigDict(revalidate_instances="always") From 8b198478262fb41fe92625210e5cfcfd4724ab0e Mon Sep 17 00:00:00 2001 From: Imogen Date: Thu, 2 Oct 2025 13:25:38 +0200 Subject: [PATCH 4/8] fix test formatting --- tests/test_docker.py | 7 ++++--- tests/test_match.py | 13 +++++++------ tests/test_types.py | 4 ++-- tests/test_util.py | 8 ++++---- 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/tests/test_docker.py b/tests/test_docker.py index 2880c0dd..e813f15e 100644 --- a/tests/test_docker.py +++ b/tests/test_docker.py @@ -1,11 +1,12 @@ """Tests for all docker functions.""" -from unittest import IsolatedAsyncioTestCase, main as run_tests from pathlib import Path +from unittest import IsolatedAsyncioTestCase, main as run_tests -from algobattle.program import Generator, Solver from algobattle.match import AlgobattleConfig, MatchConfig, RunConfig +from algobattle.program import Generator, Solver + from . import testsproblem -from .testsproblem.problem import TestProblem, TestInstance, TestSolution +from .testsproblem.problem import TestInstance, TestProblem, TestSolution class ProgramTests(IsolatedAsyncioTestCase): diff --git a/tests/test_match.py b/tests/test_match.py index 203967f3..c1619b1a 100644 --- a/tests/test_match.py +++ b/tests/test_match.py @@ -1,23 +1,24 @@ """Tests for the Match class.""" # pyright: reportMissingSuperCall=false +from pathlib import Path from typing import Any from unittest import IsolatedAsyncioTestCase, TestCase, main -from pathlib import Path from pydantic import ByteSize, ValidationError -from algobattle.battle import Fight, Iterated, Averaged, ProgramRunInfo +from algobattle.battle import Averaged, Fight, Iterated, ProgramRunInfo from algobattle.match import ( + AlgobattleConfig, DynamicProblemConfig, - MatchupStr, - ProjectConfig, Match, - AlgobattleConfig, MatchConfig, + MatchupStr, + ProjectConfig, RunConfig, TeamInfo, ) -from algobattle.program import Team, Matchup, TeamHandler +from algobattle.program import Matchup, Team, TeamHandler + from .testsproblem.problem import TestProblem diff --git a/tests/test_types.py b/tests/test_types.py index 26507683..7de1ec74 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -5,9 +5,9 @@ from pydantic import ValidationError -from algobattle.problem import InstanceModel, AttributeReference, SelfRef -from algobattle.util import Role +from algobattle.problem import AttributeReference, InstanceModel, SelfRef from algobattle.types import Ge, Interval, LaxComp, SizeIndex, UniqueItems +from algobattle.util import Role class ModelCreationTests(TestCase): diff --git a/tests/test_util.py b/tests/test_util.py index d6f42692..2548619d 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,19 +1,19 @@ """Tests for all util functions.""" -from math import inf import unittest +from math import inf -from algobattle.battle import Battle, Iterated, Averaged +from algobattle.battle import Averaged, Battle, Iterated from algobattle.problem import InstanceModel, SolutionModel, default_score from algobattle.util import Role -class DummyInstance(InstanceModel): # noqa: D101 +class DummyInstance(InstanceModel): @property def size(self) -> int: return 1 -class DummySolution(SolutionModel[DummyInstance]): # noqa: D101 +class DummySolution(SolutionModel[DummyInstance]): val: float def score(self, instance: DummyInstance, role: Role) -> float: From ec219881f19765754c5fa899f81493e3311f4981 Mon Sep 17 00:00:00 2001 From: Imogen Date: Thu, 2 Oct 2025 13:37:33 +0200 Subject: [PATCH 5/8] add docker build tests --- algobattle/program.py | 2 +- tests/test_docker.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/algobattle/program.py b/algobattle/program.py index 52dcc0a3..12ccab03 100644 --- a/algobattle/program.py +++ b/algobattle/program.py @@ -312,7 +312,7 @@ def _build_daemon_call( client().images.build( path=str(path), tag=tag, - timeout=timeout, + timeout=timeout, # type: ignore dockerfile=dockerfile, **build_kwargs, ), diff --git a/tests/test_docker.py b/tests/test_docker.py index e813f15e..6c432a1b 100644 --- a/tests/test_docker.py +++ b/tests/test_docker.py @@ -4,6 +4,7 @@ from algobattle.match import AlgobattleConfig, MatchConfig, RunConfig from algobattle.program import Generator, Solver +from algobattle.util import BuildError from . import testsproblem from .testsproblem.problem import TestInstance, TestProblem, TestSolution @@ -30,6 +31,17 @@ def setUpClass(cls) -> None: ).as_prog_config() cls.instance = TestInstance(semantics=True) + async def test_build_error(self): + """A container's build step times out.""" + with self.assertRaises(BuildError, msg="Build did not complete successfully."): + await Generator.build(path=self.problem_path / "build_error", problem=TestProblem, config=self.config) + + async def test_build_timeout(self): + """A container's build step times out.""" + config = AlgobattleConfig(match=MatchConfig(problem="Test Problem", build_timeout=1.5)).as_prog_config() + with self.assertRaises(BuildError, msg="Build ran into a timeout."): + await Generator.build(path=self.problem_path / "build_timeout", problem=TestProblem, config=config) + async def test_gen_lax_timeout(self): """The generator times out but still outputs a valid instance.""" with await Generator.build( From 7b200ca47f75ac1cc06cadecac3c6a4bb301d038 Mon Sep 17 00:00:00 2001 From: Imogen Date: Thu, 2 Oct 2025 13:49:46 +0200 Subject: [PATCH 6/8] fix CI permissions --- .github/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6ef590a2..5b8c69e8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,6 +5,9 @@ on: tags: - v*.*.* +permissions: + contents: write + jobs: pypi-publish: name: Upload release to PyPI From 27222ea22512fcbcdc7d9771761307917f179913 Mon Sep 17 00:00:00 2001 From: Imogen Date: Thu, 2 Oct 2025 14:00:56 +0200 Subject: [PATCH 7/8] update linting and test CI --- .github/workflows/ci.yml | 42 +++++++++++++++++++--------------------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d504b02..a5a6d26e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,29 +8,27 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Set up Python - uses: actions/setup-python@v2 - with: - python-version: "3.11" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install flake8 flake8-docstrings - - name: Lint with flake8 - run: flake8 . + - uses: actions/checkout@v5 + - uses: astral-sh/ruff-action@v3 + test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Set up Python - uses: actions/setup-python@v2 - with: - python-version: "3.11" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install . --user - - name: Test using unittests - run: python -m unittest --failfast + - uses: actions/checkout@v5 + + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + version: "0.8.22" + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version-file: ".python-version" + + - name: Install the project + run: uv sync --locked --all-extras --dev + + - name: Run tests + run: uv run python -m unittest --failfast From 481d491308bd5f7b4e340fb464371fe12e041e65 Mon Sep 17 00:00:00 2001 From: Imogen Date: Thu, 2 Oct 2025 14:11:07 +0200 Subject: [PATCH 8/8] fix linting issues --- .flake8 | 16 ---- algobattle/battle.py | 8 +- algobattle/problem.py | 42 ++++----- algobattle/types.py | 6 +- pyproject.toml | 8 +- tests/testsproblem/problem.py | 3 +- uv.lock | 158 +++++++--------------------------- 7 files changed, 57 insertions(+), 184 deletions(-) delete mode 100644 .flake8 diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 504e3af4..00000000 --- a/.flake8 +++ /dev/null @@ -1,16 +0,0 @@ -[flake8] -count = True -show_source = True -statistics = True -max_line_length = 120 -per_file_ignores = - tests/*: D102, D104, D107 - docs/*: D100, E261 -docstring_convention = google -ignore = D105, D401, E302, W503 -exclude = - .git, - __pycache__, - .project, - docs/src -ignore_decorators=inherit_docs diff --git a/algobattle/battle.py b/algobattle/battle.py index 69ec8dcb..5fbb5b08 100644 --- a/algobattle/battle.py +++ b/algobattle/battle.py @@ -705,13 +705,13 @@ class Config(Battle.Config): """Number of fights that will be fought.""" weighting: Annotated[float, Ge(0)] = 1.1 """How much each successive fight should be weighted more than the previous.""" - scores: set[Role] = {Role.generator, Role.solver} + scores: set[Role] = {Role.generator, Role.solver} # noqa: RUF012 """Who to show each fight's scores to.""" - instances: set[Role] = {Role.generator, Role.solver} + instances: set[Role] = {Role.generator, Role.solver} # noqa: RUF012 """Who to show the instances to.""" - generator_solutions: set[Role] = {Role.generator} + generator_solutions: set[Role] = {Role.generator} # noqa: RUF012 """Who to show the generator's solutions to, if the problem requires them.""" - solver_solutions: set[Role] = {Role.solver} + solver_solutions: set[Role] = {Role.solver} # noqa: RUF012 """Who to show the solver's solutions to.""" class UiData(Battle.UiData): diff --git a/algobattle/problem.py b/algobattle/problem.py index c121ff6b..44202c51 100644 --- a/algobattle/problem.py +++ b/algobattle/problem.py @@ -1,50 +1,40 @@ """Module defining the Problem and Solution base classes and related objects.""" from abc import ABC, abstractmethod +from collections.abc import Callable from dataclasses import dataclass from functools import wraps from importlib.metadata import entry_points from inspect import Parameter, Signature, signature from itertools import chain +from math import inf, isnan from pathlib import Path from typing import ( TYPE_CHECKING, Any, - Callable, ClassVar, + Generic, Literal, ParamSpec, Protocol, Self, - Generic, TypeVar, - overload, cast, get_args, + overload, runtime_checkable, ) -from math import inf, isnan -from annotated_types import GroupedMetadata -from pydantic import ( - GetCoreSchemaHandler, - ValidationInfo, -) +from annotated_types import GroupedMetadata +from pydantic import GetCoreSchemaHandler, ValidationInfo from pydantic.main import BaseModel from pydantic_core import CoreSchema from pydantic_core.core_schema import ( + ValidatorFunctionWrapHandler, with_info_after_validator_function, with_info_wrap_validator_function, - ValidatorFunctionWrapHandler, ) -from algobattle.util import ( - EncodableBase, - EncodableModel, - EncodableModelBase, - Role, - Encodable, - import_file_as_module, -) +from algobattle.util import Encodable, EncodableBase, EncodableModel, EncodableModelBase, Role, import_file_as_module class Instance(Encodable, ABC): @@ -72,12 +62,12 @@ def validate_instance(self) -> None: P = ParamSpec("P") -class Solution(EncodableBase, Generic[InstanceT], ABC): +class Solution(EncodableBase, ABC, Generic[InstanceT]): """A proposed solution for an instance of this problem.""" @classmethod @abstractmethod - def decode(cls, source: Path, max_size: int, role: Role, instance: InstanceT) -> Self: # noqa: D102 + def decode(cls, source: Path, max_size: int, role: Role, instance: InstanceT) -> Self: raise NotImplementedError def validate_solution(self, instance: InstanceT, role: Role) -> None: @@ -230,7 +220,7 @@ class Problem: """The definition of a problem.""" @overload - def __init__( # noqa: D107 + def __init__( self, *, name: str, @@ -244,7 +234,7 @@ def __init__( # noqa: D107 ... @overload - def __init__( # noqa: D107 + def __init__( self, *, name: str, @@ -295,7 +285,7 @@ def __init__( self.test_instance = test_instance self._problems[name] = self - __slots__ = ("name", "instance_cls", "solution_cls", "min_size", "with_solution", "score_function", "test_instance") + __slots__ = ("instance_cls", "min_size", "name", "score_function", "solution_cls", "test_instance", "with_solution") _problems: ClassVar[dict[str, Self]] = {} @overload @@ -376,7 +366,7 @@ def load(cls, name: str, file: Path | None = None) -> Self: case [e]: loaded: object = e.load() if not isinstance(loaded, cls): - raise ValueError( + raise ValueError( # noqa: TRY004 f"The entrypoint '{name}' doesn't point to a problem but a {loaded.__class__.__qualname__}." ) return loaded @@ -500,8 +490,8 @@ class AttributeReferenceMaker: _attr_ref_maker_model: ModelReference - def __getattr__(self, __name: str) -> AttributeReference: - return AttributeReference(self._attr_ref_maker_model, __name) + def __getattr__(self, name: str, /) -> AttributeReference: + return AttributeReference(self._attr_ref_maker_model, name) SelfRef = AttributeReferenceMaker("self") diff --git a/algobattle/types.py b/algobattle/types.py index b1380a3b..5d12a421 100644 --- a/algobattle/types.py +++ b/algobattle/types.py @@ -438,7 +438,7 @@ def edge_set(self) -> set[tuple[Vertex, Vertex]]: """The set of edges in this graph.""" return set(self.edges) - @cache + @cache # noqa: B019 def neighbors(self, vertex: Vertex, direction: Literal["all", "outgoing", "incoming"] = "all") -> set[Vertex]: """The neighbors of a vertex.""" res = set[Vertex]() @@ -474,7 +474,7 @@ def edge_set(self) -> set[tuple[Vertex, Vertex]]: """ return set(self.edges) | {(v, u) for (u, v) in self.edges} - @cache + @cache # noqa: B019 def neighbors(self, vertex: Vertex, direction: Literal["all", "outgoing", "incoming"] = "all") -> set[Vertex]: """The neighbors of a vertex.""" # more efficient specialization @@ -515,7 +515,7 @@ def edges_with_weights(self) -> Iterator[tuple[tuple[Vertex, Vertex], Weight]]: """Iterate over all edges and their weights.""" return zip(self.edges, self.edge_weights, strict=True) - @cache + @cache # noqa: B019 def weight(self, edge: Edge | tuple[Vertex, Vertex]) -> Weight: """Returns the weight of an edge. diff --git a/pyproject.toml b/pyproject.toml index 39977556..f8fddbf5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,9 +39,7 @@ Repository = "https://github.com/Benezivas/algobattle" [project.optional-dependencies] dev = [ - "black>=23.12.1", - "flake8>=6.1.0", - "flake8-docstrings>=1.7.0", + "ruff>=0.13.2", "mkdocs>=1.5.3", "mkdocs-material>=9.5.3", "pymdown-extensions>=10.7", @@ -55,7 +53,7 @@ algobattle = "algobattle.cli:app" [tool.ruff] preview = true line-length = 120 -exclude = ["docs"] +exclude = ["docs/src"] [tool.ruff.lint] select = [ @@ -104,7 +102,7 @@ split-on-trailing-comma = false combine-as-imports = true [tool.pyright] -exclude = ["docs"] +exclude = [".venv", "docs"] typeCheckingMode = "standard" disableBytesTypePromotions = true strictDictionaryInference = true diff --git a/tests/testsproblem/problem.py b/tests/testsproblem/problem.py index 705e5bd9..48aa8671 100644 --- a/tests/testsproblem/problem.py +++ b/tests/testsproblem/problem.py @@ -1,7 +1,8 @@ """Problem class built for tests.""" from typing import Any -from algobattle.problem import Problem, InstanceModel, SolutionModel + +from algobattle.problem import InstanceModel, Problem, SolutionModel from algobattle.util import Role, ValidationError diff --git a/uv.lock b/uv.lock index 069a94c8..1b241d91 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.11" [[package]] @@ -18,23 +18,18 @@ dependencies = [ [package.optional-dependencies] dev = [ - { name = "black" }, - { name = "flake8" }, - { name = "flake8-docstrings" }, { name = "mdx-include" }, { name = "mkdocs" }, { name = "mkdocs-material" }, { name = "mkdocstrings", extra = ["python"] }, { name = "pymdown-extensions" }, + { name = "ruff" }, ] [package.metadata] requires-dist = [ { name = "anyio", specifier = ">=4.11.0" }, - { name = "black", marker = "extra == 'dev'", specifier = ">=23.12.1" }, { name = "docker", specifier = ">=7.1.0" }, - { name = "flake8", marker = "extra == 'dev'", specifier = ">=6.1.0" }, - { name = "flake8-docstrings", marker = "extra == 'dev'", specifier = ">=1.7.0" }, { name = "jinja2", specifier = ">=3.1.6" }, { name = "mdx-include", marker = "extra == 'dev'", specifier = ">=1.4.2" }, { name = "mkdocs", marker = "extra == 'dev'", specifier = ">=1.5.3" }, @@ -42,6 +37,7 @@ requires-dist = [ { name = "mkdocstrings", extras = ["python"], marker = "extra == 'dev'", specifier = ">=0.24.0" }, { name = "pydantic", specifier = ">=2.11.9" }, { name = "pymdown-extensions", marker = "extra == 'dev'", specifier = ">=10.7" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.13.2" }, { name = "tomlkit", specifier = ">=0.13.3" }, { name = "typer", specifier = ">=0.19.2" }, { name = "typing-extensions", specifier = ">=4.15.0" }, @@ -94,35 +90,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/ff/392bff89415399a979be4a65357a41d92729ae8580a66073d8ec8d810f98/backrefs-5.9-py39-none-any.whl", hash = "sha256:f48ee18f6252b8f5777a22a00a09a85de0ca931658f1dd96d4406a34f3748c60", size = 380265, upload-time = "2025-06-22T19:34:12.405Z" }, ] -[[package]] -name = "black" -version = "25.9.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "mypy-extensions" }, - { name = "packaging" }, - { name = "pathspec" }, - { name = "platformdirs" }, - { name = "pytokens" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4b/43/20b5c90612d7bdb2bdbcceeb53d588acca3bb8f0e4c5d5c751a2c8fdd55a/black-25.9.0.tar.gz", hash = "sha256:0474bca9a0dd1b51791fcc507a4e02078a1c63f6d4e4ae5544b9848c7adfb619", size = 648393, upload-time = "2025-09-19T00:27:37.758Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/f4/7531d4a336d2d4ac6cc101662184c8e7d068b548d35d874415ed9f4116ef/black-25.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:456386fe87bad41b806d53c062e2974615825c7a52159cde7ccaeb0695fa28fa", size = 1698727, upload-time = "2025-09-19T00:31:14.264Z" }, - { url = "https://files.pythonhosted.org/packages/28/f9/66f26bfbbf84b949cc77a41a43e138d83b109502cd9c52dfc94070ca51f2/black-25.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a16b14a44c1af60a210d8da28e108e13e75a284bf21a9afa6b4571f96ab8bb9d", size = 1555679, upload-time = "2025-09-19T00:31:29.265Z" }, - { url = "https://files.pythonhosted.org/packages/bf/59/61475115906052f415f518a648a9ac679d7afbc8da1c16f8fdf68a8cebed/black-25.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aaf319612536d502fdd0e88ce52d8f1352b2c0a955cc2798f79eeca9d3af0608", size = 1617453, upload-time = "2025-09-19T00:30:42.24Z" }, - { url = "https://files.pythonhosted.org/packages/7f/5b/20fd5c884d14550c911e4fb1b0dae00d4abb60a4f3876b449c4d3a9141d5/black-25.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:c0372a93e16b3954208417bfe448e09b0de5cc721d521866cd9e0acac3c04a1f", size = 1333655, upload-time = "2025-09-19T00:30:56.715Z" }, - { url = "https://files.pythonhosted.org/packages/fb/8e/319cfe6c82f7e2d5bfb4d3353c6cc85b523d677ff59edc61fdb9ee275234/black-25.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1b9dc70c21ef8b43248f1d86aedd2aaf75ae110b958a7909ad8463c4aa0880b0", size = 1742012, upload-time = "2025-09-19T00:33:08.678Z" }, - { url = "https://files.pythonhosted.org/packages/94/cc/f562fe5d0a40cd2a4e6ae3f685e4c36e365b1f7e494af99c26ff7f28117f/black-25.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8e46eecf65a095fa62e53245ae2795c90bdecabd53b50c448d0a8bcd0d2e74c4", size = 1581421, upload-time = "2025-09-19T00:35:25.937Z" }, - { url = "https://files.pythonhosted.org/packages/84/67/6db6dff1ebc8965fd7661498aea0da5d7301074b85bba8606a28f47ede4d/black-25.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9101ee58ddc2442199a25cb648d46ba22cd580b00ca4b44234a324e3ec7a0f7e", size = 1655619, upload-time = "2025-09-19T00:30:49.241Z" }, - { url = "https://files.pythonhosted.org/packages/10/10/3faef9aa2a730306cf469d76f7f155a8cc1f66e74781298df0ba31f8b4c8/black-25.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:77e7060a00c5ec4b3367c55f39cf9b06e68965a4f2e61cecacd6d0d9b7ec945a", size = 1342481, upload-time = "2025-09-19T00:31:29.625Z" }, - { url = "https://files.pythonhosted.org/packages/48/99/3acfea65f5e79f45472c45f87ec13037b506522719cd9d4ac86484ff51ac/black-25.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0172a012f725b792c358d57fe7b6b6e8e67375dd157f64fa7a3097b3ed3e2175", size = 1742165, upload-time = "2025-09-19T00:34:10.402Z" }, - { url = "https://files.pythonhosted.org/packages/3a/18/799285282c8236a79f25d590f0222dbd6850e14b060dfaa3e720241fd772/black-25.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3bec74ee60f8dfef564b573a96b8930f7b6a538e846123d5ad77ba14a8d7a64f", size = 1581259, upload-time = "2025-09-19T00:32:49.685Z" }, - { url = "https://files.pythonhosted.org/packages/f1/ce/883ec4b6303acdeca93ee06b7622f1fa383c6b3765294824165d49b1a86b/black-25.9.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b756fc75871cb1bcac5499552d771822fd9db5a2bb8db2a7247936ca48f39831", size = 1655583, upload-time = "2025-09-19T00:30:44.505Z" }, - { url = "https://files.pythonhosted.org/packages/21/17/5c253aa80a0639ccc427a5c7144534b661505ae2b5a10b77ebe13fa25334/black-25.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:846d58e3ce7879ec1ffe816bb9df6d006cd9590515ed5d17db14e17666b2b357", size = 1343428, upload-time = "2025-09-19T00:32:13.839Z" }, - { url = "https://files.pythonhosted.org/packages/1b/46/863c90dcd3f9d41b109b7f19032ae0db021f0b2a81482ba0a1e28c84de86/black-25.9.0-py3-none-any.whl", hash = "sha256:474b34c1342cdc157d307b56c4c65bce916480c4a8f6551fdc6bf9b486a7c4ae", size = 203363, upload-time = "2025-09-19T00:27:35.724Z" }, -] - [[package]] name = "certifi" version = "2025.8.3" @@ -229,33 +196,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" }, ] -[[package]] -name = "flake8" -version = "7.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mccabe" }, - { name = "pycodestyle" }, - { name = "pyflakes" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9b/af/fbfe3c4b5a657d79e5c47a2827a362f9e1b763336a52f926126aa6dc7123/flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872", size = 48326, upload-time = "2025-06-20T19:31:35.838Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922, upload-time = "2025-06-20T19:31:34.425Z" }, -] - -[[package]] -name = "flake8-docstrings" -version = "1.7.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "flake8" }, - { name = "pydocstyle" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/93/24/f839e3a06e18f4643ccb81370909a497297909f15106e6af2fecdef46894/flake8_docstrings-1.7.0.tar.gz", hash = "sha256:4c8cc748dc16e6869728699e5d0d685da9a10b0ea718e090b1ba088e67a941af", size = 5995, upload-time = "2023-01-25T14:27:13.903Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/7d/76a278fa43250441ed9300c344f889c7fb1817080c8fb8996b840bf421c2/flake8_docstrings-1.7.0-py2.py3-none-any.whl", hash = "sha256:51f2344026da083fc084166a9353f5082b01f72901df422f74b4d953ae88ac75", size = 4994, upload-time = "2023-01-25T14:27:12.32Z" }, -] - [[package]] name = "ghp-import" version = "2.1.0" @@ -396,15 +336,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] -[[package]] -name = "mccabe" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, -] - [[package]] name = "mdurl" version = "0.1.2" @@ -556,15 +487,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d5/8f/ce008599d9adebf33ed144e7736914385e8537f5fc686fdb7cceb8c22431/mkdocstrings_python-1.18.2-py3-none-any.whl", hash = "sha256:944fe6deb8f08f33fa936d538233c4036e9f53e840994f6146e8e94eb71b600d", size = 138215, upload-time = "2025-08-28T16:11:18.176Z" }, ] -[[package]] -name = "mypy-extensions" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, -] - [[package]] name = "packaging" version = "25.0" @@ -601,15 +523,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" }, ] -[[package]] -name = "pycodestyle" -version = "2.14.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/11/e0/abfd2a0d2efe47670df87f3e3a0e2edda42f055053c85361f19c0e2c1ca8/pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783", size = 39472, upload-time = "2025-06-20T18:49:48.75Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" }, -] - [[package]] name = "pydantic" version = "2.11.9" @@ -690,27 +603,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" }, ] -[[package]] -name = "pydocstyle" -version = "6.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "snowballstemmer" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e9/5c/d5385ca59fd065e3c6a5fe19f9bc9d5ea7f2509fa8c9c22fb6b2031dd953/pydocstyle-6.3.0.tar.gz", hash = "sha256:7ce43f0c0ac87b07494eb9c0b462c0b73e6ff276807f204d6b53edc72b7e44e1", size = 36796, upload-time = "2023-01-17T20:29:19.838Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/36/ea/99ddefac41971acad68f14114f38261c1f27dac0b3ec529824ebc739bdaa/pydocstyle-6.3.0-py3-none-any.whl", hash = "sha256:118762d452a49d6b05e194ef344a55822987a462831ade91ec5c06fd2169d019", size = 38038, upload-time = "2023-01-17T20:29:18.094Z" }, -] - -[[package]] -name = "pyflakes" -version = "3.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/45/dc/fd034dc20b4b264b3d015808458391acbf9df40b1e54750ef175d39180b1/pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58", size = 64669, upload-time = "2025-06-20T18:45:27.834Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551, upload-time = "2025-06-20T18:45:26.937Z" }, -] - [[package]] name = "pygments" version = "2.19.2" @@ -745,15 +637,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] -[[package]] -name = "pytokens" -version = "0.1.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/30/5f/e959a442435e24f6fb5a01aec6c657079ceaca1b3baf18561c3728d681da/pytokens-0.1.10.tar.gz", hash = "sha256:c9a4bfa0be1d26aebce03e6884ba454e842f186a59ea43a6d3b25af58223c044", size = 12171, upload-time = "2025-02-19T14:51:22.001Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/60/e5/63bed382f6a7a5ba70e7e132b8b7b8abbcf4888ffa6be4877698dcfbed7d/pytokens-0.1.10-py3-none-any.whl", hash = "sha256:db7b72284e480e69fb085d9f251f66b3d2df8b7166059261258ff35f50fb711b", size = 12046, upload-time = "2025-02-19T14:51:18.694Z" }, -] - [[package]] name = "pywin32" version = "311" @@ -877,6 +760,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f", size = 243368, upload-time = "2025-07-25T07:32:56.73Z" }, ] +[[package]] +name = "ruff" +version = "0.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/df/8d7d8c515d33adfc540e2edf6c6021ea1c5a58a678d8cfce9fae59aabcab/ruff-0.13.2.tar.gz", hash = "sha256:cb12fffd32fb16d32cef4ed16d8c7cdc27ed7c944eaa98d99d01ab7ab0b710ff", size = 5416417, upload-time = "2025-09-25T14:54:09.936Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/84/5716a7fa4758e41bf70e603e13637c42cfb9dbf7ceb07180211b9bbf75ef/ruff-0.13.2-py3-none-linux_armv6l.whl", hash = "sha256:3796345842b55f033a78285e4f1641078f902020d8450cade03aad01bffd81c3", size = 12343254, upload-time = "2025-09-25T14:53:27.784Z" }, + { url = "https://files.pythonhosted.org/packages/9b/77/c7042582401bb9ac8eff25360e9335e901d7a1c0749a2b28ba4ecb239991/ruff-0.13.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ff7e4dda12e683e9709ac89e2dd436abf31a4d8a8fc3d89656231ed808e231d2", size = 13040891, upload-time = "2025-09-25T14:53:31.38Z" }, + { url = "https://files.pythonhosted.org/packages/c6/15/125a7f76eb295cb34d19c6778e3a82ace33730ad4e6f28d3427e134a02e0/ruff-0.13.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c75e9d2a2fafd1fdd895d0e7e24b44355984affdde1c412a6f6d3f6e16b22d46", size = 12243588, upload-time = "2025-09-25T14:53:33.543Z" }, + { url = "https://files.pythonhosted.org/packages/9e/eb/0093ae04a70f81f8be7fd7ed6456e926b65d238fc122311293d033fdf91e/ruff-0.13.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cceac74e7bbc53ed7d15d1042ffe7b6577bf294611ad90393bf9b2a0f0ec7cb6", size = 12491359, upload-time = "2025-09-25T14:53:35.892Z" }, + { url = "https://files.pythonhosted.org/packages/43/fe/72b525948a6956f07dad4a6f122336b6a05f2e3fd27471cea612349fedb9/ruff-0.13.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6ae3f469b5465ba6d9721383ae9d49310c19b452a161b57507764d7ef15f4b07", size = 12162486, upload-time = "2025-09-25T14:53:38.171Z" }, + { url = "https://files.pythonhosted.org/packages/6a/e3/0fac422bbbfb2ea838023e0d9fcf1f30183d83ab2482800e2cb892d02dfe/ruff-0.13.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f8f9e3cd6714358238cd6626b9d43026ed19c0c018376ac1ef3c3a04ffb42d8", size = 13871203, upload-time = "2025-09-25T14:53:41.943Z" }, + { url = "https://files.pythonhosted.org/packages/6b/82/b721c8e3ec5df6d83ba0e45dcf00892c4f98b325256c42c38ef136496cbf/ruff-0.13.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c6ed79584a8f6cbe2e5d7dbacf7cc1ee29cbdb5df1172e77fbdadc8bb85a1f89", size = 14929635, upload-time = "2025-09-25T14:53:43.953Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a0/ad56faf6daa507b83079a1ad7a11694b87d61e6bf01c66bd82b466f21821/ruff-0.13.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aed130b2fde049cea2019f55deb939103123cdd191105f97a0599a3e753d61b0", size = 14338783, upload-time = "2025-09-25T14:53:46.205Z" }, + { url = "https://files.pythonhosted.org/packages/47/77/ad1d9156db8f99cd01ee7e29d74b34050e8075a8438e589121fcd25c4b08/ruff-0.13.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1887c230c2c9d65ed1b4e4cfe4d255577ea28b718ae226c348ae68df958191aa", size = 13355322, upload-time = "2025-09-25T14:53:48.164Z" }, + { url = "https://files.pythonhosted.org/packages/64/8b/e87cfca2be6f8b9f41f0bb12dc48c6455e2d66df46fe61bb441a226f1089/ruff-0.13.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5bcb10276b69b3cfea3a102ca119ffe5c6ba3901e20e60cf9efb53fa417633c3", size = 13354427, upload-time = "2025-09-25T14:53:50.486Z" }, + { url = "https://files.pythonhosted.org/packages/7f/df/bf382f3fbead082a575edb860897287f42b1b3c694bafa16bc9904c11ed3/ruff-0.13.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:afa721017aa55a555b2ff7944816587f1cb813c2c0a882d158f59b832da1660d", size = 13537637, upload-time = "2025-09-25T14:53:52.887Z" }, + { url = "https://files.pythonhosted.org/packages/51/70/1fb7a7c8a6fc8bd15636288a46e209e81913b87988f26e1913d0851e54f4/ruff-0.13.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1dbc875cf3720c64b3990fef8939334e74cb0ca65b8dbc61d1f439201a38101b", size = 12340025, upload-time = "2025-09-25T14:53:54.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/27/1e5b3f1c23ca5dd4106d9d580e5c13d9acb70288bff614b3d7b638378cc9/ruff-0.13.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5b939a1b2a960e9742e9a347e5bbc9b3c3d2c716f86c6ae273d9cbd64f193f22", size = 12133449, upload-time = "2025-09-25T14:53:57.089Z" }, + { url = "https://files.pythonhosted.org/packages/2d/09/b92a5ccee289f11ab128df57d5911224197d8d55ef3bd2043534ff72ca54/ruff-0.13.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:50e2d52acb8de3804fc5f6e2fa3ae9bdc6812410a9e46837e673ad1f90a18736", size = 13051369, upload-time = "2025-09-25T14:53:59.124Z" }, + { url = "https://files.pythonhosted.org/packages/89/99/26c9d1c7d8150f45e346dc045cc49f23e961efceb4a70c47dea0960dea9a/ruff-0.13.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3196bc13ab2110c176b9a4ae5ff7ab676faaa1964b330a1383ba20e1e19645f2", size = 13523644, upload-time = "2025-09-25T14:54:01.622Z" }, + { url = "https://files.pythonhosted.org/packages/f7/00/e7f1501e81e8ec290e79527827af1d88f541d8d26151751b46108978dade/ruff-0.13.2-py3-none-win32.whl", hash = "sha256:7c2a0b7c1e87795fec3404a485096bcd790216c7c146a922d121d8b9c8f1aaac", size = 12245990, upload-time = "2025-09-25T14:54:03.647Z" }, + { url = "https://files.pythonhosted.org/packages/ee/bd/d9f33a73de84fafd0146c6fba4f497c4565fe8fa8b46874b8e438869abc2/ruff-0.13.2-py3-none-win_amd64.whl", hash = "sha256:17d95fb32218357c89355f6f6f9a804133e404fc1f65694372e02a557edf8585", size = 13324004, upload-time = "2025-09-25T14:54:06.05Z" }, + { url = "https://files.pythonhosted.org/packages/c3/12/28fa2f597a605884deb0f65c1b1ae05111051b2a7030f5d8a4ff7f4599ba/ruff-0.13.2-py3-none-win_arm64.whl", hash = "sha256:da711b14c530412c827219312b7d7fbb4877fb31150083add7e8c5336549cea7", size = 12484437, upload-time = "2025-09-25T14:54:08.022Z" }, +] + [[package]] name = "shellingham" version = "1.5.4" @@ -904,15 +813,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] -[[package]] -name = "snowballstemmer" -version = "3.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" }, -] - [[package]] name = "tomlkit" version = "0.13.3"