diff --git a/.gitignore b/.gitignore
index a71cd19..46af4f2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,7 @@
#### joe made this: http://goel.io/joe
+metrics/reports/diagrams
+
#### python ####
# Byte-compiled / optimized / DLL files
__pycache__/
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..887f187
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,20 @@
+
+
+## 1.2.0 — 2026-02-07
+
+### Added
+
+- 100% coverage of the code base with tests
+- 100% coverage with typhints
+- 100% coverage of public API documentation in two languages - Russian and English
+- cli attributes: highlighting valid commands, redesigned input history with auto-completion, interactive autocomplete selection menu for multiple candidates
+- a metrics module that allows you to test the performance of various library units
+- implementing a dependency injection pattern through an ioc container
+- implementation of a context object for transferring data between handlers within a session
+- adding a changelog
+
+### Changed
+
+- increased performance by several times (there will be real numbers in the next releases)
+- reworking the internal API, highlighting different layers and reducing connectivity
+- reworking the README and adding a translation for it
diff --git a/docs/code_snippets/argparser/snippet.py b/docs/code_snippets/argparser/snippet.py
index 4b4fab5..4553e7c 100644
--- a/docs/code_snippets/argparser/snippet.py
+++ b/docs/code_snippets/argparser/snippet.py
@@ -1,13 +1,17 @@
from argenta import App, Orchestrator
-from argenta.orchestrator.argparser import ArgParser, BooleanArgument, ValueArgument
+from argenta.orchestrator.argparser import ArgParser, BooleanArgument
-arg_parser = ArgParser(processed_args=[BooleanArgument("dev"), ValueArgument('some', possible_values=['fuck', 'cruck'])])
+arg_parser = ArgParser(
+ processed_args=[
+ BooleanArgument("dev")
+ ]
+)
orchestrator = Orchestrator(
arg_parser=arg_parser,
)
if __name__ == "__main__":
- if arg_parser.parsed_argspace.get_by_name('dev'):
- orchestrator.start_polling(App(initial_message='ArgentaDev'))
+ if arg_parser.parsed_argspace.get_by_name("dev"):
+ orchestrator.start_polling(App(initial_message="ArgentaDev"))
else:
orchestrator.start_polling(App())
diff --git a/docs/code_snippets/argspace/snippet.py b/docs/code_snippets/argspace/snippet.py
index f7f8e40..3c22275 100644
--- a/docs/code_snippets/argspace/snippet.py
+++ b/docs/code_snippets/argspace/snippet.py
@@ -6,11 +6,7 @@ arguments = [
ValueArgument("port", help="Server port", is_required=True),
]
-argparser = ArgParser(
- processed_args=arguments,
- name="WebServer",
- description="Simple web server"
-)
+argparser = ArgParser(processed_args=arguments, name="WebServer", description="Simple web server")
app = App()
orchestrator = Orchestrator(argparser)
diff --git a/docs/code_snippets/argspace/snippet4.py b/docs/code_snippets/argspace/snippet4.py
index efb3180..8fc26b2 100644
--- a/docs/code_snippets/argspace/snippet4.py
+++ b/docs/code_snippets/argspace/snippet4.py
@@ -1,11 +1,20 @@
-config_arg = argspace.get_by_name("config")
-if config_arg:
- print(f"Config path: {config_arg.value}")
+from argenta import Response, Router
+from argenta.di import FromDishka
+from argenta.orchestrator.argparser import ArgSpace
-verbose_arg = argspace.get_by_name("verbose")
-if verbose_arg and verbose_arg.value:
- print("Verbose mode enabled")
+router = Router()
-unknown_arg = argspace.get_by_name("nonexistent")
-if unknown_arg is None:
- print("Argument not found")
+
+@router.command("get_args")
+def get_args(response: Response, argspace: FromDishka[ArgSpace]):
+ config_arg = argspace.get_by_name("config")
+ if config_arg:
+ print(f"Config path: {config_arg.value}")
+
+ verbose_arg = argspace.get_by_name("verbose")
+ if verbose_arg and verbose_arg.value:
+ print("Verbose mode enabled")
+
+ unknown_arg = argspace.get_by_name("nonexistent")
+ if unknown_arg is None:
+ print("Argument not found")
diff --git a/docs/code_snippets/arguments/snippet.py b/docs/code_snippets/arguments/snippet.py
index 6e8f8e0..c49600a 100644
--- a/docs/code_snippets/arguments/snippet.py
+++ b/docs/code_snippets/arguments/snippet.py
@@ -1,28 +1,20 @@
from argenta.orchestrator.argparser import ArgParser, ValueArgument
# Create arguments
-config_arg = ValueArgument(
- "config",
- help="Path to configuration file",
- default="config.yaml"
-)
+config_arg = ValueArgument("config", help="Path to configuration file", default="config.yaml")
log_level_arg = ValueArgument(
"log-level",
help="Logging level",
possible_values=["DEBUG", "INFO", "WARNING", "ERROR"],
- default="INFO"
+ default="INFO",
)
-host_arg = ValueArgument(
- "host",
- help="Server host address",
- is_required=True
-)
+host_arg = ValueArgument("host", help="Server host address", is_required=True)
# Register in ArgParser
parser = ArgParser(
processed_args=[config_arg, log_level_arg, host_arg],
name="MyApp",
- description="My application with CLI arguments"
+ description="My application with CLI arguments",
)
\ No newline at end of file
diff --git a/docs/code_snippets/arguments/snippet2.py b/docs/code_snippets/arguments/snippet2.py
index 254ed82..b63477d 100644
--- a/docs/code_snippets/arguments/snippet2.py
+++ b/docs/code_snippets/arguments/snippet2.py
@@ -1,23 +1,9 @@
from argenta.orchestrator.argparser import ArgParser, BooleanArgument
# Create boolean arguments
-verbose_arg = BooleanArgument(
- "verbose",
- help="Enable verbose output"
-)
-
-debug_arg = BooleanArgument(
- "debug",
- help="Enable debug mode"
-)
-
-no_cache_arg = BooleanArgument(
- "no-cache",
- help="Disable caching"
-)
+verbose_arg = BooleanArgument("verbose", help="Enable verbose output")
+debug_arg = BooleanArgument("debug", help="Enable debug mode")
+no_cache_arg = BooleanArgument("no-cache", help="Disable caching")
# Register in ArgParser
-parser = ArgParser(
- processed_args=[verbose_arg, debug_arg, no_cache_arg],
- name="MyApp"
-)
\ No newline at end of file
+parser = ArgParser(processed_args=[verbose_arg, debug_arg, no_cache_arg], name="MyApp")
\ No newline at end of file
diff --git a/docs/code_snippets/command/snippet5.py b/docs/code_snippets/command/snippet5.py
index 4939f95..c088457 100644
--- a/docs/code_snippets/command/snippet5.py
+++ b/docs/code_snippets/command/snippet5.py
@@ -2,10 +2,13 @@ from argenta import Router, Command, Response
router = Router(title="System")
-@router.command(Command(
- "shutdown",
- description="Shutdown the system",
- aliases=["poweroff", "halt", "stop"]
-))
+
+@router.command(
+ Command(
+ "shutdown",
+ description="Shutdown the system",
+ aliases=["poweroff", "halt", "stop"]
+ )
+)
def handle_shutdown(response: Response):
print("Shutting down the system...")
\ No newline at end of file
diff --git a/docs/code_snippets/flag/snippet5.py b/docs/code_snippets/flag/snippet5.py
index 7200566..cc892ec 100644
--- a/docs/code_snippets/flag/snippet5.py
+++ b/docs/code_snippets/flag/snippet5.py
@@ -3,7 +3,7 @@ from argenta.command import Flag
verbose_flag = Flag(name="verbose", prefix="--")
short_flag = Flag(name="v", prefix="-")
-# Debug view
+# Debug presentation
print(repr(verbose_flag)) # Flag
print(repr(short_flag)) # Flag
diff --git a/docs/code_snippets/flags/deploy_handler.py b/docs/code_snippets/flags/deploy_handler.py
index d0f2e2b..8c09d09 100644
--- a/docs/code_snippets/flags/deploy_handler.py
+++ b/docs/code_snippets/flags/deploy_handler.py
@@ -5,10 +5,7 @@ from argenta.command.flag import ValidationStatus
router = Router()
-@router.command(Command(
- "deploy",
- flags=Flag("verbose", possible_values=PossibleValues.NEITHER)
-))
+@router.command(Command("deploy", flags=Flag("verbose", possible_values=PossibleValues.NEITHER)))
def deploy_handler(response: Response):
# Check for toggle flag presence
verbose_flag = response.input_flags.get_flag_by_name("verbose")
diff --git a/docs/code_snippets/input_flags/snippet1.py b/docs/code_snippets/input_flags/snippet1.py
index 93d528f..100b557 100644
--- a/docs/code_snippets/input_flags/snippet1.py
+++ b/docs/code_snippets/input_flags/snippet1.py
@@ -8,10 +8,7 @@ router = Router(title="Example")
Command(
"example",
description="Example command with flags",
- flags=Flags([
- Flag("name"),
- Flag("age")
- ]),
+ flags=Flags([Flag("name"), Flag("age")]),
)
)
def example_handler(response: Response):
diff --git a/docs/code_snippets/input_flags/snippet2.py b/docs/code_snippets/input_flags/snippet2.py
index 6d059b8..5686f86 100644
--- a/docs/code_snippets/input_flags/snippet2.py
+++ b/docs/code_snippets/input_flags/snippet2.py
@@ -8,11 +8,7 @@ router = Router(title="Get Flag Example")
Command(
"config",
description="Configure settings",
- flags=Flags([
- Flag("host"),
- Flag("port"),
- Flag("debug")
- ]),
+ flags=Flags([Flag("host"), Flag("port"), Flag("debug")]),
)
)
def config_handler(response: Response):
diff --git a/docs/code_snippets/input_flags/snippet3.py b/docs/code_snippets/input_flags/snippet3.py
index e4c33e8..a122879 100644
--- a/docs/code_snippets/input_flags/snippet3.py
+++ b/docs/code_snippets/input_flags/snippet3.py
@@ -1,5 +1,6 @@
from argenta import Command, Response, Router
-from argenta.command.flag import InputFlag, InputFlags, ValidationStatus
+from argenta.command.flag import InputFlag, ValidationStatus
+from argenta.command import InputFlags
router = Router(title="Add Flag Example")
diff --git a/docs/code_snippets/input_flags/snippet4.py b/docs/code_snippets/input_flags/snippet4.py
index 2afbc64..366f4c3 100644
--- a/docs/code_snippets/input_flags/snippet4.py
+++ b/docs/code_snippets/input_flags/snippet4.py
@@ -1,20 +1,15 @@
-from argenta.command.flag import InputFlag, InputFlags, ValidationStatus
+from argenta.command.flag import InputFlag, ValidationStatus
+from argenta.command import InputFlags
# Create InputFlags collection
flags = InputFlags()
# Create several flags
-flag1 = InputFlag(
- name="option1", prefix="--", input_value="value1", status=ValidationStatus.VALID
-)
+flag1 = InputFlag(name="option1", prefix="--", input_value="value1", status=ValidationStatus.VALID)
-flag2 = InputFlag(
- name="option2", prefix="--", input_value="value2", status=ValidationStatus.VALID
-)
+flag2 = InputFlag(name="option2", prefix="--", input_value="value2", status=ValidationStatus.VALID)
-flag3 = InputFlag(
- name="option3", prefix="---", input_value="value3", status=ValidationStatus.VALID
-)
+flag3 = InputFlag(name="option3", prefix="---", input_value="value3", status=ValidationStatus.VALID)
# Add all flags in one call
flags.add_flags([flag1, flag2, flag3])
diff --git a/docs/code_snippets/input_flags/snippet8.py b/docs/code_snippets/input_flags/snippet8.py
index 96dafdf..05fc9d3 100644
--- a/docs/code_snippets/input_flags/snippet8.py
+++ b/docs/code_snippets/input_flags/snippet8.py
@@ -1,5 +1,5 @@
from argenta.command.flag import InputFlag, ValidationStatus
-from argenta.command.flag.flags.models import InputFlags
+from argenta.command import InputFlags
# Create first collection
flags1 = InputFlags(
@@ -26,12 +26,8 @@ flags3 = InputFlags(
)
print(f"flags1 == flags2: {flags1 == flags2}") # True (same names)
-print(
- f"flags1 == flags3: {flags1 == flags3}"
-) # True (same names, values are not considered)
+print(f"flags1 == flags3: {flags1 == flags3}") # True (same names, values are not considered)
# Different collections
-flags4 = InputFlags(
- [InputFlag(name="flag3", input_value="value3", status=ValidationStatus.VALID)]
-)
+flags4 = InputFlags([InputFlag(name="flag3", input_value="value3", status=ValidationStatus.VALID)])
print(f"flags1 == flags4: {flags1 == flags4}") # False (different flags)
diff --git a/docs/code_snippets/metrics/add_new_benchmark.py b/docs/code_snippets/metrics/add_new_benchmark.py
new file mode 100644
index 0000000..7516352
--- /dev/null
+++ b/docs/code_snippets/metrics/add_new_benchmark.py
@@ -0,0 +1,9 @@
+from metrics.benchmarks.entity import benchmarks
+
+@benchmarks.register(
+ type_="my_category",
+ description="Description of what is being measured"
+)
+def benchmark_my_operation() -> None:
+ # Code whose performance is being measured
+ pass
diff --git a/docs/code_snippets/quickstart/calculator_app.py b/docs/code_snippets/quickstart/calculator_app.py
index 38a42f6..b48b7a9 100644
--- a/docs/code_snippets/quickstart/calculator_app.py
+++ b/docs/code_snippets/quickstart/calculator_app.py
@@ -8,11 +8,8 @@ from argenta.response.status import ResponseStatus
router = Router("Calculator")
-operations = {
- 'mul': operator.mul,
- 'sub': operator.sub,
- 'add': operator.add
-}
+operations = {"mul": operator.mul, "sub": operator.sub, "add": operator.add}
+
@router.command(
Command(
@@ -22,7 +19,9 @@ operations = {
[
Flag("a", possible_values=re.compile(r"^\d{,5}$")), # First number
Flag("b", possible_values=re.compile(r"^\d{,5}$")), # Second number
- Flag("operation", possible_values=["add", "sub", "mul"]), # Operation: add, sub, mul
+ Flag(
+ "operation", possible_values=["add", "sub", "mul"]
+ ), # Operation: add, sub, mul
]
),
)
diff --git a/docs/code_snippets/quickstart/simple_app.py b/docs/code_snippets/quickstart/simple_app.py
index a46090e..96c7179 100644
--- a/docs/code_snippets/quickstart/simple_app.py
+++ b/docs/code_snippets/quickstart/simple_app.py
@@ -6,7 +6,7 @@ app = App(
prompt=">> ",
initial_message="Simple App",
farewell_message="Goodbye!",
- repeat_command_groups_printing=False
+ repeat_command_groups_printing=False,
)
orchestrator = Orchestrator()
@@ -15,11 +15,7 @@ main_router = Router(title="Main commands")
# 3. Define command and its handler
-@main_router.command(Command(
- "hello",
- description="Prints greeting message",
- flags=Flag("name")
-))
+@main_router.command(Command("hello", description="Prints greeting message", flags=Flag("name")))
def hello_handler(response: Response):
"""This handler will be called for 'hello' command."""
name = response.input_flags.get_flag_by_name("name")
diff --git a/docs/code_snippets/quickstart/task_manager/handlers.py b/docs/code_snippets/quickstart/task_manager/handlers.py
index 826b3ea..9d4e116 100644
--- a/docs/code_snippets/quickstart/task_manager/handlers.py
+++ b/docs/code_snippets/quickstart/task_manager/handlers.py
@@ -1,7 +1,8 @@
from typing import cast
from argenta import Command, Response, Router
-from argenta.command.flag import Flag, Flags, ValidationStatus
+from argenta.command.flag import Flag, ValidationStatus
+from argenta.command import Flags
from argenta.di import FromDishka
from .repository import Priority, Task, TaskRepository
@@ -9,25 +10,29 @@ from .repository import Priority, Task, TaskRepository
router = Router(title="Task Manager")
-@router.command(Command(
+@router.command(
+ Command(
"add-task",
description="Add a new task",
- flags=Flags([
+ flags=Flags(
+ [
Flag("description"),
Flag("priority", possible_values=["low", "medium", "high"]),
- ]),
- ))
+ ]
+ ),
+ )
+)
def add_task(response: Response, repo: FromDishka[TaskRepository]):
description_flag = response.input_flags.get_flag_by_name("description")
-
+
if not description_flag or not description_flag.status == ValidationStatus.VALID:
print("Error: --description flag is required.")
return
-
+
task_description = description_flag.input_value or ""
priority_flag = response.input_flags.get_flag_by_name("priority")
-
+
if priority_flag and priority_flag.status == ValidationStatus.VALID:
priority_value = priority_flag.input_value
else:
@@ -37,14 +42,14 @@ def add_task(response: Response, repo: FromDishka[TaskRepository]):
task = Task(description=task_description, priority=priority)
repo.add_task(task)
-
+
print(f"Added task: '{task.description}' with priority '{task.priority}'")
@router.command(Command("list-tasks", description="List all tasks"))
def list_tasks(response: Response, repo: FromDishka[TaskRepository]):
tasks = repo.get_all_tasks()
-
+
if not tasks:
print("No tasks found.")
return
diff --git a/docs/code_snippets/response/data_sharing.py b/docs/code_snippets/response/data_sharing.py
index 5f0ae54..8fdaf8d 100644
--- a/docs/code_snippets/response/data_sharing.py
+++ b/docs/code_snippets/response/data_sharing.py
@@ -4,6 +4,7 @@ from argenta.di import FromDishka
router = Router(title="Authentication")
+
def authenticate_user(username: str) -> str:
return f"token_for_{username}"
diff --git a/docs/code_snippets/response/snippet2.py b/docs/code_snippets/response/snippet2.py
deleted file mode 100644
index 4342302..0000000
--- a/docs/code_snippets/response/snippet2.py
+++ /dev/null
@@ -1,25 +0,0 @@
-from argenta import Command, Response, Router
-
-router = Router(title="Data Example")
-
-
-@router.command(Command("set", description="Set data"))
-def set_handler(response: Response):
- # Update global data storage
- response.update_data(
- {
- "user_name": "John",
- "timestamp": "2024-01-01",
- "settings": {"theme": "dark", "language": "ru"},
- }
- )
- print("Data updated successfully")
-
-
-@router.command(Command("show", description="Show data"))
-def show_handler(response: Response):
- # Get data from global storage
- data = response.get_data()
- if "user_name" in data:
- print(f"User: {data['user_name']}")
- print(f"Settings: {data.get('settings', {})}")
diff --git a/docs/code_snippets/response/snippet3.py b/docs/code_snippets/response/snippet3.py
deleted file mode 100644
index 2385169..0000000
--- a/docs/code_snippets/response/snippet3.py
+++ /dev/null
@@ -1,16 +0,0 @@
-from argenta import Command, Response, Router
-
-router = Router(title="Get Data Example")
-
-
-@router.command(Command("info", description="Show all stored data"))
-def info_handler(response: Response):
- # Get all data from global storage
- all_data = response.get_data()
-
- if all_data:
- print("Stored data:")
- for key, value in all_data.items():
- print(f" {key}: {value}")
- else:
- print("No data stored")
diff --git a/docs/code_snippets/response/snippet4.py b/docs/code_snippets/response/snippet4.py
deleted file mode 100644
index 59c7947..0000000
--- a/docs/code_snippets/response/snippet4.py
+++ /dev/null
@@ -1,19 +0,0 @@
-from argenta import Command, Response, Router
-
-router = Router(title="Clear Data Example")
-
-
-@router.command(Command("clear", description="Clear all stored data"))
-def clear_handler(response: Response):
- # Clear all data storage
- response.clear_data()
- print("All data cleared")
-
-
-@router.command(Command("check", description="Check if data exists"))
-def check_handler(response: Response):
- data = response.get_data()
- if data:
- print(f"Storage contains {len(data)} item(s)")
- else:
- print("Storage is empty")
diff --git a/docs/code_snippets/response/snippet5.py b/docs/code_snippets/response/snippet5.py
deleted file mode 100644
index b0bde42..0000000
--- a/docs/code_snippets/response/snippet5.py
+++ /dev/null
@@ -1,29 +0,0 @@
-from argenta import Command, Response, Router
-
-router = Router(title="Delete Data Example")
-
-
-@router.command(Command("store", description="Store data"))
-def store_handler(response: Response):
- response.update_data(
- {
- "temp_key": "temporary value",
- "important_key": "important value",
- "another_key": "another value",
- }
- )
- print("Data stored")
-
-
-@router.command(Command("remove", description="Remove specific key"))
-def remove_handler(response: Response):
- # Delete specific key from storage
- try:
- response.delete_from_data("temp_key")
- print("Key 'temp_key' deleted")
-
- # Check what remains
- remaining = response.get_data()
- print(f"Remaining keys: {list(remaining.keys())}")
- except KeyError:
- print("Key not found")
diff --git a/docs/code_snippets/response/snippet6.py b/docs/code_snippets/response/snippet6.py
index 2f4751c..1c6dff6 100644
--- a/docs/code_snippets/response/snippet6.py
+++ b/docs/code_snippets/response/snippet6.py
@@ -10,10 +10,7 @@ router = Router(title="Flags Example")
Command(
"process",
description="Process with flags",
- flags=Flags([
- Flag("format", possible_values=["json", "xml"]),
- Flag("verbose")
- ]),
+ flags=Flags([Flag("format", possible_values=["json", "xml"]), Flag("verbose")]),
)
)
def process_handler(response: Response):
diff --git a/docs/code_snippets/testing/app_e2e_test.py b/docs/code_snippets/testing/app_e2e_test.py
index 6d0d4a9..d209d6f 100644
--- a/docs/code_snippets/testing/app_e2e_test.py
+++ b/docs/code_snippets/testing/app_e2e_test.py
@@ -8,22 +8,21 @@ from argenta import App, Orchestrator, Router, Command, Response
@pytest.fixture(autouse=True)
def patched_argv():
- with patch.object(sys, 'argv', ['program.py']):
+ with patch.object(sys, "argv", ["program.py"]):
yield
+
def test_input_incorrect_command(capsys: CaptureFixture[str]):
router = Router()
orchestrator = Orchestrator()
- @router.command(Command('test'))
+ @router.command(Command("test"))
def test(response: Response) -> None:
- print('test command')
+ print("test command")
- app = App(override_system_messages=True, print_func=print)
+ app = App(override_system_messages=True, printer=print)
app.include_router(router)
- app.set_unknown_command_handler(
- lambda command: print(f'Unknown command: {command.trigger}')
- )
+ app.set_unknown_command_handler(lambda command: print(f"Unknown command: {command.trigger}"))
with patch("builtins.input", side_effect=["help", "q"]):
orchestrator.start_polling(app)
diff --git a/docs/code_snippets/testing/app_integration_unittest.py b/docs/code_snippets/testing/app_integration_unittest.py
index f3a0caf..6cbd2f7 100644
--- a/docs/code_snippets/testing/app_integration_unittest.py
+++ b/docs/code_snippets/testing/app_integration_unittest.py
@@ -17,5 +17,5 @@ def test_simple_app() -> None:
with redirect_stdout(io.StringIO()) as stdout:
router.finds_appropriate_handler(InputCommand.parse("HELP"))
-
+
assert "Available commands:" in stdout.getvalue()
diff --git a/docs/code_snippets/testing/di_handler_unittest.py b/docs/code_snippets/testing/di_handler_unittest.py
index 62b897c..3fb4b53 100644
--- a/docs/code_snippets/testing/di_handler_unittest.py
+++ b/docs/code_snippets/testing/di_handler_unittest.py
@@ -11,18 +11,20 @@ from argenta.di.integration import setup_dishka, FromDishka
class Service:
def hello(self) -> str:
return "world"
-
+
+
def get_service() -> Service:
return Service()
-
+
router = Router(title="DI")
+
@router.command("HELLO")
def hello(response: Response, service: FromDishka[Service]) -> None:
print(f"hello {service.hello()}")
-
-
+
+
class _FakeApp:
# Minimal stub for setup_dishka; app object is not used in unit tests
registered_routers = [router]
@@ -31,12 +33,12 @@ class _FakeApp:
def test_hello_uses_service():
provider = Provider(scope=Scope.APP)
provider.provide(get_service)
-
+
container = make_container(provider)
setup_dishka(app=_FakeApp(), container=container, auto_inject=True)
# Call handler
with redirect_stdout(io.StringIO()) as stdout:
- router.finds_appropriate_handler(InputCommand.parse('HELLO'))
+ router.finds_appropriate_handler(InputCommand.parse("HELLO"))
assert "hello world" in stdout.getvalue()
diff --git a/docs/code_snippets/testing/simple_handler_unittest.py b/docs/code_snippets/testing/simple_handler_unittest.py
index 176b5e6..9ade881 100644
--- a/docs/code_snippets/testing/simple_handler_unittest.py
+++ b/docs/code_snippets/testing/simple_handler_unittest.py
@@ -7,6 +7,7 @@ from argenta.command import InputCommand
router = Router(title="Demo")
+
@router.command(Command("PING", description="Ping command"))
def ping(response: Response):
print("PONG")
diff --git a/docs/index.rst b/docs/index.rst
index 0f3d9ec..2033b34 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -64,6 +64,7 @@ Argenta предназначена для создания приложений,
root/contributing
root/code_of_conduct
+ root/metrics
.. toctree::
:hidden:
diff --git a/docs/locales/en/LC_MESSAGES/root/api/app/index.po b/docs/locales/en/LC_MESSAGES/root/api/app/index.po
index e27c887..f18dbdd 100644
--- a/docs/locales/en/LC_MESSAGES/root/api/app/index.po
+++ b/docs/locales/en/LC_MESSAGES/root/api/app/index.po
@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: Argenta \n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-01-13 21:50+0300\n"
+"POT-Creation-Date: 2026-02-06 23:44+0300\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language: en\n"
@@ -29,32 +29,32 @@ msgid ""
"взаимодействие с пользователем, координируя работу всех компонентов: "
"роутеров, обработчиков и системных сообщений."
msgstr ""
-"The ``App`` object is the core of your console application. It handles "
-"configuration, lifecycle management, command processing, and user "
-"interaction, coordinating the work of all components: routers, handlers, "
-"and system messages."
+"The ``App`` object is the implementations of your console application. It"
+" handles configuration, lifecycle management, command processing, and "
+"user interaction, coordinating the work of all components: routers, "
+"handlers, and system messages."
#: ../../root/api/app/index.rst:11
msgid "Инициализация"
msgstr "Initialization"
-#: ../../root/api/app/index.rst:37
+#: ../../root/api/app/index.rst:31
msgid "Создаёт и настраивает экземпляр приложения."
msgstr "Creates and configures an application instance."
-#: ../../root/api/app/index.rst:39
+#: ../../root/api/app/index.rst:33
msgid "``prompt``: Приглашение к вводу, отображаемое перед каждой командой."
msgstr "``prompt``: Input prompt displayed before each command."
-#: ../../root/api/app/index.rst:40
+#: ../../root/api/app/index.rst:34
msgid "``initial_message``: Сообщение, выводимое при запуске приложения."
msgstr "``initial_message``: Message displayed when the application starts."
-#: ../../root/api/app/index.rst:41
+#: ../../root/api/app/index.rst:35
msgid "``farewell_message``: Сообщение, выводимое при выходе из приложения."
msgstr "``farewell_message``: Message displayed when exiting the application."
-#: ../../root/api/app/index.rst:42
+#: ../../root/api/app/index.rst:36
msgid ""
"``exit_command``: Команда, которая маркируется как триггер для выхода из "
"приложения."
@@ -62,7 +62,7 @@ msgstr ""
"``exit_command``: Command that is marked as a trigger for exiting the "
"application."
-#: ../../root/api/app/index.rst:43
+#: ../../root/api/app/index.rst:37
msgid ""
"``system_router_title``: Заголовок для системного роутера (содержит "
"команду выхода)."
@@ -70,7 +70,7 @@ msgstr ""
"``system_router_title``: Title for the system router (contains the exit "
"command)."
-#: ../../root/api/app/index.rst:44
+#: ../../root/api/app/index.rst:38
msgid ""
"``dividing_line``: Тип разделительной линии (``StaticDividingLine`` или "
"``DynamicDividingLine``)."
@@ -78,7 +78,7 @@ msgstr ""
"``dividing_line``: Type of dividing line (``StaticDividingLine`` or "
"``DynamicDividingLine``)."
-#: ../../root/api/app/index.rst:45
+#: ../../root/api/app/index.rst:39
msgid ""
"``repeat_command_groups_printing``: Если ``True``, список доступных "
"команд выводится перед каждым вводом."
@@ -86,7 +86,7 @@ msgstr ""
"``repeat_command_groups_printing``: If ``True``, the list of available "
"commands is displayed before each input."
-#: ../../root/api/app/index.rst:46
+#: ../../root/api/app/index.rst:40
msgid ""
"``override_system_messages``: Если ``True``, стандартное форматирование "
"(цвета, ASCII-арт) отключается."
@@ -94,7 +94,7 @@ msgstr ""
"``override_system_messages``: If ``True``, standard formatting (colors, "
"ASCII art) is disabled."
-#: ../../root/api/app/index.rst:47
+#: ../../root/api/app/index.rst:41
msgid ""
"``autocompleter``: Экземпляр класса :ref:`AutoCompleter "
"`, отвечающий за автодополнение команд."
@@ -103,29 +103,28 @@ msgstr ""
"` class responsible for command "
"autocompletion."
-#: ../../root/api/app/index.rst:48
-msgid ""
-"``print_func``: Функция для вывода всех системных сообщений (по умолчанию"
-" ``rich.Console().print``)."
+#: ../../root/api/app/index.rst:42
+#, fuzzy
+msgid "``printer``: Функция для вывода всех системных сообщений."
msgstr ""
"``print_func``: Function for outputting all system messages (defaults to "
"``rich.Console().print``)."
-#: ../../root/api/app/index.rst:53
+#: ../../root/api/app/index.rst:47
msgid ""
"В приложениях на Argenta регистр вводимых команд не важен, проверка на "
"существование и роутинг команд производится на основании триггеров, "
"приведённых к нижнему регистру."
msgstr ""
-"In applications on Argenta, the case of the entered commands is not important, checking for the "
-" existence and routing of commands is performed based on triggers "
-"reduced to lowercase."
+"In applications on Argenta, the case of the entered commands is not "
+"important, checking for the existence and routing of commands is "
+"performed based on triggers reduced to lowercase."
-#: ../../root/api/app/index.rst:56
+#: ../../root/api/app/index.rst:50
msgid "Основные методы"
msgstr "Main Methods"
-#: ../../root/api/app/index.rst:60
+#: ../../root/api/app/index.rst:54
msgid ""
"Регистрирует роутер в приложении. Все команды из этого роутера становятся"
" доступными для вызова."
@@ -137,19 +136,19 @@ msgstr ""
msgid "Parameters"
msgstr "Parameters"
-#: ../../root/api/app/index.rst:62
+#: ../../root/api/app/index.rst:56
msgid "Экземпляр ``Router`` для регистрации."
msgstr "``Router`` instance to register."
-#: ../../root/api/app/index.rst:66
+#: ../../root/api/app/index.rst:60
msgid "Регистрирует несколько роутеров одновременно."
msgstr "Registers multiple routers simultaneously."
-#: ../../root/api/app/index.rst:68
+#: ../../root/api/app/index.rst:62
msgid "Последовательность экземпляров ``Router`` для регистрации."
msgstr "Sequence of ``Router`` instances to register."
-#: ../../root/api/app/index.rst:72
+#: ../../root/api/app/index.rst:66
msgid ""
"Добавляет текстовое сообщение, которое выводится при запуске приложения "
"после ``initial_message``."
@@ -157,11 +156,11 @@ msgstr ""
"Adds a text message that is displayed when the application starts after "
"``initial_message``."
-#: ../../root/api/app/index.rst:74
+#: ../../root/api/app/index.rst:68
msgid "Строка с сообщением."
msgstr "String with the message."
-#: ../../root/api/app/index.rst:77
+#: ../../root/api/app/index.rst:71
msgid ""
"Для вывода стандартных сообщений можно использовать готовые шаблоны из "
":ref:`PredefinedMessages `."
@@ -169,11 +168,11 @@ msgstr ""
"For outputting standard messages, you can use ready-made templates from "
":ref:`PredefinedMessages `."
-#: ../../root/api/app/index.rst:82
+#: ../../root/api/app/index.rst:76
msgid "Методы установки обработчиков"
msgstr "Handler Setup Methods"
-#: ../../root/api/app/index.rst:84
+#: ../../root/api/app/index.rst:78
msgid ""
"``App`` позволяет настраивать реакцию на различные события, такие как "
"ошибки ввода или неизвестные команды."
@@ -181,7 +180,7 @@ msgstr ""
"``App`` allows you to configure responses to various events, such as "
"input errors or unknown commands."
-#: ../../root/api/app/index.rst:87
+#: ../../root/api/app/index.rst:81
msgid ""
"Подробнее об исключениях и их обработке в соответствующем :ref:`разделе "
"документации `."
@@ -189,59 +188,59 @@ msgstr ""
"For more details on exceptions and their handling, see the corresponding "
":ref:`documentation section `."
-#: ../../root/api/app/index.rst:93
+#: ../../root/api/app/index.rst:87
msgid "Устанавливает шаблон для форматирования описания команды."
msgstr "Sets the template for formatting command descriptions."
-#: ../../root/api/app/index.rst:95
+#: ../../root/api/app/index.rst:89
msgid "Обработчик принимает триггер команды (``str``) и её описание (``str``)."
msgstr ""
"The handler accepts the command trigger (``str``) and its description "
"(``str``)."
-#: ../../root/api/app/index.rst:101
+#: ../../root/api/app/index.rst:95
msgid "Устанавливает обработчик при некорректном введённом синтаксисе флагов."
msgstr "Sets the handler for incorrect flag syntax input."
-#: ../../root/api/app/index.rst:103 ../../root/api/app/index.rst:111
+#: ../../root/api/app/index.rst:97 ../../root/api/app/index.rst:105
msgid "Обработчик принимает строку, введённую пользователем."
msgstr "The handler accepts the string entered by the user."
-#: ../../root/api/app/index.rst:109
+#: ../../root/api/app/index.rst:103
msgid "Устанавливает обработчик при повторяющихся флагах в введённой команде."
msgstr "Sets the handler for duplicate flags in the entered command."
-#: ../../root/api/app/index.rst:117
+#: ../../root/api/app/index.rst:111
msgid "Устанавливает обработчик при вводе неизвестной команды."
msgstr "Sets the handler for entering an unknown command."
-#: ../../root/api/app/index.rst:119
+#: ../../root/api/app/index.rst:113
msgid "Обработчик принимает объект ``InputCommand`` - объект введённой команды."
msgstr ""
"The handler accepts an ``InputCommand`` object - the entered command "
"object."
-#: ../../root/api/app/index.rst:125
+#: ../../root/api/app/index.rst:119
msgid "Устанавливает обработчик при вводе пустой строки."
msgstr "Sets the handler for entering an empty string."
-#: ../../root/api/app/index.rst:127
+#: ../../root/api/app/index.rst:121
msgid "Обработчик не принимает аргументов."
msgstr "The handler accepts no arguments."
-#: ../../root/api/app/index.rst:133
+#: ../../root/api/app/index.rst:127
msgid "Переопределяет стандартное поведение при вызове команды выхода."
msgstr "Overrides the default behavior when the exit command is invoked."
-#: ../../root/api/app/index.rst:135
+#: ../../root/api/app/index.rst:129
msgid "Обработчик принимает объект ``Response``."
msgstr "The handler accepts a ``Response`` object."
-#: ../../root/api/app/index.rst:148
+#: ../../root/api/app/index.rst:142
msgid "PredefinedMessages"
msgstr "PredefinedMessages"
-#: ../../root/api/app/index.rst:150
+#: ../../root/api/app/index.rst:144
msgid ""
"``PredefinedMessages`` — это контейнер, содержащий набор готовых к "
"использованию сообщений. Они отформатированы с использованием синтаксиса "
@@ -252,31 +251,31 @@ msgstr ""
"messages. They are formatted using ``rich`` syntax and are intended for "
"displaying standard information, such as usage hints."
-#: ../../root/api/app/index.rst:152
+#: ../../root/api/app/index.rst:146
msgid "Рекомендуется использовать их при старте приложения."
msgstr "It is recommended to use them when starting the application."
-#: ../../root/api/app/index.rst:179
+#: ../../root/api/app/index.rst:173
msgid "Строка: ``[b dim]Usage[/b dim]: [i] <[green]flags[/green]>[/i]``"
msgstr "String: ``[b dim]Usage[/b dim]: [i] <[green]flags[/green]>[/i]``"
-#: ../../root/api/app/index.rst:181
+#: ../../root/api/app/index.rst:175
msgid "Отображается как: ``Usage: ``"
msgstr "Displayed as: ``Usage: ``"
-#: ../../root/api/app/index.rst:185
+#: ../../root/api/app/index.rst:179
msgid "Строка: ``[b dim]Help[/b dim]: [i][/i] [b red]--help[/b red]``"
msgstr "String: ``[b dim]Help[/b dim]: [i][/i] [b red]--help[/b red]``"
-#: ../../root/api/app/index.rst:187
+#: ../../root/api/app/index.rst:181
msgid "Отображается как: ``Help: --help``"
msgstr "Displayed as: ``Help: --help``"
-#: ../../root/api/app/index.rst:191
+#: ../../root/api/app/index.rst:185
msgid "Строка: ``[b dim]Autocomplete[/b dim]: [i][/i] [bold]``"
msgstr "String: ``[b dim]Autocomplete[/b dim]: [i][/i] [bold]``"
-#: ../../root/api/app/index.rst:193
+#: ../../root/api/app/index.rst:187
msgid "Отображается как: ``Autocomplete: ``"
msgstr "Displayed as: ``Autocomplete: ``"
diff --git a/docs/locales/en/LC_MESSAGES/root/api/orchestrator/argparser.po b/docs/locales/en/LC_MESSAGES/root/api/orchestrator/argparser.po
index b45ecd7..24acdc5 100644
--- a/docs/locales/en/LC_MESSAGES/root/api/orchestrator/argparser.po
+++ b/docs/locales/en/LC_MESSAGES/root/api/orchestrator/argparser.po
@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: Argenta \n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2025-12-02 22:29+0300\n"
+"POT-Creation-Date: 2026-02-06 23:44+0300\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language: en\n"
@@ -30,10 +30,11 @@ msgid ""
"позволяет получать внешнюю конфигурацию в момент старта (например, путь к"
" файлу настроек, флаги отладки или режим запуска)."
msgstr ""
-"``ArgParser`` is designed for processing **command-line arguments** passed to the "
-"application at startup. It's important not to confuse them with flags that the user "
-"enters in interactive mode. ``ArgParser`` allows receiving external configuration at "
-"startup (e.g., path to settings file, debug flags, or launch mode)."
+"``ArgParser`` is designed for processing **command-line arguments** "
+"passed to the application at startup. It's important not to confuse them "
+"with flags that the user enters in interactive mode. ``ArgParser`` allows"
+" receiving external configuration at startup (e.g., path to settings "
+"file, debug flags, or launch mode)."
#: ../../root/api/orchestrator/argparser.rst:11
msgid "Инициализация"
@@ -81,8 +82,9 @@ msgid ""
"экземпляр ``ArgParser``, атрибут ``parsed_argspace`` будет содержать "
"пустой ``ArgSpace``."
msgstr ""
-"Before initializing ``Orchestrator``, to whose constructor an ``ArgParser`` instance "
-"was passed, the ``parsed_argspace`` attribute will contain an empty ``ArgSpace``."
+"Before initializing ``Orchestrator``, to whose constructor an "
+"``ArgParser`` instance was passed, the ``parsed_argspace`` attribute will"
+" contain an empty ``ArgSpace``."
#: ../../root/api/orchestrator/argparser.rst:40
msgid ""
@@ -90,8 +92,9 @@ msgid ""
"``Orchestrator``, поэтому использовать ``parsed_argspace`` "
"**целесообразно только после** этого."
msgstr ""
-"Parsing and validation of arguments occur during ``Orchestrator`` initialization, "
-"so using ``parsed_argspace`` is **advisable only after** that."
+"Parsing and validation of arguments occur during ``Orchestrator`` "
+"initialization, so using ``parsed_argspace`` is **advisable only after** "
+"that."
#: ../../root/api/orchestrator/argparser.rst:45
msgid "Лучшие практики"
@@ -104,9 +107,10 @@ msgid ""
"``ArgSpace`` через DI. Подробнее см. :ref:`здесь "
"`."
msgstr ""
-"Using the ``parsed_argspace`` attribute is recommended only during the application "
-"setup phase. In handlers, the best practice is to obtain ``ArgSpace`` through DI. "
-"For more details, see :ref:`here `."
+"Using the ``parsed_argspace`` attribute is recommended only during the "
+"application setup phase. In handlers, the best practice is to obtain "
+"``ArgSpace`` through DI. For more details, see :ref:`here "
+"`."
#: ../../root/api/orchestrator/argparser.rst:49
msgid "**Пример использования:**"
@@ -129,8 +133,8 @@ msgid ""
"При работе с аргументами командной строки стандартный ``ArgumentParser`` "
"автоматически обрабатывает следующие ситуации:"
msgstr ""
-"When working with command-line arguments, the standard ``ArgumentParser`` "
-"automatically handles the following situations:"
+"When working with command-line arguments, the standard ``ArgumentParser``"
+" automatically handles the following situations:"
#: ../../root/api/orchestrator/argparser.rst:63
msgid "**Отсутствие обязательного аргумента:**"
@@ -149,6 +153,12 @@ msgid ""
"При использовании аргумента с ``is_deprecated=True`` выводится "
"предупреждение, но выполнение продолжается:"
msgstr ""
-"When using an argument with ``is_deprecated=True``, a warning is displayed, "
-"but execution continues:"
+"When using an argument with ``is_deprecated=True``, a warning is "
+"displayed, but execution continues:"
+
+#: ../../root/api/orchestrator/argparser.rst:90
+msgid ""
+"Параметр поддерживается начиная с версии CPython 3.13, если версия ниже, "
+"то параметр будет игнорироваться."
+msgstr ""
diff --git a/docs/locales/en/LC_MESSAGES/root/contributing.po b/docs/locales/en/LC_MESSAGES/root/contributing.po
index dcedf27..071af73 100644
--- a/docs/locales/en/LC_MESSAGES/root/contributing.po
+++ b/docs/locales/en/LC_MESSAGES/root/contributing.po
@@ -579,7 +579,7 @@ msgstr ""
msgid ""
"Откройте `127.0.0.1:8000` в браузере, чтобы просмотреть сгенерированную "
"документацию."
-msgstr "Open `127.0.0.1:8000` in your browser to view the generated documentation."
+msgstr "Open `127.0.0.1:8000` in your browser to presentation the generated documentation."
#: ../../root/contributing.rst:233
msgid ""
diff --git a/docs/locales/en/LC_MESSAGES/root/dependency_injection.po b/docs/locales/en/LC_MESSAGES/root/dependency_injection.po
index b4d124d..07a130a 100644
--- a/docs/locales/en/LC_MESSAGES/root/dependency_injection.po
+++ b/docs/locales/en/LC_MESSAGES/root/dependency_injection.po
@@ -109,7 +109,7 @@ msgstr "How Does It Work?"
#: ../../root/dependency_injection.rst:51
msgid "В основе DI в Argenta лежат **провайдеры** и **контейнер**."
-msgstr "At the core of DI in Argenta are **providers** and a **container**."
+msgstr "At the implementations of DI in Argenta are **providers** and a **container**."
#: ../../root/dependency_injection.rst:53
msgid ""
diff --git a/docs/locales/en/LC_MESSAGES/root/flags.po b/docs/locales/en/LC_MESSAGES/root/flags.po
index cc51eb2..cab78b5 100644
--- a/docs/locales/en/LC_MESSAGES/root/flags.po
+++ b/docs/locales/en/LC_MESSAGES/root/flags.po
@@ -49,7 +49,7 @@ msgstr ""
"The main purpose of flags is to provide a way to change the command's "
"logic without reworking it. A command can operate in several modes: "
"standard, verbose, debug, or simplified. Flags switch these modes on user"
-" demand, keeping the core functionality unchanged."
+" demand, keeping the implementations functionality unchanged."
#: ../../root/flags.rst:17
msgid "Опциональность и удобство"
diff --git a/docs/locales/en/LC_MESSAGES/root/metrics.po b/docs/locales/en/LC_MESSAGES/root/metrics.po
new file mode 100644
index 0000000..56b31bb
--- /dev/null
+++ b/docs/locales/en/LC_MESSAGES/root/metrics.po
@@ -0,0 +1,293 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) 2025, kolo
+# This file is distributed under the same license as the Argenta package.
+# FIRST AUTHOR , 2026.
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: Argenta \n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2026-02-06 23:44+0300\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME \n"
+"Language: en\n"
+"Language-Team: en \n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=utf-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Generated-By: Babel 2.17.0\n"
+
+#: ../../root/metrics.rst:2
+msgid "Метрики"
+msgstr "Metrics"
+
+#: ../../root/metrics.rst:4
+msgid ""
+"Система метрик ``Argenta`` предоставляет инструменты для измерения "
+"производительности ключевых компонентов библиотеки. Это позволяет "
+"отслеживать регрессию/прогрессию производительности между релизами и "
+"оптимизировать критические участки кода."
+msgstr ""
+"The ``Argenta`` metrics system provides tools for measuring the performance "
+"of key library components. This allows tracking performance regression/progression "
+"between releases and optimizing critical code sections."
+
+#: ../../root/metrics.rst:9
+msgid "Запуск метрик"
+msgstr "Running Metrics"
+
+#: ../../root/metrics.rst:11
+msgid ""
+"Для работы с метриками необходимо склонировать репозиторий и установить "
+"зависимости:"
+msgstr ""
+"To work with metrics, you need to clone the repository and install "
+"dependencies:"
+
+#: ../../root/metrics.rst:19
+msgid "Запуск системы метрик:"
+msgstr "Running the metrics system:"
+
+#: ../../root/metrics.rst:25
+msgid ""
+"После запуска откроется интерактивная сессия с доступными командами для "
+"работы с бенчмарками."
+msgstr ""
+"After launch, an interactive session will open with available commands for "
+"working with benchmarks."
+
+#: ../../root/metrics.rst:30
+msgid "Доступные команды"
+msgstr "Available Commands"
+
+#: ../../root/metrics.rst:33
+msgid "run-all"
+msgstr "run-all"
+
+#: ../../root/metrics.rst:35
+msgid ""
+"Запускает все зарегистрированные бенчмарки и выводит результаты в виде "
+"таблиц."
+msgstr ""
+"Runs all registered benchmarks and outputs results as tables."
+
+#: ../../root/metrics.rst:37 ../../root/metrics.rst:55
+#: ../../root/metrics.rst:78 ../../root/metrics.rst:97
+#: ../../root/metrics.rst:117
+msgid "**Синтаксис:**"
+msgstr "**Syntax:**"
+
+#: ../../root/metrics.rst:43 ../../root/metrics.rst:84
+#: ../../root/metrics.rst:103
+msgid "**Флаги:**"
+msgstr "**Flags:**"
+
+#: ../../root/metrics.rst:45
+msgid ""
+"``--without-gc`` — отключает сборщик мусора во время выполнения "
+"бенчмарков для более стабильных результатов"
+msgstr ""
+"``--without-gc`` — disables garbage collector during benchmark execution "
+"for more stable results"
+
+#: ../../root/metrics.rst:46
+msgid "``--without-system-info`` — скрывает информацию о системе в выводе"
+msgstr "``--without-system-info`` — hides system information in output"
+
+#: ../../root/metrics.rst:51
+msgid "list-types"
+msgstr "list-types"
+
+#: ../../root/metrics.rst:53
+msgid ""
+"Выводит список всех доступных типов бенчмарков с количеством тестов в "
+"каждой категории."
+msgstr ""
+"Displays a list of all available benchmark types with the number of tests "
+"in each category."
+
+#: ../../root/metrics.rst:61
+msgid "**Пример вывода:**"
+msgstr "**Example output:**"
+
+#: ../../root/metrics.rst:74
+msgid "run-type"
+msgstr "run-type"
+
+#: ../../root/metrics.rst:76
+msgid "Запускает бенчмарки определённого типа."
+msgstr "Runs benchmarks of a specific type."
+
+#: ../../root/metrics.rst:86
+msgid "``--type`` — тип бенчмарков для запуска (обязательный)"
+msgstr "``--type`` — benchmark type to run (required)"
+
+#: ../../root/metrics.rst:87 ../../root/metrics.rst:106
+msgid "``--without-gc`` — отключает сборщик мусора"
+msgstr "``--without-gc`` — disables garbage collector"
+
+#: ../../root/metrics.rst:88
+msgid "``--without-system-info`` — скрывает информацию о системе"
+msgstr "``--without-system-info`` — hides system information"
+
+#: ../../root/metrics.rst:93
+msgid "diagrams-generate"
+msgstr "diagrams-generate"
+
+#: ../../root/metrics.rst:95
+msgid ""
+"Генерирует визуальные диаграммы сравнения производительности для всех "
+"бенчмарков."
+msgstr ""
+"Generates visual performance comparison diagrams for all benchmarks."
+
+#: ../../root/metrics.rst:105
+msgid ""
+"``--iterations`` — количество итераций для каждого бенчмарка (по "
+"умолчанию 100)"
+msgstr ""
+"``--iterations`` — number of iterations for each benchmark (default 100)"
+
+#: ../../root/metrics.rst:108
+msgid ""
+"Диаграммы сохраняются в директорию "
+"``metrics/reports/diagrams//``."
+msgstr ""
+"Diagrams are saved to the ``metrics/reports/diagrams//`` directory."
+
+#: ../../root/metrics.rst:113
+msgid "release-generate"
+msgstr "release-generate"
+
+#: ../../root/metrics.rst:115
+msgid ""
+"Генерирует полный отчёт о производительности для текущей версии "
+"библиотеки. Используется при подготовке релизов."
+msgstr ""
+"Generates a complete performance report for the current library version. "
+"Used when preparing releases."
+
+#: ../../root/metrics.rst:123
+msgid "Команда автоматически:"
+msgstr "The command automatically:"
+
+#: ../../root/metrics.rst:125
+msgid "Определяет текущую версию библиотеки"
+msgstr "Determines the current library version"
+
+#: ../../root/metrics.rst:126
+msgid "Запускает все бенчмарки с 1000 итераций и отключённым GC"
+msgstr "Runs all benchmarks with 1000 iterations and disabled GC"
+
+#: ../../root/metrics.rst:127
+msgid "Генерирует JSON-отчёты и диаграммы сравнения"
+msgstr "Generates JSON reports and comparison diagrams"
+
+#: ../../root/metrics.rst:128
+msgid "Сохраняет результаты в ``metrics/reports/releases//``"
+msgstr "Saves results to ``metrics/reports/releases//``"
+
+#: ../../root/metrics.rst:133
+msgid "Интерпретация результатов"
+msgstr "Interpreting Results"
+
+#: ../../root/metrics.rst:135
+msgid "Результаты бенчмарков включают следующие метрики:"
+msgstr "Benchmark results include the following metrics:"
+
+#: ../../root/metrics.rst:137
+msgid "**Среднее время (mean)**"
+msgstr "**Mean time (mean)**"
+
+#: ../../root/metrics.rst:138
+msgid ""
+"Среднее время выполнения операции. Основная метрика для сравнения "
+"производительности."
+msgstr ""
+"Average operation execution time. The primary metric for performance comparison."
+
+#: ../../root/metrics.rst:140
+msgid "**Медиана (median)**"
+msgstr "**Median (median)**"
+
+#: ../../root/metrics.rst:141
+msgid ""
+"Медианное значение времени выполнения. Менее чувствительна к выбросам, "
+"чем среднее."
+msgstr ""
+"Median execution time value. Less sensitive to outliers than the mean."
+
+#: ../../root/metrics.rst:143
+msgid "**Стандартное отклонение (std)**"
+msgstr "**Standard deviation (std)**"
+
+#: ../../root/metrics.rst:144
+msgid ""
+"Показывает стабильность измерений. Меньшее значение означает более "
+"предсказуемую производительность."
+msgstr ""
+"Shows measurement stability. A lower value means more predictable performance."
+
+#: ../../root/metrics.rst:149
+msgid "Рекомендации по использованию"
+msgstr "Usage Recommendations"
+
+#: ../../root/metrics.rst:151
+msgid "**Для оптимизации**"
+msgstr "**For optimization**"
+
+#: ../../root/metrics.rst:152
+msgid ""
+"Используйте ``run-type`` для фокусировки на конкретной области и "
+"``--without-gc`` для более точных измерений."
+msgstr ""
+"Use ``run-type`` to focus on a specific area and ``--without-gc`` for more "
+"accurate measurements."
+
+#: ../../root/metrics.rst:154
+msgid "**Для визуализации**"
+msgstr "**For visualization**"
+
+#: ../../root/metrics.rst:155
+msgid ""
+"Команда ``diagrams-generate`` создаёт наглядные графики, удобные для "
+"презентаций и документации."
+msgstr ""
+"The ``diagrams-generate`` command creates clear charts suitable for "
+"presentations and documentation."
+
+#: ../../root/metrics.rst:157
+msgid "**Для стабильных результатов**"
+msgstr "**For stable results**"
+
+#: ../../root/metrics.rst:158
+msgid ""
+"Закройте ресурсоёмкие приложения, используйте флаг ``--without-gc`` и "
+"увеличивайте количество итераций через ``--iterations``."
+msgstr ""
+"Close resource-intensive applications, use the ``--without-gc`` flag, and "
+"increase the number of iterations via ``--iterations``."
+
+#: ../../root/metrics.rst:163
+msgid "Добавление новых бенчмарков"
+msgstr "Adding New Benchmarks"
+
+#: ../../root/metrics.rst:165
+msgid ""
+"Вы можете реализовать свои бенчмарки для тестирования специфичных юнитов "
+"библиотеки. Новые бенчмарки добавляются через декоратор "
+"``@benchmarks.register``:"
+msgstr ""
+"You can implement your own benchmarks to test specific library units. "
+"New benchmarks are added via the ``@benchmarks.register`` decorator:"
+
+#: ../../root/metrics.rst:173
+msgid ""
+"Бенчмарк должен быть импортирован в ``metrics/benchmarks/__init__.py`` "
+"для автоматической регистрации."
+msgstr ""
+"The benchmark must be imported in ``metrics/benchmarks/__init__.py`` for "
+"automatic registration."
+
diff --git a/docs/locales/en/LC_MESSAGES/root/overriding_formatting.po b/docs/locales/en/LC_MESSAGES/root/overriding_formatting.po
index 9c72754..95c947a 100644
--- a/docs/locales/en/LC_MESSAGES/root/overriding_formatting.po
+++ b/docs/locales/en/LC_MESSAGES/root/overriding_formatting.po
@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: Argenta \n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2025-12-04 20:39+0300\n"
+"POT-Creation-Date: 2026-02-06 23:44+0300\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language: en\n"
@@ -90,20 +90,20 @@ msgstr "Output Customization"
#: ../../root/overriding_formatting.rst:32
msgid ""
"Для полной замены логики вывода текста в конструкторе ``App`` "
-"предусмотрен параметр ``print_func``."
+"предусмотрен параметр ``printer``."
msgstr ""
"For complete replacement of text output logic, the ``App`` constructor "
-"provides the ``print_func`` parameter."
+"provides the ``printer`` parameter."
#: ../../root/overriding_formatting.rst:34
msgid ""
-"**print_func**: ``Callable[[str], None]`` Этот параметр позволяет "
-"передать любую вызываемую сущность (например, функцию), которая будет "
+"**printer**: ``Callable[[str], None]`` Этот параметр позволяет передать "
+"любую вызываемую сущность (например, функцию), которая будет "
"использоваться для вывода всех системных сообщений. По умолчанию это "
"``rich.console.Console().print``. Вы можете передать сюда свою функцию, "
"чтобы, например, логировать вывод в файл или отправлять его по сети."
msgstr ""
-"**print_func**: ``Callable[[str], None]`` This parameter allows passing "
+"**printer**: ``Callable[[str], None]`` This parameter allows passing "
"any callable entity (for example, a function) that will be used to output"
" all system messages. By default, this is "
"``rich.console.Console().print``. You can pass your own function here to,"
diff --git a/docs/root/api/app/index.rst b/docs/root/api/app/index.rst
index 9646edc..cc4462a 100644
--- a/docs/root/api/app/index.rst
+++ b/docs/root/api/app/index.rst
@@ -10,29 +10,23 @@ App
Инициализация
-------------
-.. code-block:: python
- :linenos:
-
- AVAILABLE_DIVIDING_LINES: TypeAlias = StaticDividingLine | DynamicDividingLine
- DEFAULT_DIVIDING_LINE: StaticDividingLine = StaticDividingLine()
-
- DEFAULT_PRINT_FUNC: Printer = Console().print
- DEFAULT_AUTOCOMPLETER: AutoCompleter = AutoCompleter()
- DEFAULT_EXIT_COMMAND: Command = Command("Q", description="Exit command")
-
.. code-block:: python
:linenos:
- def __init__(self, *, prompt: str = "What do you want to do?\n\n",
- initial_message: str = "Argenta\n",
- farewell_message: str = "\nSee you\n",
- exit_command: Command = DEFAULT_EXIT_COMMAND,
- system_router_title: str | None = "System points:",
- dividing_line: AVAILABLE_DIVIDING_LINES = DEFAULT_DIVIDING_LINE,
- repeat_command_groups_printing: bool = False,
- override_system_messages: bool = False,
- autocompleter: AutoCompleter = DEFAULT_AUTOCOMPLETER,
- print_func: Printer = DEFAULT_PRINT_FUNC) -> None
+ def __init__(
+ self,
+ *,
+ prompt: str = ">>> ",
+ initial_message: str = "Argenta",
+ farewell_message: str = "See you",
+ exit_command: Command = Command("q", description="Exit command"),
+ system_router_title: str = "System points:",
+ dividing_line: StaticDividingLine | DynamicDividingLine | None = None,
+ repeat_command_groups_printing: bool = False,
+ override_system_messages: bool = False,
+ autocompleter: AutoCompleter | None = None,
+ printer: Printer = Console().print,
+ ) -> None:
Создаёт и настраивает экземпляр приложения.
@@ -45,7 +39,7 @@ App
* ``repeat_command_groups_printing``: Если ``True``, список доступных команд выводится перед каждым вводом.
* ``override_system_messages``: Если ``True``, стандартное форматирование (цвета, ASCII-арт) отключается.
* ``autocompleter``: Экземпляр класса :ref:`AutoCompleter `, отвечающий за автодополнение команд.
- * ``print_func``: Функция для вывода всех системных сообщений (по умолчанию ``rich.Console().print``).
+ * ``printer``: Функция для вывода всех системных сообщений.
-----
diff --git a/docs/root/api/orchestrator/argparser.rst b/docs/root/api/orchestrator/argparser.rst
index 60eb582..e28c097 100644
--- a/docs/root/api/orchestrator/argparser.rst
+++ b/docs/root/api/orchestrator/argparser.rst
@@ -84,3 +84,8 @@ ArgParser
$ python app.py --old-param value
Warning: argument --old-param is deprecated
+
+.. warning::
+
+ Параметр поддерживается начиная с версии CPython 3.13, если версия ниже, то параметр будет игнорироваться.
+
diff --git a/docs/root/metrics.rst b/docs/root/metrics.rst
new file mode 100644
index 0000000..5c37805
--- /dev/null
+++ b/docs/root/metrics.rst
@@ -0,0 +1,173 @@
+Метрики
+=======
+
+Система метрик ``Argenta`` предоставляет инструменты для измерения производительности ключевых компонентов библиотеки. Это позволяет отслеживать регрессию/прогрессию производительности между релизами и оптимизировать критические участки кода.
+
+-----
+
+Запуск метрик
+-------------
+
+Для работы с метриками необходимо склонировать репозиторий и установить зависимости:
+
+.. code-block:: bash
+
+ git clone https://github.com/koloideal/Argenta.git
+ cd Argenta
+ uv sync --group metrics
+
+Запуск системы метрик:
+
+.. code-block:: bash
+
+ python -m metrics
+
+После запуска откроется интерактивная сессия с доступными командами для работы с бенчмарками.
+
+-----
+
+Доступные команды
+-----------------
+
+run-all
+~~~~~~~
+
+Запускает все зарегистрированные бенчмарки и выводит результаты в виде таблиц.
+
+**Синтаксис:**
+
+.. code-block:: shell
+
+ run-all [--without-gc] [--without-system-info]
+
+**Флаги:**
+
+- ``--without-gc`` — отключает сборщик мусора во время выполнения бенчмарков для более стабильных результатов
+- ``--without-system-info`` — скрывает информацию о системе в выводе
+
+-----
+
+list-types
+~~~~~~~~~~
+
+Выводит список всех доступных типов бенчмарков с количеством тестов в каждой категории.
+
+**Синтаксис:**
+
+.. code-block:: shell
+
+ list-types
+
+**Пример вывода:**
+
+.. code-block:: text
+
+ Available benchmark types:
+
+ • flag_validation (9 benchmarks)
+ • input_command_parse (7 benchmarks)
+ • finds_appropriate_handler (5 benchmarks)
+
+-----
+
+run-type
+~~~~~~~~
+
+Запускает бенчмарки определённого типа.
+
+**Синтаксис:**
+
+.. code-block:: shell
+
+ run-type --type [--without-gc] [--without-system-info]
+
+**Флаги:**
+
+- ``--type`` — тип бенчмарков для запуска (обязательный)
+- ``--without-gc`` — отключает сборщик мусора
+- ``--without-system-info`` — скрывает информацию о системе
+
+-----
+
+diagrams-generate
+~~~~~~~~~~~~~~~~~
+
+Генерирует визуальные диаграммы сравнения производительности для всех бенчмарков.
+
+**Синтаксис:**
+
+.. code-block:: shell
+
+ diagrams-generate [--iterations ] [--without-gc]
+
+**Флаги:**
+
+- ``--iterations`` — количество итераций для каждого бенчмарка (по умолчанию 100)
+- ``--without-gc`` — отключает сборщик мусора
+
+Диаграммы сохраняются в директорию ``metrics/reports/diagrams//``.
+
+-----
+
+release-generate
+~~~~~~~~~~~~~~~~
+
+Генерирует полный отчёт о производительности для текущей версии библиотеки. Используется при подготовке релизов.
+
+**Синтаксис:**
+
+.. code-block:: shell
+
+ release-generate
+
+Команда автоматически:
+
+1. Определяет текущую версию библиотеки
+2. Запускает все бенчмарки с 1000 итераций и отключённым GC
+3. Генерирует JSON-отчёты и диаграммы сравнения
+4. Сохраняет результаты в ``metrics/reports/releases//``
+
+-----
+
+Интерпретация результатов
+-------------------------
+
+Результаты бенчмарков включают следующие метрики:
+
+**Среднее время (mean)**
+ Среднее время выполнения операции. Основная метрика для сравнения производительности.
+
+**Медиана (median)**
+ Медианное значение времени выполнения. Менее чувствительна к выбросам, чем среднее.
+
+**Стандартное отклонение (std)**
+ Показывает стабильность измерений. Меньшее значение означает более предсказуемую производительность.
+
+-----
+
+Рекомендации по использованию
+------------------------------
+
+**Для оптимизации**
+ Используйте ``run-type`` для фокусировки на конкретной области и ``--without-gc`` для более точных измерений.
+
+**Для визуализации**
+ Команда ``diagrams-generate`` создаёт наглядные графики, удобные для презентаций и документации.
+
+**Для стабильных результатов**
+ Закройте ресурсоёмкие приложения, используйте флаг ``--without-gc`` и увеличивайте количество итераций через ``--iterations``.
+
+-----
+
+Добавление новых бенчмарков
+----------------------------
+
+Вы можете реализовать свои бенчмарки для тестирования специфичных юнитов библиотеки. Новые бенчмарки добавляются через декоратор ``@benchmarks.register``:
+
+.. literalinclude:: ../code_snippets/metrics/add_new_benchmark.py
+ :language: python
+ :linenos:
+
+.. important::
+
+ Бенчмарк должен быть импортирован в ``metrics/benchmarks/__init__.py`` для автоматической регистрации.
diff --git a/docs/root/overriding_formatting.rst b/docs/root/overriding_formatting.rst
index 1d279af..f67a92c 100644
--- a/docs/root/overriding_formatting.rst
+++ b/docs/root/overriding_formatting.rst
@@ -29,9 +29,9 @@
Кастомизация вывода
-------------------
-Для полной замены логики вывода текста в конструкторе ``App`` предусмотрен параметр ``print_func``.
+Для полной замены логики вывода текста в конструкторе ``App`` предусмотрен параметр ``printer``.
-* **print_func**: ``Callable[[str], None]``
+* **printer**: ``Callable[[str], None]``
Этот параметр позволяет передать любую вызываемую сущность (например, функцию), которая будет использоваться для вывода всех системных сообщений. По умолчанию это ``rich.console.Console().print``. Вы можете передать сюда свою функцию, чтобы, например, логировать вывод в файл или отправлять его по сети.
.. important::
diff --git a/metrics/__init__.py b/metrics/__init__.py
index 63f99dd..e69de29 100644
--- a/metrics/__init__.py
+++ b/metrics/__init__.py
@@ -1 +0,0 @@
-from .benchmarks import *
\ No newline at end of file
diff --git a/metrics/__main__.py b/metrics/__main__.py
index 6e7f0c4..1f7fc08 100644
--- a/metrics/__main__.py
+++ b/metrics/__main__.py
@@ -1,48 +1,18 @@
-from concurrent.futures import ProcessPoolExecutor
-import os
-
-from rich.console import Console
-from rich.table import Table
-from rich.panel import Panel
-from rich.text import Text
-
-from metrics.utils import run_benchmark, BenchmarkResult
-from .registry import Benchmarks, Benchmark
+from argenta import App, Orchestrator, Command
+from argenta.app import DynamicDividingLine
+from .handlers import router
-def main():
- console = Console()
- all_benchmarks: list[Benchmark] = Benchmarks.get_benchmarks()
+app = App(initial_message="metrics", exit_command=Command('exit', aliases=['quit']))
+orchestrator = Orchestrator()
- workers = os.cpu_count() or 1
- with ProcessPoolExecutor(max_workers=workers) as executor:
- results = executor.map(run_benchmark, all_benchmarks)
- type_paired_benchmarks: dict[str, list[BenchmarkResult]] = {}
-
- for result in results:
- type_paired_benchmarks.setdefault(result.type_, []).append(result)
-
- for type_, benchmarks in type_paired_benchmarks.items():
- header_text = Text(f"TYPE: {type_.upper()}", style="bold magenta")
- console.print(Panel(header_text, expand=False, border_style="magenta"))
-
- table = Table(show_header=True, header_style="bold cyan", border_style="blue", show_lines=True)
- table.add_column("Name", style="green")
- table.add_column("Description", style="dim")
- table.add_column("Iterations", justify="right")
- table.add_column("Avg Time (ms)", justify="right", style="bold yellow")
-
- for benchmark in benchmarks:
- table.add_row(
- benchmark.name,
- benchmark.description,
- str(benchmark.iterations),
- str(benchmark.avg_time)
- )
-
- console.print(table)
- console.print()
+def main() -> None:
+ app.include_router(router)
+ app.set_description_message_pattern(
+ lambda command, description: f'[bold cyan]▸[/bold cyan] [bold white]{command}[/bold white] [dim]│[/dim] [yellow italic]{description}[/yellow italic]'
+ )
+ orchestrator.start_polling(app)
if __name__ == "__main__":
diff --git a/metrics/benchmarks/__init__.py b/metrics/benchmarks/__init__.py
index 64424de..37d80b8 100644
--- a/metrics/benchmarks/__init__.py
+++ b/metrics/benchmarks/__init__.py
@@ -1 +1,6 @@
-from .pre_cycle_setup import *
\ No newline at end of file
+from .pre_cycle_setup import *
+from .most_similar_command import *
+from .finds_appropriate_handler import *
+from .validate_routers_for_collisions import *
+from .input_command_parse import *
+from .flag_validation import *
\ No newline at end of file
diff --git a/metrics/benchmarks/core/__init__.py b/metrics/benchmarks/core/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/metrics/benchmarks/core/exceptions.py b/metrics/benchmarks/core/exceptions.py
new file mode 100644
index 0000000..bf199c1
--- /dev/null
+++ b/metrics/benchmarks/core/exceptions.py
@@ -0,0 +1,20 @@
+class BenchmarkNotFound(Exception):
+ def __init__(self, benchmark_name: str):
+ self.benchmark_name = benchmark_name
+
+ def __str__(self) -> str:
+ return f"Benchmark with name '{self.benchmark_name}' not found"
+
+
+class BenchmarksNotFound(Exception):
+ def __init__(self, type_: str):
+ self.type_ = type_
+
+ def __str__(self) -> str:
+ return f"Benchmarks with type '{self.type_}' not found"
+
+class BenchmarksWithSameNameAlreadyExists(Exception):
+ def __init__(self, benchmark_name: str):
+ self.benchmark_name = benchmark_name
+ def __str__(self) -> str:
+ return f"Benchmarks with name '{self.benchmark_name}' already exists"
diff --git a/metrics/benchmarks/core/models.py b/metrics/benchmarks/core/models.py
new file mode 100644
index 0000000..3a79880
--- /dev/null
+++ b/metrics/benchmarks/core/models.py
@@ -0,0 +1,165 @@
+__all__ = [
+ "Benchmark",
+ "Benchmarks",
+ "BenchmarkResult",
+ "BenchmarkGroupResult"
+]
+
+import io
+from contextlib import redirect_stdout
+from dataclasses import dataclass
+import time
+import gc
+import statistics
+from typing import Callable, override
+
+from .exceptions import BenchmarkNotFound, BenchmarksNotFound, BenchmarksWithSameNameAlreadyExists
+
+FuncForBenchmark = Callable[[], None]
+MILLISECONDS_IN_SECONDS = 1000
+
+
+@dataclass(frozen=True, slots=True)
+class BenchmarkResult:
+ type_: str
+ name: str
+ description: str
+ iterations: int
+ is_gc_disabled: bool
+ avg_time: float
+ median_time: float
+ std_dev: float
+
+
+@dataclass(frozen=True, slots=True)
+class BenchmarkGroupResult:
+ type_: str
+ iterations: int
+ is_gc_disabled: bool
+ benchmark_results: list[BenchmarkResult]
+
+
+class Benchmark:
+ def __init__(
+ self,
+ func: FuncForBenchmark,
+ *,
+ type_: str,
+ name: str,
+ description: str
+ ) -> None:
+ self.func = func
+ self.type_ = type_
+ self.name = name
+ self.description = description
+
+ def single_run(self) -> float:
+ with redirect_stdout(io.StringIO()):
+ start = time.perf_counter()
+ self.func()
+ end = time.perf_counter()
+ return (end - start) * MILLISECONDS_IN_SECONDS
+
+ def multiple_runs(self, iterations: int, is_gc_disabled: bool = False) -> tuple[float, ...]:
+ run_attempts: list[float] = []
+ if is_gc_disabled:
+ was_gc_enabled = gc.isenabled()
+ gc.disable()
+ for _ in range(iterations):
+ run_attempts.append(self.single_run())
+ if was_gc_enabled:
+ gc.enable()
+ gc.collect()
+ return tuple(run_attempts)
+ else:
+ for _ in range(iterations):
+ run_attempts.append(self.single_run())
+ return tuple(run_attempts)
+
+ @override
+ def __repr__(self) -> str:
+ return f'Benchmark<{self.type_=}, {self.name=}, {self.description=}>'
+
+ @override
+ def __str__(self) -> str:
+ return f'benchmark {self.name} with type {self.type_}'
+
+
+class Benchmarks:
+ def __init__(self, *benchmarks: Benchmark) -> None:
+ self._benchmarks: list[Benchmark] = list(benchmarks)
+ self._benchmarks_grouped_by_type: dict[str, list[Benchmark]] = {}
+ self._benchmarks_paired_by_name: dict[str, Benchmark] = {}
+
+ def register(
+ self,
+ type_: str,
+ description: str = ""
+ ) -> Callable[[FuncForBenchmark], FuncForBenchmark]:
+ def decorator(func: FuncForBenchmark) -> FuncForBenchmark:
+ benchmark = Benchmark(
+ func,
+ type_=type_,
+ name=func.__name__,
+ description=description or f'description for {func.__name__} with type {type_}',
+ )
+ if self._benchmarks_paired_by_name.get(func.__name__):
+ raise BenchmarksWithSameNameAlreadyExists(func.__name__)
+
+ self._benchmarks_paired_by_name[func.__name__] = benchmark
+ self._benchmarks.append(benchmark)
+ self._benchmarks_grouped_by_type.setdefault(type_, []).append(benchmark)
+ return func
+ return decorator
+
+ def run_benchmark_by_name(self, name: str, iterations: int = 100, is_gc_disables: bool = False) -> BenchmarkResult:
+ benchmark = self.get_benchmark_by_name(name)
+ if not benchmark:
+ raise BenchmarkNotFound(name)
+ run_attempts: tuple[float, ...] = benchmark.multiple_runs(iterations, is_gc_disables)
+
+ avg = round(statistics.mean(run_attempts), 4)
+ median = round(statistics.median(run_attempts), 4)
+ std_dev = round(statistics.stdev(run_attempts) if len(run_attempts) > 1 else 0, 4)
+
+ return BenchmarkResult(
+ type_=benchmark.type_,
+ name=benchmark.name,
+ description=benchmark.description,
+ iterations=iterations,
+ is_gc_disabled=is_gc_disables,
+ avg_time=avg,
+ median_time=median,
+ std_dev=std_dev
+ )
+
+ def run_benchmarks_by_type(self, type_: str, iterations: int = 100, is_gc_disabled: bool = False) -> BenchmarkGroupResult:
+ benchmarks = self.get_benchmarks_by_type(type_)
+ if not benchmarks:
+ raise BenchmarksNotFound(type_)
+ benchmark_results: list[BenchmarkResult] = []
+
+ for benchmark in benchmarks:
+ benchmark_results.append(self.run_benchmark_by_name(benchmark.name, iterations, is_gc_disabled))
+
+ return BenchmarkGroupResult(
+ type_=type_,
+ iterations=iterations,
+ is_gc_disabled=is_gc_disabled,
+ benchmark_results=benchmark_results
+ )
+
+ def run_benchmarks_grouped_by_type(self, iterations: int = 100, is_gc_disabled: bool = False) -> list[BenchmarkGroupResult]:
+ results: list[BenchmarkGroupResult] = []
+ for type_, benchmarks in self._benchmarks_grouped_by_type.items():
+ results.append(self.run_benchmarks_by_type(type_, iterations, is_gc_disabled))
+ return results
+
+ def get_benchmarks_by_type(self, type_: str) -> list[Benchmark]:
+ return self._benchmarks_grouped_by_type.get(type_, [])
+
+ def get_benchmark_by_name(self, name: str) -> Benchmark | None:
+ return self._benchmarks_paired_by_name.get(name)
+
+ def get_types(self) -> set[str]:
+ return set(self._benchmarks_grouped_by_type.keys())
diff --git a/metrics/benchmarks/entity.py b/metrics/benchmarks/entity.py
new file mode 100644
index 0000000..2365dee
--- /dev/null
+++ b/metrics/benchmarks/entity.py
@@ -0,0 +1,3 @@
+from .core.models import Benchmarks
+
+benchmarks = Benchmarks()
\ No newline at end of file
diff --git a/metrics/benchmarks/finds_appropriate_handler.py b/metrics/benchmarks/finds_appropriate_handler.py
new file mode 100644
index 0000000..822b5e4
--- /dev/null
+++ b/metrics/benchmarks/finds_appropriate_handler.py
@@ -0,0 +1,80 @@
+__all__ = [
+ "benchmark_simple_command",
+ "benchmark_command_with_flags",
+ "benchmark_many_commands",
+ "benchmark_command_with_many_flags",
+ "benchmark_extreme_router"
+]
+
+from argenta.command.models import Command, InputCommand
+from argenta.command import Flag, Flags
+from argenta.response import Response
+from argenta.router import Router
+
+from .entity import benchmarks
+
+
+@benchmarks.register(type_="finds_appropriate_handler", description="Simple command (no flags)")
+def benchmark_simple_command() -> None:
+ router = Router()
+
+ @router.command(Command('test'))
+ def handler(_res: Response) -> None:
+ pass
+
+ input_cmd = InputCommand.parse('test')
+ router.finds_appropriate_handler(input_cmd)
+
+
+@benchmarks.register(type_="finds_appropriate_handler", description="Command with flags (3 flags)")
+def benchmark_command_with_flags() -> None:
+ router = Router()
+
+ @router.command(Command('test', flags=Flags([Flag('a'), Flag('b'), Flag('c')])))
+ def handler(_res: Response) -> None:
+ pass
+
+ input_cmd = InputCommand.parse('test -a -b -c')
+ router.finds_appropriate_handler(input_cmd)
+
+
+@benchmarks.register(type_="finds_appropriate_handler", description="Many commands (50 commands)")
+def benchmark_many_commands() -> None:
+ router = Router()
+
+ for i in range(50):
+ @router.command(Command(f'cmd{i}'))
+ def handler(_res: Response) -> None:
+ pass
+
+ input_cmd = InputCommand.parse('cmd25')
+ router.finds_appropriate_handler(input_cmd)
+
+
+@benchmarks.register(type_="finds_appropriate_handler", description="Command with many flags (20 flags)")
+def benchmark_command_with_many_flags() -> None:
+ router = Router()
+
+ flags = Flags([Flag(f'flag{i}') for i in range(20)])
+
+ @router.command(Command('test', flags=flags))
+ def handler(_res: Response) -> None:
+ pass
+
+ input_cmd = InputCommand.parse('test ' + ' '.join(f'-flag{i}' for i in range(10)))
+ router.finds_appropriate_handler(input_cmd)
+
+
+@benchmarks.register(type_="finds_appropriate_handler", description="Extreme (100 commands, 10 flags each)")
+def benchmark_extreme_router() -> None:
+ router = Router()
+
+ for i in range(100):
+ flags = Flags([Flag(f'f{i}_{j}') for j in range(10)])
+
+ @router.command(Command(f'cmd{i}', flags=flags))
+ def handler(_res: Response) -> None:
+ pass
+
+ input_cmd = InputCommand.parse('cmd50 -f50_0 -f50_1 -f50_2')
+ router.finds_appropriate_handler(input_cmd)
diff --git a/metrics/benchmarks/flag_validation.py b/metrics/benchmarks/flag_validation.py
new file mode 100644
index 0000000..c3144f1
--- /dev/null
+++ b/metrics/benchmarks/flag_validation.py
@@ -0,0 +1,102 @@
+__all__ = [
+ "benchmark_validate_all_single_flag",
+ "benchmark_validate_neither_single_flag",
+ "benchmark_validate_list_small",
+ "benchmark_validate_list_large",
+ "benchmark_validate_regex_simple",
+ "benchmark_validate_regex_complex",
+ "benchmark_validate_multiple_flags_10",
+ "benchmark_validate_multiple_flags_50",
+ "benchmark_validate_extreme_100_flags"
+]
+
+import re
+
+from argenta.command.flag import Flag, InputFlag, PossibleValues
+
+from .entity import benchmarks
+
+
+@benchmarks.register(type_="flag_validation", description="Single flag with PossibleValues.ALL")
+def benchmark_validate_all_single_flag() -> None:
+ flag = Flag("test", possible_values=PossibleValues.ALL)
+ flag.validate_input_flag_value("some_value")
+
+
+@benchmarks.register(type_="flag_validation", description="Single flag with PossibleValues.NEITHER")
+def benchmark_validate_neither_single_flag() -> None:
+ flag = Flag("test", possible_values=PossibleValues.NEITHER)
+ flag.validate_input_flag_value("")
+
+
+@benchmarks.register(type_="flag_validation", description="List validation (5 possible values)")
+def benchmark_validate_list_small() -> None:
+ flag = Flag("env", possible_values=["dev", "staging", "prod", "test", "local"])
+ flag.validate_input_flag_value("prod")
+
+
+@benchmarks.register(type_="flag_validation", description="List validation (50 possible values)")
+def benchmark_validate_list_large() -> None:
+ possible_values = [f"value{i}" for i in range(50)]
+ flag = Flag("option", possible_values=possible_values)
+ flag.validate_input_flag_value("value25")
+
+
+@benchmarks.register(type_="flag_validation", description="Regex validation (simple pattern)")
+def benchmark_validate_regex_simple() -> None:
+ pattern = re.compile(r"^\d+$")
+ flag = Flag("port", possible_values=pattern)
+ flag.validate_input_flag_value("8080")
+
+
+@benchmarks.register(type_="flag_validation", description="Regex validation (complex pattern)")
+def benchmark_validate_regex_complex() -> None:
+ pattern = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")
+ flag = Flag("email", possible_values=pattern)
+ flag.validate_input_flag_value("user@example.com")
+
+
+@benchmarks.register(type_="flag_validation", description="Multiple flags validation (10 flags)")
+def benchmark_validate_multiple_flags_10() -> None:
+ flags = [
+ Flag(f"flag{i}", possible_values=PossibleValues.ALL)
+ for i in range(10)
+ ]
+ input_flags = [
+ InputFlag(f"flag{i}", input_value=f"value{i}")
+ for i in range(10)
+ ]
+
+ for flag, input_flag in zip(flags, input_flags):
+ flag.validate_input_flag_value(input_flag.input_value)
+
+
+@benchmarks.register(type_="flag_validation", description="Multiple flags validation (50 flags)")
+def benchmark_validate_multiple_flags_50() -> None:
+ flags = [
+ Flag(f"flag{i}", possible_values=PossibleValues.ALL)
+ for i in range(50)
+ ]
+ input_flags = [
+ InputFlag(f"flag{i}", input_value=f"value{i}")
+ for i in range(50)
+ ]
+
+ for flag, input_flag in zip(flags, input_flags):
+ flag.validate_input_flag_value(input_flag.input_value)
+
+
+@benchmarks.register(type_="flag_validation", description="Extreme (100 flags with regex validation)")
+def benchmark_validate_extreme_100_flags() -> None:
+ pattern = re.compile(r"^[a-zA-Z0-9_-]+$")
+ flags = [
+ Flag(f"flag{i}", possible_values=pattern)
+ for i in range(100)
+ ]
+ input_flags = [
+ InputFlag(f"flag{i}", input_value=f"valid_value_{i}")
+ for i in range(100)
+ ]
+
+ for flag, input_flag in zip(flags, input_flags):
+ flag.validate_input_flag_value(input_flag.input_value)
diff --git a/metrics/benchmarks/input_command_parse.py b/metrics/benchmarks/input_command_parse.py
new file mode 100644
index 0000000..9e22cf3
--- /dev/null
+++ b/metrics/benchmarks/input_command_parse.py
@@ -0,0 +1,51 @@
+__all__ = [
+ "benchmark_parse_simple_command",
+ "benchmark_command_with_few_flags",
+ "benchmark_command_with_flags_and_values",
+ "benchmark_command_with_mixed_prefixes",
+ "benchmark_command_with_long_values",
+ "benchmark_command_with_quoted_values",
+ "benchmark_extreme_many_flags"
+]
+
+from argenta.command.models import InputCommand
+
+from .entity import benchmarks
+
+
+@benchmarks.register(type_="input_command_parse", description="Simple command (no flags)")
+def benchmark_parse_simple_command() -> None:
+ InputCommand.parse("start")
+
+
+@benchmarks.register(type_="input_command_parse", description="Command with few flags (3 flags)")
+def benchmark_command_with_few_flags() -> None:
+ InputCommand.parse("start -a -b -c")
+
+
+@benchmarks.register(type_="input_command_parse", description="Command with flags and values (5 flags)")
+def benchmark_command_with_flags_and_values() -> None:
+ InputCommand.parse("start --host localhost --port 8080 --debug --verbose -c config.json")
+
+
+@benchmarks.register(type_="input_command_parse", description="Command with mixed prefixes (-, --, ---)")
+def benchmark_command_with_mixed_prefixes() -> None:
+ InputCommand.parse("cmd -a --bb ---ccc -d value --ee value2 ---fff value3")
+
+
+@benchmarks.register(type_="input_command_parse", description="Command with long values (10 flags)")
+def benchmark_command_with_long_values() -> None:
+ long_value = "a" * 100
+ cmd = f"process --data {long_value} --config {long_value} --output {long_value}"
+ InputCommand.parse(cmd)
+
+
+@benchmarks.register(type_="input_command_parse", description="Command with quoted values (5 flags)")
+def benchmark_command_with_quoted_values() -> None:
+ InputCommand.parse("cmd --text 'hello world' --path '/usr/local/bin' --msg \"test message\"")
+
+
+@benchmarks.register(type_="input_command_parse", description="Extreme (50 flags with values)")
+def benchmark_extreme_many_flags() -> None:
+ flags = " ".join(f"--flag{i} value{i}" for i in range(50))
+ InputCommand.parse(f"command {flags}")
diff --git a/metrics/benchmarks/most_similar_command.py b/metrics/benchmarks/most_similar_command.py
new file mode 100644
index 0000000..bcc1d3b
--- /dev/null
+++ b/metrics/benchmarks/most_similar_command.py
@@ -0,0 +1,59 @@
+__all__ = [
+ "benchmark_few_commands",
+ "benchmark_many_commands_most_similar",
+ "benchmark_many_aliases",
+ "benchmark_partial_match",
+ "benchmark_extreme_commands"
+]
+
+from argenta import App
+from argenta.command.models import Command
+from argenta.response import Response
+from argenta.router import Router
+
+from .entity import benchmarks
+
+
+def setup_app_with_commands(command_count: int, aliases_per_command: int = 0) -> App:
+ app = App(override_system_messages=True)
+ router = Router()
+
+ for i in range(command_count):
+ aliases = {f'alias{i}_{j}' for j in range(aliases_per_command)} if aliases_per_command else set()
+
+ @router.command(Command(f'command{i}', aliases=aliases))
+ def handler(_res: Response) -> None:
+ pass
+
+ app.include_router(router)
+ return app
+
+
+@benchmarks.register(type_="most_similar_command", description="Few commands (10 commands, no match)")
+def benchmark_few_commands() -> None:
+ app = setup_app_with_commands(10)
+ app._most_similar_command("unknown")
+
+
+@benchmarks.register(type_="most_similar_command", description="Many commands (50 commands, no match)")
+def benchmark_many_commands_most_similar() -> None:
+ app = setup_app_with_commands(50)
+ app._most_similar_command("unknown")
+
+
+@benchmarks.register(type_="most_similar_command", description="Many aliases (20 commands, 10 aliases each)")
+def benchmark_many_aliases() -> None:
+ app = setup_app_with_commands(20, aliases_per_command=10)
+ app._most_similar_command("unknown")
+
+
+@benchmarks.register(type_="most_similar_command", description="Partial match (50 commands, prefix match)")
+def benchmark_partial_match() -> None:
+ app = setup_app_with_commands(50)
+ app._most_similar_command("comm")
+
+
+@benchmarks.register(type_="most_similar_command", description="Extreme (100 commands, 20 aliases each)")
+def benchmark_extreme_commands() -> None:
+ app = setup_app_with_commands(100, aliases_per_command=20)
+ app._most_similar_command("comm")
diff --git a/metrics/benchmarks/pre_cycle_setup.py b/metrics/benchmarks/pre_cycle_setup.py
index 4d0e386..03c3676 100644
--- a/metrics/benchmarks/pre_cycle_setup.py
+++ b/metrics/benchmarks/pre_cycle_setup.py
@@ -1,22 +1,21 @@
__all__ = [
"benchmark_no_aliases",
- "benchmark_many_aliases",
+ "benchmark_with_many_aliases",
"benchmark_few_aliases",
"benchmark_extreme_aliases",
"benchmark_very_many_aliases"
]
from argenta import App
-from argenta.router import Router
from argenta.command.models import Command
from argenta.response import Response
+from argenta.router import Router
-from ..utils import get_time_of_pre_cycle_setup
-from ..registry import benchmark
+from .entity import benchmarks
-@benchmark(type_="pre_cycle_setup", description="With no aliases")
-def benchmark_no_aliases() -> float:
+@benchmarks.register(type_="pre_cycle_setup", description="With no aliases")
+def benchmark_no_aliases() -> None:
app = App(override_system_messages=True)
router = Router()
@@ -33,12 +32,11 @@ def benchmark_no_aliases() -> float:
pass
app.include_router(router)
- execution_time = get_time_of_pre_cycle_setup(app)
- return execution_time
+ app._pre_cycle_setup()
-@benchmark(type_="pre_cycle_setup", description="With few aliases (6 total)")
-def benchmark_few_aliases() -> float:
+@benchmarks.register(type_="pre_cycle_setup", description="With few aliases (6 total)")
+def benchmark_few_aliases() -> None:
app = App(override_system_messages=True)
router = Router()
@@ -55,12 +53,11 @@ def benchmark_few_aliases() -> float:
pass
app.include_router(router)
- execution_time = get_time_of_pre_cycle_setup(app)
- return execution_time
+ app._pre_cycle_setup()
-@benchmark(type_="pre_cycle_setup", description="With many aliases (15 total)")
-def benchmark_many_aliases() -> float:
+@benchmarks.register(type_="pre_cycle_setup", description="With many aliases (15 total)")
+def benchmark_with_many_aliases() -> None:
app = App(override_system_messages=True)
router = Router()
@@ -77,12 +74,11 @@ def benchmark_many_aliases() -> float:
pass
app.include_router(router)
- execution_time = get_time_of_pre_cycle_setup(app)
- return execution_time
+ app._pre_cycle_setup()
-@benchmark(type_="pre_cycle_setup", description="With very many aliases (60 total)")
-def benchmark_very_many_aliases() -> float:
+@benchmarks.register(type_="pre_cycle_setup", description="With very many aliases (60 total)")
+def benchmark_very_many_aliases() -> None:
app = App(override_system_messages=True)
router = Router()
@@ -99,12 +95,11 @@ def benchmark_very_many_aliases() -> float:
pass
app.include_router(router)
- execution_time = get_time_of_pre_cycle_setup(app)
- return execution_time
+ app._pre_cycle_setup()
-@benchmark(type_="pre_cycle_setup", description="With extreme aliases (300 total)")
-def benchmark_extreme_aliases() -> float:
+@benchmarks.register(type_="pre_cycle_setup", description="With extreme aliases (300 total)")
+def benchmark_extreme_aliases() -> None:
app = App(override_system_messages=True)
router = Router()
@@ -121,5 +116,4 @@ def benchmark_extreme_aliases() -> float:
pass
app.include_router(router)
- execution_time = get_time_of_pre_cycle_setup(app)
- return execution_time
+ app._pre_cycle_setup()
diff --git a/metrics/benchmarks/validate_routers_for_collisions.py b/metrics/benchmarks/validate_routers_for_collisions.py
new file mode 100644
index 0000000..60e9aa7
--- /dev/null
+++ b/metrics/benchmarks/validate_routers_for_collisions.py
@@ -0,0 +1,102 @@
+__all__ = [
+ "benchmark_few_routers",
+ "benchmark_many_routers",
+ "benchmark_many_commands_per_router",
+ "benchmark_many_aliases_per_command",
+ "benchmark_extreme_routers"
+]
+
+from argenta import App
+from argenta.command.models import Command
+from argenta.response import Response
+from argenta.router import Router
+
+from .entity import benchmarks
+
+
+@benchmarks.register(type_="validate_routers_for_collisions", description="With few routers (3 routers, 1 command each)")
+def benchmark_few_routers() -> None:
+ app = App(override_system_messages=True)
+
+ for i in range(3):
+ router = Router()
+
+ @router.command(Command(f'cmd{i}'))
+ def handler(_res: Response) -> None:
+ pass
+
+ app.include_router(router)
+
+ app._setup_system_router()
+ app._validate_routers_for_collisions()
+
+
+@benchmarks.register(type_="validate_routers_for_collisions", description="With many routers (10 routers, 1 command each)")
+def benchmark_many_routers() -> None:
+ app = App(override_system_messages=True)
+
+ for i in range(10):
+ router = Router()
+
+ @router.command(Command(f'cmd{i}'))
+ def handler(_res: Response) -> None:
+ pass
+
+ app.include_router(router)
+
+ app._setup_system_router()
+ app._validate_routers_for_collisions()
+
+
+@benchmarks.register(type_="validate_routers_for_collisions", description="With many commands per router (3 routers, 10 commands each)")
+def benchmark_many_commands_per_router() -> None:
+ app = App(override_system_messages=True)
+
+ for i in range(3):
+ router = Router()
+
+ for j in range(10):
+ @router.command(Command(f'cmd{i}_{j}'))
+ def handler(_res: Response) -> None:
+ pass
+
+ app.include_router(router)
+
+ app._setup_system_router()
+ app._validate_routers_for_collisions()
+
+
+@benchmarks.register(type_="validate_routers_for_collisions", description="With many aliases (3 routers, 5 commands, 10 aliases each)")
+def benchmark_many_aliases_per_command() -> None:
+ app = App(override_system_messages=True)
+
+ for i in range(3):
+ router = Router()
+
+ for j in range(5):
+ @router.command(Command(f'cmd{i}_{j}', aliases={f'alias{i}_{j}_{k}' for k in range(10)}))
+ def handler(_res: Response) -> None:
+ pass
+
+ app.include_router(router)
+
+ app._setup_system_router()
+ app._validate_routers_for_collisions()
+
+
+@benchmarks.register(type_="validate_routers_for_collisions", description="Extreme (20 routers, 10 commands, 20 aliases each)")
+def benchmark_extreme_routers() -> None:
+ app = App(override_system_messages=True)
+
+ for i in range(20):
+ router = Router()
+
+ for j in range(10):
+ @router.command(Command(f'cmd{i}_{j}', aliases={f'alias{i}_{j}_{k}' for k in range(20)}))
+ def handler(_res: Response) -> None:
+ pass
+
+ app.include_router(router)
+
+ app._setup_system_router()
+ app._validate_routers_for_collisions()
diff --git a/metrics/handlers.py b/metrics/handlers.py
new file mode 100644
index 0000000..4f42044
--- /dev/null
+++ b/metrics/handlers.py
@@ -0,0 +1,179 @@
+import re
+from datetime import datetime
+from importlib.metadata import version
+from pathlib import Path
+
+from rich.console import Console
+
+from argenta.command import Flag, PossibleValues, Flags
+from argenta.command.flag import ValidationStatus
+from argenta.command.models import Command
+from argenta.response import Response
+from argenta.router import Router
+from .benchmarks.core.models import BenchmarkGroupResult
+from .benchmarks.entity import benchmarks as registered_benchmarks
+from .services.report_table_generator import ReportTableGenerator
+from .services.system_info_reader import get_system_info
+from .services.diagram_generator import DiagramGenerator
+from .services.release_generator import ReleaseGenerator
+
+console = Console()
+router = Router(title="Metrics commands:", disable_redirect_stdout=True)
+
+POSITIVE_INTEGER_PATTERN = re.compile(r"^[1-9]\d*$")
+
+
+@router.command(
+ Command(
+ "run-all",
+ description="Print all benchmarks results",
+ flags=Flags([
+ Flag('without-gc', possible_values=PossibleValues.NEITHER),
+ Flag('without-system-info', possible_values=PossibleValues.NEITHER)
+ ])
+ )
+)
+def all_print_handler(response: Response) -> None:
+ report_generator = ReportTableGenerator(get_system_info())
+
+ without_system_info = response.input_flags.get_flag_by_name("without-system-info", with_status=ValidationStatus.VALID)
+ if not without_system_info:
+ console.print(report_generator.generate_system_info_header())
+ console.print(report_generator.generate_system_info_table())
+
+ is_gc_disabled = response.input_flags.get_flag_by_name("without-gc", with_status=ValidationStatus.VALID)
+ type_grouped_benchmarks: list[BenchmarkGroupResult] = registered_benchmarks.run_benchmarks_grouped_by_type(is_gc_disabled=bool(is_gc_disabled))
+
+ for benchmark_group_result in type_grouped_benchmarks:
+ console.print(report_generator.generate_benchmark_table_header(benchmark_group_result))
+ console.print(report_generator.generate_benchmark_report_table(benchmark_group_result))
+
+
+@router.command(Command("list-types", description="List all benchmark types"))
+def list_types_handler(_: Response) -> None:
+ types = registered_benchmarks.get_types()
+
+ if not types:
+ console.print("[yellow]No benchmark types found[/yellow]")
+ return
+
+ console.print("[bold cyan]Available benchmark types:[/bold cyan]\n")
+ for type_ in types:
+ benchmarks_count = len(registered_benchmarks.get_benchmarks_by_type(type_))
+ console.print(f" [green]•[/green] [bold]{type_}[/bold] ({benchmarks_count} benchmarks)")
+
+
+@router.command(
+ Command(
+ "run-type",
+ description="Run benchmarks by specific type",
+ flags=Flags([
+ Flag('type', possible_values=registered_benchmarks.get_types()),
+ Flag('without-gc', possible_values=PossibleValues.NEITHER),
+ Flag('without-system-info', possible_values=PossibleValues.NEITHER)
+ ])
+ )
+)
+def run_type_handler(response: Response) -> None:
+ type_flag = response.input_flags.get_flag_by_name("type")
+
+ if not type_flag:
+ console.print("[red]Error: --type flag is required[/red]")
+ console.print("[yellow]Usage: run-type --type [/yellow]")
+ return
+
+ benchmark_type = type_flag.input_value
+
+ if not type_flag.status == ValidationStatus.VALID:
+ console.print(f"[red]Error: No benchmarks found for type '{benchmark_type}'[/red]")
+ console.print("\n[yellow]Available types:[/yellow]")
+ types = registered_benchmarks.get_types()
+ for t in types:
+ console.print(f" • {t}")
+ return
+
+ report_generator = ReportTableGenerator(get_system_info())
+
+ without_system_info = response.input_flags.get_flag_by_name("without-system-info", with_status=ValidationStatus.VALID)
+ if not without_system_info:
+ console.print(report_generator.generate_system_info_header())
+ console.print(report_generator.generate_system_info_table())
+
+ is_gc_disabled = response.input_flags.get_flag_by_name("without-gc", with_status=ValidationStatus.VALID, default=False)
+ benchmark_group_result = registered_benchmarks.run_benchmarks_by_type(benchmark_type, is_gc_disabled=bool(is_gc_disabled))
+
+ console.print(report_generator.generate_benchmark_table_header(benchmark_group_result))
+ console.print(report_generator.generate_benchmark_report_table(benchmark_group_result))
+
+
+@router.command(Command("release-generate", description="Generate release report"))
+def release_generate_handler(_: Response) -> None:
+ lib_version = version("argenta")
+
+ console.print(f"[cyan]Generating release report for version:[/cyan] [bold]{lib_version}[/bold]")
+ console.print("[dim]Running benchmarks (1000 iterations, GC disabled)...[/dim]\n")
+
+ type_grouped_benchmarks: list[BenchmarkGroupResult] = registered_benchmarks.run_benchmarks_grouped_by_type(
+ iterations=1000,
+ is_gc_disabled=True
+ )
+
+ release_generator = ReleaseGenerator(lib_version)
+ output_dir = release_generator.generate_release(type_grouped_benchmarks)
+
+ console.print(f"[green]✓[/green] Benchmarks completed. Generating release report...\n")
+
+ for benchmark_group in type_grouped_benchmarks:
+ console.print(f"[cyan]Generated for:[/cyan] [bold]{benchmark_group.type_}[/bold]")
+ console.print(f" [green]✓[/green] {benchmark_group.type_}_comparison.png")
+ console.print(f" [green]✓[/green] {benchmark_group.type_}.json\n")
+
+ console.print(f"[bold green]✓ Release report generated successfully[/bold green]")
+ console.print(f"[cyan]Output directory:[/cyan] [bold]{output_dir}[/bold]")
+
+
+@router.command(
+ Command(
+ "diagrams-generate",
+ description="Generate diagrams for all benchmarks",
+ flags=Flags([
+ Flag('without-gc', possible_values=PossibleValues.NEITHER),
+ Flag('iterations', possible_values=POSITIVE_INTEGER_PATTERN)
+ ])
+ )
+)
+def diagrams_generate_handler(response: Response) -> None:
+ iterations = 100
+ iterations_flag = response.input_flags.get_flag_by_name("iterations", with_status=ValidationStatus.VALID)
+ if iterations_flag:
+ iterations = int(iterations_flag.input_value)
+
+ is_gc_disabled = bool(response.input_flags.get_flag_by_name("without-gc", with_status=ValidationStatus.VALID))
+
+ console.print("[cyan]Running all benchmarks...[/cyan]")
+ console.print(f"[dim]Iterations: {iterations}, GC Disabled: {is_gc_disabled}[/dim]\n")
+
+ type_grouped_benchmarks: list[BenchmarkGroupResult] = registered_benchmarks.run_benchmarks_grouped_by_type(
+ iterations=iterations,
+ is_gc_disabled=is_gc_disabled
+ )
+
+ timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
+ output_dir = Path("metrics/reports/diagrams") / timestamp
+ output_dir.mkdir(parents=True, exist_ok=True)
+
+ diagram_generator = DiagramGenerator(output_dir)
+
+ console.print(f"[green]✓[/green] Benchmarks completed. Generating diagrams...\n")
+
+ generated_count = 0
+
+ for benchmark_group in type_grouped_benchmarks:
+ console.print(f"[cyan]Generating diagram for:[/cyan] [bold]{benchmark_group.type_}[/bold]")
+
+ comparison_path = diagram_generator.generate_comparison_diagram(benchmark_group)
+ generated_count += 1
+ console.print(f" [green]✓[/green] {comparison_path.name}\n")
+
+ console.print(f"[bold green]✓ Successfully generated {generated_count} diagrams[/bold green]")
+ console.print(f"[cyan]Output directory:[/cyan] [bold]{output_dir}[/bold]")
diff --git a/metrics/registry.py b/metrics/registry.py
deleted file mode 100644
index 40c9537..0000000
--- a/metrics/registry.py
+++ /dev/null
@@ -1,98 +0,0 @@
-__all__ = [
- "Benchmark",
- "Benchmarks",
- "benchmark"
-]
-
-from typing import Callable, ClassVar, overload, override
-
-BenchmarkAsFunc = Callable[[], float]
-
-
-class Benchmark:
- def __init__(
- self,
- func: BenchmarkAsFunc,
- *,
- type_: str,
- name: str,
- description: str,
- iterations: int
- ) -> None:
- self.func = func
- self.type_ = type_
- self.name = name
- self.description = description
- self.iterations = iterations
-
- def run(self) -> float:
- return self.func()
-
- @override
- def __repr__(self) -> str:
- return f'Benchmark<{self.type_=}, {self.name=}, {self.description=}, {self.iterations=}>'
-
- @override
- def __str__(self) -> str:
- return f'Benchmark({self.type_=}, {self.name=}, {self.description=}, {self.iterations=})'
-
-
-class Benchmarks:
- _benchmarks: ClassVar[list[Benchmark]] = []
-
- @overload
- @classmethod
- def register(
- cls,
- call: BenchmarkAsFunc,
- *,
- type_: str = "",
- description: str = "",
- iterations: int = 100,
- ) -> BenchmarkAsFunc:
- ...
-
- @overload
- @classmethod
- def register(
- cls,
- call: None = None,
- *,
- type_: str = "",
- description: str = "",
- iterations: int = 100,
- ) -> Callable[[BenchmarkAsFunc], BenchmarkAsFunc]:
- ...
-
- @classmethod
- def register(
- cls,
- call: BenchmarkAsFunc | None = None,
- *,
- type_: str = "",
- description: str = "",
- iterations: int = 100,
- ) -> Callable[[BenchmarkAsFunc], BenchmarkAsFunc] | BenchmarkAsFunc:
- def decorator(func: BenchmarkAsFunc) -> BenchmarkAsFunc:
- cls._benchmarks.append(
- Benchmark(
- func,
- type_=type_,
- name=func.__name__,
- description=description or f'description for {func.__name__} with {iterations} iterations',
- iterations=iterations
- )
- )
- return func
-
- if call is None:
- return decorator
- else:
- return decorator(call)
-
- @classmethod
- def get_benchmarks(cls) -> list[Benchmark]:
- return cls._benchmarks
-
-
-benchmark = Benchmarks.register
diff --git a/metrics/reports/releases/1.1.2/finds_appropriate_handler/finds_appropriate_handler.json b/metrics/reports/releases/1.1.2/finds_appropriate_handler/finds_appropriate_handler.json
new file mode 100644
index 0000000..e5ee8f1
--- /dev/null
+++ b/metrics/reports/releases/1.1.2/finds_appropriate_handler/finds_appropriate_handler.json
@@ -0,0 +1,42 @@
+{
+ "type": "finds_appropriate_handler",
+ "iterations": 1000,
+ "gc_disabled": true,
+ "benchmarks": [
+ {
+ "name": "benchmark_simple_command",
+ "description": "Simple command (no flags)",
+ "avg_time": 0.036,
+ "median_time": 0.0354,
+ "std_dev": 0.0087
+ },
+ {
+ "name": "benchmark_command_with_flags",
+ "description": "Command with flags (3 flags)",
+ "avg_time": 0.0557,
+ "median_time": 0.0545,
+ "std_dev": 0.0171
+ },
+ {
+ "name": "benchmark_many_commands",
+ "description": "Many commands (50 commands)",
+ "avg_time": 1.0453,
+ "median_time": 1.0388,
+ "std_dev": 0.0322
+ },
+ {
+ "name": "benchmark_command_with_many_flags",
+ "description": "Command with many flags (20 flags)",
+ "avg_time": 0.1322,
+ "median_time": 0.131,
+ "std_dev": 0.0045
+ },
+ {
+ "name": "benchmark_extreme_router",
+ "description": "Extreme (100 commands, 10 flags each)",
+ "avg_time": 3.2471,
+ "median_time": 3.235,
+ "std_dev": 0.0814
+ }
+ ]
+}
\ No newline at end of file
diff --git a/metrics/reports/releases/1.1.2/finds_appropriate_handler/finds_appropriate_handler_comparison.png b/metrics/reports/releases/1.1.2/finds_appropriate_handler/finds_appropriate_handler_comparison.png
new file mode 100644
index 0000000..3e4bbcc
Binary files /dev/null and b/metrics/reports/releases/1.1.2/finds_appropriate_handler/finds_appropriate_handler_comparison.png differ
diff --git a/metrics/reports/releases/1.1.2/flag_validation/flag_validation.json b/metrics/reports/releases/1.1.2/flag_validation/flag_validation.json
new file mode 100644
index 0000000..d1e5ca3
--- /dev/null
+++ b/metrics/reports/releases/1.1.2/flag_validation/flag_validation.json
@@ -0,0 +1,70 @@
+{
+ "type": "flag_validation",
+ "iterations": 1000,
+ "gc_disabled": true,
+ "benchmarks": [
+ {
+ "name": "benchmark_validate_all_single_flag",
+ "description": "Single flag with PossibleValues.ALL",
+ "avg_time": 0.0008,
+ "median_time": 0.0008,
+ "std_dev": 0.0002
+ },
+ {
+ "name": "benchmark_validate_neither_single_flag",
+ "description": "Single flag with PossibleValues.NEITHER",
+ "avg_time": 0.0008,
+ "median_time": 0.0008,
+ "std_dev": 0.0002
+ },
+ {
+ "name": "benchmark_validate_list_small",
+ "description": "List validation (5 possible values)",
+ "avg_time": 0.001,
+ "median_time": 0.0009,
+ "std_dev": 0.0007
+ },
+ {
+ "name": "benchmark_validate_list_large",
+ "description": "List validation (50 possible values)",
+ "avg_time": 0.0079,
+ "median_time": 0.0078,
+ "std_dev": 0.0021
+ },
+ {
+ "name": "benchmark_validate_regex_simple",
+ "description": "Regex validation (simple pattern)",
+ "avg_time": 0.0017,
+ "median_time": 0.0016,
+ "std_dev": 0.0028
+ },
+ {
+ "name": "benchmark_validate_regex_complex",
+ "description": "Regex validation (complex pattern)",
+ "avg_time": 0.0018,
+ "median_time": 0.0016,
+ "std_dev": 0.0051
+ },
+ {
+ "name": "benchmark_validate_multiple_flags_10",
+ "description": "Multiple flags validation (10 flags)",
+ "avg_time": 0.0145,
+ "median_time": 0.0144,
+ "std_dev": 0.0013
+ },
+ {
+ "name": "benchmark_validate_multiple_flags_50",
+ "description": "Multiple flags validation (50 flags)",
+ "avg_time": 0.0661,
+ "median_time": 0.0658,
+ "std_dev": 0.0024
+ },
+ {
+ "name": "benchmark_validate_extreme_100_flags",
+ "description": "Extreme (100 flags with regex validation)",
+ "avg_time": 0.1599,
+ "median_time": 0.1589,
+ "std_dev": 0.0065
+ }
+ ]
+}
\ No newline at end of file
diff --git a/metrics/reports/releases/1.1.2/flag_validation/flag_validation_comparison.png b/metrics/reports/releases/1.1.2/flag_validation/flag_validation_comparison.png
new file mode 100644
index 0000000..2d558d8
Binary files /dev/null and b/metrics/reports/releases/1.1.2/flag_validation/flag_validation_comparison.png differ
diff --git a/metrics/reports/releases/1.1.2/input_command_parse/input_command_parse.json b/metrics/reports/releases/1.1.2/input_command_parse/input_command_parse.json
new file mode 100644
index 0000000..d7d3a03
--- /dev/null
+++ b/metrics/reports/releases/1.1.2/input_command_parse/input_command_parse.json
@@ -0,0 +1,56 @@
+{
+ "type": "input_command_parse",
+ "iterations": 1000,
+ "gc_disabled": true,
+ "benchmarks": [
+ {
+ "name": "benchmark_parse_simple_command",
+ "description": "Simple command (no flags)",
+ "avg_time": 0.0096,
+ "median_time": 0.0095,
+ "std_dev": 0.0012
+ },
+ {
+ "name": "benchmark_command_with_few_flags",
+ "description": "Command with few flags (3 flags)",
+ "avg_time": 0.0216,
+ "median_time": 0.0213,
+ "std_dev": 0.0021
+ },
+ {
+ "name": "benchmark_command_with_flags_and_values",
+ "description": "Command with flags and values (5 flags)",
+ "avg_time": 0.06,
+ "median_time": 0.0595,
+ "std_dev": 0.0025
+ },
+ {
+ "name": "benchmark_command_with_mixed_prefixes",
+ "description": "Command with mixed prefixes (-, --, ---)",
+ "avg_time": 0.0542,
+ "median_time": 0.0538,
+ "std_dev": 0.0028
+ },
+ {
+ "name": "benchmark_command_with_long_values",
+ "description": "Command with long values (10 flags)",
+ "avg_time": 0.2092,
+ "median_time": 0.2082,
+ "std_dev": 0.0067
+ },
+ {
+ "name": "benchmark_command_with_quoted_values",
+ "description": "Command with quoted values (5 flags)",
+ "avg_time": 0.0481,
+ "median_time": 0.0477,
+ "std_dev": 0.0023
+ },
+ {
+ "name": "benchmark_extreme_many_flags",
+ "description": "Extreme (50 flags with values)",
+ "avg_time": 0.7907,
+ "median_time": 0.7884,
+ "std_dev": 0.0417
+ }
+ ]
+}
\ No newline at end of file
diff --git a/metrics/reports/releases/1.1.2/input_command_parse/input_command_parse_comparison.png b/metrics/reports/releases/1.1.2/input_command_parse/input_command_parse_comparison.png
new file mode 100644
index 0000000..45ac191
Binary files /dev/null and b/metrics/reports/releases/1.1.2/input_command_parse/input_command_parse_comparison.png differ
diff --git a/metrics/reports/releases/1.1.2/most_similar_command/most_similar_command.json b/metrics/reports/releases/1.1.2/most_similar_command/most_similar_command.json
new file mode 100644
index 0000000..7d51637
--- /dev/null
+++ b/metrics/reports/releases/1.1.2/most_similar_command/most_similar_command.json
@@ -0,0 +1,42 @@
+{
+ "type": "most_similar_command",
+ "iterations": 1000,
+ "gc_disabled": true,
+ "benchmarks": [
+ {
+ "name": "benchmark_few_commands",
+ "description": "Few commands (10 commands, no match)",
+ "avg_time": 0.251,
+ "median_time": 0.2488,
+ "std_dev": 0.012
+ },
+ {
+ "name": "benchmark_many_commands_most_similar",
+ "description": "Many commands (50 commands, no match)",
+ "avg_time": 1.1933,
+ "median_time": 1.1878,
+ "std_dev": 0.0305
+ },
+ {
+ "name": "benchmark_many_aliases",
+ "description": "Many aliases (20 commands, 10 aliases each)",
+ "avg_time": 1.2151,
+ "median_time": 1.2124,
+ "std_dev": 0.0282
+ },
+ {
+ "name": "benchmark_partial_match",
+ "description": "Partial match (50 commands, prefix match)",
+ "avg_time": 1.6781,
+ "median_time": 1.6689,
+ "std_dev": 0.0573
+ },
+ {
+ "name": "benchmark_extreme_commands",
+ "description": "Extreme (100 commands, 20 aliases each)",
+ "avg_time": 10.5539,
+ "median_time": 10.5288,
+ "std_dev": 0.1603
+ }
+ ]
+}
\ No newline at end of file
diff --git a/metrics/reports/releases/1.1.2/most_similar_command/most_similar_command_comparison.png b/metrics/reports/releases/1.1.2/most_similar_command/most_similar_command_comparison.png
new file mode 100644
index 0000000..ec07f2c
Binary files /dev/null and b/metrics/reports/releases/1.1.2/most_similar_command/most_similar_command_comparison.png differ
diff --git a/metrics/reports/releases/1.1.2/pre_cycle_setup/pre_cycle_setup.json b/metrics/reports/releases/1.1.2/pre_cycle_setup/pre_cycle_setup.json
new file mode 100644
index 0000000..4b95e2d
--- /dev/null
+++ b/metrics/reports/releases/1.1.2/pre_cycle_setup/pre_cycle_setup.json
@@ -0,0 +1,42 @@
+{
+ "type": "pre_cycle_setup",
+ "iterations": 1000,
+ "gc_disabled": true,
+ "benchmarks": [
+ {
+ "name": "benchmark_no_aliases",
+ "description": "With no aliases",
+ "avg_time": 7.4799,
+ "median_time": 7.4576,
+ "std_dev": 0.1645
+ },
+ {
+ "name": "benchmark_few_aliases",
+ "description": "With few aliases (6 total)",
+ "avg_time": 7.4135,
+ "median_time": 7.4061,
+ "std_dev": 0.1709
+ },
+ {
+ "name": "benchmark_with_many_aliases",
+ "description": "With many aliases (15 total)",
+ "avg_time": 7.4018,
+ "median_time": 7.3943,
+ "std_dev": 0.1589
+ },
+ {
+ "name": "benchmark_very_many_aliases",
+ "description": "With very many aliases (60 total)",
+ "avg_time": 7.476,
+ "median_time": 7.4575,
+ "std_dev": 0.2156
+ },
+ {
+ "name": "benchmark_extreme_aliases",
+ "description": "With extreme aliases (300 total)",
+ "avg_time": 7.7167,
+ "median_time": 7.706,
+ "std_dev": 0.2052
+ }
+ ]
+}
\ No newline at end of file
diff --git a/metrics/reports/releases/1.1.2/pre_cycle_setup/pre_cycle_setup_comparison.png b/metrics/reports/releases/1.1.2/pre_cycle_setup/pre_cycle_setup_comparison.png
new file mode 100644
index 0000000..1d7be18
Binary files /dev/null and b/metrics/reports/releases/1.1.2/pre_cycle_setup/pre_cycle_setup_comparison.png differ
diff --git a/metrics/reports/releases/1.1.2/validate_routers_for_collisions/validate_routers_for_collisions.json b/metrics/reports/releases/1.1.2/validate_routers_for_collisions/validate_routers_for_collisions.json
new file mode 100644
index 0000000..279cda5
--- /dev/null
+++ b/metrics/reports/releases/1.1.2/validate_routers_for_collisions/validate_routers_for_collisions.json
@@ -0,0 +1,42 @@
+{
+ "type": "validate_routers_for_collisions",
+ "iterations": 1000,
+ "gc_disabled": true,
+ "benchmarks": [
+ {
+ "name": "benchmark_few_routers",
+ "description": "With few routers (3 routers, 1 command each)",
+ "avg_time": 0.0959,
+ "median_time": 0.0944,
+ "std_dev": 0.0097
+ },
+ {
+ "name": "benchmark_many_routers",
+ "description": "With many routers (10 routers, 1 command each)",
+ "avg_time": 0.2488,
+ "median_time": 0.2467,
+ "std_dev": 0.0081
+ },
+ {
+ "name": "benchmark_many_commands_per_router",
+ "description": "With many commands per router (3 routers, 10 commands each)",
+ "avg_time": 0.6474,
+ "median_time": 0.6401,
+ "std_dev": 0.0304
+ },
+ {
+ "name": "benchmark_many_aliases_per_command",
+ "description": "With many aliases (3 routers, 5 commands, 10 aliases each)",
+ "avg_time": 0.5261,
+ "median_time": 0.5156,
+ "std_dev": 0.0475
+ },
+ {
+ "name": "benchmark_extreme_routers",
+ "description": "Extreme (20 routers, 10 commands, 20 aliases each)",
+ "avg_time": 9.9128,
+ "median_time": 9.9518,
+ "std_dev": 0.2373
+ }
+ ]
+}
\ No newline at end of file
diff --git a/metrics/reports/releases/1.1.2/validate_routers_for_collisions/validate_routers_for_collisions_comparison.png b/metrics/reports/releases/1.1.2/validate_routers_for_collisions/validate_routers_for_collisions_comparison.png
new file mode 100644
index 0000000..662f7ed
Binary files /dev/null and b/metrics/reports/releases/1.1.2/validate_routers_for_collisions/validate_routers_for_collisions_comparison.png differ
diff --git a/metrics/services/__init__.py b/metrics/services/__init__.py
new file mode 100644
index 0000000..ee3ba46
--- /dev/null
+++ b/metrics/services/__init__.py
@@ -0,0 +1,6 @@
+from .diagram_generator import DiagramGenerator
+from .report_table_generator import ReportTableGenerator
+from .system_info_reader import get_system_info
+from .release_generator import ReleaseGenerator
+
+__all__ = ["DiagramGenerator", "ReportTableGenerator", "get_system_info", "ReleaseGenerator"]
diff --git a/metrics/services/diagram_generator.py b/metrics/services/diagram_generator.py
new file mode 100644
index 0000000..9b9d1ee
--- /dev/null
+++ b/metrics/services/diagram_generator.py
@@ -0,0 +1,110 @@
+__all__ = ["DiagramGenerator"]
+
+from pathlib import Path
+
+import matplotlib
+import matplotlib.pyplot as plt
+
+from ..benchmarks.core.models import BenchmarkGroupResult
+
+
+class DiagramGenerator:
+ def __init__(self, output_dir: Path | str) -> None:
+ self.output_dir: Path = Path(output_dir) if isinstance(output_dir, str) else output_dir
+
+ matplotlib.use('Agg')
+ plt.style.use('seaborn-v0_8-whitegrid')
+
+ def generate_comparison_diagram(self, benchmark_group: BenchmarkGroupResult) -> Path:
+ results = benchmark_group.benchmark_results
+ sorted_results = sorted(results, key=lambda br: br.avg_time)
+
+ descriptions: list[str] = [br.description for br in sorted_results]
+ avg_times: list[float] = [br.avg_time for br in sorted_results]
+ median_times: list[float] = [br.median_time for br in sorted_results]
+ std_devs: list[float] = [br.std_dev for br in sorted_results]
+
+ max_value = max(
+ max(avg_times) if avg_times else 0,
+ max(median_times) if median_times else 0,
+ max(std_devs) if std_devs else 0
+ )
+ y_limit = max_value / 0.85 if max_value > 0 else 1.0
+
+ items_count = len(descriptions)
+ x_positions: list[int] = list(range(items_count))
+
+ bar_width = 0.25
+
+ x_std_dev = [x - bar_width for x in x_positions]
+ x_avg = [x for x in x_positions]
+ x_median = [x + bar_width for x in x_positions]
+
+ fig, ax = plt.subplots(figsize=(16, 8))
+ fig.patch.set_facecolor('white')
+
+ bars_std = ax.bar(x_std_dev, std_devs, bar_width, label='Std Deviation',
+ color='#2ecc71', alpha=0.9, edgecolor='#27ae60', linewidth=1.5)
+ bars_avg = ax.bar(x_avg, avg_times, bar_width, label='Average Time',
+ color='#3498db', alpha=0.9, edgecolor='#2980b9', linewidth=1.5)
+ bars_median = ax.bar(x_median, median_times, bar_width, label='Median Time',
+ color='#e74c3c', alpha=0.9, edgecolor='#c0392b', linewidth=1.5)
+
+ for bar_group in [bars_std, bars_avg, bars_median]:
+ for bar in bar_group:
+ height = bar.get_height()
+ ax.text(
+ bar.get_x() + bar.get_width() / 2.,
+ height,
+ f'{height:.3f}',
+ ha='center', va='bottom', fontsize=9, fontweight='bold'
+ )
+
+ ax.set_ylabel('Time (ms)', fontsize=14, fontweight='bold', labelpad=10)
+
+ title_text = f'{benchmark_group.type_.replace("_", " ").title()}'
+ metadata_text = f'Iterations: {benchmark_group.iterations} | GC: {"Disabled" if benchmark_group.is_gc_disabled else "Enabled"}'
+
+ ax.text(0.5, 1.08, title_text, transform=ax.transAxes,
+ fontsize=18, fontweight='bold', ha='center', color='#2c3e50')
+ ax.text(0.5, 1.03, metadata_text, transform=ax.transAxes,
+ fontsize=12, ha='center', color='#7f8c8d', style='italic')
+
+ ax.set_xticks(x_positions)
+ ax.set_xticklabels([])
+
+ for i, (pos, desc) in enumerate(zip(x_positions, descriptions)):
+ text_x_pos = pos - bar_width - (bar_width / 2)
+ ax.text(
+ text_x_pos,
+ y_limit * 0.02,
+ desc,
+ rotation=90, va='bottom', ha='right', fontsize=10,
+ color='#2c3e50'
+ )
+
+ ax.set_ylim(0, y_limit)
+
+ legend = ax.legend(loc='upper left', fontsize=12, framealpha=0.95,
+ edgecolor='#34495e', fancybox=True, shadow=True)
+ legend.get_frame().set_facecolor('#ecf0f1')
+
+ ax.grid(axis='y', alpha=0.4, linestyle='--', linewidth=0.8)
+ ax.set_axisbelow(True)
+
+ ax.spines['top'].set_visible(False)
+ ax.spines['right'].set_visible(False)
+ ax.spines['left'].set_color('#7f8c8d')
+ ax.spines['bottom'].set_color('#7f8c8d')
+
+ plt.tight_layout()
+
+ filename = f"{benchmark_group.type_}_comparison.png"
+ output_path = self.output_dir / filename
+
+ self.output_dir.mkdir(parents=True, exist_ok=True)
+
+ plt.savefig(output_path, dpi=200, bbox_inches='tight', facecolor='white')
+ plt.close(fig)
+
+ return output_path
diff --git a/metrics/services/release_generator.py b/metrics/services/release_generator.py
new file mode 100644
index 0000000..e4c0079
--- /dev/null
+++ b/metrics/services/release_generator.py
@@ -0,0 +1,49 @@
+__all__ = ["ReleaseGenerator"]
+
+import json
+import shutil
+from pathlib import Path
+
+from ..benchmarks.core.models import BenchmarkGroupResult
+from .diagram_generator import DiagramGenerator
+
+
+class ReleaseGenerator:
+ def __init__(self, lib_version: str) -> None:
+ self.lib_version = lib_version
+ self.output_dir = Path("metrics/reports/releases") / lib_version
+
+ def generate_release(self, benchmark_groups: list[BenchmarkGroupResult]) -> Path:
+ if self.output_dir.exists():
+ shutil.rmtree(self.output_dir)
+
+ self.output_dir.mkdir(parents=True, exist_ok=True)
+
+ for benchmark_group in benchmark_groups:
+ type_dir = self.output_dir / benchmark_group.type_
+ type_dir.mkdir(exist_ok=True)
+
+ diagram_generator = DiagramGenerator(type_dir)
+ diagram_generator.generate_comparison_diagram(benchmark_group)
+
+ json_data = {
+ "type": benchmark_group.type_,
+ "iterations": benchmark_group.iterations,
+ "gc_disabled": benchmark_group.is_gc_disabled,
+ "benchmarks": [
+ {
+ "name": br.name,
+ "description": br.description,
+ "avg_time": br.avg_time,
+ "median_time": br.median_time,
+ "std_dev": br.std_dev
+ }
+ for br in benchmark_group.benchmark_results
+ ]
+ }
+
+ json_path = type_dir / f"{benchmark_group.type_}.json"
+ with open(json_path, 'w', encoding='utf-8') as f:
+ json.dump(json_data, f, indent=2, ensure_ascii=False)
+
+ return self.output_dir
diff --git a/metrics/services/report_table_generator.py b/metrics/services/report_table_generator.py
new file mode 100644
index 0000000..658020d
--- /dev/null
+++ b/metrics/services/report_table_generator.py
@@ -0,0 +1,72 @@
+from rich.panel import Panel
+from rich.table import Table
+from rich.text import Text
+
+from ..benchmarks.core.models import BenchmarkGroupResult
+from metrics.services.system_info_reader import SystemInfo
+
+
+class ReportTableGenerator:
+ def __init__(self, system_info: SystemInfo):
+ self.system_info = system_info
+ self._cached_benchmark_tables: dict[int, Table] = {}
+ self._cached_system_info_table: Table | None = None
+
+ def generate_benchmark_report_table(self, benchmark_group_result: BenchmarkGroupResult) -> Table:
+ if cached_result := self._cached_benchmark_tables.get(id(benchmark_group_result)):
+ return cached_result
+
+ table = Table(show_header=True, header_style="bold cyan", border_style="blue", show_lines=True)
+ table.add_column("Description", style="dim")
+ table.add_column("Avg Time", justify="right", style="bold yellow")
+ table.add_column("Median Time", justify="right", style="bold yellow")
+ table.add_column("Stdev", justify="right", style="bold yellow")
+
+ for benchmark in benchmark_group_result.benchmark_results:
+ table.add_row(
+ benchmark.description,
+ str(benchmark.avg_time),
+ str(benchmark.median_time),
+ str(benchmark.std_dev),
+ )
+ self._cached_benchmark_tables[id(benchmark_group_result)] = table
+ return table
+
+ @staticmethod
+ def generate_benchmark_table_header(benchmark_group_result: BenchmarkGroupResult) -> Panel:
+ header_text = Text(f"TYPE: {benchmark_group_result.type_.upper()} ; "
+ f"ITERATIONS: {benchmark_group_result.iterations} ; "
+ f"GC {"DISABLED" if benchmark_group_result.is_gc_disabled else "ENABLED"} ; "
+ f"ALL TIME IN MS",
+ style="bold magenta")
+ return Panel(header_text, expand=False, border_style="magenta")
+
+ def generate_system_info_table(self) -> Table:
+ if self._cached_system_info_table is not None:
+ return self._cached_system_info_table
+
+ table = Table(show_header=True, header_style="bold cyan", border_style="blue", show_lines=True)
+ table.add_column("Parameter", style="green")
+ table.add_column("Value", style="yellow")
+
+ table.add_row("OS Name", self.system_info.os_info.name)
+ table.add_row("OS Kernel Version", self.system_info.os_info.kernel_version)
+ table.add_row("Architecture", self.system_info.cpu_info.architecture)
+ table.add_row("CPU", self.system_info.cpu_info.name)
+ table.add_row("CPU Physical Cores", str(self.system_info.cpu_info.physical_cores))
+ table.add_row("CPU Logical Cores", str(self.system_info.cpu_info.logical_cores))
+ table.add_row("CPU Max Frequency", str(self.system_info.cpu_info.max_frequency) + ' GHz')
+ table.add_row("Total RAM", str(self.system_info.memory_info.total_ram) + ' GB')
+ table.add_row("Used RAM", str(self.system_info.memory_info.used_ram) + ' GB')
+ table.add_row("Available RAM", str(self.system_info.memory_info.available_ram) + ' GB')
+ table.add_row("Python Version", self.system_info.python_info.version)
+ table.add_row("Python Implementation", self.system_info.python_info.implementation)
+ table.add_row("Python Compiler", self.system_info.python_info.compiler)
+
+ self._cached_system_info_table = table
+ return table
+
+ @staticmethod
+ def generate_system_info_header() -> Panel:
+ header_text = Text("SYSTEM INFO", style="bold magenta")
+ return Panel(header_text, expand=False, border_style="magenta")
\ No newline at end of file
diff --git a/metrics/services/system_info_reader.py b/metrics/services/system_info_reader.py
new file mode 100644
index 0000000..6f832b2
--- /dev/null
+++ b/metrics/services/system_info_reader.py
@@ -0,0 +1,126 @@
+__all__ = [
+ "SystemInfo",
+ "get_system_info"
+]
+
+from dataclasses import dataclass
+import platform
+import sys
+
+import cpuinfo
+import psutil
+
+
+@dataclass(frozen=True, slots=True)
+class SystemInfo:
+ os_info: OSInfo
+ cpu_info: CPUInfo
+ memory_info: MemoryInfo
+ python_info: PythonInfo
+
+@dataclass(frozen=True, slots=True)
+class OSInfo:
+ name: str
+ kernel_version: str
+
+@dataclass(frozen=True, slots=True)
+class CPUInfo:
+ name: str
+ architecture: str
+ physical_cores: int
+ logical_cores: int
+ max_frequency: float
+
+@dataclass(frozen=True, slots=True)
+class MemoryInfo:
+ total_ram: float # in GB
+ used_ram: float # in GB
+ available_ram: float # in GB
+
+@dataclass(frozen=True, slots=True)
+class PythonInfo:
+ version: str
+ implementation: str
+ compiler: str
+
+
+def get_system_info() -> SystemInfo:
+ os_info = get_os_info()
+ cpu_info = get_cpu_info()
+ memory_info = get_memory_info()
+ python_info = get_python_info()
+ return SystemInfo(
+ os_info=os_info,
+ cpu_info=cpu_info,
+ memory_info=memory_info,
+ python_info=python_info,
+ )
+
+def get_os_info() -> OSInfo:
+ system = platform.system()
+
+ if system == "Windows":
+ ver = sys.getwindowsversion()
+ kernel_version = f"{ver.major}.{ver.minor}.{ver.build}"
+
+ if ver.build >= 22000:
+ product_name = "Windows 11"
+ else:
+ product_name = "Windows 10"
+
+ return OSInfo(
+ name=product_name,
+ kernel_version=kernel_version,
+ )
+ elif system == "Darwin":
+ return OSInfo(
+ kernel_version=platform.release(),
+ name=f"macOS {platform.mac_ver()[0]}"
+ )
+ else:
+ return OSInfo(
+ kernel_version=platform.release(),
+ name=platform.system()
+ )
+
+def get_cpu_info() -> CPUInfo:
+ cpu_info = cpuinfo.get_cpu_info()
+ cpu_name = cpu_info["brand_raw"]
+ cpu_architecture = cpu_info["arch"]
+ cpu_physical_cores = psutil.cpu_count(logical=False)
+ cpu_logical_cores = psutil.cpu_count(logical=True)
+
+ cpu_freq = psutil.cpu_freq()
+ cpu_max_frequency = cpu_freq.max
+
+ return CPUInfo(
+ name=cpu_name,
+ architecture=cpu_architecture,
+ physical_cores=cpu_physical_cores,
+ logical_cores=cpu_logical_cores,
+ max_frequency=cpu_max_frequency
+ )
+
+def get_memory_info() -> MemoryInfo:
+ mem = psutil.virtual_memory()
+ total_ram = round(mem.total / (1024**3), 2)
+ used_ram = round(mem.used / (1024**3), 2)
+ available_ram = round(mem.available / (1024**3), 2)
+
+ return MemoryInfo(
+ total_ram=total_ram,
+ used_ram=used_ram,
+ available_ram=available_ram,
+ )
+
+def get_python_info() -> PythonInfo:
+ python_version = platform.python_version()
+ python_implementation = platform.python_implementation()
+ python_compiler = platform.python_compiler()
+ return PythonInfo(
+ version=python_version,
+ implementation=python_implementation,
+ compiler=python_compiler
+ )
+
+
diff --git a/metrics/utils.py b/metrics/utils.py
deleted file mode 100644
index 2153323..0000000
--- a/metrics/utils.py
+++ /dev/null
@@ -1,44 +0,0 @@
-__all__ = [
- "get_time_of_pre_cycle_setup",
- "attempts_to_average",
- "run_benchmark",
- "BenchmarkResult"
-]
-
-import io
-from contextlib import redirect_stdout
-import time
-from dataclasses import dataclass
-from decimal import Decimal, ROUND_HALF_UP
-
-from argenta import App
-from metrics.registry import Benchmark
-
-
-def get_time_of_pre_cycle_setup(app: App) -> float:
- start = time.perf_counter()
- with redirect_stdout(io.StringIO()):
- app._pre_cycle_setup() # pyright: ignore[reportPrivateUsage]
- end = time.perf_counter()
- return (end - start) * 1000 # as milliseconds
-
-
-def attempts_to_average(bench_attempts: list[float], iterations: int) -> Decimal:
- return Decimal(sum(bench_attempts) / iterations).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
-
-
-@dataclass(frozen=True)
-class BenchmarkResult:
- type_: str
- name: str
- description: str
- iterations: int
- avg_time: Decimal
-
-
-def run_benchmark(benchmark: Benchmark) -> BenchmarkResult:
- bench_attempts: list[float] = []
- for _ in range(benchmark.iterations):
- bench_attempts.append(benchmark.run())
- avg = attempts_to_average(bench_attempts, benchmark.iterations)
- return BenchmarkResult(benchmark.type_, benchmark.name, benchmark.description, benchmark.iterations, avg)
diff --git a/mock/local_test.py b/mock/local_test.py
index 3e47833..f87e021 100644
--- a/mock/local_test.py
+++ b/mock/local_test.py
@@ -1,14 +1,18 @@
-from argenta.app import AutoCompleter
+from argenta import App, Command, Response, Router
-if __name__ == "__main__":
- test_commands: set[str] = {"start", "qwertyu", "stop", "exit"}
- hist_file: str = "history.txt"
+app = App(override_system_messages=True)
+router = Router()
- ac: AutoCompleter = AutoCompleter(autocomplete_button='tab')
- ac.initial_setup(test_commands)
+@router.command(Command('command'))
+def handler(_res: Response) -> None:
+ pass
- while True:
- inp: str = ac.prompt(">>> ").strip()
- if inp == "exit":
- break
+@router.command(Command('command_other'))
+def handler2(_res: Response) -> None:
+ pass
+
+app.include_routers(router)
+app._pre_cycle_setup()
+
+assert app._most_similar_command('command_') == 'command'
\ No newline at end of file
diff --git a/mock/min_app/main.py b/mock/min_app/main.py
index a2b4326..87e534c 100644
--- a/mock/min_app/main.py
+++ b/mock/min_app/main.py
@@ -4,7 +4,7 @@ from argenta.app import DynamicDividingLine
from .routers import router
-app: App = App(prompt='>>> ', dividing_line=DynamicDividingLine('~'))
+app: App = App(prompt='>>> ', dividing_line=None)
orchestrator: Orchestrator = Orchestrator()
def main() -> None:
diff --git a/mock/mock_app/main.py b/mock/mock_app/main.py
index 1305567..65a5623 100644
--- a/mock/mock_app/main.py
+++ b/mock/mock_app/main.py
@@ -1,10 +1,12 @@
+from prompt_toolkit import HTML
+
from argenta import App, Orchestrator
-from argenta.app import PredefinedMessages
+from argenta.app import PredefinedMessages, StaticDividingLine, AutoCompleter
from argenta.app.dividing_line.models import DynamicDividingLine
from mock.mock_app.routers import work_router
app: App = App(
- dividing_line=DynamicDividingLine('^'),
+ dividing_line=StaticDividingLine('~')
)
orchestrator: Orchestrator = Orchestrator()
diff --git a/mock/mock_app/routers.py b/mock/mock_app/routers.py
index a433c50..3aafb75 100644
--- a/mock/mock_app/routers.py
+++ b/mock/mock_app/routers.py
@@ -1,17 +1,18 @@
from argenta import Command, Response, Router
from argenta.command import Flag, Flags
+from argenta.command.flag import ValidationStatus
-work_router: Router = Router(title="Base points:", disable_redirect_stdout=True)
+work_router: Router = Router(title="Base points:")
@work_router.command(
Command(
- "hello",
+ "hello",
flags=Flags([
Flag("test")
]),
description="Hello, world!")
)
def command_help(response: Response):
- c = input("Enter your name: ")
- print(f"Hello, {c}!")
+ n = input('sfgdheth')
+ print(f"Hello,{n} {response.input_flags.get_flag_by_name('test', with_status=ValidationStatus.VALID)}")
diff --git a/pyproject.toml b/pyproject.toml
index 00fc3e4..1f25c0b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,9 +1,9 @@
[project]
name = "argenta"
-version = "1.1.2"
+version = "1.2.0"
description = "Python library for building modular CLI applications"
authors = [{ name = "kolo", email = "kolo.is.main@gmail.com" }]
-requires-python = ">=3.12"
+requires-python = ">=3.12,<3.15"
readme = "README.md"
license = { text = "MIT" }
dependencies = [
@@ -14,6 +14,13 @@ dependencies = [
]
[dependency-groups]
+dev = [
+ {include-group = "linters"},
+ {include-group = "typecheckers"},
+ {include-group = "docs"},
+ {include-group = "tests"},
+ "scriv>=1.8.0",
+]
linters = [
"isort>=7.0.0",
"ruff>=0.12.12",
@@ -35,17 +42,14 @@ tests = [
"pytest-cov>=7.0.0",
"pytest-mock>=3.15.1",
]
+metrics = [
+ "matplotlib>=3.10.8",
+ "psutil>=7.2.1",
+ "py-cpuinfo>=9.0.0",
+]
[tool.ruff]
-exclude = [
- ".idea",
- "venv",
- ".git",
- "poetry.lock",
- ".__pycache__",
- "tests"
-]
-line-length=90
+line-length=100
[tool.pyright]
typeCheckingMode = "strict"
@@ -68,6 +72,19 @@ omit = [
"src/argenta/metrics/*"
]
+[tool.scriv]
+format = "md"
+output_file = "CHANGELOG.md"
+fragment_directory = "changelog.d"
+categories = [
+ "Added",
+ "Changed",
+ "Deprecated",
+ "Removed",
+ "Fixed",
+]
+md_header_level = "2"
+
[tool.mypy]
disable_error_code = "import-untyped"
@@ -75,5 +92,5 @@ disable_error_code = "import-untyped"
line_length=90
[build-system]
-requires = ["hatchling"]
-build-backend = "hatchling.build"
+requires = ["uv_build"]
+build-backend = "uv_build"
diff --git a/src/argenta/app/autocompleter/entity.py b/src/argenta/app/autocompleter/entity.py
index dedc320..8b0704d 100644
--- a/src/argenta/app/autocompleter/entity.py
+++ b/src/argenta/app/autocompleter/entity.py
@@ -3,12 +3,14 @@ __all__ = ["AutoCompleter"]
import sys
from typing import Callable, Iterable
-from prompt_toolkit import PromptSession, HTML
+from prompt_toolkit import HTML, PromptSession
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
-from prompt_toolkit.completion import Completer, Completion, CompleteEvent
+from prompt_toolkit.completion import (CompleteEvent, Completer, Completion,
+ ThreadedCompleter)
+from prompt_toolkit.cursor_shapes import CursorShape
from prompt_toolkit.document import Document
from prompt_toolkit.formatted_text import StyleAndTextTuples
-from prompt_toolkit.history import History, ThreadedHistory, FileHistory, InMemoryHistory
+from prompt_toolkit.history import FileHistory, History, InMemoryHistory, ThreadedHistory
from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent
from prompt_toolkit.lexers import Lexer
from prompt_toolkit.styles import Style
@@ -97,34 +99,36 @@ class AutoCompleter:
def _(event: KeyPressEvent) -> None:
buff = event.app.current_buffer
-
if buff.complete_state:
buff.complete_next()
- else:
- completions = list(buff.completer.get_completions(buff.document, CompleteEvent()))
- if len(completions) == 1:
- buff.apply_completion(completions[0])
- else:
- buff.start_completion(select_first=False)
+ return
+ comps_gen = iter(buff.completer.get_completions(buff.document, CompleteEvent()))
+ try:
+ first = next(comps_gen)
+ except StopIteration:
+ return
+ try:
+ _ = next(comps_gen)
+ buff.start_completion(select_first=False)
+ except StopIteration:
+ buff.apply_completion(first)
kb.add(self.autocomplete_button)(_)
history: InMemoryHistory | ThreadedHistory
-
if self.history_filename:
history = ThreadedHistory(FileHistory(self.history_filename))
else:
history = InMemoryHistory()
style = Style.from_dict({'valid': '#00ff00', 'invalid': '#ff0000'})
-
self._session = PromptSession(
history=history,
- completer=HistoryCompleter(history, all_commands),
+ completer=ThreadedCompleter(HistoryCompleter(history, all_commands)),
complete_while_typing=False,
key_bindings=kb,
auto_suggest=AutoSuggestFromHistory() if self.auto_suggestions else None,
- style=style if self.command_highlighting else style,
+ style=style if self.command_highlighting else None,
lexer=CommandLexer(all_commands) if self.command_highlighting else None,
)
@@ -134,5 +138,6 @@ class AutoCompleter:
if self._session is None:
raise RuntimeError("Call initial_setup() before using prompt()")
return self._session.prompt(
- HTML(prompt_text) if isinstance(prompt_text, str) else prompt_text
+ HTML(prompt_text) if isinstance(prompt_text, str) else prompt_text,
+ cursor=CursorShape.BLINKING_BEAM
)
\ No newline at end of file
diff --git a/src/argenta/app/behavior_handlers/__init__.py b/src/argenta/app/behavior_handlers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/argenta/app/behavior_handlers/models.py b/src/argenta/app/behavior_handlers/models.py
new file mode 100644
index 0000000..49a0161
--- /dev/null
+++ b/src/argenta/app/behavior_handlers/models.py
@@ -0,0 +1,104 @@
+from rich.markup import escape
+
+from argenta.app.presentation.renderers import Renderer
+from argenta.app.protocols import (DescriptionMessageGenerator, EmptyCommandHandler,
+ MostSimilarCommandGetter, NonStandardBehaviorHandler,
+ Printer)
+from argenta.command import InputCommand
+from argenta.response.entity import Response
+
+
+class BehaviorHandlersFabric:
+ def __init__(
+ self,
+ printer: Printer,
+ renderer: Renderer,
+ most_similar_command_getter: MostSimilarCommandGetter,
+ ) -> None:
+ self._printer = printer
+ self._renderer = renderer
+ self._most_similar_command_getter = most_similar_command_getter
+
+ def generate_incorrect_input_syntax_handler(self) -> NonStandardBehaviorHandler[str]:
+ return lambda raw_command: self._printer(
+ self._renderer.render_text_for_incorrect_input_syntax_handler(
+ raw_command=escape(raw_command)
+ )
+ )
+
+ def generate_repeated_input_flags_handler(self) -> NonStandardBehaviorHandler[str]:
+ return lambda raw_command: self._printer(
+ self._renderer.render_text_for_repeated_input_flags_handler(
+ raw_command=escape(raw_command)
+ )
+ )
+
+ def generate_empty_input_command_handler(self) -> EmptyCommandHandler:
+ return lambda: self._printer(self._renderer.render_text_for_empty_input_command_handler())
+
+ def generate_unknown_command_handler(self) -> NonStandardBehaviorHandler[InputCommand]:
+ def unknown_command_handler(command: InputCommand) -> None:
+ command_trigger: str = command.trigger
+ most_similar_command_trigger: str | None = self._most_similar_command_getter(command_trigger)
+ self._printer(
+ self._renderer.render_text_for_unknown_command_handler(
+ command_trigger=command_trigger,
+ most_similar_command_trigger=most_similar_command_trigger
+ )
+ )
+ return unknown_command_handler
+
+ def generate_exit_command_handler(self, farewell_message: str) -> NonStandardBehaviorHandler[Response]:
+ return lambda _: self._printer(farewell_message)
+
+ def generate_description_message_generator(self) -> DescriptionMessageGenerator:
+ return lambda command, description: self._renderer.render_text_for_description_message_generator(
+ command=command,
+ description=description
+ )
+
+
+class BehaviorHandlersSettersMixin:
+ def __init__(
+ self,
+ description_message_generator: DescriptionMessageGenerator,
+ incorrect_input_syntax_handler: NonStandardBehaviorHandler[str],
+ repeated_input_flags_handler: NonStandardBehaviorHandler[str],
+ empty_input_command_handler: EmptyCommandHandler,
+ unknown_command_handler: NonStandardBehaviorHandler[InputCommand],
+ exit_command_handler: NonStandardBehaviorHandler[Response]
+ ):
+ self._description_message_generator: DescriptionMessageGenerator = description_message_generator
+ self._incorrect_input_syntax_handler: NonStandardBehaviorHandler[str] = incorrect_input_syntax_handler
+ self._repeated_input_flags_handler: NonStandardBehaviorHandler[str] = repeated_input_flags_handler
+ self._empty_input_command_handler: EmptyCommandHandler = empty_input_command_handler
+ self._unknown_command_handler: NonStandardBehaviorHandler[InputCommand] = unknown_command_handler
+ self._exit_command_handler: NonStandardBehaviorHandler[Response] = exit_command_handler
+
+ def set_description_message_pattern(self, _: DescriptionMessageGenerator, /) -> None:
+ self._description_message_generator = _
+
+ def set_incorrect_input_syntax_handler(self, _: NonStandardBehaviorHandler[str], /) -> None:
+ self._incorrect_input_syntax_handler = _
+
+ def set_repeated_input_flags_handler(self, _: NonStandardBehaviorHandler[str], /) -> None:
+ self._repeated_input_flags_handler = _
+
+ def set_unknown_command_handler(self, _: NonStandardBehaviorHandler[InputCommand], /) -> None:
+ self._unknown_command_handler = _
+
+ def set_empty_command_handler(self, _: EmptyCommandHandler, /) -> None:
+ """
+ Public. Sets the handler for empty commands when entering a command
+ :param _: handler for empty commands when entering a command
+ :return: None
+ """
+ self._empty_input_command_handler = _
+
+ def set_exit_command_handler(self, _: NonStandardBehaviorHandler[Response], /) -> None:
+ """
+ Public. Sets the handler for exit command when entering a command
+ :param _: handler for exit command when entering a command
+ :return: None
+ """
+ self._exit_command_handler = _
diff --git a/src/argenta/app/dividing_line/models.py b/src/argenta/app/dividing_line/models.py
index f64bbc1..52574ee 100644
--- a/src/argenta/app/dividing_line/models.py
+++ b/src/argenta/app/dividing_line/models.py
@@ -41,9 +41,9 @@ class StaticDividingLine(BaseDividingLine):
:return: full line of dividing line as str
"""
if is_override:
- return f"\n{self.length * self.get_unit_part()}\n"
+ return self.length * self.get_unit_part()
else:
- return f"\n[dim]{self.length * self.get_unit_part()}[/dim]\n"
+ return f"[dim]{self.length * self.get_unit_part()}[/dim]"
class DynamicDividingLine(BaseDividingLine):
@@ -63,6 +63,6 @@ class DynamicDividingLine(BaseDividingLine):
:return: full line of dividing line as str
"""
if is_override:
- return f"\n{length * self.get_unit_part()}\n"
+ return length * self.get_unit_part()
else:
- return f"\n[dim]{self.get_unit_part() * length}[/dim]\n"
+ return f"[dim]{self.get_unit_part() * length}[/dim]"
diff --git a/src/argenta/app/models.py b/src/argenta/app/models.py
index e3c62b5..91ff7af 100644
--- a/src/argenta/app/models.py
+++ b/src/argenta/app/models.py
@@ -1,246 +1,94 @@
__all__ = ["App"]
-import io
-import re
-from contextlib import redirect_stdout
-from typing import Callable, Never, TypeAlias
+import difflib
+from typing import Never, TypeAlias
-from art import text2art
-from prompt_toolkit import HTML
from rich.console import Console
-from rich.markup import escape
from argenta.app.autocompleter import AutoCompleter
+from argenta.app.behavior_handlers.models import (BehaviorHandlersFabric,
+ BehaviorHandlersSettersMixin)
from argenta.app.dividing_line.models import DynamicDividingLine, StaticDividingLine
-from argenta.app.protocols import (
- DescriptionMessageGenerator,
- EmptyCommandHandler,
- NonStandardBehaviorHandler,
- Printer,
-)
+from argenta.app.presentation.renderers import PlainRenderer, Renderer, RichRenderer
+from argenta.app.presentation.viewers import Viewer
+from argenta.app.protocols import Printer
from argenta.app.registered_routers.entity import RegisteredRouters
-from argenta.command.exceptions import (
- InputCommandException,
- RepeatedInputFlagsException,
- UnprocessedInputFlagException,
-)
-from argenta.router.exceptions import RepeatedAliasNameException, RepeatedTriggerNameException
+from argenta.command.exceptions import (InputCommandException,
+ RepeatedInputFlagsException,
+ UnprocessedInputFlagException)
from argenta.command.models import Command, InputCommand
from argenta.response import Response
from argenta.router import Router
+from argenta.router.exceptions import (RepeatedAliasNameException,
+ RepeatedTriggerNameException)
Matches: TypeAlias = list[str] | list[Never]
-_ANSI_ESCAPE_RE: re.Pattern[str] = re.compile(r"\u001b\[[0-9;]*m")
-
-class BaseApp:
+class BaseApp(BehaviorHandlersSettersMixin):
def __init__(
self,
*,
- prompt: str | HTML,
+ prompt: str,
initial_message: str,
farewell_message: str,
exit_command: Command,
system_router_title: str,
- dividing_line: StaticDividingLine | DynamicDividingLine,
+ dividing_line: StaticDividingLine | DynamicDividingLine | None,
repeat_command_groups_printing: bool,
override_system_messages: bool,
autocompleter: AutoCompleter,
- print_func: Printer,
+ printer: Printer,
) -> None:
- self._prompt: str | HTML = prompt
- self._print_func: Printer = print_func
+ self._prompt: str = prompt
+ self._printer: Printer = printer
self._exit_command: Command = exit_command
- self._dividing_line: StaticDividingLine | DynamicDividingLine = dividing_line
+ self._dividing_line: StaticDividingLine | DynamicDividingLine | None = dividing_line
self._repeat_command_groups_printing: bool = repeat_command_groups_printing
self._override_system_messages: bool = override_system_messages
self._autocompleter: AutoCompleter = autocompleter
- self.system_router: Router = Router(title=system_router_title)
+ self._system_router: Router = Router(title=system_router_title)
- self._farewell_message: str = farewell_message
- self._initial_message: str = initial_message
-
- self._stdout_buffer: io.StringIO = io.StringIO()
-
- self._description_message_gen: DescriptionMessageGenerator = (
- lambda command, description: f"{command} *=*=* {description}"
- )
self.registered_routers: RegisteredRouters = RegisteredRouters()
self._messages_on_startup: list[str] = []
- self._incorrect_input_syntax_handler: NonStandardBehaviorHandler[str] = (
- lambda _: print_func(f"Incorrect flag syntax: {_}")
+ self._renderer: Renderer = PlainRenderer() if self._override_system_messages else RichRenderer()
+
+ self._viewer: Viewer = Viewer(
+ printer=self._printer,
+ renderer=self._renderer,
+ dividing_line=self._dividing_line,
+ override_system_messages=self._override_system_messages,
)
- self._repeated_input_flags_handler: NonStandardBehaviorHandler[str] = (
- lambda _: print_func(f"Repeated input flags: {_}")
- )
- self._empty_input_command_handler: EmptyCommandHandler = lambda: print_func(
- "Empty input command"
- )
- self._unknown_command_handler: NonStandardBehaviorHandler[InputCommand] = (
- lambda _: print_func(f"Unknown command: {_.trigger}")
- )
- self._exit_command_handler: NonStandardBehaviorHandler[Response] = (
- lambda _: print_func(self._farewell_message)
+ self._handlers_fabric: BehaviorHandlersFabric = BehaviorHandlersFabric(
+ printer=self._printer,
+ renderer=self._renderer,
+ most_similar_command_getter=self._most_similar_command
)
- def set_description_message_pattern(self, _: DescriptionMessageGenerator, /) -> None:
- """
- Public. Sets the output pattern of the available commands
- :param _: output pattern of the available commands
- :return: None
- """
- self._description_message_gen = _
+ self._initial_message: str = self._renderer.render_initial_message(initial_message)
+ self._farewell_message: str = self._renderer.render_farewell_message(farewell_message)
- def set_incorrect_input_syntax_handler(
- self, _: NonStandardBehaviorHandler[str], /
- ) -> None:
- """
- Public. Sets the handler for incorrect flags when entering a command
- :param _: handler for incorrect flags when entering a command
- :return: None
- """
- self._incorrect_input_syntax_handler = _
-
- def set_repeated_input_flags_handler(
- self, _: NonStandardBehaviorHandler[str], /
- ) -> None:
- """
- Public. Sets the handler for repeated flags when entering a command
- :param _: handler for repeated flags when entering a command
- :return: None
- """
- self._repeated_input_flags_handler = _
-
- def set_unknown_command_handler(
- self, _: NonStandardBehaviorHandler[InputCommand], /
- ) -> None:
- """
- Public. Sets the handler for unknown commands when entering a command
- :param _: handler for unknown commands when entering a command
- :return: None
- """
- self._unknown_command_handler = _
-
- def set_empty_command_handler(self, _: EmptyCommandHandler, /) -> None:
- """
- Public. Sets the handler for empty commands when entering a command
- :param _: handler for empty commands when entering a command
- :return: None
- """
- self._empty_input_command_handler = _
-
- def set_exit_command_handler(
- self, _: NonStandardBehaviorHandler[Response], /
- ) -> None:
- """
- Public. Sets the handler for exit command when entering a command
- :param _: handler for exit command when entering a command
- :return: None
- """
- self._exit_command_handler = _
-
- def _print_command_group_description(self) -> None:
- """
- Private. Prints the description of the available commands
- :return: None
- """
- for registered_router in self.registered_routers:
- self._print_func(registered_router.title)
- for command_handler in registered_router.command_handlers:
- handled_command = command_handler.handled_command
- self._print_func(
- self._description_message_gen(
- handled_command.trigger,
- handled_command.description,
- )
- )
- self._print_func("")
-
- def _print_framed_text(self, text: str) -> None:
- """
- Private. Outputs text by framing it in a static or dynamic split strip
- :param text: framed text
- :return: None
- """
- if isinstance(self._dividing_line, DynamicDividingLine):
- clear_text = _ANSI_ESCAPE_RE.sub("", text)
- max_length_line = max([len(line) for line in clear_text.split("\n")])
- max_length_line = (
- max_length_line
- if 10 <= max_length_line <= 80
- else 80
- if max_length_line > 80
- else 10
- )
-
- self._print_func(
- self._dividing_line.get_full_dynamic_line(
- length=max_length_line, is_override=self._override_system_messages
- )
- )
- print(text.strip("\n"))
- self._print_func(
- self._dividing_line.get_full_dynamic_line(
- length=max_length_line, is_override=self._override_system_messages
- )
- )
-
- elif isinstance(self._dividing_line, StaticDividingLine): # pyright: ignore[reportUnnecessaryIsInstance]
- self._print_func(
- self._dividing_line.get_full_static_line(
- is_override=self._override_system_messages
- )
- )
- print(text.strip("\n"))
- self._print_func(
- self._dividing_line.get_full_static_line(
- is_override=self._override_system_messages
- )
- )
-
- else:
- raise NotImplementedError
+ super().__init__(
+ description_message_generator = self._handlers_fabric.generate_description_message_generator(),
+ incorrect_input_syntax_handler = self._handlers_fabric.generate_incorrect_input_syntax_handler(),
+ repeated_input_flags_handler = self._handlers_fabric.generate_repeated_input_flags_handler(),
+ empty_input_command_handler = self._handlers_fabric.generate_empty_input_command_handler(),
+ unknown_command_handler = self._handlers_fabric.generate_unknown_command_handler(),
+ exit_command_handler = self._handlers_fabric.generate_exit_command_handler(self._farewell_message)
+ )
def _is_exit_command(self, command: InputCommand) -> bool:
- """
- Private. Checks if the given command is an exit command
- :param command: command to check
- :return: is it an exit command or not as bool
- """
- trigger = command.trigger
- exit_trigger = self._exit_command.trigger
- if trigger.lower() == exit_trigger.lower():
- return True
- elif trigger.lower() in [x.lower() for x in self._exit_command.aliases]:
- return True
- return False
-
+ if not self._system_router.command_handlers.get_command_handler_by_trigger(command.trigger.lower()):
+ return False
+ return True
+
def _is_unknown_command(self, input_command: InputCommand) -> bool:
if not self.registered_routers.get_router_by_trigger(input_command.trigger.lower()):
return True
return False
- def _capture_stdout(self, func: Callable[[], None]) -> str:
- """
- Private. Captures stdout from a function call using a reusable buffer
- :param func: function to execute with captured stdout
- :return: captured stdout as string
- """
- self._stdout_buffer.seek(0)
- self._stdout_buffer.truncate(0)
- with redirect_stdout(self._stdout_buffer):
- func()
- return self._stdout_buffer.getvalue()
-
def _error_handler(self, error: InputCommandException, raw_command: str) -> None:
- """
- Private. Handles parsing errors of the entered command
- :param error: error being handled
- :param raw_command: the raw input command
- :return: None
- """
if isinstance(error, UnprocessedInputFlagException):
self._incorrect_input_syntax_handler(raw_command)
elif isinstance(error, RepeatedInputFlagsException):
@@ -248,172 +96,100 @@ class BaseApp:
else:
self._empty_input_command_handler()
- def _setup_system_router(self) -> None:
- """
- Private. Sets up system router
- :return: None
- """
-
- @self.system_router.command(self._exit_command)
- def _(response: Response) -> None:
- self._exit_command_handler(response)
-
- self.registered_routers.add_registered_router(self.system_router)
-
def _validate_routers_for_collisions(self) -> None:
- """
- Private. Validates that there are no trigger/alias collisions between routers
- :return: None
- :raises: RepeatedTriggerNameException or RepeatedAliasNameException if collision detected
- """
-
- all_triggers: set[str] = set()
- all_aliases: set[str] = set()
-
+ seen_names: set[str] = set()
+
for router_entity in self.registered_routers:
- union_units: set[str] = all_triggers | all_aliases
- trigger_collisions: set[str] = union_units & router_entity.triggers
- if trigger_collisions:
+ if not seen_names.isdisjoint(router_entity.triggers):
raise RepeatedTriggerNameException()
-
- alias_collisions: set[str] = union_units & router_entity.aliases
+
+ alias_collisions = seen_names.intersection(router_entity.aliases)
if alias_collisions:
raise RepeatedAliasNameException(alias_collisions)
-
- all_triggers.update(router_entity.triggers)
- all_aliases.update(router_entity.aliases)
+
+ seen_names.update(router_entity.triggers)
+ seen_names.update(router_entity.aliases)
def _most_similar_command(self, unknown_command: str) -> str | None:
all_commands = self.registered_routers.get_triggers()
+ matches = difflib.get_close_matches(unknown_command, all_commands, n=1)
+ return matches[0] if matches else None
- matches_startswith_unknown_command: Matches = sorted(
- cmd for cmd in all_commands if cmd.startswith(unknown_command)
- )
- matches_startswith_cmd: Matches = sorted(
- cmd for cmd in all_commands if unknown_command.startswith(cmd)
- )
+ def _setup_system_router(self) -> None:
+ @self._system_router.command(self._exit_command)
+ def _(response: Response) -> None:
+ self._exit_command_handler(response)
- matches: Matches = matches_startswith_unknown_command or matches_startswith_cmd
-
- if len(matches) == 1:
- return matches[0]
- elif len(matches) > 1:
- return sorted(matches, key=lambda cmd: len(cmd))[0]
- else:
- return None
-
- def _setup_default_view(self) -> None:
- """
- Private. Sets up default app view
- :return: None
- """
- self._prompt = f"{self._prompt}"
- self._initial_message = (
- "\n" + f"[bold red]{text2art(self._initial_message, font='tarty1')}" + "\n"
- )
- self._farewell_message = (
- "[bold red]\n\n"
- + str(text2art(self._farewell_message, font="chanky")) # pyright: ignore[reportUnknownArgumentType]
- + "\n[/bold red]\n"
- + "[red i]github.com/koloideal/Argenta[/red i] | [red bold i]made by kolo[/red bold i]\n"
- )
- self._description_message_gen = lambda command, description: (
- f"[bold red]{escape('[' + command + ']')}[/bold red] "
- f"[blue dim]*=*=*[/blue dim] "
- f"[bold yellow italic]{escape(description)}"
- )
- self._incorrect_input_syntax_handler = lambda raw_command: self._print_func(
- f"[red bold]Incorrect flag syntax: {escape(raw_command)}"
- )
- self._repeated_input_flags_handler = lambda raw_command: self._print_func(
- f"[red bold]Repeated input flags: {escape(raw_command)}"
- )
- self._empty_input_command_handler = lambda: self._print_func(
- "[red bold]Empty input command"
- )
-
- def unknown_command_handler(command: InputCommand) -> None:
- cmd_trg: str = command.trigger
- mst_sim_cmd: str | None = self._most_similar_command(cmd_trg)
- first_part_of_text = (
- f"[red]Unknown command:[/red] [blue]{escape(cmd_trg)}[/blue]"
- )
- second_part_of_text = (
- ("[red], most similar:[/red] " + ("[blue]" + mst_sim_cmd + "[/blue]"))
- if mst_sim_cmd
- else ""
- )
- self._print_func(first_part_of_text + second_part_of_text)
-
- self._unknown_command_handler = unknown_command_handler
+ self.registered_routers.add_registered_router(self._system_router)
def _pre_cycle_setup(self) -> None:
- """
- Private. Configures various aspects of the application before the start of the cycle
- :return: None
- """
self._setup_system_router()
self._validate_routers_for_collisions()
-
self._autocompleter.initial_setup(self.registered_routers.get_triggers())
- self._print_func(self._initial_message)
-
- for message in self._messages_on_startup:
- self._print_func(message)
if self._messages_on_startup:
- print("\n")
+ self._viewer.view_messages_on_startup(self._messages_on_startup)
+
if not self._repeat_command_groups_printing:
- self._print_command_group_description()
+ self._viewer.view_command_groups_description(self._description_message_generator, self.registered_routers)
def _process_exist_and_valid_command(self, input_command: InputCommand) -> None:
processing_router = self.registered_routers.get_router_by_trigger(input_command.trigger.lower())
-
+
if not processing_router:
raise RuntimeError(f"Router for '{input_command.trigger}' not found. Panic!")
- if processing_router.disable_redirect_stdout:
- dividing_line_unit_part: str = self._dividing_line.get_unit_part()
- self._print_func(
- StaticDividingLine(dividing_line_unit_part).get_full_static_line(
- is_override=self._override_system_messages
+ self._viewer.view_framed_text_from_generator(
+ output_text_generator=lambda: processing_router.finds_appropriate_handler(input_command),
+ is_stdout_redirected_by_router=processing_router.is_redirect_stdout_disabled
+ )
+
+ def _run_polling(self) -> None:
+ self._viewer.view_initial_message(self._initial_message)
+ self._pre_cycle_setup()
+ while True:
+ if self._repeat_command_groups_printing:
+ self._viewer.view_command_groups_description(self._description_message_generator, self.registered_routers)
+
+ print() # pre-prompt gap
+ raw_command: str = self._autocompleter.prompt(self._renderer.render_prompt(self._prompt))
+ print() # post-prompt gap
+
+ try:
+ input_command: InputCommand = InputCommand.parse(raw_command=raw_command)
+ except InputCommandException as error: # noqa F841
+ self._viewer.view_framed_text_from_generator(
+ output_text_generator=lambda: self._error_handler(error, raw_command) # noqa
)
- )
- processing_router.finds_appropriate_handler(input_command)
- self._print_func(
- StaticDividingLine(dividing_line_unit_part).get_full_static_line(
- is_override=self._override_system_messages
+ continue
+
+ if self._is_unknown_command(input_command):
+ self._viewer.view_framed_text_from_generator(
+ output_text_generator=lambda: self._unknown_command_handler(input_command)
)
- )
- else:
- stdout_result = self._capture_stdout(
- lambda: processing_router.finds_appropriate_handler(input_command)
- )
- self._print_framed_text(stdout_result)
+ continue
+ if self._is_exit_command(input_command):
+ self._system_router.finds_appropriate_handler(input_command)
+ return
-AVAILABLE_DIVIDING_LINES: TypeAlias = StaticDividingLine | DynamicDividingLine
-DEFAULT_DIVIDING_LINE: StaticDividingLine = StaticDividingLine()
-
-DEFAULT_PRINT_FUNC: Printer = Console().print
-DEFAULT_AUTOCOMPLETER: AutoCompleter = AutoCompleter()
-DEFAULT_EXIT_COMMAND: Command = Command("q", description="Exit command")
+ self._process_exist_and_valid_command(input_command)
class App(BaseApp):
def __init__(
self,
*,
- prompt: str | HTML = ">>> ",
- initial_message: str = "Argenta\n",
- farewell_message: str = "\nSee you\n",
- exit_command: Command = DEFAULT_EXIT_COMMAND,
+ prompt: str = ">>> ",
+ initial_message: str = "Argenta",
+ farewell_message: str = "See you",
+ exit_command: Command = Command("q", description="Exit command"),
system_router_title: str = "System points:",
- dividing_line: AVAILABLE_DIVIDING_LINES = DEFAULT_DIVIDING_LINE,
+ dividing_line: StaticDividingLine | DynamicDividingLine | None = None,
repeat_command_groups_printing: bool = False,
override_system_messages: bool = False,
- autocompleter: AutoCompleter = DEFAULT_AUTOCOMPLETER,
- print_func: Printer = DEFAULT_PRINT_FUNC,
+ autocompleter: AutoCompleter | None = None,
+ printer: Printer = Console().print,
) -> None:
"""
Public. The essence of the application itself.
@@ -427,7 +203,7 @@ class App(BaseApp):
:param repeat_command_groups_printing: whether to repeat the available commands and their description
:param override_system_messages: whether to redefine the default formatting of system messages
:param autocompleter: the entity of the autocompleter
- :param print_func: system messages text output function
+ :param printer: system messages text output function
:return: None
"""
super().__init__(
@@ -439,45 +215,9 @@ class App(BaseApp):
dividing_line=dividing_line,
repeat_command_groups_printing=repeat_command_groups_printing,
override_system_messages=override_system_messages,
- autocompleter=autocompleter,
- print_func=print_func,
+ autocompleter=autocompleter or AutoCompleter(),
+ printer=printer,
)
- if not self._override_system_messages:
- self._setup_default_view()
-
- def run_polling(self) -> None:
- """
- Private. Starts the user input processing cycle
- :return: None
- """
- self._pre_cycle_setup()
- while True:
- if self._repeat_command_groups_printing:
- self._print_command_group_description()
-
- raw_command: str = self._autocompleter.prompt(self._prompt)
-
- try:
- input_command: InputCommand = InputCommand.parse(raw_command=raw_command)
- except InputCommandException as error: # noqa F841
- stderr_result = self._capture_stdout(
- lambda: self._error_handler(error, raw_command) # noqa F821
- )
- self._print_framed_text(stderr_result)
- continue
-
- if self._is_exit_command(input_command):
- self.system_router.finds_appropriate_handler(input_command)
- return
-
- if self._is_unknown_command(input_command):
- stdout_res = self._capture_stdout(
- lambda: self._unknown_command_handler(input_command)
- )
- self._print_framed_text(stdout_res)
- continue
-
- self._process_exist_and_valid_command(input_command)
def include_router(self, router: Router) -> None:
"""
diff --git a/src/argenta/app/presentation/__init__.py b/src/argenta/app/presentation/__init__.py
new file mode 100644
index 0000000..bfe06c7
--- /dev/null
+++ b/src/argenta/app/presentation/__init__.py
@@ -0,0 +1,4 @@
+from .renderers import PlainRenderer, Renderer, RichRenderer
+from .viewers import Viewer
+
+__all__ = ["Renderer", "RichRenderer", "PlainRenderer", "Viewer"]
diff --git a/src/argenta/app/presentation/renderers.py b/src/argenta/app/presentation/renderers.py
new file mode 100644
index 0000000..0df27ef
--- /dev/null
+++ b/src/argenta/app/presentation/renderers.py
@@ -0,0 +1,182 @@
+from typing import Iterable, Protocol
+
+from art import text2art
+
+from argenta.app.protocols import DescriptionMessageGenerator
+from argenta.app.registered_routers.entity import RegisteredRouters
+
+
+class Renderer(Protocol):
+ @staticmethod
+ def render_prompt(
+ text: str
+ ) -> str: ...
+ @staticmethod
+ def render_initial_message(
+ text: str
+ ) -> str: ...
+ @staticmethod
+ def render_farewell_message(
+ text: str
+ ) -> str: ...
+ @staticmethod
+ def render_messages_on_startup(
+ messages: Iterable[str]
+ ) -> str: ...
+ @staticmethod
+ def render_text_for_description_message_generator(
+ command: str,
+ description: str
+ ) -> str: ...
+ @staticmethod
+ def render_command_groups_description(
+ description_message_generator: DescriptionMessageGenerator,
+ registered_routers: RegisteredRouters
+ ) -> str: ...
+ @staticmethod
+ def render_text_for_incorrect_input_syntax_handler(
+ raw_command: str
+ ) -> str: ...
+ @staticmethod
+ def render_text_for_repeated_input_flags_handler(
+ raw_command: str
+ ) -> str: ...
+ @staticmethod
+ def render_text_for_empty_input_command_handler() -> str: ...
+ @staticmethod
+ def render_text_for_unknown_command_handler(
+ command_trigger: str,
+ most_similar_command_trigger: str | None
+ ) -> str: ...
+
+
+class RichRenderer(Renderer):
+ @staticmethod
+ def render_prompt(text: str) -> str:
+ return f"{text}"
+
+ @staticmethod
+ def render_initial_message(text: str) -> str:
+ return f"[bold red]{text2art(text, font='tarty1')}[/bold red]"
+
+ @staticmethod
+ def render_farewell_message(text: str) -> str:
+ return (
+ "[bold red]"
+ + str(text2art(text, font="chanky"))
+ + "[/bold red]\n"
+ + "[red i]https://github.com/koloideal/Argenta[/red i] | [red bold i]made by kolo[/red bold i]"
+ )
+
+ @staticmethod
+ def render_text_for_description_message_generator(command: str, description: str) -> str:
+ return (
+ f"[bold red]<{command}>[/bold red] "
+ f"[blue dim]*=*=*[/blue dim] "
+ f"[bold yellow italic]{description}[/bold yellow italic]"
+ )
+
+ @staticmethod
+ def render_messages_on_startup(messages: Iterable[str]) -> str:
+ return "\n" + "\n".join(messages)
+
+ @staticmethod
+ def render_command_groups_description(
+ description_message_generator: DescriptionMessageGenerator,
+ registered_routers: RegisteredRouters
+ ) -> str:
+ command_groups_description = ""
+ for registered_router in registered_routers:
+ command_groups_description += "\n\n" + registered_router.title
+ for command_handler in registered_router.command_handlers:
+ handled_command = command_handler.handled_command
+ command_groups_description += '\n' + description_message_generator(
+ handled_command.trigger,
+ handled_command.description,
+ )
+ return command_groups_description
+
+ @staticmethod
+ def render_text_for_incorrect_input_syntax_handler(raw_command: str) -> str:
+ return f"[red bold]Incorrect flag syntax: {raw_command}[/red bold]"
+
+ @staticmethod
+ def render_text_for_repeated_input_flags_handler(raw_command: str) -> str:
+ return f"[red bold]Repeated input flags: {raw_command}[/red bold]"
+
+ @staticmethod
+ def render_text_for_empty_input_command_handler() -> str:
+ return "[red bold]Empty input command[/red bold]"
+
+ @staticmethod
+ def render_text_for_unknown_command_handler(
+ command_trigger: str,
+ most_similar_command_trigger: str | None
+ ) -> str:
+ return (
+ f"[red]Unknown command:[/red] [blue]{command_trigger}[/blue]"
+ + (f"[red], most similar:[/red] [blue]{most_similar_command_trigger}[/blue]"
+ if most_similar_command_trigger else "")
+ )
+
+
+class PlainRenderer(Renderer):
+ @staticmethod
+ def render_prompt(text: str) -> str:
+ return text
+
+ @staticmethod
+ def render_initial_message(text: str) -> str:
+ return text
+
+ @staticmethod
+ def render_farewell_message(text: str) -> str:
+ return f"\n{text} | https://github.com/koloideal/Argenta | made by kolo"
+
+ @staticmethod
+ def render_text_for_description_message_generator(command: str, description: str) -> str:
+ return f"{command} *=*=* {description}"
+
+ @staticmethod
+ def render_messages_on_startup(messages: Iterable[str]) -> str:
+ return "\n" + "\n".join(messages)
+
+ @staticmethod
+ def render_command_groups_description(
+ description_message_generator: DescriptionMessageGenerator,
+ registered_routers: RegisteredRouters,
+ ) -> str:
+ command_groups_description = ""
+ for registered_router in registered_routers:
+ command_groups_description += "\n\n" + registered_router.title
+ for command_handler in registered_router.command_handlers:
+ handled_command = command_handler.handled_command
+ command_groups_description += "\n" + description_message_generator(
+ handled_command.trigger,
+ handled_command.description,
+ )
+ return command_groups_description
+
+ @staticmethod
+ def render_text_for_incorrect_input_syntax_handler(raw_command: str) -> str:
+ return f"Incorrect flag syntax: {raw_command}"
+
+ @staticmethod
+ def render_text_for_repeated_input_flags_handler(raw_command: str) -> str:
+ return f"Repeated input flags: {raw_command}"
+
+ @staticmethod
+ def render_text_for_empty_input_command_handler() -> str:
+ return "Empty input command"
+
+ @staticmethod
+ def render_text_for_unknown_command_handler(
+ command_trigger: str,
+ most_similar_command_trigger: str | None
+ ) -> str:
+ return (
+ f"Unknown command: {command_trigger}"
+ + (f", most similar: {most_similar_command_trigger}"
+ if most_similar_command_trigger else "")
+ )
+
diff --git a/src/argenta/app/presentation/viewers.py b/src/argenta/app/presentation/viewers.py
new file mode 100644
index 0000000..44eb168
--- /dev/null
+++ b/src/argenta/app/presentation/viewers.py
@@ -0,0 +1,95 @@
+__all__ = ["Viewer"]
+
+import re
+from contextlib import redirect_stdout
+from io import StringIO
+from typing import Callable, Iterable, TypeAlias
+
+from rich.text import Text
+
+from argenta.app import DynamicDividingLine, StaticDividingLine
+from argenta.app.presentation.renderers import Renderer
+from argenta.app.protocols import DescriptionMessageGenerator, Printer
+from argenta.app.registered_routers.entity import RegisteredRouters
+
+AVAILABLE_DIVIDING_LINES: TypeAlias = StaticDividingLine | DynamicDividingLine | None
+
+
+class Viewer:
+ ANSI_ESCAPE_RE: re.Pattern[str] = re.compile(r"\u001b\[[0-9;]*m")
+
+ def __init__(
+ self,
+ printer: Printer,
+ renderer: Renderer,
+ dividing_line: AVAILABLE_DIVIDING_LINES,
+ override_system_messages: bool
+ ):
+ self._printer = printer
+ self._renderer = renderer
+ self._dividing_line = dividing_line
+ self._override_system_messages = override_system_messages
+ self._stdout_buffer: StringIO = StringIO()
+
+ def _capture_stdout(self, func: Callable[[], None]) -> str:
+ self._stdout_buffer.seek(0)
+ self._stdout_buffer.truncate(0)
+ with redirect_stdout(self._stdout_buffer):
+ func()
+ return self._stdout_buffer.getvalue()
+
+ def view_messages_on_startup(self, messages: Iterable[str]) -> None:
+ self._printer(self._renderer.render_messages_on_startup(messages))
+
+ def view_command_groups_description(
+ self,
+ description_message_generator: DescriptionMessageGenerator,
+ registered_routers: RegisteredRouters
+ ) -> None:
+ self._printer(
+ self._renderer.render_command_groups_description(
+ description_message_generator,
+ registered_routers
+ )
+ )
+
+ def view_initial_message(self, initial_message: str) -> None:
+ self._printer(initial_message)
+
+ def view_framed_text_from_generator(
+ self,
+ output_text_generator: Callable[[], None],
+ is_stdout_redirected_by_router: bool = False,
+ ) -> None:
+ match (self._dividing_line, is_stdout_redirected_by_router):
+ case (None, bool()):
+ output_text_generator()
+ case (DynamicDividingLine(), False):
+ stdout_result = self._capture_stdout(
+ lambda: output_text_generator()
+ )
+ clear_text = self.ANSI_ESCAPE_RE.sub("", stdout_result)
+ max_length_line = max([len(line) for line in clear_text.split("\n")])
+ max_length_line = (
+ max_length_line
+ if 10 <= max_length_line <= 100
+ else 100
+ if max_length_line > 100
+ else 10
+ )
+ dynamic_dividing_line_as_str: str = self._dividing_line.get_full_dynamic_line(
+ length=max_length_line, is_override=self._override_system_messages
+ )
+ self._printer(dynamic_dividing_line_as_str + "\n")
+ self._printer(Text.from_ansi(stdout_result.strip("\n")).markup)
+ self._printer('\n' + dynamic_dividing_line_as_str)
+
+ case (StaticDividingLine() as dividing_line, bool()) | (DynamicDividingLine() as dividing_line, True):
+ static_dividing_line_as_str: str = StaticDividingLine(dividing_line.get_unit_part()).get_full_static_line(
+ is_override=self._override_system_messages
+ )
+ self._printer(static_dividing_line_as_str + '\n')
+ output_text_generator()
+ self._printer('\n' + static_dividing_line_as_str)
+ case _:
+ raise NotImplementedError(f"Dividing line with type {self._dividing_line} is not implemented")
diff --git a/src/argenta/app/protocols.py b/src/argenta/app/protocols.py
index c5232f6..985c8e0 100644
--- a/src/argenta/app/protocols.py
+++ b/src/argenta/app/protocols.py
@@ -1,10 +1,17 @@
-__all__ = ["NonStandardBehaviorHandler", "EmptyCommandHandler", "Printer", "DescriptionMessageGenerator", "HandlerFunc"]
+__all__ = [
+ "NonStandardBehaviorHandler",
+ "EmptyCommandHandler",
+ "MostSimilarCommandGetter",
+ "Printer",
+ "DescriptionMessageGenerator",
+ "HandlerFunc",
+]
+
+from typing import Any, Protocol, TypeVar
-from typing import ParamSpec, Protocol, TypeVar
from argenta.response import Response
-T = TypeVar("T", contravariant=True)
-P = ParamSpec("P")
+T = TypeVar("T", contravariant=True)
class NonStandardBehaviorHandler(Protocol[T]):
@@ -22,11 +29,16 @@ class Printer(Protocol):
raise NotImplementedError
+class MostSimilarCommandGetter(Protocol):
+ def __call__(self, _unknown_trigger: str, /) -> str | None:
+ raise NotImplementedError
+
+
class DescriptionMessageGenerator(Protocol):
def __call__(self, _command: str, _description: str, /) -> str:
raise NotImplementedError
class HandlerFunc(Protocol):
- def __call__(self, response: Response) -> None:
+ def __call__(self, response: Response, /, *args: Any, **kwargs: Any) -> None:
raise NotImplementedError
diff --git a/src/argenta/command/__init__.py b/src/argenta/command/__init__.py
index 6bc02cc..070f53d 100644
--- a/src/argenta/command/__init__.py
+++ b/src/argenta/command/__init__.py
@@ -1,8 +1,8 @@
from argenta.command.flag import Flag as Flag
-from argenta.command.flag import Flags as Flags
from argenta.command.flag import InputFlag as InputFlag
-from argenta.command.flag import InputFlags as InputFlags
from argenta.command.flag import PossibleValues as PossibleValues
from argenta.command.flag.defaults import PredefinedFlags as PredefinedFlags
+from argenta.command.flag.models import Flags as Flags
+from argenta.command.flag.models import InputFlags as InputFlags
from argenta.command.models import Command as Command
from argenta.command.models import InputCommand as InputCommand
diff --git a/src/argenta/command/flag/__init__.py b/src/argenta/command/flag/__init__.py
index dc8e2be..148d4b3 100644
--- a/src/argenta/command/flag/__init__.py
+++ b/src/argenta/command/flag/__init__.py
@@ -1,6 +1,6 @@
-from argenta.command.flag.flags.models import Flags as Flags
-from argenta.command.flag.flags.models import InputFlags as InputFlags
from argenta.command.flag.models import Flag as Flag
+from argenta.command.flag.models import Flags as Flags
from argenta.command.flag.models import InputFlag as InputFlag
+from argenta.command.flag.models import InputFlags as InputFlags
from argenta.command.flag.models import PossibleValues as PossibleValues
from argenta.command.flag.models import ValidationStatus as ValidationStatus
diff --git a/src/argenta/command/flag/flags/__init__.py b/src/argenta/command/flag/flags/__init__.py
deleted file mode 100644
index a7d3a22..0000000
--- a/src/argenta/command/flag/flags/__init__.py
+++ /dev/null
@@ -1,2 +0,0 @@
-from argenta.command.flag.flags.models import Flags as Flags
-from argenta.command.flag.flags.models import InputFlags as InputFlags
diff --git a/src/argenta/command/flag/flags/models.py b/src/argenta/command/flag/flags/models.py
deleted file mode 100644
index 18ab340..0000000
--- a/src/argenta/command/flag/flags/models.py
+++ /dev/null
@@ -1,107 +0,0 @@
-__all__ = ["Flags", "InputFlags"]
-
-from collections.abc import Iterator
-from typing import Generic, TypeVar, override
-
-from argenta.command.flag.models import Flag, InputFlag
-
-FlagType = TypeVar("FlagType")
-
-
-class BaseFlags(Generic[FlagType]):
- def __init__(self, flags: list[FlagType] | None = None) -> None:
- """
- Public. A model that combines the registered flags
- :param flags: the flags that will be registered
- :return: None
- """
- self.flags: list[FlagType] = flags if flags else []
-
- def add_flag(self, flag: FlagType) -> None:
- """
- Public. Adds a flag to the list of flags
- :param flag: flag to add
- :return: None
- """
- self.flags.append(flag)
-
- def add_flags(self, flags: list[FlagType]) -> None:
- """
- Public. Adds a list of flags to the list of flags
- :param flags: list of flags to add
- :return: None
- """
- self.flags.extend(flags)
-
- def __len__(self) -> int:
- return len(self.flags)
-
- def __iter__(self) -> Iterator[FlagType]:
- return iter(self.flags)
-
- def __getitem__(self, flag_index: int) -> FlagType:
- return self.flags[flag_index]
-
- def __bool__(self) -> bool:
- return bool(self.flags)
-
-
-class Flags(BaseFlags[Flag]):
- def get_flag_by_name(self, name: str) -> Flag | None:
- """
- Public. Returns the flag entity by its name or None if not found
- :param name: the name of the flag to get
- :return: entity of the flag or None
- """
- return next((flag for flag in self.flags if flag.name == name), None)
-
- @override
- def __eq__(self, other: object) -> bool:
- if not isinstance(other, Flags):
- return False
-
- if len(self.flags) != len(other.flags):
- return False
-
- flag_pairs: zip[tuple[Flag, Flag]] = zip(self.flags, other.flags)
- return all(s_flag == o_flag for s_flag, o_flag in flag_pairs)
-
- def __contains__(self, flag_to_check: object) -> bool:
- if isinstance(flag_to_check, Flag):
- for flag in self.flags:
- if flag == flag_to_check:
- return True
- return False
- else:
- raise TypeError
-
-
-class InputFlags(BaseFlags[InputFlag]):
- def get_flag_by_name(self, name: str) -> InputFlag | None:
- """
- Public. Returns the flag entity by its name or None if not found
- :param name: the name of the flag to get
- :return: entity of the flag or None
- """
- return next((flag for flag in self.flags if flag.name == name), None)
-
- @override
- def __eq__(self, other: object) -> bool:
- if not isinstance(other, InputFlags):
- return False
-
- if len(self.flags) != len(other.flags):
- return False
-
- paired_flags: zip[tuple[InputFlag, InputFlag]] = zip(self.flags, other.flags)
-
- return all(my_flag == other_flag for my_flag, other_flag in paired_flags)
-
- def __contains__(self, ingressable_item: object) -> bool:
- if isinstance(ingressable_item, InputFlag):
- for flag in self.flags:
- if flag == ingressable_item:
- return True
- return False
- else:
- raise TypeError
diff --git a/src/argenta/command/flag/models.py b/src/argenta/command/flag/models.py
index 32b5891..b0d7c3e 100644
--- a/src/argenta/command/flag/models.py
+++ b/src/argenta/command/flag/models.py
@@ -1,8 +1,8 @@
-__all__ = ["PossibleValues", "ValidationStatus", "Flag", "InputFlag"]
+__all__ = ["PossibleValues", "ValidationStatus", "Flag", "InputFlag", "InputFlags", "Flags"]
from enum import Enum
from re import Pattern
-from typing import Literal, override
+from typing import Any, Container, Generic, Iterator, Literal, TypeVar, override
PREFIX_TYPE = Literal["-", "--", "---"]
@@ -24,7 +24,7 @@ class Flag:
name: str,
*,
prefix: PREFIX_TYPE = "--",
- possible_values: list[str] | Pattern[str] | PossibleValues = PossibleValues.ALL,
+ possible_values: Container[str] | Pattern[str] | PossibleValues = PossibleValues.ALL,
) -> None:
"""
Public. The entity of the flag being registered for subsequent processing
@@ -35,7 +35,7 @@ class Flag:
"""
self.name: str = name
self.prefix: PREFIX_TYPE = prefix
- self.possible_values: list[str] | Pattern[str] | PossibleValues = possible_values
+ self.possible_values: Container[str] | Pattern[str] | PossibleValues = possible_values
def validate_input_flag_value(self, input_flag_value: str) -> bool:
"""
@@ -91,7 +91,7 @@ class InputFlag:
Public. The entity of the flag of the entered command
:param name: the name of the input flag
:param prefix: the prefix of the input flag
- :param value: the value of the input flag
+ :param input_value: the value of the input flag
:return: None
"""
self.name: str = name
@@ -122,3 +122,115 @@ class InputFlag:
return self.name == other.name
else:
raise NotImplementedError
+
+
+FlagType = TypeVar("FlagType")
+
+
+class BaseFlags(Generic[FlagType]):
+ def __init__(self, flags: list[FlagType] | None = None) -> None:
+ """
+ Public. A model that combines the registered flags
+ :param flags: the flags that will be registered
+ :return: None
+ """
+ self.flags: list[FlagType] = flags if flags else []
+
+ def add_flag(self, flag: FlagType) -> None:
+ """
+ Public. Adds a flag to the list of flags
+ :param flag: flag to add
+ :return: None
+ """
+ self.flags.append(flag)
+
+ def add_flags(self, flags: list[FlagType]) -> None:
+ """
+ Public. Adds a list of flags to the list of flags
+ :param flags: list of flags to add
+ :return: None
+ """
+ self.flags.extend(flags)
+
+ def __len__(self) -> int:
+ return len(self.flags)
+
+ def __iter__(self) -> Iterator[FlagType]:
+ return iter(self.flags)
+
+ def __getitem__(self, flag_index: int) -> FlagType:
+ return self.flags[flag_index]
+
+ def __bool__(self) -> bool:
+ return bool(self.flags)
+
+
+class Flags(BaseFlags[Flag]):
+ def get_flag_by_name(self, name: str) -> Flag | None:
+ """
+ Public. Returns the flag entity by its name or None if not found
+ :param name: the name of the flag to get
+ :return: entity of the flag or None
+ """
+ return next((flag for flag in self.flags if flag.name == name), None)
+
+ @override
+ def __eq__(self, other: object) -> bool:
+ if not isinstance(other, Flags):
+ return False
+
+ if len(self.flags) != len(other.flags):
+ return False
+
+ flag_pairs: Iterator[tuple[Flag, Flag]] = zip(self.flags, other.flags)
+ return all(s_flag == o_flag for s_flag, o_flag in flag_pairs)
+
+ def __contains__(self, flag_to_check: object) -> bool:
+ if isinstance(flag_to_check, Flag):
+ for flag in self.flags:
+ if flag == flag_to_check:
+ return True
+ return False
+ else:
+ raise TypeError
+
+
+class InputFlags(BaseFlags[InputFlag]):
+ def get_flag_by_name(
+ self,
+ name: str,
+ with_status: ValidationStatus | None = None,
+ default: Any = None
+ ) -> InputFlag | None:
+ """
+ Public. Returns the flag entity by its name or None if not found
+ :param default:
+ :param with_status:
+ :param name: the name of the flag to get
+ :return: entity of the flag or None
+ """
+ if with_status is None:
+ return next((flag for flag in self.flags if flag.name == name), default)
+ else:
+ return next((flag for flag in self.flags if flag.name == name and flag.status == with_status), default)
+
+ @override
+ def __eq__(self, other: object) -> bool:
+ if not isinstance(other, InputFlags):
+ return False
+
+ if len(self.flags) != len(other.flags):
+ return False
+
+ paired_flags: Iterator[tuple[InputFlag, InputFlag]] = zip(self.flags, other.flags)
+
+ return all(my_flag == other_flag for my_flag, other_flag in paired_flags)
+
+ def __contains__(self, ingressable_item: object) -> bool:
+ if isinstance(ingressable_item, InputFlag):
+ for flag in self.flags:
+ if flag == ingressable_item:
+ return True
+ return False
+ else:
+ raise TypeError
diff --git a/src/argenta/command/models.py b/src/argenta/command/models.py
index fc8507f..d54ad40 100644
--- a/src/argenta/command/models.py
+++ b/src/argenta/command/models.py
@@ -1,14 +1,12 @@
__all__ = ["Command", "InputCommand"]
import shlex
-from typing import Literal, Never, Self, cast
+from typing import Iterable, Literal, Never, Self, cast
-from argenta.command.exceptions import (
- EmptyInputCommandException,
- RepeatedInputFlagsException,
- UnprocessedInputFlagException,
-)
-from argenta.command.flag.flags.models import Flags, InputFlags
+from argenta.command import Flags, InputFlags
+from argenta.command.exceptions import (EmptyInputCommandException,
+ RepeatedInputFlagsException,
+ UnprocessedInputFlagException)
from argenta.command.flag.models import Flag, InputFlag, ValidationStatus
ParseFlagsResult = tuple[InputFlags, str | None, str | None]
@@ -16,10 +14,6 @@ ParseResult = tuple[str, InputFlags]
MIN_FLAG_PREFIX: str = "-"
PREFIX_TYPE = Literal["-", "--", "---"]
-DEFAULT_WITHOUT_FLAGS: Flags = Flags()
-DEFAULT_WITHOUT_ALIASES: set[Never] = set()
-
-DEFAULT_WITHOUT_INPUT_FLAGS: InputFlags = InputFlags()
class Command:
@@ -28,8 +22,8 @@ class Command:
trigger: str,
*,
description: str = "Some useful command",
- flags: Flag | Flags = DEFAULT_WITHOUT_FLAGS,
- aliases: set[str] | set[Never] = DEFAULT_WITHOUT_ALIASES,
+ flags: Flag | Flags | None = None,
+ aliases: Iterable[str] | None = None,
):
"""
Public. The command that can and should be registered in the Router
@@ -38,11 +32,16 @@ class Command:
:param flags: processed commands
:param aliases: string synonyms for the main trigger
"""
- pretty_flags = flags if isinstance(flags, Flags) else Flags([flags])
+ pretty_flags: Flags = (
+ flags if isinstance(flags, Flags)
+ else Flags([flags])
+ if flags is not None
+ else Flags()
+ )
self.registered_flags: Flags = pretty_flags
self.trigger: str = trigger
self.description: str = description
- self.aliases: set[str] | set[Never] = aliases
+ self.aliases: Iterable[str] | Iterable[Never] = aliases or set()
self._paired_string_entity_flag: dict[str, Flag] = {
flag.string_entity: flag for flag in pretty_flags
@@ -68,7 +67,7 @@ class InputCommand:
self,
trigger: str,
*,
- input_flags: InputFlag | InputFlags = DEFAULT_WITHOUT_INPUT_FLAGS,
+ input_flags: InputFlag | InputFlags | None = None,
):
"""
Private. The model of the input command, after parsing
@@ -81,6 +80,8 @@ class InputCommand:
input_flags
if isinstance(input_flags, InputFlags)
else InputFlags([input_flags])
+ if input_flags is not None
+ else InputFlags()
)
@classmethod
@@ -90,7 +91,14 @@ class InputCommand:
:param raw_command: raw input command
:return: model of the input command, after parsing as InputCommand
"""
- tokens = shlex.split(raw_command)
+ lexer = shlex.shlex(raw_command, posix=True)
+ lexer.whitespace_split = True
+ lexer.commenters = ""
+
+ try:
+ tokens = list(lexer)
+ except ValueError as e:
+ raise UnprocessedInputFlagException from e
if not tokens:
raise EmptyInputCommandException
diff --git a/src/argenta/orchestrator/entity.py b/src/argenta/orchestrator/entity.py
index c6d5193..ad38754 100644
--- a/src/argenta/orchestrator/entity.py
+++ b/src/argenta/orchestrator/entity.py
@@ -39,4 +39,4 @@ class Orchestrator:
)
setup_dishka(app, container, auto_inject=self._auto_inject_handlers)
- app.run_polling()
+ app._run_polling()
diff --git a/src/argenta/response/entity.py b/src/argenta/response/entity.py
index 985e864..09a0296 100644
--- a/src/argenta/response/entity.py
+++ b/src/argenta/response/entity.py
@@ -2,7 +2,7 @@ __all__ = ["Response"]
from dishka import Container
-from argenta.command.flag.flags.models import InputFlags
+from argenta.command import InputFlags
from argenta.response.status import ResponseStatus
EMPTY_INPUT_FLAGS: InputFlags = InputFlags()
diff --git a/src/argenta/router/entity.py b/src/argenta/router/entity.py
index c32695a..9a6de00 100644
--- a/src/argenta/router/entity.py
+++ b/src/argenta/router/entity.py
@@ -6,9 +6,8 @@ from typing import Callable
from rich.console import Console
from argenta.app.protocols import HandlerFunc
-from argenta.command import Command, InputCommand
+from argenta.command import Command, InputCommand, InputFlags
from argenta.command.flag import ValidationStatus
-from argenta.command.flag.flags import InputFlags
from argenta.response import Response, ResponseStatus
from argenta.router.command_handler.entity import CommandHandler, CommandHandlers
from argenta.router.exceptions import (RepeatedAliasNameException,
@@ -36,7 +35,7 @@ class Router:
:return: None
"""
self.title: str = title
- self.disable_redirect_stdout: bool = disable_redirect_stdout
+ self.is_redirect_stdout_disabled: bool = disable_redirect_stdout
self.command_handlers: CommandHandlers = CommandHandlers()
self.aliases: set[str] = set()
@@ -57,7 +56,7 @@ class Router:
self._update_routing_keys(redefined_command)
def decorator(func: HandlerFunc) -> HandlerFunc:
- _validate_func_args(func)
+ self._validate_func_args(func)
self.command_handlers.add_handler(CommandHandler(func, redefined_command))
return func
@@ -117,7 +116,7 @@ class Router:
handle_command = command_handler.handled_command
if handle_command.registered_flags.flags:
if input_command_flags.flags:
- response: Response = _structuring_input_flags(handle_command, input_command_flags)
+ response: Response = self._structuring_input_flags(handle_command, input_command_flags)
command_handler.handling(response)
else:
response = Response(ResponseStatus.ALL_FLAGS_VALID)
@@ -134,53 +133,53 @@ class Router:
response = Response(ResponseStatus.ALL_FLAGS_VALID)
command_handler.handling(response)
+ @staticmethod
+ def _structuring_input_flags(handled_command: Command, input_flags: InputFlags) -> Response:
+ """
+ Private. Validates flags of input command
+ :param handled_command: entity of the handled command
+ :param input_flags:
+ :return: entity of response as Response
+ """
+ invalid_value_flags, undefined_flags = False, False
-def _structuring_input_flags(handled_command: Command, input_flags: InputFlags) -> Response:
- """
- Private. Validates flags of input command
- :param handled_command: entity of the handled command
- :param input_flags:
- :return: entity of response as Response
- """
- invalid_value_flags, undefined_flags = False, False
+ for flag in input_flags:
+ flag_status: ValidationStatus = handled_command.validate_input_flag(flag)
+ flag.status = flag_status
+ if flag_status == ValidationStatus.INVALID:
+ invalid_value_flags = True
+ elif flag_status == ValidationStatus.UNDEFINED:
+ undefined_flags = True
- for flag in input_flags:
- flag_status: ValidationStatus = handled_command.validate_input_flag(flag)
- flag.status = flag_status
- if flag_status == ValidationStatus.INVALID:
- invalid_value_flags = True
- elif flag_status == ValidationStatus.UNDEFINED:
- undefined_flags = True
-
- status = ResponseStatus.from_flags(
- has_invalid_value_flags=invalid_value_flags,
- has_undefined_flags=undefined_flags
- )
-
- return Response(status=status, input_flags=input_flags)
-
-
-def _validate_func_args(func: HandlerFunc) -> None:
- """
- Private. Validates the arguments of the handler
- :param func: entity of the handler func
- :return: None if func is valid else raise exception
- """
- transferred_args = getfullargspec(func).args
- if len(transferred_args) == 0:
- raise RequiredArgumentNotPassedException()
-
- response_arg: str = transferred_args[0]
- func_annotations: dict[str, None] = get_annotations(func)
-
- response_arg_annotation = func_annotations.get(response_arg)
-
- if response_arg_annotation is not None and response_arg_annotation is not Response:
- source_line: int = getsourcelines(func)[1]
- Console().print(
- f'\nFile "{getsourcefile(func)}", line {source_line}\n[b red]WARNING:[/b red] [i]The typehint '
- + f"of argument([green]{response_arg}[/green]) passed to the handler must be [/i][bold blue]{Response}[/bold blue],"
- + f" [i]but[/i] [bold blue]{response_arg_annotation}[/bold blue] [i]is specified[/i]",
- highlight=False,
+ status = ResponseStatus.from_flags(
+ has_invalid_value_flags=invalid_value_flags,
+ has_undefined_flags=undefined_flags
)
+
+ return Response(status=status, input_flags=input_flags)
+
+ @staticmethod
+ def _validate_func_args(func: HandlerFunc) -> None:
+ """
+ Private. Validates the arguments of the handler
+ :param func: entity of the handler func
+ :return: None if func is valid else raise exception
+ """
+ transferred_args = getfullargspec(func).args
+ if len(transferred_args) == 0:
+ raise RequiredArgumentNotPassedException()
+
+ response_arg: str = transferred_args[0]
+ func_annotations: dict[str, None] = get_annotations(func)
+
+ response_arg_annotation = func_annotations.get(response_arg)
+
+ if response_arg_annotation is not None and response_arg_annotation is not Response:
+ source_line: int = getsourcelines(func)[1]
+ Console().print(
+ f'\nFile "{getsourcefile(func)}", line {source_line}\n[b red]WARNING:[/b red] [i]The typehint '
+ + f"of argument([green]{response_arg}[/green]) passed to the handler must be [/i][bold blue]{Response}[/bold blue],"
+ + f" [i]but[/i] [bold blue]{response_arg_annotation}[/bold blue] [i]is specified[/i]",
+ highlight=False,
+ )
\ No newline at end of file
diff --git a/tests/system_tests/test_system_handling_non_standard_behavior.py b/tests/system_tests/test_system_handling_non_standard_behavior.py
index 696d97f..4a65aba 100644
--- a/tests/system_tests/test_system_handling_non_standard_behavior.py
+++ b/tests/system_tests/test_system_handling_non_standard_behavior.py
@@ -5,8 +5,7 @@ from collections.abc import Iterator
import pytest
from argenta import App, Orchestrator, Router
-from argenta.command import Command, PredefinedFlags
-from argenta.command.flag.flags.models import Flags
+from argenta.command import Command, PredefinedFlags, Flags
from argenta.command.flag.models import ValidationStatus
from argenta.response import Response
@@ -36,7 +35,7 @@ def test_empty_input_triggers_empty_command_handler(monkeypatch: pytest.MonkeyPa
def test(_response: Response) -> None: # pyright: ignore[reportUnusedFunction]
print('test command')
- app = App(override_system_messages=True, print_func=print)
+ app = App(override_system_messages=True, printer=print)
app.include_router(router)
app.set_empty_command_handler(lambda: print('Empty input command'))
orchestrator.start_polling(app)
@@ -62,7 +61,7 @@ def test_unknown_command_triggers_unknown_command_handler(monkeypatch: pytest.Mo
def test(_response: Response) -> None: # pyright: ignore[reportUnusedFunction]
print('test command')
- app = App(override_system_messages=True, print_func=print)
+ app = App(override_system_messages=True, printer=print)
app.include_router(router)
app.set_unknown_command_handler(lambda command: print(f'Unknown command: {command.trigger}'))
orchestrator.start_polling(app)
@@ -83,7 +82,7 @@ def test_mixed_valid_and_unknown_commands_handled_correctly(monkeypatch: pytest.
def test(_response: Response) -> None: # pyright: ignore[reportUnusedFunction]
print('test command')
- app = App(override_system_messages=True, print_func=print)
+ app = App(override_system_messages=True, printer=print)
app.include_router(router)
app.set_unknown_command_handler(lambda command: print(f'Unknown command: {command.trigger}'))
orchestrator.start_polling(app)
@@ -108,7 +107,7 @@ def test_multiple_commands_with_unknown_command_in_between(monkeypatch: pytest.M
def test1(_response: Response) -> None: # pyright: ignore[reportUnusedFunction]
print('more command')
- app = App(override_system_messages=True, print_func=print)
+ app = App(override_system_messages=True, printer=print)
app.include_router(router)
app.set_unknown_command_handler(lambda command: print(f'Unknown command: {command.trigger}'))
orchestrator.start_polling(app)
@@ -136,7 +135,7 @@ def test_unregistered_flag_without_value_is_accessible(monkeypatch: pytest.Monke
if undefined_flag and undefined_flag.status == ValidationStatus.UNDEFINED:
print(f'test command with undefined flag: {undefined_flag.string_entity}')
- app = App(override_system_messages=True, print_func=print)
+ app = App(override_system_messages=True, printer=print)
app.include_router(router)
orchestrator.start_polling(app)
@@ -160,7 +159,7 @@ def test_unregistered_flag_with_value_is_accessible(monkeypatch: pytest.MonkeyPa
else:
raise
- app = App(override_system_messages=True, print_func=print)
+ app = App(override_system_messages=True, printer=print)
app.include_router(router)
orchestrator.start_polling(app)
@@ -183,7 +182,7 @@ def test_registered_and_unregistered_flags_coexist(monkeypatch: pytest.MonkeyPat
if undefined_flag and undefined_flag.status == ValidationStatus.UNDEFINED:
print(f'connecting to host with flag: {undefined_flag.string_entity} {undefined_flag.input_value}')
- app = App(override_system_messages=True, print_func=print)
+ app = App(override_system_messages=True, printer=print)
app.include_router(router)
orchestrator.start_polling(app)
@@ -208,7 +207,7 @@ def test_flag_without_value_triggers_incorrect_syntax_handler(monkeypatch: pytes
def test(_response: Response) -> None: # pyright: ignore[reportUnusedFunction]
print('test command')
- app = App(override_system_messages=True, print_func=print)
+ app = App(override_system_messages=True, printer=print)
app.include_router(router)
app.set_incorrect_input_syntax_handler(lambda command: print(f'Incorrect flag syntax: "{command}"'))
orchestrator.start_polling(app)
@@ -234,7 +233,7 @@ def test_repeated_flags_trigger_repeated_flags_handler(monkeypatch: pytest.Monke
def test(_response: Response) -> None: # pyright: ignore[reportUnusedFunction]
print('test command')
- app = App(override_system_messages=True, print_func=print)
+ app = App(override_system_messages=True, printer=print)
app.include_router(router)
app.set_repeated_input_flags_handler(lambda command: print(f'Repeated input flags: "{command}"'))
orchestrator.start_polling(app)
diff --git a/tests/system_tests/test_system_handling_normal_behavior.py b/tests/system_tests/test_system_handling_normal_behavior.py
index dc231aa..e7bf0de 100644
--- a/tests/system_tests/test_system_handling_normal_behavior.py
+++ b/tests/system_tests/test_system_handling_normal_behavior.py
@@ -5,9 +5,8 @@ from collections.abc import Iterator
import pytest
from argenta import App, Orchestrator, Router
-from argenta.command import Command, PredefinedFlags
+from argenta.command import Command, PredefinedFlags, Flags
from argenta.command.flag import Flag
-from argenta.command.flag.flags import Flags
from argenta.command.flag.models import PossibleValues, ValidationStatus
from argenta.response import Response
@@ -37,7 +36,7 @@ def test_simple_command_executes_successfully(monkeypatch: pytest.MonkeyPatch, c
def test(_response: Response) -> None: # pyright: ignore[reportUnusedFunction]
print('test command')
- app = App(override_system_messages=True, print_func=print)
+ app = App(override_system_messages=True, printer=print)
app.include_router(router)
orchestrator.start_polling(app)
@@ -61,7 +60,7 @@ def test_two_commands_execute_sequentially(monkeypatch: pytest.MonkeyPatch, caps
def test2(_response: Response) -> None: # pyright: ignore[reportUnusedFunction]
print('some command')
- app = App(override_system_messages=True, print_func=print)
+ app = App(override_system_messages=True, printer=print)
app.include_router(router)
orchestrator.start_polling(app)
@@ -89,7 +88,7 @@ def test_three_commands_execute_sequentially(monkeypatch: pytest.MonkeyPatch, ca
def test2(_response: Response) -> None: # pyright: ignore[reportUnusedFunction]
print('more command')
- app = App(override_system_messages=True, print_func=print)
+ app = App(override_system_messages=True, printer=print)
app.include_router(router)
orchestrator.start_polling(app)
@@ -117,7 +116,7 @@ def test_custom_flag_without_value_is_recognized(monkeypatch: pytest.MonkeyPatch
if valid_flag and valid_flag.status == ValidationStatus.VALID:
print(f'\nhelp for {valid_flag.name} flag\n')
- app = App(override_system_messages=True, print_func=print)
+ app = App(override_system_messages=True, printer=print)
app.include_router(router)
orchestrator.start_polling(app)
@@ -140,7 +139,7 @@ def test_custom_flag_with_regex_validation_accepts_valid_value(monkeypatch: pyte
if valid_flag and valid_flag.status == ValidationStatus.VALID:
print(f'flag value for {valid_flag.name} flag : {valid_flag.input_value}')
- app = App(override_system_messages=True, repeat_command_groups_printing=True, print_func=print)
+ app = App(override_system_messages=True, repeat_command_groups_printing=True, printer=print)
app.include_router(router)
orchestrator.start_polling(app)
@@ -168,7 +167,7 @@ def test_predefined_short_help_flag_is_recognized(monkeypatch: pytest.MonkeyPatc
if valid_flag and valid_flag.status == ValidationStatus.VALID:
print(f'help for {valid_flag.name} flag')
- app = App(override_system_messages=True, print_func=print)
+ app = App(override_system_messages=True, printer=print)
app.include_router(router)
orchestrator.start_polling(app)
@@ -191,7 +190,7 @@ def test_predefined_info_flag_is_recognized(monkeypatch: pytest.MonkeyPatch, cap
if valid_flag and valid_flag.status == ValidationStatus.VALID:
print('info about test command')
- app = App(override_system_messages=True, print_func=print)
+ app = App(override_system_messages=True, printer=print)
app.include_router(router)
orchestrator.start_polling(app)
@@ -214,7 +213,7 @@ def test_predefined_host_flag_with_value_is_recognized(monkeypatch: pytest.Monke
if valid_flag and valid_flag.status == ValidationStatus.VALID:
print(f'connecting to host {valid_flag.input_value}')
- app = App(override_system_messages=True, print_func=print)
+ app = App(override_system_messages=True, printer=print)
app.include_router(router)
orchestrator.start_polling(app)
@@ -243,7 +242,7 @@ def test_two_predefined_flags_are_recognized_together(monkeypatch: pytest.Monkey
if (host_flag and host_flag.status == ValidationStatus.VALID) and (port_flag and port_flag.status == ValidationStatus.VALID):
print(f'connecting to host {host_flag.input_value} and port {port_flag.input_value}')
- app = App(override_system_messages=True, print_func=print)
+ app = App(override_system_messages=True, printer=print)
app.include_router(router)
orchestrator.start_polling(app)
diff --git a/tests/unit_tests/test_app.py b/tests/unit_tests/test_app.py
index 17dd8a0..3c14e22 100644
--- a/tests/unit_tests/test_app.py
+++ b/tests/unit_tests/test_app.py
@@ -3,7 +3,6 @@ import pytest
from pytest import CaptureFixture
from argenta.app import App
-from argenta.app.dividing_line import DynamicDividingLine, StaticDividingLine
from argenta.app.protocols import DescriptionMessageGenerator, NonStandardBehaviorHandler
from argenta.command.models import Command, InputCommand
from argenta.response import Response
@@ -18,26 +17,31 @@ from argenta.router import Router
def test_default_exit_command_lowercase_q_is_recognized() -> None:
app = App()
+ app._setup_system_router()
assert app._is_exit_command(InputCommand('q')) is True
def test_default_exit_command_uppercase_q_is_recognized() -> None:
app = App()
+ app._setup_system_router()
assert app._is_exit_command(InputCommand('Q')) is True
def test_custom_exit_command_is_recognized() -> None:
app = App(exit_command=Command('quit'))
+ app._setup_system_router()
assert app._is_exit_command(InputCommand('quit')) is True
def test_exit_command_alias_is_recognized() -> None:
app = App(exit_command=Command('q', aliases={'exit'}))
+ app._setup_system_router()
assert app._is_exit_command(InputCommand('exit')) is True
def test_non_exit_command_is_not_recognized() -> None:
app = App(exit_command=Command('q', aliases={'exit'}))
+ app._setup_system_router()
assert app._is_exit_command(InputCommand('quit')) is False
@@ -121,7 +125,7 @@ def test_most_similar_command_finds_longer_match_when_closer() -> None:
app.include_routers(router)
app._pre_cycle_setup()
- assert app._most_similar_command('command_') == 'command_other'
+ assert app._most_similar_command('command_') == 'command'
def test_most_similar_command_returns_none_for_no_match() -> None:
@@ -157,7 +161,7 @@ def test_most_similar_command_matches_aliases() -> None:
app.include_routers(router)
app._pre_cycle_setup()
- assert app._most_similar_command('othe') == 'other_name'
+ assert app._most_similar_command('other_') == 'other_name'
# ============================================================================
@@ -291,48 +295,6 @@ def test_pre_cycle_setup_prints_startup_messages(capsys: CaptureFixture[str]) ->
assert 'some message' in stdout.out
-# ============================================================================
-# Tests for framed text printing
-# ============================================================================
-
-
-def test_print_framed_text_with_static_dividing_line(capsys: CaptureFixture[str]) -> None:
- app = App(override_system_messages=True, dividing_line=StaticDividingLine(length=5))
- app._print_framed_text('test')
-
- captured = capsys.readouterr()
-
- assert '\n-----\n\ntest\n\n-----\n' in captured.out
-
-
-def test_print_framed_text_with_dynamic_dividing_line_short_text(capsys: CaptureFixture[str]) -> None:
- app = App(override_system_messages=True, dividing_line=DynamicDividingLine())
- app._print_framed_text('some long test')
-
- captured = capsys.readouterr()
-
- assert '\n--------------\n\nsome long test\n\n--------------\n' in captured.out
-
-
-def test_print_framed_text_with_dynamic_dividing_line_long_text(capsys: CaptureFixture[str]) -> None:
- app = App(override_system_messages=True, dividing_line=DynamicDividingLine())
- app._print_framed_text('test as test as test')
-
- captured = capsys.readouterr()
-
- assert '\n' + '-'*20 + '\n\ntest as test as test\n\n' + '-'*20 + '\n' in captured.out
-
-
-def test_print_framed_text_with_unsupported_dividing_line_raises_error() -> None:
- class OtherDividingLine:
- pass
-
- app = App(override_system_messages=True, dividing_line=OtherDividingLine()) # pyright: ignore[reportArgumentType]
-
- with pytest.raises(NotImplementedError):
- app._print_framed_text('some long test')
-
-
# ============================================================================
# Tests for handler configuration
# ============================================================================
@@ -343,7 +305,7 @@ def test_set_description_message_pattern_stores_generator() -> None:
descr_gen: DescriptionMessageGenerator = lambda command, description: command + '-+-' + description
app.set_description_message_pattern(descr_gen)
- assert app._description_message_gen is descr_gen
+ assert app._description_message_generator is descr_gen
def test_set_exit_command_handler_stores_handler() -> None:
@@ -354,22 +316,6 @@ def test_set_exit_command_handler_stores_handler() -> None:
assert app._exit_command_handler is handler
-# ============================================================================
-# Tests for default view setup
-# ============================================================================
-
-
-def test_setup_default_view_formats_prompt() -> None:
- app = App(prompt='>>')
- assert app._prompt == '>>'
-
-
-def test_setup_default_view_sets_default_unknown_command_handler() -> None:
- app = App()
- app._setup_default_view()
- assert app._unknown_command_handler(InputCommand('nonexists')) is None
-
-
# ============================================================================
# Tests for command processing
# ============================================================================
@@ -672,11 +618,17 @@ def test_app_handlers_work_with_multiple_routers() -> None:
app.set_unknown_command_handler(custom_handler)
- # Both commands should be known
assert not app._is_unknown_command(InputCommand('cmd1'))
assert not app._is_unknown_command(InputCommand('cmd2'))
- # Unknown command should trigger handler
assert app._is_unknown_command(InputCommand('unknown'))
app._unknown_command_handler(InputCommand('unknown'))
assert call_tracker['called']
+
+
+def test_process_exist_and_valid_command_raises_runtime_error_when_router_not_found() -> None:
+ app = App()
+ app._pre_cycle_setup()
+
+ with pytest.raises(RuntimeError, match="Router for 'nonexistent' not found. Panic!"):
+ app._process_exist_and_valid_command(InputCommand('nonexistent'))
diff --git a/tests/unit_tests/test_autocompleter.py b/tests/unit_tests/test_autocompleter.py
index b7022a3..c900a2c 100644
--- a/tests/unit_tests/test_autocompleter.py
+++ b/tests/unit_tests/test_autocompleter.py
@@ -1,4 +1,12 @@
+import os
+import sys
+import tempfile
+from typing import Any, Callable
+from unittest.mock import MagicMock, patch
+
import pytest
+from prompt_toolkit import HTML
+from prompt_toolkit.completion import CompleteEvent
from prompt_toolkit.document import Document
from prompt_toolkit.history import InMemoryHistory
@@ -75,7 +83,7 @@ def test_history_completer_returns_matching_commands() -> None:
completer = HistoryCompleter(history, {"status"})
doc = Document("sta")
- completions = list(completer.get_completions(doc, None))
+ completions = list(completer.get_completions(doc, CompleteEvent()))
completion_texts = [c.text for c in completions]
assert "start server" in completion_texts
@@ -91,7 +99,7 @@ def test_history_completer_returns_all_when_empty_input() -> None:
completer = HistoryCompleter(history, {"status"})
doc = Document("")
- completions = list(completer.get_completions(doc, None))
+ completions = list(completer.get_completions(doc, CompleteEvent()))
completion_texts = [c.text for c in completions]
assert len(completion_texts) == 3
@@ -107,7 +115,7 @@ def test_history_completer_returns_empty_when_no_matches() -> None:
completer = HistoryCompleter(history, {"stop"})
doc = Document("xyz")
- completions = list(completer.get_completions(doc, None))
+ completions = list(completer.get_completions(doc, CompleteEvent()))
assert len(completions) == 0
@@ -119,7 +127,7 @@ def test_history_completer_deduplicates_commands() -> None:
completer = HistoryCompleter(history, {"start"})
doc = Document("sta")
- completions = list(completer.get_completions(doc, None))
+ completions = list(completer.get_completions(doc, CompleteEvent()))
assert len(completions) == 1
@@ -132,7 +140,7 @@ def test_history_completer_sorts_results() -> None:
completer = HistoryCompleter(history, set())
doc = Document("st")
- completions = list(completer.get_completions(doc, None))
+ completions = list(completer.get_completions(doc, CompleteEvent()))
completion_texts = [c.text for c in completions]
assert completion_texts == ["start", "status", "stop"]
@@ -160,3 +168,311 @@ def test_find_common_prefix_with_empty_list() -> None:
matches: list[str] = []
prefix = HistoryCompleter._find_common_prefix(matches)
assert prefix == ""
+
+
+def test_command_lexer_handles_out_of_range_lineno() -> None:
+ lexer = CommandLexer({"start", "stop"})
+ doc = Document("start")
+ get_line_tokens = lexer.lex_document(doc)
+ tokens = get_line_tokens(1)
+ assert tokens == []
+
+
+def test_history_completer_returns_early_when_no_matches() -> None:
+ history = InMemoryHistory()
+ completer = HistoryCompleter(history, {"start", "stop"})
+ doc = Document("xyz")
+
+ result = completer.get_completions(doc, CompleteEvent())
+ completions = list(result)
+ assert completions == []
+
+
+def test_autocompleter_initial_setup_with_commands() -> None:
+ completer = AutoCompleter()
+
+ with patch.object(sys.stdin, 'isatty', return_value=True), \
+ patch('argenta.app.autocompleter.entity.PromptSession') as mock_session:
+ completer.initial_setup({"start", "stop", "status"})
+
+ assert completer._session is not None
+ assert completer._fallback_mode is False
+ mock_session.assert_called_once()
+
+
+def test_autocompleter_initial_setup_with_history_file() -> None:
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.txt') as f:
+ history_file = f.name
+
+ try:
+ completer = AutoCompleter(history_filename=history_file)
+
+ with patch.object(sys.stdin, 'isatty', return_value=True), \
+ patch('argenta.app.autocompleter.entity.PromptSession'), \
+ patch('argenta.app.autocompleter.entity.ThreadedHistory') as mock_threaded_history:
+ completer.initial_setup({"start", "stop"})
+
+ assert completer._session is not None
+ assert completer._fallback_mode is False
+ mock_threaded_history.assert_called_once()
+ finally:
+ if os.path.exists(history_file):
+ os.unlink(history_file)
+
+
+def test_autocompleter_initial_setup_without_history_file() -> None:
+ completer = AutoCompleter(history_filename=None)
+
+ with patch.object(sys.stdin, 'isatty', return_value=True), \
+ patch('argenta.app.autocompleter.entity.PromptSession'), \
+ patch('argenta.app.autocompleter.entity.InMemoryHistory') as mock_in_memory:
+ completer.initial_setup({"start", "stop"})
+
+ assert completer._session is not None
+ assert completer._fallback_mode is False
+ mock_in_memory.assert_called_once()
+
+
+def test_autocompleter_initial_setup_with_custom_autocomplete_button() -> None:
+ completer = AutoCompleter(autocomplete_button="c-space")
+
+ with patch.object(sys.stdin, 'isatty', return_value=True), \
+ patch('argenta.app.autocompleter.entity.PromptSession'):
+ completer.initial_setup({"start", "stop"})
+
+ assert completer._session is not None
+ assert completer.autocomplete_button == "c-space"
+
+
+def test_autocompleter_initial_setup_without_auto_suggestions() -> None:
+ completer = AutoCompleter(auto_suggestions=False)
+
+ with patch.object(sys.stdin, 'isatty', return_value=True), \
+ patch('argenta.app.autocompleter.entity.PromptSession') as mock_session:
+ completer.initial_setup({"start", "stop"})
+
+ assert completer._session is not None
+ call_kwargs = mock_session.call_args[1]
+ assert call_kwargs['auto_suggest'] is None
+
+
+def test_autocompleter_initial_setup_without_command_highlighting() -> None:
+ completer = AutoCompleter(command_highlighting=False)
+
+ with patch.object(sys.stdin, 'isatty', return_value=True), \
+ patch('argenta.app.autocompleter.entity.PromptSession') as mock_session:
+ completer.initial_setup({"start", "stop"})
+
+ assert completer._session is not None
+ call_kwargs = mock_session.call_args[1]
+ assert call_kwargs['style'] is None
+ assert call_kwargs['lexer'] is None
+
+
+def test_autocompleter_key_binding_handler_with_complete_state() -> None:
+ completer = AutoCompleter()
+
+ captured_handler: Callable[[Any], None] | None = None
+
+ def capture_kb_add(key: str) -> Callable[[Callable[[Any], None]], Callable[[Any], None]]:
+ def decorator(func: Callable[[Any], None]) -> Callable[[Any], None]:
+ nonlocal captured_handler
+ captured_handler = func
+ return func
+ return decorator
+
+ with patch.object(sys.stdin, 'isatty', return_value=True), \
+ patch('argenta.app.autocompleter.entity.PromptSession'), \
+ patch('argenta.app.autocompleter.entity.KeyBindings') as mock_kb_class:
+
+ mock_kb = MagicMock()
+ mock_kb.add = capture_kb_add
+ mock_kb_class.return_value = mock_kb
+
+ completer.initial_setup({"start", "stop"})
+
+ assert captured_handler is not None
+
+ mock_event = MagicMock()
+ mock_buff = MagicMock()
+ mock_buff.complete_state = True
+ mock_event.app.current_buffer = mock_buff
+
+ captured_handler(mock_event)
+
+ mock_buff.complete_next.assert_called_once()
+
+
+def test_autocompleter_key_binding_handler_no_completions() -> None:
+ completer = AutoCompleter()
+
+ captured_handler: Callable[[Any], None] | None = None
+
+ def capture_kb_add(key: str) -> Callable[[Callable[[Any], None]], Callable[[Any], None]]:
+ def decorator(func: Callable[[Any], None]) -> Callable[[Any], None]:
+ nonlocal captured_handler
+ captured_handler = func
+ return func
+ return decorator
+
+ with patch.object(sys.stdin, 'isatty', return_value=True), \
+ patch('argenta.app.autocompleter.entity.PromptSession'), \
+ patch('argenta.app.autocompleter.entity.KeyBindings') as mock_kb_class:
+
+ mock_kb = MagicMock()
+ mock_kb.add = capture_kb_add
+ mock_kb_class.return_value = mock_kb
+
+ completer.initial_setup({"start", "stop"})
+
+ mock_event = MagicMock()
+ mock_buff = MagicMock()
+ mock_buff.complete_state = False
+ mock_completer = MagicMock()
+ mock_completer.get_completions.return_value = iter([])
+ mock_buff.completer = mock_completer
+ mock_event.app.current_buffer = mock_buff
+
+ assert captured_handler is not None
+ captured_handler(mock_event)
+
+ mock_buff.start_completion.assert_not_called()
+ mock_buff.apply_completion.assert_not_called()
+
+
+def test_autocompleter_key_binding_handler_single_completion() -> None:
+ completer = AutoCompleter()
+
+ captured_handler: Callable[[Any], None] | None = None
+
+ def capture_kb_add(key: str) -> Callable[[Callable[[Any], None]], Callable[[Any], None]]:
+ def decorator(func: Callable[[Any], None]) -> Callable[[Any], None]:
+ nonlocal captured_handler
+ captured_handler = func
+ return func
+ return decorator
+
+ with patch.object(sys.stdin, 'isatty', return_value=True), \
+ patch('argenta.app.autocompleter.entity.PromptSession'), \
+ patch('argenta.app.autocompleter.entity.KeyBindings') as mock_kb_class:
+
+ mock_kb = MagicMock()
+ mock_kb.add = capture_kb_add
+ mock_kb_class.return_value = mock_kb
+
+ completer.initial_setup({"start", "stop"})
+
+ mock_event = MagicMock()
+ mock_buff = MagicMock()
+ mock_buff.complete_state = False
+ mock_completion = MagicMock()
+ mock_completer = MagicMock()
+ mock_completer.get_completions.return_value = iter([mock_completion])
+ mock_buff.completer = mock_completer
+ mock_event.app.current_buffer = mock_buff
+
+ assert captured_handler is not None
+ captured_handler(mock_event)
+
+ mock_buff.apply_completion.assert_called_once_with(mock_completion)
+ mock_buff.start_completion.assert_not_called()
+
+
+def test_autocompleter_key_binding_handler_multiple_completions() -> None:
+ completer = AutoCompleter()
+
+ captured_handler: Callable[[Any], None] | None = None
+
+ def capture_kb_add(key: str) -> Callable[[Callable[[Any], None]], Callable[[Any], None]]:
+ def decorator(func: Callable[[Any], None]) -> Callable[[Any], None]:
+ nonlocal captured_handler
+ captured_handler = func
+ return func
+ return decorator
+
+ with patch.object(sys.stdin, 'isatty', return_value=True), \
+ patch('argenta.app.autocompleter.entity.PromptSession'), \
+ patch('argenta.app.autocompleter.entity.KeyBindings') as mock_kb_class:
+
+ mock_kb = MagicMock()
+ mock_kb.add = capture_kb_add
+ mock_kb_class.return_value = mock_kb
+
+ completer.initial_setup({"start", "stop"})
+
+ mock_event = MagicMock()
+ mock_buff = MagicMock()
+ mock_buff.complete_state = False
+ mock_completion1 = MagicMock()
+ mock_completion2 = MagicMock()
+ mock_completer = MagicMock()
+ mock_completer.get_completions.return_value = iter([mock_completion1, mock_completion2])
+ mock_buff.completer = mock_completer
+ mock_event.app.current_buffer = mock_buff
+
+ assert captured_handler is not None
+ captured_handler(mock_event)
+
+ mock_buff.start_completion.assert_called_once_with(select_first=False)
+ mock_buff.apply_completion.assert_not_called()
+
+
+def test_autocompleter_prompt_in_fallback_mode_with_string() -> None:
+ completer = AutoCompleter()
+
+ with patch.object(sys.stdin, 'isatty', return_value=False):
+ completer.initial_setup({"start", "stop"})
+
+ assert completer._fallback_mode is True
+
+ with patch('builtins.input', return_value='test input'):
+ result = completer.prompt(">>> ")
+
+ assert result == 'test input'
+
+
+def test_autocompleter_prompt_in_fallback_mode_with_html() -> None:
+ completer = AutoCompleter()
+
+ with patch.object(sys.stdin, 'isatty', return_value=False):
+ completer.initial_setup({"start", "stop"})
+
+ assert completer._fallback_mode is True
+
+ with patch('builtins.input', return_value='test input'):
+ result = completer.prompt(HTML(">>> "))
+
+ assert result == 'test input'
+
+
+def test_autocompleter_prompt_with_html_in_normal_mode() -> None:
+ completer = AutoCompleter()
+
+ mock_session = MagicMock()
+ mock_session.prompt.return_value = 'test result'
+ completer._session = mock_session
+ completer._fallback_mode = False
+
+ html_prompt = HTML(">>> ")
+ result = completer.prompt(html_prompt)
+
+ assert result == 'test result'
+ mock_session.prompt.assert_called_once()
+ call_args = mock_session.prompt.call_args
+ assert call_args[0][0] == html_prompt
+
+
+def test_autocompleter_prompt_with_string_in_normal_mode() -> None:
+ completer = AutoCompleter()
+
+ mock_session = MagicMock()
+ mock_session.prompt.return_value = 'test result'
+ completer._session = mock_session
+ completer._fallback_mode = False
+
+ result = completer.prompt(">>> ")
+
+ assert result == 'test result'
+ mock_session.prompt.assert_called_once()
+ call_args = mock_session.prompt.call_args
+ assert isinstance(call_args[0][0], HTML)
diff --git a/tests/unit_tests/test_behavior_handlers.py b/tests/unit_tests/test_behavior_handlers.py
new file mode 100644
index 0000000..7dcb843
--- /dev/null
+++ b/tests/unit_tests/test_behavior_handlers.py
@@ -0,0 +1,229 @@
+import pytest
+from unittest.mock import Mock
+
+from argenta.app.behavior_handlers.models import BehaviorHandlersFabric, BehaviorHandlersSettersMixin
+from argenta.app.presentation.renderers import PlainRenderer
+from argenta.command.models import InputCommand
+from argenta.response import Response, ResponseStatus
+
+
+@pytest.fixture
+def mock_printer() -> Mock:
+ return Mock()
+
+
+@pytest.fixture
+def mock_most_similar_getter() -> Mock:
+ return Mock(return_value="similar_cmd")
+
+
+@pytest.fixture
+def behavior_fabric(mock_printer: Mock, mock_most_similar_getter: Mock) -> BehaviorHandlersFabric:
+ renderer = PlainRenderer()
+ return BehaviorHandlersFabric(mock_printer, renderer, mock_most_similar_getter)
+
+
+class TestBehaviorHandlersFabric:
+ def test_initialization(self, mock_printer: Mock, mock_most_similar_getter: Mock):
+ renderer = PlainRenderer()
+ fabric = BehaviorHandlersFabric(mock_printer, renderer, mock_most_similar_getter)
+
+ assert fabric._printer == mock_printer
+ assert fabric._renderer == renderer
+ assert fabric._most_similar_command_getter == mock_most_similar_getter
+
+ def test_generate_incorrect_input_syntax_handler(self, behavior_fabric: BehaviorHandlersFabric, mock_printer: Mock):
+ handler = behavior_fabric.generate_incorrect_input_syntax_handler()
+
+ handler("bad --flag")
+
+ mock_printer.assert_called_once()
+ call_arg = mock_printer.call_args[0][0]
+ assert "Incorrect flag syntax" in call_arg
+ assert "bad --flag" in call_arg
+
+ def test_generate_repeated_input_flags_handler(self, behavior_fabric: BehaviorHandlersFabric, mock_printer: Mock):
+ handler = behavior_fabric.generate_repeated_input_flags_handler()
+
+ handler("cmd --flag --flag")
+
+ mock_printer.assert_called_once()
+ call_arg = mock_printer.call_args[0][0]
+ assert "Repeated input flags" in call_arg
+ assert "cmd --flag --flag" in call_arg
+
+ def test_generate_empty_input_command_handler(self, behavior_fabric: BehaviorHandlersFabric, mock_printer: Mock):
+ handler = behavior_fabric.generate_empty_input_command_handler()
+
+ handler()
+
+ mock_printer.assert_called_once()
+ call_arg = mock_printer.call_args[0][0]
+ assert "Empty input command" in call_arg
+
+ def test_generate_unknown_command_handler(self, behavior_fabric: BehaviorHandlersFabric, mock_printer: Mock, mock_most_similar_getter: Mock):
+ handler = behavior_fabric.generate_unknown_command_handler()
+
+ input_command = InputCommand("unknown")
+ handler(input_command)
+
+ mock_most_similar_getter.assert_called_once_with("unknown")
+ mock_printer.assert_called_once()
+ call_arg = mock_printer.call_args[0][0]
+ assert "Unknown command" in call_arg
+ assert "unknown" in call_arg
+ assert "similar_cmd" in call_arg
+
+ def test_generate_unknown_command_handler_no_similar(self, mock_printer: Mock):
+ renderer = PlainRenderer()
+ most_similar_getter = Mock(return_value=None)
+ fabric = BehaviorHandlersFabric(mock_printer, renderer, most_similar_getter)
+
+ handler = fabric.generate_unknown_command_handler()
+ input_command = InputCommand("unknown")
+ handler(input_command)
+
+ most_similar_getter.assert_called_once_with("unknown")
+ mock_printer.assert_called_once()
+ call_arg = mock_printer.call_args[0][0]
+ assert "Unknown command" in call_arg
+ assert "unknown" in call_arg
+ assert "most similar" not in call_arg
+
+ def test_generate_exit_command_handler(self, behavior_fabric: BehaviorHandlersFabric, mock_printer: Mock):
+ handler = behavior_fabric.generate_exit_command_handler("Goodbye!")
+
+ response = Response(ResponseStatus.ALL_FLAGS_VALID)
+ handler(response)
+
+ mock_printer.assert_called_once_with("Goodbye!")
+
+ def test_generate_description_message_generator(self, behavior_fabric: BehaviorHandlersFabric):
+ generator = behavior_fabric.generate_description_message_generator()
+
+ result = generator("test", "Test command")
+
+ assert "test" in result
+ assert "Test command" in result
+
+
+class TestBehaviorHandlersSettersMixin:
+ def test_initialization(self):
+ desc_gen = lambda cmd, desc: f"{cmd}: {desc}"
+ incorrect_handler = lambda raw: None
+ repeated_handler = lambda raw: None
+ empty_handler = lambda: None
+ unknown_handler = lambda cmd: None
+ exit_handler = lambda resp: None
+
+ mixin = BehaviorHandlersSettersMixin(
+ desc_gen,
+ incorrect_handler,
+ repeated_handler,
+ empty_handler,
+ unknown_handler,
+ exit_handler
+ )
+
+ assert mixin._description_message_generator == desc_gen
+ assert mixin._incorrect_input_syntax_handler == incorrect_handler
+ assert mixin._repeated_input_flags_handler == repeated_handler
+ assert mixin._empty_input_command_handler == empty_handler
+ assert mixin._unknown_command_handler == unknown_handler
+ assert mixin._exit_command_handler == exit_handler
+
+ def test_set_description_message_pattern(self):
+ initial_gen = lambda cmd, desc: f"{cmd}: {desc}"
+ mixin = BehaviorHandlersSettersMixin(
+ initial_gen,
+ lambda raw: None,
+ lambda raw: None,
+ lambda: None,
+ lambda cmd: None,
+ lambda resp: None
+ )
+
+ new_gen = lambda cmd, desc: f"{cmd} -> {desc}"
+ mixin.set_description_message_pattern(new_gen)
+
+ assert mixin._description_message_generator == new_gen
+
+ def test_set_incorrect_input_syntax_handler(self):
+ initial_handler = lambda raw: None
+ mixin = BehaviorHandlersSettersMixin(
+ lambda cmd, desc: f"{cmd}: {desc}",
+ initial_handler,
+ lambda raw: None,
+ lambda: None,
+ lambda cmd: None,
+ lambda resp: None
+ )
+
+ new_handler = lambda raw: print(f"Error: {raw}")
+ mixin.set_incorrect_input_syntax_handler(new_handler)
+
+ assert mixin._incorrect_input_syntax_handler == new_handler
+
+ def test_set_repeated_input_flags_handler(self):
+ initial_handler = lambda raw: None
+ mixin = BehaviorHandlersSettersMixin(
+ lambda cmd, desc: f"{cmd}: {desc}",
+ lambda raw: None,
+ initial_handler,
+ lambda: None,
+ lambda cmd: None,
+ lambda resp: None
+ )
+
+ new_handler = lambda raw: print(f"Repeated: {raw}")
+ mixin.set_repeated_input_flags_handler(new_handler)
+
+ assert mixin._repeated_input_flags_handler == new_handler
+
+ def test_set_unknown_command_handler(self):
+ initial_handler = lambda cmd: None
+ mixin = BehaviorHandlersSettersMixin(
+ lambda cmd, desc: f"{cmd}: {desc}",
+ lambda raw: None,
+ lambda raw: None,
+ lambda: None,
+ initial_handler,
+ lambda resp: None
+ )
+
+ new_handler = lambda cmd: print(f"Unknown: {cmd.trigger}")
+ mixin.set_unknown_command_handler(new_handler)
+
+ assert mixin._unknown_command_handler == new_handler
+
+ def test_set_empty_command_handler(self):
+ initial_handler = lambda: None
+ mixin = BehaviorHandlersSettersMixin(
+ lambda cmd, desc: f"{cmd}: {desc}",
+ lambda raw: None,
+ lambda raw: None,
+ initial_handler,
+ lambda cmd: None,
+ lambda resp: None
+ )
+
+ new_handler = lambda: print("Empty command")
+ mixin.set_empty_command_handler(new_handler)
+
+ assert mixin._empty_input_command_handler == new_handler
+
+ def test_set_exit_command_handler(self):
+ initial_handler = lambda resp: None
+ mixin = BehaviorHandlersSettersMixin(
+ lambda cmd, desc: f"{cmd}: {desc}",
+ lambda raw: None,
+ lambda raw: None,
+ lambda: None,
+ lambda cmd: None,
+ initial_handler
+ )
+
+ new_handler = lambda resp: print("Exiting...")
+ mixin.set_exit_command_handler(new_handler)
+
+ assert mixin._exit_command_handler == new_handler
diff --git a/tests/unit_tests/test_command.py b/tests/unit_tests/test_command.py
index db8dada..a6a8be0 100644
--- a/tests/unit_tests/test_command.py
+++ b/tests/unit_tests/test_command.py
@@ -8,7 +8,7 @@ from argenta.command.exceptions import (
UnprocessedInputFlagException,
)
from argenta.command.flag import Flag, InputFlag
-from argenta.command.flag.flags import Flags
+from argenta.command import Flags
from argenta.command.flag.models import PossibleValues, ValidationStatus
from argenta.command.models import Command, InputCommand
@@ -58,6 +58,11 @@ def test_parse_raises_error_for_empty_command() -> None:
with pytest.raises(EmptyInputCommandException):
InputCommand.parse('')
+
+def test_parse_raises_error_slash_on_the_end() -> None:
+ with pytest.raises(UnprocessedInputFlagException):
+ InputCommand.parse('ssh --host 192.168.0.3\\')
+
# ============================================================================
# Tests for flag validation - valid flags
diff --git a/tests/unit_tests/test_dividing_line.py b/tests/unit_tests/test_dividing_line.py
index 4324826..d8c77af 100644
--- a/tests/unit_tests/test_dividing_line.py
+++ b/tests/unit_tests/test_dividing_line.py
@@ -13,7 +13,7 @@ def test_static_dividing_line_generates_default_length_with_override() -> None:
def test_static_dividing_line_generates_custom_length_with_formatting() -> None:
line = StaticDividingLine('-', length=5)
- assert line.get_full_static_line(is_override=False) == '\n[dim]-----[/dim]\n'
+ assert line.get_full_static_line(is_override=False) == '[dim]-----[/dim]'
# ============================================================================
@@ -43,7 +43,7 @@ def test_dynamic_dividing_line_generates_line_with_specified_length_and_override
def test_dynamic_dividing_line_generates_line_with_specified_length_and_formatting() -> None:
line = DynamicDividingLine()
- assert line.get_full_dynamic_line(length=5, is_override=False) == '\n[dim]-----[/dim]\n'
+ assert line.get_full_dynamic_line(length=5, is_override=False) == '[dim]-----[/dim]'
# ============================================================================
diff --git a/tests/unit_tests/test_flag.py b/tests/unit_tests/test_flag.py
index 3d4e211..120b55d 100644
--- a/tests/unit_tests/test_flag.py
+++ b/tests/unit_tests/test_flag.py
@@ -3,7 +3,7 @@ import re
import pytest
from argenta.command.flag import Flag, InputFlag, PossibleValues
-from argenta.command.flag.flags import Flags, InputFlags
+from argenta.command import Flags, InputFlags
# ============================================================================
@@ -164,6 +164,49 @@ def test_input_flags_get_by_name_returns_none_for_missing_flag() -> None:
assert input_flags.get_flag_by_name('case') is None
+def test_input_flags_get_by_name_with_status_finds_matching_flag() -> None:
+ from argenta.command.flag import ValidationStatus
+
+ flag1 = InputFlag(name='test', input_value='valid', status=ValidationStatus.VALID)
+ flag2 = InputFlag(name='other', input_value='invalid', status=ValidationStatus.INVALID)
+ input_flags = InputFlags([flag1, flag2])
+
+ result = input_flags.get_flag_by_name('test', with_status=ValidationStatus.VALID)
+ assert result == flag1
+
+
+def test_input_flags_get_by_name_with_status_returns_none_when_status_mismatch() -> None:
+ from argenta.command.flag import ValidationStatus
+
+ flag = InputFlag(name='test', input_value='value', status=ValidationStatus.VALID)
+ input_flags = InputFlags([flag])
+
+ result = input_flags.get_flag_by_name('test', with_status=ValidationStatus.INVALID)
+ assert result is None
+
+
+def test_input_flags_get_by_name_with_status_returns_default_when_not_found() -> None:
+ from argenta.command.flag import ValidationStatus
+
+ flag = InputFlag(name='test', input_value='value', status=ValidationStatus.VALID)
+ input_flags = InputFlags([flag])
+
+ result = input_flags.get_flag_by_name('missing', with_status=ValidationStatus.VALID, default='default_value')
+ assert result == 'default_value'
+
+
+def test_input_flags_get_by_name_with_status_filters_by_both_name_and_status() -> None:
+ from argenta.command.flag import ValidationStatus
+
+ flag1 = InputFlag(name='test', input_value='value1', status=ValidationStatus.VALID)
+ flag2 = InputFlag(name='test', input_value='value2', status=ValidationStatus.INVALID)
+ flag3 = InputFlag(name='other', input_value='value3', status=ValidationStatus.VALID)
+ input_flags = InputFlags([flag1, flag2, flag3])
+
+ result = input_flags.get_flag_by_name('test', with_status=ValidationStatus.INVALID)
+ assert result == flag2
+
+
# ============================================================================
# Tests for InputFlags collection - equality and containment
# ============================================================================
diff --git a/tests/unit_tests/test_orchestrator.py b/tests/unit_tests/test_orchestrator.py
index 9393006..c2b7f27 100644
--- a/tests/unit_tests/test_orchestrator.py
+++ b/tests/unit_tests/test_orchestrator.py
@@ -99,7 +99,7 @@ def test_start_polling_creates_dishka_container(
"""Test that start_polling creates a dishka container"""
mock_make_container = mocker.patch('argenta.orchestrator.entity.make_container')
_mock_setup_dishka = mocker.patch('argenta.orchestrator.entity.setup_dishka')
- mocker.patch.object(sample_app, 'run_polling')
+ mocker.patch.object(sample_app, '_run_polling')
orchestrator = Orchestrator(arg_parser=mock_argparser)
orchestrator.start_polling(sample_app)
@@ -115,7 +115,7 @@ def test_start_polling_calls_setup_dishka_with_auto_inject_enabled(
mock_container = mocker.MagicMock() # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
mocker.patch('argenta.orchestrator.entity.make_container', return_value=mock_container)
mock_setup_dishka = mocker.patch('argenta.orchestrator.entity.setup_dishka')
- mocker.patch.object(sample_app, 'run_polling')
+ mocker.patch.object(sample_app, '_run_polling')
orchestrator = Orchestrator(arg_parser=mock_argparser, auto_inject_handlers=True)
orchestrator.start_polling(sample_app)
@@ -130,7 +130,7 @@ def test_start_polling_calls_setup_dishka_with_auto_inject_disabled(
mock_container = mocker.MagicMock() # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
mocker.patch('argenta.orchestrator.entity.make_container', return_value=mock_container)
mock_setup_dishka = mocker.patch('argenta.orchestrator.entity.setup_dishka')
- mocker.patch.object(sample_app, 'run_polling')
+ mocker.patch.object(sample_app, '_run_polling')
orchestrator = Orchestrator(arg_parser=mock_argparser, auto_inject_handlers=False)
orchestrator.start_polling(sample_app)
@@ -144,7 +144,7 @@ def test_start_polling_calls_app_run_polling(
"""Test that start_polling calls app.run_polling()"""
mocker.patch('argenta.orchestrator.entity.make_container')
mocker.patch('argenta.orchestrator.entity.setup_dishka')
- mock_run_polling = mocker.patch.object(sample_app, 'run_polling')
+ mock_run_polling = mocker.patch.object(sample_app, '_run_polling')
orchestrator = Orchestrator(arg_parser=mock_argparser)
orchestrator.start_polling(sample_app)
@@ -159,7 +159,7 @@ def test_start_polling_includes_custom_providers_in_container(
custom_provider = Provider()
mock_make_container = mocker.patch('argenta.orchestrator.entity.make_container')
mocker.patch('argenta.orchestrator.entity.setup_dishka')
- mocker.patch.object(sample_app, 'run_polling')
+ mocker.patch.object(sample_app, '_run_polling')
orchestrator = Orchestrator(arg_parser=mock_argparser, custom_providers=[custom_provider])
orchestrator.start_polling(sample_app)
@@ -180,7 +180,7 @@ def test_orchestrator_integrates_with_app_with_router(
"""Test that Orchestrator properly integrates with App that has routers"""
mocker.patch('argenta.orchestrator.entity.make_container')
mocker.patch('argenta.orchestrator.entity.setup_dishka')
- mock_run_polling = mocker.patch.object(sample_app, 'run_polling')
+ mock_run_polling = mocker.patch.object(sample_app, '_run_polling')
sample_app.include_router(sample_router)
@@ -202,7 +202,7 @@ def test_orchestrator_passes_argparser_to_container_context(
"""Test that Orchestrator passes ArgParser instance to container context"""
mock_make_container = mocker.patch('argenta.orchestrator.entity.make_container')
mocker.patch('argenta.orchestrator.entity.setup_dishka')
- mocker.patch.object(sample_app, 'run_polling')
+ mocker.patch.object(sample_app, '_run_polling')
orchestrator = Orchestrator(arg_parser=mock_argparser)
orchestrator.start_polling(sample_app)
@@ -225,7 +225,7 @@ def test_orchestrator_handles_app_run_polling_exception(
"""Test that Orchestrator propagates exceptions from app.run_polling()"""
mocker.patch('argenta.orchestrator.entity.make_container')
mocker.patch('argenta.orchestrator.entity.setup_dishka')
- mocker.patch.object(sample_app, 'run_polling', side_effect=RuntimeError("Test error"))
+ mocker.patch.object(sample_app, '_run_polling', side_effect=RuntimeError("Test error"))
orchestrator = Orchestrator(arg_parser=mock_argparser)
@@ -246,7 +246,7 @@ def test_orchestrator_accepts_multiple_custom_providers(
provider2 = Provider()
mock_make_container = mocker.patch('argenta.orchestrator.entity.make_container')
mocker.patch('argenta.orchestrator.entity.setup_dishka')
- mocker.patch.object(sample_app, 'run_polling')
+ mocker.patch.object(sample_app, '_run_polling')
orchestrator = Orchestrator(
arg_parser=mock_argparser,
diff --git a/tests/unit_tests/test_renderers.py b/tests/unit_tests/test_renderers.py
new file mode 100644
index 0000000..2865df6
--- /dev/null
+++ b/tests/unit_tests/test_renderers.py
@@ -0,0 +1,126 @@
+from argenta.app.presentation.renderers import RichRenderer, PlainRenderer
+from argenta.app.registered_routers.entity import RegisteredRouters
+from argenta.command.models import Command
+from argenta.response import Response
+from argenta.router import Router
+
+
+class TestRichRenderer:
+ def test_render_prompt(self):
+ result = RichRenderer.render_prompt("Enter command")
+ assert result == "Enter command"
+
+ def test_render_text_for_description_message_generator(self):
+ result = RichRenderer.render_text_for_description_message_generator("test", "Test command")
+ assert "[bold red][/bold red]" in result
+ assert "[bold yellow italic]Test command[/bold yellow italic]" in result
+
+ def test_render_text_for_incorrect_input_syntax_handler(self):
+ result = RichRenderer.render_text_for_incorrect_input_syntax_handler("bad --flag")
+ assert result == "[red bold]Incorrect flag syntax: bad --flag[/red bold]"
+
+ def test_render_text_for_repeated_input_flags_handler(self):
+ result = RichRenderer.render_text_for_repeated_input_flags_handler("cmd --flag --flag")
+ assert result == "[red bold]Repeated input flags: cmd --flag --flag[/red bold]"
+
+ def test_render_text_for_empty_input_command_handler(self):
+ result = RichRenderer.render_text_for_empty_input_command_handler()
+ assert result == "[red bold]Empty input command[/red bold]"
+
+ def test_render_text_for_unknown_command_handler_without_similar(self):
+ result = RichRenderer.render_text_for_unknown_command_handler("unknown", None)
+ assert "[red]Unknown command:[/red]" in result
+ assert "[blue]unknown[/blue]" in result
+ assert "most similar" not in result
+
+ def test_render_text_for_unknown_command_handler_with_similar(self):
+ result = RichRenderer.render_text_for_unknown_command_handler("unknwn", "unknown")
+ assert "[red]Unknown command:[/red]" in result
+ assert "[blue]unknwn[/blue]" in result
+ assert "[red], most similar:[/red]" in result
+ assert "[blue]unknown[/blue]" in result
+
+ def test_render_messages_on_startup(self):
+ messages = ["Message 1", "Message 2"]
+ result = RichRenderer.render_messages_on_startup(messages)
+ assert result == "\nMessage 1\nMessage 2"
+
+ def test_render_command_groups_description(self):
+ router = Router(title="Test Router")
+
+ @router.command(Command("test", description="Test command"))
+ def handler(_: Response):
+ pass
+
+ registered_routers = RegisteredRouters()
+ registered_routers.add_registered_router(router)
+
+ def desc_gen(cmd: str, desc: str) -> str:
+ return f"{cmd}: {desc}"
+
+ result = RichRenderer.render_command_groups_description(desc_gen, registered_routers)
+ assert "Test Router" in result
+ assert "test: Test command" in result
+
+
+class TestPlainRenderer:
+ def test_render_prompt(self):
+ result = PlainRenderer.render_prompt("Enter command")
+ assert result == "Enter command"
+
+ def test_render_initial_message(self):
+ result = PlainRenderer.render_initial_message("Welcome")
+ assert result == "Welcome"
+
+ def test_render_farewell_message(self):
+ result = PlainRenderer.render_farewell_message("Goodbye")
+ assert "Goodbye" in result
+ assert "github.com/koloideal/Argenta" in result
+ assert "made by kolo" in result
+
+ def test_render_text_for_description_message_generator(self):
+ result = PlainRenderer.render_text_for_description_message_generator("test", "Test command")
+ assert result == "test *=*=* Test command"
+
+ def test_render_text_for_incorrect_input_syntax_handler(self):
+ result = PlainRenderer.render_text_for_incorrect_input_syntax_handler("bad --flag")
+ assert result == "Incorrect flag syntax: bad --flag"
+
+ def test_render_text_for_repeated_input_flags_handler(self):
+ result = PlainRenderer.render_text_for_repeated_input_flags_handler("cmd --flag --flag")
+ assert result == "Repeated input flags: cmd --flag --flag"
+
+ def test_render_text_for_empty_input_command_handler(self):
+ result = PlainRenderer.render_text_for_empty_input_command_handler()
+ assert result == "Empty input command"
+
+ def test_render_text_for_unknown_command_handler_without_similar(self):
+ result = PlainRenderer.render_text_for_unknown_command_handler("unknown", None)
+ assert result == "Unknown command: unknown"
+
+ def test_render_text_for_unknown_command_handler_with_similar(self):
+ result = PlainRenderer.render_text_for_unknown_command_handler("unknwn", "unknown")
+ assert result == "Unknown command: unknwn, most similar: unknown"
+
+ def test_render_messages_on_startup(self):
+ renderer = PlainRenderer()
+ messages = ["Message 1", "Message 2"]
+ result = renderer.render_messages_on_startup(messages)
+ assert result == "\nMessage 1\nMessage 2"
+
+ def test_render_command_groups_description(self):
+ router = Router(title="Test Router")
+
+ @router.command(Command("test", description="Test command"))
+ def handler(_: Response):
+ pass
+
+ registered_routers = RegisteredRouters()
+ registered_routers.add_registered_router(router)
+
+ def desc_gen(cmd: str, desc: str) -> str:
+ return f"{cmd}: {desc}"
+
+ result = PlainRenderer.render_command_groups_description(desc_gen, registered_routers)
+ assert "Test Router" in result
+ assert "test: Test command" in result
diff --git a/tests/unit_tests/test_response.py b/tests/unit_tests/test_response.py
index fdbdf3d..b86eda5 100644
--- a/tests/unit_tests/test_response.py
+++ b/tests/unit_tests/test_response.py
@@ -2,7 +2,7 @@ from datetime import date, datetime
import pytest
-from argenta.command.flag.flags.models import InputFlags
+from argenta.command import InputFlags
from argenta.command.flag.models import InputFlag
from argenta.data_bridge import DataBridge
from argenta.response.entity import EMPTY_INPUT_FLAGS, Response
diff --git a/tests/unit_tests/test_router.py b/tests/unit_tests/test_router.py
index 21bc6d3..0a0a656 100644
--- a/tests/unit_tests/test_router.py
+++ b/tests/unit_tests/test_router.py
@@ -3,13 +3,11 @@ import re
import pytest
from pytest import CaptureFixture
-from argenta.command import Command, InputCommand
+from argenta.command import Command, InputCommand, Flags, InputFlags
from argenta.command.flag import Flag, InputFlag
-from argenta.command.flag.flags import Flags, InputFlags
from argenta.command.flag.models import PossibleValues, ValidationStatus
from argenta.response.entity import Response
from argenta.router import Router
-from argenta.router.entity import _structuring_input_flags, _validate_func_args
from argenta.router.exceptions import (
RepeatedAliasNameException,
RepeatedFlagNameException,
@@ -58,7 +56,7 @@ def test_validate_func_args_raises_error_for_missing_response_parameter() -> Non
def handler() -> None:
pass
with pytest.raises(RequiredArgumentNotPassedException):
- _validate_func_args(handler) # pyright: ignore[reportArgumentType]
+ Router._validate_func_args(handler) # pyright: ignore[reportArgumentType]
def test_validate_func_args_prints_warning_for_wrong_type_hint(capsys: CaptureFixture[str]) -> None:
@@ -68,7 +66,7 @@ def test_validate_func_args_prints_warning_for_wrong_type_hint(capsys: CaptureFi
def func(_response: NotResponse) -> None:
pass
- _validate_func_args(func)
+ Router._validate_func_args(func)
output = capsys.readouterr()
@@ -78,7 +76,7 @@ def test_validate_func_args_prints_warning_for_wrong_type_hint(capsys: CaptureFi
def test_validate_func_args_accepts_missing_type_hint(capsys: CaptureFixture[str]) -> None:
def func(response) -> None: # pyright: ignore[reportMissingParameterType, reportUnknownParameterType]
pass
- _validate_func_args(func) # pyright: ignore[reportUnknownArgumentType]
+ Router._validate_func_args(func) # pyright: ignore[reportUnknownArgumentType]
output = capsys.readouterr()
assert output.out == ''
@@ -91,19 +89,19 @@ def test_validate_func_args_accepts_missing_type_hint(capsys: CaptureFixture[str
def test_structuring_input_flags_marks_unregistered_flag_as_undefined() -> None:
cmd = Command('cmd')
input_flags = InputFlags([InputFlag('ssh', input_value='', status=None)])
- assert _structuring_input_flags(cmd, input_flags).input_flags == InputFlags([InputFlag('ssh', input_value='', status=ValidationStatus.UNDEFINED)])
+ assert Router._structuring_input_flags(cmd, input_flags).input_flags == InputFlags([InputFlag('ssh', input_value='', status=ValidationStatus.UNDEFINED)])
def test_structuring_input_flags_marks_unregistered_flag_with_value_as_undefined() -> None:
cmd = Command('cmd')
input_flags = InputFlags([InputFlag('ssh', input_value='some', status=None)])
- assert _structuring_input_flags(cmd, input_flags).input_flags == InputFlags([InputFlag('ssh', input_value='some', status=ValidationStatus.UNDEFINED)])
+ assert Router._structuring_input_flags(cmd, input_flags).input_flags == InputFlags([InputFlag('ssh', input_value='some', status=ValidationStatus.UNDEFINED)])
def test_structuring_input_flags_marks_flag_undefined_when_different_flag_registered() -> None:
cmd = Command('cmd', flags=Flag('port'))
input_flags = InputFlags([InputFlag('ssh', input_value='some2', status=None)])
- assert _structuring_input_flags(cmd, input_flags).input_flags == InputFlags([InputFlag('ssh', input_value='some2', status=ValidationStatus.UNDEFINED)])
+ assert Router._structuring_input_flags(cmd, input_flags).input_flags == InputFlags([InputFlag('ssh', input_value='some2', status=ValidationStatus.UNDEFINED)])
# ============================================================================
@@ -114,19 +112,19 @@ def test_structuring_input_flags_marks_flag_undefined_when_different_flag_regist
def test_structuring_input_flags_marks_flag_invalid_when_value_provided_for_neither() -> None:
command = Command('cmd', flags=Flag('ssh', possible_values=PossibleValues.NEITHER))
input_flags = InputFlags([InputFlag('ssh', input_value='some3', status=None)])
- assert _structuring_input_flags(command, input_flags).input_flags == InputFlags([InputFlag('ssh', input_value='some3', status=ValidationStatus.INVALID)])
+ assert Router._structuring_input_flags(command, input_flags).input_flags == InputFlags([InputFlag('ssh', input_value='some3', status=ValidationStatus.INVALID)])
def test_structuring_input_flags_marks_flag_invalid_when_value_not_matching_regex() -> None:
command = Command('cmd', flags=Flag('ssh', possible_values=re.compile(r'some[1-5]$')))
input_flags = InputFlags([InputFlag('ssh', input_value='some40', status=None)])
- assert _structuring_input_flags(command, input_flags).input_flags == InputFlags([InputFlag('ssh', input_value='some40', status=ValidationStatus.INVALID)])
+ assert Router._structuring_input_flags(command, input_flags).input_flags == InputFlags([InputFlag('ssh', input_value='some40', status=ValidationStatus.INVALID)])
def test_structuring_input_flags_marks_flag_invalid_when_value_not_in_list() -> None:
command = Command('cmd', flags=Flag('ssh', possible_values=['example']))
input_flags = InputFlags([InputFlag('ssh', input_value='example2', status=None)])
- assert _structuring_input_flags(command, input_flags).input_flags == InputFlags([InputFlag('ssh', input_value='example2', status=ValidationStatus.INVALID)])
+ assert Router._structuring_input_flags(command, input_flags).input_flags == InputFlags([InputFlag('ssh', input_value='example2', status=ValidationStatus.INVALID)])
# ============================================================================
@@ -137,25 +135,25 @@ def test_structuring_input_flags_marks_flag_invalid_when_value_not_in_list() ->
def test_structuring_input_flags_marks_registered_flag_as_valid() -> None:
command = Command('cmd', flags=Flag('port'))
input_flags = InputFlags([InputFlag('port', input_value='some2', status=None)])
- assert _structuring_input_flags(command, input_flags).input_flags == InputFlags([InputFlag('port', input_value='some2', status=ValidationStatus.VALID)])
+ assert Router._structuring_input_flags(command, input_flags).input_flags == InputFlags([InputFlag('port', input_value='some2', status=ValidationStatus.VALID)])
def test_structuring_input_flags_marks_flag_valid_when_value_in_list() -> None:
command = Command('cmd', flags=Flag('port', possible_values=['some2', 'some3']))
input_flags = InputFlags([InputFlag('port', input_value='some2', status=None)])
- assert _structuring_input_flags(command, input_flags).input_flags == InputFlags([InputFlag('port', input_value='some2', status=ValidationStatus.VALID)])
+ assert Router._structuring_input_flags(command, input_flags).input_flags == InputFlags([InputFlag('port', input_value='some2', status=ValidationStatus.VALID)])
def test_structuring_input_flags_marks_flag_valid_when_value_matches_regex() -> None:
command = Command('cmd', flags=Flag('ssh', possible_values=re.compile(r'more[1-5]$')))
input_flags = InputFlags([InputFlag('ssh', input_value='more5', status=None)])
- assert _structuring_input_flags(command, input_flags).input_flags == InputFlags([InputFlag('ssh', input_value='more5', status=ValidationStatus.VALID)])
+ assert Router._structuring_input_flags(command, input_flags).input_flags == InputFlags([InputFlag('ssh', input_value='more5', status=ValidationStatus.VALID)])
def test_structuring_input_flags_marks_flag_valid_when_empty_value_for_neither() -> None:
command = Command('cmd', flags=Flag('ssh', possible_values=PossibleValues.NEITHER))
input_flags = InputFlags([InputFlag('ssh', input_value='', status=None)])
- assert _structuring_input_flags(command, input_flags).input_flags == InputFlags([InputFlag('ssh', input_value='', status=ValidationStatus.VALID)])
+ assert Router._structuring_input_flags(command, input_flags).input_flags == InputFlags([InputFlag('ssh', input_value='', status=ValidationStatus.VALID)])
# ============================================================================
@@ -250,6 +248,17 @@ def test_finds_appropriate_handler_executes_handler_with_flags_by_alias(capsys:
assert "Hello World!" in output.out
+def test_finds_appropriate_handler_raises_runtime_error_when_handler_not_found() -> None:
+ router = Router()
+
+ @router.command('hello')
+ def handler(_res: Response) -> None:
+ pass
+
+ with pytest.raises(RuntimeError, match="Handler for 'unknown' command not found. Panic!"):
+ router.finds_appropriate_handler(InputCommand('unknown'))
+
+
# ============================================================================
# Tests for alias and trigger collision detection
# ============================================================================
diff --git a/tests/unit_tests/test_viewers.py b/tests/unit_tests/test_viewers.py
new file mode 100644
index 0000000..a519f0d
--- /dev/null
+++ b/tests/unit_tests/test_viewers.py
@@ -0,0 +1,155 @@
+import pytest
+from unittest.mock import Mock
+
+from argenta.app.presentation.viewers import Viewer
+from argenta.app.presentation.renderers import PlainRenderer
+from argenta.app.dividing_line.models import StaticDividingLine, DynamicDividingLine
+from argenta.app.registered_routers.entity import RegisteredRouters
+from argenta.command.models import Command
+from argenta.response import Response
+from argenta.router import Router
+
+
+@pytest.fixture
+def mock_printer() -> Mock:
+ return Mock()
+
+
+@pytest.fixture
+def mock_output_generator() -> Mock:
+ return Mock()
+
+
+class TestViewer:
+ def test_viewer_initialization(self, mock_printer: Mock):
+ renderer = PlainRenderer()
+ dividing_line = StaticDividingLine()
+
+ viewer = Viewer(mock_printer, renderer, dividing_line, False)
+
+ assert viewer._printer == mock_printer
+ assert viewer._renderer == renderer
+ assert viewer._dividing_line == dividing_line
+ assert viewer._override_system_messages is False
+
+ def test_view_initial_message(self, mock_printer: Mock):
+ renderer = PlainRenderer()
+ viewer = Viewer(mock_printer, renderer, None, False)
+
+ viewer.view_initial_message("Welcome")
+
+ mock_printer.assert_called_once_with("Welcome")
+
+ def test_view_messages_on_startup(self, mock_printer: Mock):
+ renderer = PlainRenderer()
+ viewer = Viewer(mock_printer, renderer, None, False)
+
+ messages = ["Message 1", "Message 2"]
+ viewer.view_messages_on_startup(messages)
+
+ mock_printer.assert_called_once()
+ call_arg = mock_printer.call_args[0][0]
+ assert "Message 1" in call_arg
+ assert "Message 2" in call_arg
+
+ def test_view_command_groups_description(self, mock_printer: Mock):
+ renderer = PlainRenderer()
+ viewer = Viewer(mock_printer, renderer, None, False)
+
+ router = Router(title="Test Router")
+
+ @router.command(Command("test", description="Test command"))
+ def handler(_: Response):
+ pass
+
+ registered_routers = RegisteredRouters()
+ registered_routers.add_registered_router(router)
+
+ def desc_gen(cmd: str, desc: str) -> str:
+ return f"{cmd}: {desc}"
+
+ viewer.view_command_groups_description(desc_gen, registered_routers)
+
+ mock_printer.assert_called_once()
+ call_arg = mock_printer.call_args[0][0]
+ assert "Test Router" in call_arg
+ assert "test: Test command" in call_arg
+
+ def test_view_framed_text_with_no_dividing_line(self, mock_printer: Mock, mock_output_generator: Mock):
+ renderer = PlainRenderer()
+ viewer = Viewer(mock_printer, renderer, None, False)
+
+ viewer.view_framed_text_from_generator(mock_output_generator)
+
+ mock_output_generator.assert_called_once()
+
+ def test_view_framed_text_with_static_dividing_line(self, mock_printer: Mock, mock_output_generator: Mock):
+ renderer = PlainRenderer()
+ dividing_line = StaticDividingLine("=")
+ viewer = Viewer(mock_printer, renderer, dividing_line, False)
+
+ viewer.view_framed_text_from_generator(mock_output_generator)
+
+ mock_output_generator.assert_called_once()
+ assert mock_printer.call_count >= 2
+
+ def test_capture_stdout(self, mock_printer: Mock):
+ renderer = PlainRenderer()
+ viewer = Viewer(mock_printer, renderer, None, False)
+
+ def test_func():
+ print("test output")
+
+ result = viewer._capture_stdout(test_func)
+ assert "test output" in result
+
+ def test_capture_stdout_reuses_buffer(self, mock_printer: Mock):
+ renderer = PlainRenderer()
+ viewer = Viewer(mock_printer, renderer, None, False)
+
+ def test_func1():
+ print("output 1")
+
+ def test_func2():
+ print("output 2")
+
+ result1 = viewer._capture_stdout(test_func1)
+ result2 = viewer._capture_stdout(test_func2)
+
+ assert "output 1" in result1
+ assert "output 1" not in result2
+ assert "output 2" in result2
+
+ def test_view_framed_text_with_dynamic_dividing_line(self, mock_printer: Mock):
+ renderer = PlainRenderer()
+ dividing_line = DynamicDividingLine("=")
+ viewer = Viewer(mock_printer, renderer, dividing_line, False)
+
+ def output_generator():
+ print("test output")
+
+ viewer.view_framed_text_from_generator(output_generator)
+
+ assert mock_printer.call_count >= 2
+
+ def test_view_framed_text_with_router_stdout_redirect(self, mock_printer: Mock, mock_output_generator: Mock):
+ renderer = PlainRenderer()
+ dividing_line = DynamicDividingLine("=")
+ viewer = Viewer(mock_printer, renderer, dividing_line, False)
+
+ viewer.view_framed_text_from_generator(mock_output_generator, is_stdout_redirected_by_router=True)
+
+ mock_output_generator.assert_called_once()
+ assert mock_printer.call_count >= 2
+
+ def test_view_framed_text_with_unimplemented_dividing_line(self, mock_printer: Mock, mock_output_generator: Mock):
+ class NotImplementedDividingLine:
+ pass
+
+ renderer = PlainRenderer()
+ dividing_line = NotImplementedDividingLine()
+ viewer = Viewer(mock_printer, renderer, dividing_line, False)
+
+ with pytest.raises(NotImplementedError):
+ viewer.view_framed_text_from_generator(mock_output_generator, is_stdout_redirected_by_router=True)
+
diff --git a/uv.lock b/uv.lock
index bdb3c02..64639d7 100644
--- a/uv.lock
+++ b/uv.lock
@@ -49,6 +49,22 @@ dependencies = [
]
[package.dev-dependencies]
+dev = [
+ { name = "esbonio" },
+ { name = "isort" },
+ { name = "mypy" },
+ { name = "pyfakefs" },
+ { name = "pytest" },
+ { name = "pytest-cov" },
+ { name = "pytest-mock" },
+ { name = "ruff" },
+ { name = "scriv" },
+ { name = "shibuya" },
+ { name = "sphinx" },
+ { name = "sphinx-autobuild" },
+ { name = "sphinx-intl" },
+ { name = "wemake-python-styleguide" },
+]
docs = [
{ name = "esbonio" },
{ name = "shibuya" },
@@ -61,6 +77,11 @@ linters = [
{ name = "ruff" },
{ name = "wemake-python-styleguide" },
]
+metrics = [
+ { name = "matplotlib" },
+ { name = "psutil" },
+ { name = "py-cpuinfo" },
+]
tests = [
{ name = "pyfakefs" },
{ name = "pytest" },
@@ -80,6 +101,22 @@ requires-dist = [
]
[package.metadata.requires-dev]
+dev = [
+ { name = "esbonio", specifier = ">=1.0.0" },
+ { name = "isort", specifier = ">=7.0.0" },
+ { name = "mypy", specifier = ">=1.14.1" },
+ { name = "pyfakefs", specifier = ">=5.5.0" },
+ { name = "pytest", specifier = ">=8.3.2" },
+ { name = "pytest-cov", specifier = ">=7.0.0" },
+ { name = "pytest-mock", specifier = ">=3.15.1" },
+ { name = "ruff", specifier = ">=0.12.12" },
+ { name = "scriv", specifier = ">=1.8.0" },
+ { name = "shibuya", specifier = ">=2025.9.25" },
+ { name = "sphinx", specifier = ">=8.2.3" },
+ { name = "sphinx-autobuild", specifier = ">=2025.8.25" },
+ { name = "sphinx-intl", specifier = ">=2.3.2" },
+ { name = "wemake-python-styleguide", specifier = ">=0.17.0" },
+]
docs = [
{ name = "esbonio", specifier = ">=1.0.0" },
{ name = "shibuya", specifier = ">=2025.9.25" },
@@ -92,6 +129,11 @@ linters = [
{ name = "ruff", specifier = ">=0.12.12" },
{ name = "wemake-python-styleguide", specifier = ">=0.17.0" },
]
+metrics = [
+ { name = "matplotlib", specifier = ">=3.10.8" },
+ { name = "psutil", specifier = ">=7.2.1" },
+ { name = "py-cpuinfo", specifier = ">=9.0.0" },
+]
tests = [
{ name = "pyfakefs", specifier = ">=5.5.0" },
{ name = "pytest", specifier = ">=8.3.2" },
@@ -218,6 +260,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" },
]
+[[package]]
+name = "click-log"
+version = "0.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/32/32/228be4f971e4bd556c33d52a22682bfe318ffe57a1ddb7a546f347a90260/click-log-0.4.0.tar.gz", hash = "sha256:3970f8570ac54491237bcdb3d8ab5e3eef6c057df29f8c3d1151a51a9c23b975", size = 9985, upload-time = "2022-03-13T11:10:15.262Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ae/5a/4f025bc751087833686892e17e7564828e409c43b632878afeae554870cd/click_log-0.4.0-py2.py3-none-any.whl", hash = "sha256:a43e394b528d52112af599f2fc9e4b7cf3c15f94e53581f74fa6867e68c91756", size = 4273, upload-time = "2022-03-13T11:10:17.594Z" },
+]
+
[[package]]
name = "colorama"
version = "0.4.6"
@@ -227,6 +281,72 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
+[[package]]
+name = "contourpy"
+version = "1.3.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" },
+ { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" },
+ { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" },
+ { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" },
+ { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" },
+ { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" },
+ { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" },
+ { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" },
+ { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" },
+ { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" },
+ { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" },
+ { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" },
+ { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" },
+ { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" },
+ { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" },
+ { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" },
+ { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" },
+ { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" },
+ { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" },
+ { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" },
+ { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" },
+ { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" },
+ { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" },
+ { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" },
+ { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" },
+ { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" },
+]
+
[[package]]
name = "coverage"
version = "7.11.0"
@@ -301,6 +421,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/04/642c1d8a448ae5ea1369eac8495740a79eb4e581a9fb0cbdce56bbf56da1/coverage-7.11.0-py3-none-any.whl", hash = "sha256:4b7589765348d78fb4e5fb6ea35d07564e387da2fc5efff62e0222971f155f68", size = 207761, upload-time = "2025-10-15T15:15:06.439Z" },
]
+[[package]]
+name = "cycler"
+version = "0.12.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" },
+]
+
[[package]]
name = "dishka"
version = "1.7.2"
@@ -349,6 +478,47 @@ 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 = "fonttools"
+version = "4.61.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ec/ca/cf17b88a8df95691275a3d77dc0a5ad9907f328ae53acbe6795da1b2f5ed/fonttools-4.61.1.tar.gz", hash = "sha256:6675329885c44657f826ef01d9e4fb33b9158e9d93c537d84ad8399539bc6f69", size = 3565756, upload-time = "2025-12-12T17:31:24.246Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6f/16/7decaa24a1bd3a70c607b2e29f0adc6159f36a7e40eaba59846414765fd4/fonttools-4.61.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f3cb4a569029b9f291f88aafc927dd53683757e640081ca8c412781ea144565e", size = 2851593, upload-time = "2025-12-12T17:30:04.225Z" },
+ { url = "https://files.pythonhosted.org/packages/94/98/3c4cb97c64713a8cf499b3245c3bf9a2b8fd16a3e375feff2aed78f96259/fonttools-4.61.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41a7170d042e8c0024703ed13b71893519a1a6d6e18e933e3ec7507a2c26a4b2", size = 2400231, upload-time = "2025-12-12T17:30:06.47Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/37/82dbef0f6342eb01f54bca073ac1498433d6ce71e50c3c3282b655733b31/fonttools-4.61.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10d88e55330e092940584774ee5e8a6971b01fc2f4d3466a1d6c158230880796", size = 4954103, upload-time = "2025-12-12T17:30:08.432Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/44/f3aeac0fa98e7ad527f479e161aca6c3a1e47bb6996b053d45226fe37bf2/fonttools-4.61.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15acc09befd16a0fb8a8f62bc147e1a82817542d72184acca9ce6e0aeda9fa6d", size = 5004295, upload-time = "2025-12-12T17:30:10.56Z" },
+ { url = "https://files.pythonhosted.org/packages/14/e8/7424ced75473983b964d09f6747fa09f054a6d656f60e9ac9324cf40c743/fonttools-4.61.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e6bcdf33aec38d16508ce61fd81838f24c83c90a1d1b8c68982857038673d6b8", size = 4944109, upload-time = "2025-12-12T17:30:12.874Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/8b/6391b257fa3d0b553d73e778f953a2f0154292a7a7a085e2374b111e5410/fonttools-4.61.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5fade934607a523614726119164ff621e8c30e8fa1ffffbbd358662056ba69f0", size = 5093598, upload-time = "2025-12-12T17:30:15.79Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/71/fd2ea96cdc512d92da5678a1c98c267ddd4d8c5130b76d0f7a80f9a9fde8/fonttools-4.61.1-cp312-cp312-win32.whl", hash = "sha256:75da8f28eff26defba42c52986de97b22106cb8f26515b7c22443ebc9c2d3261", size = 2269060, upload-time = "2025-12-12T17:30:18.058Z" },
+ { url = "https://files.pythonhosted.org/packages/80/3b/a3e81b71aed5a688e89dfe0e2694b26b78c7d7f39a5ffd8a7d75f54a12a8/fonttools-4.61.1-cp312-cp312-win_amd64.whl", hash = "sha256:497c31ce314219888c0e2fce5ad9178ca83fe5230b01a5006726cdf3ac9f24d9", size = 2319078, upload-time = "2025-12-12T17:30:22.862Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/cf/00ba28b0990982530addb8dc3e9e6f2fa9cb5c20df2abdda7baa755e8fe1/fonttools-4.61.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8c56c488ab471628ff3bfa80964372fc13504ece601e0d97a78ee74126b2045c", size = 2846454, upload-time = "2025-12-12T17:30:24.938Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/ca/468c9a8446a2103ae645d14fee3f610567b7042aba85031c1c65e3ef7471/fonttools-4.61.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dc492779501fa723b04d0ab1f5be046797fee17d27700476edc7ee9ae535a61e", size = 2398191, upload-time = "2025-12-12T17:30:27.343Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/4b/d67eedaed19def5967fade3297fed8161b25ba94699efc124b14fb68cdbc/fonttools-4.61.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:64102ca87e84261419c3747a0d20f396eb024bdbeb04c2bfb37e2891f5fadcb5", size = 4928410, upload-time = "2025-12-12T17:30:29.771Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/8d/6fb3494dfe61a46258cd93d979cf4725ded4eb46c2a4ca35e4490d84daea/fonttools-4.61.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c1b526c8d3f615a7b1867f38a9410849c8f4aef078535742198e942fba0e9bd", size = 4984460, upload-time = "2025-12-12T17:30:32.073Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/f1/a47f1d30b3dc00d75e7af762652d4cbc3dff5c2697a0dbd5203c81afd9c3/fonttools-4.61.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:41ed4b5ec103bd306bb68f81dc166e77409e5209443e5773cb4ed837bcc9b0d3", size = 4925800, upload-time = "2025-12-12T17:30:34.339Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/01/e6ae64a0981076e8a66906fab01539799546181e32a37a0257b77e4aa88b/fonttools-4.61.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b501c862d4901792adaec7c25b1ecc749e2662543f68bb194c42ba18d6eec98d", size = 5067859, upload-time = "2025-12-12T17:30:36.593Z" },
+ { url = "https://files.pythonhosted.org/packages/73/aa/28e40b8d6809a9b5075350a86779163f074d2b617c15d22343fce81918db/fonttools-4.61.1-cp313-cp313-win32.whl", hash = "sha256:4d7092bb38c53bbc78e9255a59158b150bcdc115a1e3b3ce0b5f267dc35dd63c", size = 2267821, upload-time = "2025-12-12T17:30:38.478Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/59/453c06d1d83dc0951b69ef692d6b9f1846680342927df54e9a1ca91c6f90/fonttools-4.61.1-cp313-cp313-win_amd64.whl", hash = "sha256:21e7c8d76f62ab13c9472ccf74515ca5b9a761d1bde3265152a6dc58700d895b", size = 2318169, upload-time = "2025-12-12T17:30:40.951Z" },
+ { url = "https://files.pythonhosted.org/packages/32/8f/4e7bf82c0cbb738d3c2206c920ca34ca74ef9dabde779030145d28665104/fonttools-4.61.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fff4f534200a04b4a36e7ae3cb74493afe807b517a09e99cb4faa89a34ed6ecd", size = 2846094, upload-time = "2025-12-12T17:30:43.511Z" },
+ { url = "https://files.pythonhosted.org/packages/71/09/d44e45d0a4f3a651f23a1e9d42de43bc643cce2971b19e784cc67d823676/fonttools-4.61.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d9203500f7c63545b4ce3799319fe4d9feb1a1b89b28d3cb5abd11b9dd64147e", size = 2396589, upload-time = "2025-12-12T17:30:45.681Z" },
+ { url = "https://files.pythonhosted.org/packages/89/18/58c64cafcf8eb677a99ef593121f719e6dcbdb7d1c594ae5a10d4997ca8a/fonttools-4.61.1-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa646ecec9528bef693415c79a86e733c70a4965dd938e9a226b0fc64c9d2e6c", size = 4877892, upload-time = "2025-12-12T17:30:47.709Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/ec/9e6b38c7ba1e09eb51db849d5450f4c05b7e78481f662c3b79dbde6f3d04/fonttools-4.61.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11f35ad7805edba3aac1a3710d104592df59f4b957e30108ae0ba6c10b11dd75", size = 4972884, upload-time = "2025-12-12T17:30:49.656Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/87/b5339da8e0256734ba0dbbf5b6cdebb1dd79b01dc8c270989b7bcd465541/fonttools-4.61.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b931ae8f62db78861b0ff1ac017851764602288575d65b8e8ff1963fed419063", size = 4924405, upload-time = "2025-12-12T17:30:51.735Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/47/e3409f1e1e69c073a3a6fd8cb886eb18c0bae0ee13db2c8d5e7f8495e8b7/fonttools-4.61.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b148b56f5de675ee16d45e769e69f87623a4944f7443850bf9a9376e628a89d2", size = 5035553, upload-time = "2025-12-12T17:30:54.823Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/b6/1f6600161b1073a984294c6c031e1a56ebf95b6164249eecf30012bb2e38/fonttools-4.61.1-cp314-cp314-win32.whl", hash = "sha256:9b666a475a65f4e839d3d10473fad6d47e0a9db14a2f4a224029c5bfde58ad2c", size = 2271915, upload-time = "2025-12-12T17:30:57.913Z" },
+ { url = "https://files.pythonhosted.org/packages/52/7b/91e7b01e37cc8eb0e1f770d08305b3655e4f002fc160fb82b3390eabacf5/fonttools-4.61.1-cp314-cp314-win_amd64.whl", hash = "sha256:4f5686e1fe5fce75d82d93c47a438a25bf0d1319d2843a926f741140b2b16e0c", size = 2323487, upload-time = "2025-12-12T17:30:59.804Z" },
+ { url = "https://files.pythonhosted.org/packages/39/5c/908ad78e46c61c3e3ed70c3b58ff82ab48437faf84ec84f109592cabbd9f/fonttools-4.61.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e76ce097e3c57c4bcb67c5aa24a0ecdbd9f74ea9219997a707a4061fbe2707aa", size = 2929571, upload-time = "2025-12-12T17:31:02.574Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/41/975804132c6dea64cdbfbaa59f3518a21c137a10cccf962805b301ac6ab2/fonttools-4.61.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9cfef3ab326780c04d6646f68d4b4742aae222e8b8ea1d627c74e38afcbc9d91", size = 2435317, upload-time = "2025-12-12T17:31:04.974Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/5a/aef2a0a8daf1ebaae4cfd83f84186d4a72ee08fd6a8451289fcd03ffa8a4/fonttools-4.61.1-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a75c301f96db737e1c5ed5fd7d77d9c34466de16095a266509e13da09751bd19", size = 4882124, upload-time = "2025-12-12T17:31:07.456Z" },
+ { url = "https://files.pythonhosted.org/packages/80/33/d6db3485b645b81cea538c9d1c9219d5805f0877fda18777add4671c5240/fonttools-4.61.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:91669ccac46bbc1d09e9273546181919064e8df73488ea087dcac3e2968df9ba", size = 5100391, upload-time = "2025-12-12T17:31:09.732Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/d6/675ba631454043c75fcf76f0ca5463eac8eb0666ea1d7badae5fea001155/fonttools-4.61.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c33ab3ca9d3ccd581d58e989d67554e42d8d4ded94ab3ade3508455fe70e65f7", size = 4978800, upload-time = "2025-12-12T17:31:11.681Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/33/d3ec753d547a8d2bdaedd390d4a814e8d5b45a093d558f025c6b990b554c/fonttools-4.61.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:664c5a68ec406f6b1547946683008576ef8b38275608e1cee6c061828171c118", size = 5006426, upload-time = "2025-12-12T17:31:13.764Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/40/cc11f378b561a67bea850ab50063366a0d1dd3f6d0a30ce0f874b0ad5664/fonttools-4.61.1-cp314-cp314t-win32.whl", hash = "sha256:aed04cabe26f30c1647ef0e8fbb207516fd40fe9472e9439695f5c6998e60ac5", size = 2335377, upload-time = "2025-12-12T17:31:16.49Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/ff/c9a2b66b39f8628531ea58b320d66d951267c98c6a38684daa8f50fb02f8/fonttools-4.61.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2180f14c141d2f0f3da43f3a81bc8aa4684860f6b0e6f9e165a4831f24e6a23b", size = 2400613, upload-time = "2025-12-12T17:31:18.769Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/4e/ce75a57ff3aebf6fc1f4e9d508b8e5810618a33d900ad6c19eb30b290b97/fonttools-4.61.1-py3-none-any.whl", hash = "sha256:17d2bf5d541add43822bcf0c43d7d847b160c9bb01d15d5007d84e2217aaa371", size = 1148996, upload-time = "2025-12-12T17:31:21.03Z" },
+]
+
[[package]]
name = "h11"
version = "0.16.0"
@@ -406,6 +576,78 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
+[[package]]
+name = "kiwisolver"
+version = "1.4.9"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5c/3c/85844f1b0feb11ee581ac23fe5fce65cd049a200c1446708cc1b7f922875/kiwisolver-1.4.9.tar.gz", hash = "sha256:c3b22c26c6fd6811b0ae8363b95ca8ce4ea3c202d3d0975b2914310ceb1bcc4d", size = 97564, upload-time = "2025-08-10T21:27:49.279Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/86/c9/13573a747838aeb1c76e3267620daa054f4152444d1f3d1a2324b78255b5/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ac5a486ac389dddcc5bef4f365b6ae3ffff2c433324fb38dd35e3fab7c957999", size = 123686, upload-time = "2025-08-10T21:26:10.034Z" },
+ { url = "https://files.pythonhosted.org/packages/51/ea/2ecf727927f103ffd1739271ca19c424d0e65ea473fbaeea1c014aea93f6/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2ba92255faa7309d06fe44c3a4a97efe1c8d640c2a79a5ef728b685762a6fd2", size = 66460, upload-time = "2025-08-10T21:26:11.083Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/5a/51f5464373ce2aeb5194508298a508b6f21d3867f499556263c64c621914/kiwisolver-1.4.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a2899935e724dd1074cb568ce7ac0dce28b2cd6ab539c8e001a8578eb106d14", size = 64952, upload-time = "2025-08-10T21:26:12.058Z" },
+ { url = "https://files.pythonhosted.org/packages/70/90/6d240beb0f24b74371762873e9b7f499f1e02166a2d9c5801f4dbf8fa12e/kiwisolver-1.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f6008a4919fdbc0b0097089f67a1eb55d950ed7e90ce2cc3e640abadd2757a04", size = 1474756, upload-time = "2025-08-10T21:26:13.096Z" },
+ { url = "https://files.pythonhosted.org/packages/12/42/f36816eaf465220f683fb711efdd1bbf7a7005a2473d0e4ed421389bd26c/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67bb8b474b4181770f926f7b7d2f8c0248cbcb78b660fdd41a47054b28d2a752", size = 1276404, upload-time = "2025-08-10T21:26:14.457Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/64/bc2de94800adc830c476dce44e9b40fd0809cddeef1fde9fcf0f73da301f/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2327a4a30d3ee07d2fbe2e7933e8a37c591663b96ce42a00bc67461a87d7df77", size = 1294410, upload-time = "2025-08-10T21:26:15.73Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/42/2dc82330a70aa8e55b6d395b11018045e58d0bb00834502bf11509f79091/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a08b491ec91b1d5053ac177afe5290adacf1f0f6307d771ccac5de30592d198", size = 1343631, upload-time = "2025-08-10T21:26:17.045Z" },
+ { url = "https://files.pythonhosted.org/packages/22/fd/f4c67a6ed1aab149ec5a8a401c323cee7a1cbe364381bb6c9c0d564e0e20/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8fc5c867c22b828001b6a38d2eaeb88160bf5783c6cb4a5e440efc981ce286d", size = 2224963, upload-time = "2025-08-10T21:26:18.737Z" },
+ { url = "https://files.pythonhosted.org/packages/45/aa/76720bd4cb3713314677d9ec94dcc21ced3f1baf4830adde5bb9b2430a5f/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3b3115b2581ea35bb6d1f24a4c90af37e5d9b49dcff267eeed14c3893c5b86ab", size = 2321295, upload-time = "2025-08-10T21:26:20.11Z" },
+ { url = "https://files.pythonhosted.org/packages/80/19/d3ec0d9ab711242f56ae0dc2fc5d70e298bb4a1f9dfab44c027668c673a1/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:858e4c22fb075920b96a291928cb7dea5644e94c0ee4fcd5af7e865655e4ccf2", size = 2487987, upload-time = "2025-08-10T21:26:21.49Z" },
+ { url = "https://files.pythonhosted.org/packages/39/e9/61e4813b2c97e86b6fdbd4dd824bf72d28bcd8d4849b8084a357bc0dd64d/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed0fecd28cc62c54b262e3736f8bb2512d8dcfdc2bcf08be5f47f96bf405b145", size = 2291817, upload-time = "2025-08-10T21:26:22.812Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/41/85d82b0291db7504da3c2defe35c9a8a5c9803a730f297bd823d11d5fb77/kiwisolver-1.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:f68208a520c3d86ea51acf688a3e3002615a7f0238002cccc17affecc86a8a54", size = 73895, upload-time = "2025-08-10T21:26:24.37Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/92/5f3068cf15ee5cb624a0c7596e67e2a0bb2adee33f71c379054a491d07da/kiwisolver-1.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:2c1a4f57df73965f3f14df20b80ee29e6a7930a57d2d9e8491a25f676e197c60", size = 64992, upload-time = "2025-08-10T21:26:25.732Z" },
+ { url = "https://files.pythonhosted.org/packages/31/c1/c2686cda909742ab66c7388e9a1a8521a59eb89f8bcfbee28fc980d07e24/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5d0432ccf1c7ab14f9949eec60c5d1f924f17c037e9f8b33352fa05799359b8", size = 123681, upload-time = "2025-08-10T21:26:26.725Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/f0/f44f50c9f5b1a1860261092e3bc91ecdc9acda848a8b8c6abfda4a24dd5c/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efb3a45b35622bb6c16dbfab491a8f5a391fe0e9d45ef32f4df85658232ca0e2", size = 66464, upload-time = "2025-08-10T21:26:27.733Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/7a/9d90a151f558e29c3936b8a47ac770235f436f2120aca41a6d5f3d62ae8d/kiwisolver-1.4.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a12cf6398e8a0a001a059747a1cbf24705e18fe413bc22de7b3d15c67cffe3f", size = 64961, upload-time = "2025-08-10T21:26:28.729Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/e9/f218a2cb3a9ffbe324ca29a9e399fa2d2866d7f348ec3a88df87fc248fc5/kiwisolver-1.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b67e6efbf68e077dd71d1a6b37e43e1a99d0bff1a3d51867d45ee8908b931098", size = 1474607, upload-time = "2025-08-10T21:26:29.798Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/28/aac26d4c882f14de59041636292bc838db8961373825df23b8eeb807e198/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5656aa670507437af0207645273ccdfee4f14bacd7f7c67a4306d0dcaeaf6eed", size = 1276546, upload-time = "2025-08-10T21:26:31.401Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/ad/8bfc1c93d4cc565e5069162f610ba2f48ff39b7de4b5b8d93f69f30c4bed/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bfc08add558155345129c7803b3671cf195e6a56e7a12f3dde7c57d9b417f525", size = 1294482, upload-time = "2025-08-10T21:26:32.721Z" },
+ { url = "https://files.pythonhosted.org/packages/da/f1/6aca55ff798901d8ce403206d00e033191f63d82dd708a186e0ed2067e9c/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:40092754720b174e6ccf9e845d0d8c7d8e12c3d71e7fc35f55f3813e96376f78", size = 1343720, upload-time = "2025-08-10T21:26:34.032Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/91/eed031876c595c81d90d0f6fc681ece250e14bf6998c3d7c419466b523b7/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:497d05f29a1300d14e02e6441cf0f5ee81c1ff5a304b0d9fb77423974684e08b", size = 2224907, upload-time = "2025-08-10T21:26:35.824Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/ec/4d1925f2e49617b9cca9c34bfa11adefad49d00db038e692a559454dfb2e/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bdd1a81a1860476eb41ac4bc1e07b3f07259e6d55bbf739b79c8aaedcf512799", size = 2321334, upload-time = "2025-08-10T21:26:37.534Z" },
+ { url = "https://files.pythonhosted.org/packages/43/cb/450cd4499356f68802750c6ddc18647b8ea01ffa28f50d20598e0befe6e9/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:e6b93f13371d341afee3be9f7c5964e3fe61d5fa30f6a30eb49856935dfe4fc3", size = 2488313, upload-time = "2025-08-10T21:26:39.191Z" },
+ { url = "https://files.pythonhosted.org/packages/71/67/fc76242bd99f885651128a5d4fa6083e5524694b7c88b489b1b55fdc491d/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d75aa530ccfaa593da12834b86a0724f58bff12706659baa9227c2ccaa06264c", size = 2291970, upload-time = "2025-08-10T21:26:40.828Z" },
+ { url = "https://files.pythonhosted.org/packages/75/bd/f1a5d894000941739f2ae1b65a32892349423ad49c2e6d0771d0bad3fae4/kiwisolver-1.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:dd0a578400839256df88c16abddf9ba14813ec5f21362e1fe65022e00c883d4d", size = 73894, upload-time = "2025-08-10T21:26:42.33Z" },
+ { url = "https://files.pythonhosted.org/packages/95/38/dce480814d25b99a391abbddadc78f7c117c6da34be68ca8b02d5848b424/kiwisolver-1.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:d4188e73af84ca82468f09cadc5ac4db578109e52acb4518d8154698d3a87ca2", size = 64995, upload-time = "2025-08-10T21:26:43.889Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/37/7d218ce5d92dadc5ebdd9070d903e0c7cf7edfe03f179433ac4d13ce659c/kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:5a0f2724dfd4e3b3ac5a82436a8e6fd16baa7d507117e4279b660fe8ca38a3a1", size = 126510, upload-time = "2025-08-10T21:26:44.915Z" },
+ { url = "https://files.pythonhosted.org/packages/23/b0/e85a2b48233daef4b648fb657ebbb6f8367696a2d9548a00b4ee0eb67803/kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1b11d6a633e4ed84fc0ddafd4ebfd8ea49b3f25082c04ad12b8315c11d504dc1", size = 67903, upload-time = "2025-08-10T21:26:45.934Z" },
+ { url = "https://files.pythonhosted.org/packages/44/98/f2425bc0113ad7de24da6bb4dae1343476e95e1d738be7c04d31a5d037fd/kiwisolver-1.4.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61874cdb0a36016354853593cffc38e56fc9ca5aa97d2c05d3dcf6922cd55a11", size = 66402, upload-time = "2025-08-10T21:26:47.101Z" },
+ { url = "https://files.pythonhosted.org/packages/98/d8/594657886df9f34c4177cc353cc28ca7e6e5eb562d37ccc233bff43bbe2a/kiwisolver-1.4.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:60c439763a969a6af93b4881db0eed8fadf93ee98e18cbc35bc8da868d0c4f0c", size = 1582135, upload-time = "2025-08-10T21:26:48.665Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/c6/38a115b7170f8b306fc929e166340c24958347308ea3012c2b44e7e295db/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92a2f997387a1b79a75e7803aa7ded2cfbe2823852ccf1ba3bcf613b62ae3197", size = 1389409, upload-time = "2025-08-10T21:26:50.335Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/3b/e04883dace81f24a568bcee6eb3001da4ba05114afa622ec9b6fafdc1f5e/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31d512c812daea6d8b3be3b2bfcbeb091dbb09177706569bcfc6240dcf8b41c", size = 1401763, upload-time = "2025-08-10T21:26:51.867Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/80/20ace48e33408947af49d7d15c341eaee69e4e0304aab4b7660e234d6288/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:52a15b0f35dad39862d376df10c5230155243a2c1a436e39eb55623ccbd68185", size = 1453643, upload-time = "2025-08-10T21:26:53.592Z" },
+ { url = "https://files.pythonhosted.org/packages/64/31/6ce4380a4cd1f515bdda976a1e90e547ccd47b67a1546d63884463c92ca9/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a30fd6fdef1430fd9e1ba7b3398b5ee4e2887783917a687d86ba69985fb08748", size = 2330818, upload-time = "2025-08-10T21:26:55.051Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/e9/3f3fcba3bcc7432c795b82646306e822f3fd74df0ee81f0fa067a1f95668/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cc9617b46837c6468197b5945e196ee9ca43057bb7d9d1ae688101e4e1dddf64", size = 2419963, upload-time = "2025-08-10T21:26:56.421Z" },
+ { url = "https://files.pythonhosted.org/packages/99/43/7320c50e4133575c66e9f7dadead35ab22d7c012a3b09bb35647792b2a6d/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:0ab74e19f6a2b027ea4f845a78827969af45ce790e6cb3e1ebab71bdf9f215ff", size = 2594639, upload-time = "2025-08-10T21:26:57.882Z" },
+ { url = "https://files.pythonhosted.org/packages/65/d6/17ae4a270d4a987ef8a385b906d2bdfc9fce502d6dc0d3aea865b47f548c/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dba5ee5d3981160c28d5490f0d1b7ed730c22470ff7f6cc26cfcfaacb9896a07", size = 2391741, upload-time = "2025-08-10T21:26:59.237Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/8f/8f6f491d595a9e5912971f3f863d81baddccc8a4d0c3749d6a0dd9ffc9df/kiwisolver-1.4.9-cp313-cp313t-win_arm64.whl", hash = "sha256:0749fd8f4218ad2e851e11cc4dc05c7cbc0cbc4267bdfdb31782e65aace4ee9c", size = 68646, upload-time = "2025-08-10T21:27:00.52Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/32/6cc0fbc9c54d06c2969faa9c1d29f5751a2e51809dd55c69055e62d9b426/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9928fe1eb816d11ae170885a74d074f57af3a0d65777ca47e9aeb854a1fba386", size = 123806, upload-time = "2025-08-10T21:27:01.537Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/dd/2bfb1d4a4823d92e8cbb420fe024b8d2167f72079b3bb941207c42570bdf/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d0005b053977e7b43388ddec89fa567f43d4f6d5c2c0affe57de5ebf290dc552", size = 66605, upload-time = "2025-08-10T21:27:03.335Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/69/00aafdb4e4509c2ca6064646cba9cd4b37933898f426756adb2cb92ebbed/kiwisolver-1.4.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2635d352d67458b66fd0667c14cb1d4145e9560d503219034a18a87e971ce4f3", size = 64925, upload-time = "2025-08-10T21:27:04.339Z" },
+ { url = "https://files.pythonhosted.org/packages/43/dc/51acc6791aa14e5cb6d8a2e28cefb0dc2886d8862795449d021334c0df20/kiwisolver-1.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:767c23ad1c58c9e827b649a9ab7809fd5fd9db266a9cf02b0e926ddc2c680d58", size = 1472414, upload-time = "2025-08-10T21:27:05.437Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/bb/93fa64a81db304ac8a246f834d5094fae4b13baf53c839d6bb6e81177129/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72d0eb9fba308b8311685c2268cf7d0a0639a6cd027d8128659f72bdd8a024b4", size = 1281272, upload-time = "2025-08-10T21:27:07.063Z" },
+ { url = "https://files.pythonhosted.org/packages/70/e6/6df102916960fb8d05069d4bd92d6d9a8202d5a3e2444494e7cd50f65b7a/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f68e4f3eeca8fb22cc3d731f9715a13b652795ef657a13df1ad0c7dc0e9731df", size = 1298578, upload-time = "2025-08-10T21:27:08.452Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/47/e142aaa612f5343736b087864dbaebc53ea8831453fb47e7521fa8658f30/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d84cd4061ae292d8ac367b2c3fa3aad11cb8625a95d135fe93f286f914f3f5a6", size = 1345607, upload-time = "2025-08-10T21:27:10.125Z" },
+ { url = "https://files.pythonhosted.org/packages/54/89/d641a746194a0f4d1a3670fb900d0dbaa786fb98341056814bc3f058fa52/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a60ea74330b91bd22a29638940d115df9dc00af5035a9a2a6ad9399ffb4ceca5", size = 2230150, upload-time = "2025-08-10T21:27:11.484Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/6b/5ee1207198febdf16ac11f78c5ae40861b809cbe0e6d2a8d5b0b3044b199/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ce6a3a4e106cf35c2d9c4fa17c05ce0b180db622736845d4315519397a77beaf", size = 2325979, upload-time = "2025-08-10T21:27:12.917Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/ff/b269eefd90f4ae14dcc74973d5a0f6d28d3b9bb1afd8c0340513afe6b39a/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:77937e5e2a38a7b48eef0585114fe7930346993a88060d0bf886086d2aa49ef5", size = 2491456, upload-time = "2025-08-10T21:27:14.353Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/d4/10303190bd4d30de547534601e259a4fbf014eed94aae3e5521129215086/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:24c175051354f4a28c5d6a31c93906dc653e2bf234e8a4bbfb964892078898ce", size = 2294621, upload-time = "2025-08-10T21:27:15.808Z" },
+ { url = "https://files.pythonhosted.org/packages/28/e0/a9a90416fce5c0be25742729c2ea52105d62eda6c4be4d803c2a7be1fa50/kiwisolver-1.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:0763515d4df10edf6d06a3c19734e2566368980d21ebec439f33f9eb936c07b7", size = 75417, upload-time = "2025-08-10T21:27:17.436Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/10/6949958215b7a9a264299a7db195564e87900f709db9245e4ebdd3c70779/kiwisolver-1.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:0e4e2bf29574a6a7b7f6cb5fa69293b9f96c928949ac4a53ba3f525dffb87f9c", size = 66582, upload-time = "2025-08-10T21:27:18.436Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/79/60e53067903d3bc5469b369fe0dfc6b3482e2133e85dae9daa9527535991/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d976bbb382b202f71c67f77b0ac11244021cfa3f7dfd9e562eefcea2df711548", size = 126514, upload-time = "2025-08-10T21:27:19.465Z" },
+ { url = "https://files.pythonhosted.org/packages/25/d1/4843d3e8d46b072c12a38c97c57fab4608d36e13fe47d47ee96b4d61ba6f/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2489e4e5d7ef9a1c300a5e0196e43d9c739f066ef23270607d45aba368b91f2d", size = 67905, upload-time = "2025-08-10T21:27:20.51Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/ae/29ffcbd239aea8b93108de1278271ae764dfc0d803a5693914975f200596/kiwisolver-1.4.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e2ea9f7ab7fbf18fffb1b5434ce7c69a07582f7acc7717720f1d69f3e806f90c", size = 66399, upload-time = "2025-08-10T21:27:21.496Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/ae/d7ba902aa604152c2ceba5d352d7b62106bedbccc8e95c3934d94472bfa3/kiwisolver-1.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b34e51affded8faee0dfdb705416153819d8ea9250bbbf7ea1b249bdeb5f1122", size = 1582197, upload-time = "2025-08-10T21:27:22.604Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/41/27c70d427eddb8bc7e4f16420a20fefc6f480312122a59a959fdfe0445ad/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8aacd3d4b33b772542b2e01beb50187536967b514b00003bdda7589722d2a64", size = 1390125, upload-time = "2025-08-10T21:27:24.036Z" },
+ { url = "https://files.pythonhosted.org/packages/41/42/b3799a12bafc76d962ad69083f8b43b12bf4fe78b097b12e105d75c9b8f1/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7cf974dd4e35fa315563ac99d6287a1024e4dc2077b8a7d7cd3d2fb65d283134", size = 1402612, upload-time = "2025-08-10T21:27:25.773Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/b5/a210ea073ea1cfaca1bb5c55a62307d8252f531beb364e18aa1e0888b5a0/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:85bd218b5ecfbee8c8a82e121802dcb519a86044c9c3b2e4aef02fa05c6da370", size = 1453990, upload-time = "2025-08-10T21:27:27.089Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/ce/a829eb8c033e977d7ea03ed32fb3c1781b4fa0433fbadfff29e39c676f32/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0856e241c2d3df4efef7c04a1e46b1936b6120c9bcf36dd216e3acd84bc4fb21", size = 2331601, upload-time = "2025-08-10T21:27:29.343Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/4b/b5e97eb142eb9cd0072dacfcdcd31b1c66dc7352b0f7c7255d339c0edf00/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9af39d6551f97d31a4deebeac6f45b156f9755ddc59c07b402c148f5dbb6482a", size = 2422041, upload-time = "2025-08-10T21:27:30.754Z" },
+ { url = "https://files.pythonhosted.org/packages/40/be/8eb4cd53e1b85ba4edc3a9321666f12b83113a178845593307a3e7891f44/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:bb4ae2b57fc1d8cbd1cf7b1d9913803681ffa903e7488012be5b76dedf49297f", size = 2594897, upload-time = "2025-08-10T21:27:32.803Z" },
+ { url = "https://files.pythonhosted.org/packages/99/dd/841e9a66c4715477ea0abc78da039832fbb09dac5c35c58dc4c41a407b8a/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:aedff62918805fb62d43a4aa2ecd4482c380dc76cd31bd7c8878588a61bd0369", size = 2391835, upload-time = "2025-08-10T21:27:34.23Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/28/4b2e5c47a0da96896fdfdb006340ade064afa1e63675d01ea5ac222b6d52/kiwisolver-1.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:1fa333e8b2ce4d9660f2cda9c0e1b6bafcfb2457a9d259faa82289e73ec24891", size = 79988, upload-time = "2025-08-10T21:27:35.587Z" },
+ { url = "https://files.pythonhosted.org/packages/80/be/3578e8afd18c88cdf9cb4cffde75a96d2be38c5a903f1ed0ceec061bd09e/kiwisolver-1.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:4a48a2ce79d65d363597ef7b567ce3d14d68783d2b2263d98db3d9477805ba32", size = 70260, upload-time = "2025-08-10T21:27:36.606Z" },
+]
+
[[package]]
name = "lsprotocol"
version = "2025.0.0"
@@ -494,6 +736,60 @@ 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 = "matplotlib"
+version = "3.10.8"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "contourpy" },
+ { name = "cycler" },
+ { name = "fonttools" },
+ { name = "kiwisolver" },
+ { name = "numpy" },
+ { name = "packaging" },
+ { name = "pillow" },
+ { name = "pyparsing" },
+ { name = "python-dateutil" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8a/76/d3c6e3a13fe484ebe7718d14e269c9569c4eb0020a968a327acb3b9a8fe6/matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3", size = 34806269, upload-time = "2025-12-10T22:56:51.155Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9e/67/f997cdcbb514012eb0d10cd2b4b332667997fb5ebe26b8d41d04962fa0e6/matplotlib-3.10.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:64fcc24778ca0404ce0cb7b6b77ae1f4c7231cdd60e6778f999ee05cbd581b9a", size = 8260453, upload-time = "2025-12-10T22:55:30.709Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/65/07d5f5c7f7c994f12c768708bd2e17a4f01a2b0f44a1c9eccad872433e2e/matplotlib-3.10.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9a5ca4ac220a0cdd1ba6bcba3608547117d30468fefce49bb26f55c1a3d5c58", size = 8148321, upload-time = "2025-12-10T22:55:33.265Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/f3/c5195b1ae57ef85339fd7285dfb603b22c8b4e79114bae5f4f0fcf688677/matplotlib-3.10.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3ab4aabc72de4ff77b3ec33a6d78a68227bf1123465887f9905ba79184a1cc04", size = 8716944, upload-time = "2025-12-10T22:55:34.922Z" },
+ { url = "https://files.pythonhosted.org/packages/00/f9/7638f5cc82ec8a7aa005de48622eecc3ed7c9854b96ba15bd76b7fd27574/matplotlib-3.10.8-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24d50994d8c5816ddc35411e50a86ab05f575e2530c02752e02538122613371f", size = 9550099, upload-time = "2025-12-10T22:55:36.789Z" },
+ { url = "https://files.pythonhosted.org/packages/57/61/78cd5920d35b29fd2a0fe894de8adf672ff52939d2e9b43cb83cd5ce1bc7/matplotlib-3.10.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:99eefd13c0dc3b3c1b4d561c1169e65fe47aab7b8158754d7c084088e2329466", size = 9613040, upload-time = "2025-12-10T22:55:38.715Z" },
+ { url = "https://files.pythonhosted.org/packages/30/4e/c10f171b6e2f44d9e3a2b96efa38b1677439d79c99357600a62cc1e9594e/matplotlib-3.10.8-cp312-cp312-win_amd64.whl", hash = "sha256:dd80ecb295460a5d9d260df63c43f4afbdd832d725a531f008dad1664f458adf", size = 8142717, upload-time = "2025-12-10T22:55:41.103Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/76/934db220026b5fef85f45d51a738b91dea7d70207581063cd9bd8fafcf74/matplotlib-3.10.8-cp312-cp312-win_arm64.whl", hash = "sha256:3c624e43ed56313651bc18a47f838b60d7b8032ed348911c54906b130b20071b", size = 8012751, upload-time = "2025-12-10T22:55:42.684Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/b9/15fd5541ef4f5b9a17eefd379356cf12175fe577424e7b1d80676516031a/matplotlib-3.10.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3f2e409836d7f5ac2f1c013110a4d50b9f7edc26328c108915f9075d7d7a91b6", size = 8261076, upload-time = "2025-12-10T22:55:44.648Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/a0/2ba3473c1b66b9c74dc7107c67e9008cb1782edbe896d4c899d39ae9cf78/matplotlib-3.10.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56271f3dac49a88d7fca5060f004d9d22b865f743a12a23b1e937a0be4818ee1", size = 8148794, upload-time = "2025-12-10T22:55:46.252Z" },
+ { url = "https://files.pythonhosted.org/packages/75/97/a471f1c3eb1fd6f6c24a31a5858f443891d5127e63a7788678d14e249aea/matplotlib-3.10.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a0a7f52498f72f13d4a25ea70f35f4cb60642b466cbb0a9be951b5bc3f45a486", size = 8718474, upload-time = "2025-12-10T22:55:47.864Z" },
+ { url = "https://files.pythonhosted.org/packages/01/be/cd478f4b66f48256f42927d0acbcd63a26a893136456cd079c0cc24fbabf/matplotlib-3.10.8-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:646d95230efb9ca614a7a594d4fcacde0ac61d25e37dd51710b36477594963ce", size = 9549637, upload-time = "2025-12-10T22:55:50.048Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/7c/8dc289776eae5109e268c4fb92baf870678dc048a25d4ac903683b86d5bf/matplotlib-3.10.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f89c151aab2e2e23cb3fe0acad1e8b82841fd265379c4cecd0f3fcb34c15e0f6", size = 9613678, upload-time = "2025-12-10T22:55:52.21Z" },
+ { url = "https://files.pythonhosted.org/packages/64/40/37612487cc8a437d4dd261b32ca21fe2d79510fe74af74e1f42becb1bdb8/matplotlib-3.10.8-cp313-cp313-win_amd64.whl", hash = "sha256:e8ea3e2d4066083e264e75c829078f9e149fa119d27e19acd503de65e0b13149", size = 8142686, upload-time = "2025-12-10T22:55:54.253Z" },
+ { url = "https://files.pythonhosted.org/packages/66/52/8d8a8730e968185514680c2a6625943f70269509c3dcfc0dcf7d75928cb8/matplotlib-3.10.8-cp313-cp313-win_arm64.whl", hash = "sha256:c108a1d6fa78a50646029cb6d49808ff0fc1330fda87fa6f6250c6b5369b6645", size = 8012917, upload-time = "2025-12-10T22:55:56.268Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/27/51fe26e1062f298af5ef66343d8ef460e090a27fea73036c76c35821df04/matplotlib-3.10.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ad3d9833a64cf48cc4300f2b406c3d0f4f4724a91c0bd5640678a6ba7c102077", size = 8305679, upload-time = "2025-12-10T22:55:57.856Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/1e/4de865bc591ac8e3062e835f42dd7fe7a93168d519557837f0e37513f629/matplotlib-3.10.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:eb3823f11823deade26ce3b9f40dcb4a213da7a670013929f31d5f5ed1055b22", size = 8198336, upload-time = "2025-12-10T22:55:59.371Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/cb/2f7b6e75fb4dce87ef91f60cac4f6e34f4c145ab036a22318ec837971300/matplotlib-3.10.8-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d9050fee89a89ed57b4fb2c1bfac9a3d0c57a0d55aed95949eedbc42070fea39", size = 8731653, upload-time = "2025-12-10T22:56:01.032Z" },
+ { url = "https://files.pythonhosted.org/packages/46/b3/bd9c57d6ba670a37ab31fb87ec3e8691b947134b201f881665b28cc039ff/matplotlib-3.10.8-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b44d07310e404ba95f8c25aa5536f154c0a8ec473303535949e52eb71d0a1565", size = 9561356, upload-time = "2025-12-10T22:56:02.95Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/3d/8b94a481456dfc9dfe6e39e93b5ab376e50998cddfd23f4ae3b431708f16/matplotlib-3.10.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0a33deb84c15ede243aead39f77e990469fff93ad1521163305095b77b72ce4a", size = 9614000, upload-time = "2025-12-10T22:56:05.411Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/cd/bc06149fe5585ba800b189a6a654a75f1f127e8aab02fd2be10df7fa500c/matplotlib-3.10.8-cp313-cp313t-win_amd64.whl", hash = "sha256:3a48a78d2786784cc2413e57397981fb45c79e968d99656706018d6e62e57958", size = 8220043, upload-time = "2025-12-10T22:56:07.551Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/de/b22cf255abec916562cc04eef457c13e58a1990048de0c0c3604d082355e/matplotlib-3.10.8-cp313-cp313t-win_arm64.whl", hash = "sha256:15d30132718972c2c074cd14638c7f4592bd98719e2308bccea40e0538bc0cb5", size = 8062075, upload-time = "2025-12-10T22:56:09.178Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/43/9c0ff7a2f11615e516c3b058e1e6e8f9614ddeca53faca06da267c48345d/matplotlib-3.10.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b53285e65d4fa4c86399979e956235deb900be5baa7fc1218ea67fbfaeaadd6f", size = 8262481, upload-time = "2025-12-10T22:56:10.885Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/ca/e8ae28649fcdf039fda5ef554b40a95f50592a3c47e6f7270c9561c12b07/matplotlib-3.10.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32f8dce744be5569bebe789e46727946041199030db8aeb2954d26013a0eb26b", size = 8151473, upload-time = "2025-12-10T22:56:12.377Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/6f/009d129ae70b75e88cbe7e503a12a4c0670e08ed748a902c2568909e9eb5/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cf267add95b1c88300d96ca837833d4112756045364f5c734a2276038dae27d", size = 9553896, upload-time = "2025-12-10T22:56:14.432Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/26/4221a741eb97967bc1fd5e4c52b9aa5a91b2f4ec05b59f6def4d820f9df9/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2cf5bd12cecf46908f286d7838b2abc6c91cda506c0445b8223a7c19a00df008", size = 9824193, upload-time = "2025-12-10T22:56:16.29Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/f3/3abf75f38605772cf48a9daf5821cd4f563472f38b4b828c6fba6fa6d06e/matplotlib-3.10.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:41703cc95688f2516b480f7f339d8851a6035f18e100ee6a32bc0b8536a12a9c", size = 9615444, upload-time = "2025-12-10T22:56:18.155Z" },
+ { url = "https://files.pythonhosted.org/packages/93/a5/de89ac80f10b8dc615807ee1133cd99ac74082581196d4d9590bea10690d/matplotlib-3.10.8-cp314-cp314-win_amd64.whl", hash = "sha256:83d282364ea9f3e52363da262ce32a09dfe241e4080dcedda3c0db059d3c1f11", size = 8272719, upload-time = "2025-12-10T22:56:20.366Z" },
+ { url = "https://files.pythonhosted.org/packages/69/ce/b006495c19ccc0a137b48083168a37bd056392dee02f87dba0472f2797fe/matplotlib-3.10.8-cp314-cp314-win_arm64.whl", hash = "sha256:2c1998e92cd5999e295a731bcb2911c75f597d937341f3030cc24ef2733d78a8", size = 8144205, upload-time = "2025-12-10T22:56:22.239Z" },
+ { url = "https://files.pythonhosted.org/packages/68/d9/b31116a3a855bd313c6fcdb7226926d59b041f26061c6c5b1be66a08c826/matplotlib-3.10.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b5a2b97dbdc7d4f353ebf343744f1d1f1cca8aa8bfddb4262fcf4306c3761d50", size = 8305785, upload-time = "2025-12-10T22:56:24.218Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/90/6effe8103f0272685767ba5f094f453784057072f49b393e3ea178fe70a5/matplotlib-3.10.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f5c3e4da343bba819f0234186b9004faba952cc420fbc522dc4e103c1985908", size = 8198361, upload-time = "2025-12-10T22:56:26.787Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/65/a73188711bea603615fc0baecca1061429ac16940e2385433cc778a9d8e7/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f62550b9a30afde8c1c3ae450e5eb547d579dd69b25c2fc7a1c67f934c1717a", size = 9561357, upload-time = "2025-12-10T22:56:28.953Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/3d/b5c5d5d5be8ce63292567f0e2c43dde9953d3ed86ac2de0a72e93c8f07a1/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:495672de149445ec1b772ff2c9ede9b769e3cb4f0d0aa7fa730d7f59e2d4e1c1", size = 9823610, upload-time = "2025-12-10T22:56:31.455Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/4b/e7beb6bbd49f6bae727a12b270a2654d13c397576d25bd6786e47033300f/matplotlib-3.10.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:595ba4d8fe983b88f0eec8c26a241e16d6376fe1979086232f481f8f3f67494c", size = 9614011, upload-time = "2025-12-10T22:56:33.85Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/e6/76f2813d31f032e65f6f797e3f2f6e4aab95b65015924b1c51370395c28a/matplotlib-3.10.8-cp314-cp314t-win_amd64.whl", hash = "sha256:25d380fe8b1dc32cf8f0b1b448470a77afb195438bafdf1d858bfb876f3edf7b", size = 8362801, upload-time = "2025-12-10T22:56:36.107Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/49/d651878698a0b67f23aa28e17f45a6d6dd3d3f933fa29087fa4ce5947b5a/matplotlib-3.10.8-cp314-cp314t-win_arm64.whl", hash = "sha256:113bb52413ea508ce954a02c10ffd0d565f9c3bc7f2eddc27dfe1731e71c7b5f", size = 8192560, upload-time = "2025-12-10T22:56:38.008Z" },
+]
+
[[package]]
name = "mccabe"
version = "0.7.0"
@@ -553,6 +849,67 @@ 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 = "numpy"
+version = "2.4.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/24/62/ae72ff66c0f1fd959925b4c11f8c2dea61f47f6acaea75a08512cdfe3fed/numpy-2.4.1.tar.gz", hash = "sha256:a1ceafc5042451a858231588a104093474c6a5c57dcc724841f5c888d237d690", size = 20721320, upload-time = "2026-01-10T06:44:59.619Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/78/7f/ec53e32bf10c813604edf07a3682616bd931d026fcde7b6d13195dfb684a/numpy-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d3703409aac693fa82c0aee023a1ae06a6e9d065dba10f5e8e80f642f1e9d0a2", size = 16656888, upload-time = "2026-01-10T06:42:40.913Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/e0/1f9585d7dae8f14864e948fd7fa86c6cb72dee2676ca2748e63b1c5acfe0/numpy-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7211b95ca365519d3596a1d8688a95874cc94219d417504d9ecb2df99fa7bfa8", size = 12373956, upload-time = "2026-01-10T06:42:43.091Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/43/9762e88909ff2326f5e7536fa8cb3c49fb03a7d92705f23e6e7f553d9cb3/numpy-2.4.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:5adf01965456a664fc727ed69cc71848f28d063217c63e1a0e200a118d5eec9a", size = 5202567, upload-time = "2026-01-10T06:42:45.107Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/ee/34b7930eb61e79feb4478800a4b95b46566969d837546aa7c034c742ef98/numpy-2.4.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:26f0bcd9c79a00e339565b303badc74d3ea2bd6d52191eeca5f95936cad107d0", size = 6549459, upload-time = "2026-01-10T06:42:48.152Z" },
+ { url = "https://files.pythonhosted.org/packages/79/e3/5f115fae982565771be994867c89bcd8d7208dbfe9469185497d70de5ddf/numpy-2.4.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0093e85df2960d7e4049664b26afc58b03236e967fb942354deef3208857a04c", size = 14404859, upload-time = "2026-01-10T06:42:49.947Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/7d/9c8a781c88933725445a859cac5d01b5871588a15969ee6aeb618ba99eee/numpy-2.4.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad270f438cbdd402c364980317fb6b117d9ec5e226fff5b4148dd9aa9fc6e02", size = 16371419, upload-time = "2026-01-10T06:42:52.409Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/d2/8aa084818554543f17cf4162c42f162acbd3bb42688aefdba6628a859f77/numpy-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:297c72b1b98100c2e8f873d5d35fb551fce7040ade83d67dd51d38c8d42a2162", size = 16182131, upload-time = "2026-01-10T06:42:54.694Z" },
+ { url = "https://files.pythonhosted.org/packages/60/db/0425216684297c58a8df35f3284ef56ec4a043e6d283f8a59c53562caf1b/numpy-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf6470d91d34bf669f61d515499859fa7a4c2f7c36434afb70e82df7217933f9", size = 18295342, upload-time = "2026-01-10T06:42:56.991Z" },
+ { url = "https://files.pythonhosted.org/packages/31/4c/14cb9d86240bd8c386c881bafbe43f001284b7cce3bc01623ac9475da163/numpy-2.4.1-cp312-cp312-win32.whl", hash = "sha256:b6bcf39112e956594b3331316d90c90c90fb961e39696bda97b89462f5f3943f", size = 5959015, upload-time = "2026-01-10T06:42:59.631Z" },
+ { url = "https://files.pythonhosted.org/packages/51/cf/52a703dbeb0c65807540d29699fef5fda073434ff61846a564d5c296420f/numpy-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:e1a27bb1b2dee45a2a53f5ca6ff2d1a7f135287883a1689e930d44d1ff296c87", size = 12310730, upload-time = "2026-01-10T06:43:01.627Z" },
+ { url = "https://files.pythonhosted.org/packages/69/80/a828b2d0ade5e74a9fe0f4e0a17c30fdc26232ad2bc8c9f8b3197cf7cf18/numpy-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:0e6e8f9d9ecf95399982019c01223dc130542960a12edfa8edd1122dfa66a8a8", size = 10312166, upload-time = "2026-01-10T06:43:03.673Z" },
+ { url = "https://files.pythonhosted.org/packages/04/68/732d4b7811c00775f3bd522a21e8dd5a23f77eb11acdeb663e4a4ebf0ef4/numpy-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d797454e37570cfd61143b73b8debd623c3c0952959adb817dd310a483d58a1b", size = 16652495, upload-time = "2026-01-10T06:43:06.283Z" },
+ { url = "https://files.pythonhosted.org/packages/20/ca/857722353421a27f1465652b2c66813eeeccea9d76d5f7b74b99f298e60e/numpy-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82c55962006156aeef1629b953fd359064aa47e4d82cfc8e67f0918f7da3344f", size = 12368657, upload-time = "2026-01-10T06:43:09.094Z" },
+ { url = "https://files.pythonhosted.org/packages/81/0d/2377c917513449cc6240031a79d30eb9a163d32a91e79e0da47c43f2c0c8/numpy-2.4.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:71abbea030f2cfc3092a0ff9f8c8fdefdc5e0bf7d9d9c99663538bb0ecdac0b9", size = 5197256, upload-time = "2026-01-10T06:43:13.634Z" },
+ { url = "https://files.pythonhosted.org/packages/17/39/569452228de3f5de9064ac75137082c6214be1f5c532016549a7923ab4b5/numpy-2.4.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:5b55aa56165b17aaf15520beb9cbd33c9039810e0d9643dd4379e44294c7303e", size = 6545212, upload-time = "2026-01-10T06:43:15.661Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/a4/77333f4d1e4dac4395385482557aeecf4826e6ff517e32ca48e1dafbe42a/numpy-2.4.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0faba4a331195bfa96f93dd9dfaa10b2c7aa8cda3a02b7fd635e588fe821bf5", size = 14402871, upload-time = "2026-01-10T06:43:17.324Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/87/d341e519956273b39d8d47969dd1eaa1af740615394fe67d06f1efa68773/numpy-2.4.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e3087f53e2b4428766b54932644d148613c5a595150533ae7f00dab2f319a8", size = 16359305, upload-time = "2026-01-10T06:43:19.376Z" },
+ { url = "https://files.pythonhosted.org/packages/32/91/789132c6666288eaa20ae8066bb99eba1939362e8f1a534949a215246e97/numpy-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:49e792ec351315e16da54b543db06ca8a86985ab682602d90c60ef4ff4db2a9c", size = 16181909, upload-time = "2026-01-10T06:43:21.808Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/b8/090b8bd27b82a844bb22ff8fdf7935cb1980b48d6e439ae116f53cdc2143/numpy-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:79e9e06c4c2379db47f3f6fc7a8652e7498251789bf8ff5bd43bf478ef314ca2", size = 18284380, upload-time = "2026-01-10T06:43:23.957Z" },
+ { url = "https://files.pythonhosted.org/packages/67/78/722b62bd31842ff029412271556a1a27a98f45359dea78b1548a3a9996aa/numpy-2.4.1-cp313-cp313-win32.whl", hash = "sha256:3d1a100e48cb266090a031397863ff8a30050ceefd798f686ff92c67a486753d", size = 5957089, upload-time = "2026-01-10T06:43:27.535Z" },
+ { url = "https://files.pythonhosted.org/packages/da/a6/cf32198b0b6e18d4fbfa9a21a992a7fca535b9bb2b0cdd217d4a3445b5ca/numpy-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:92a0e65272fd60bfa0d9278e0484c2f52fe03b97aedc02b357f33fe752c52ffb", size = 12307230, upload-time = "2026-01-10T06:43:29.298Z" },
+ { url = "https://files.pythonhosted.org/packages/44/6c/534d692bfb7d0afe30611320c5fb713659dcb5104d7cc182aff2aea092f5/numpy-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:20d4649c773f66cc2fc36f663e091f57c3b7655f936a4c681b4250855d1da8f5", size = 10313125, upload-time = "2026-01-10T06:43:31.782Z" },
+ { url = "https://files.pythonhosted.org/packages/da/a1/354583ac5c4caa566de6ddfbc42744409b515039e085fab6e0ff942e0df5/numpy-2.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f93bc6892fe7b0663e5ffa83b61aab510aacffd58c16e012bb9352d489d90cb7", size = 12496156, upload-time = "2026-01-10T06:43:34.237Z" },
+ { url = "https://files.pythonhosted.org/packages/51/b0/42807c6e8cce58c00127b1dc24d365305189991f2a7917aa694a109c8d7d/numpy-2.4.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:178de8f87948163d98a4c9ab5bee4ce6519ca918926ec8df195af582de28544d", size = 5324663, upload-time = "2026-01-10T06:43:36.211Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/55/7a621694010d92375ed82f312b2f28017694ed784775269115323e37f5e2/numpy-2.4.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:98b35775e03ab7f868908b524fc0a84d38932d8daf7b7e1c3c3a1b6c7a2c9f15", size = 6645224, upload-time = "2026-01-10T06:43:37.884Z" },
+ { url = "https://files.pythonhosted.org/packages/50/96/9fa8635ed9d7c847d87e30c834f7109fac5e88549d79ef3324ab5c20919f/numpy-2.4.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:941c2a93313d030f219f3a71fd3d91a728b82979a5e8034eb2e60d394a2b83f9", size = 14462352, upload-time = "2026-01-10T06:43:39.479Z" },
+ { url = "https://files.pythonhosted.org/packages/03/d1/8cf62d8bb2062da4fb82dd5d49e47c923f9c0738032f054e0a75342faba7/numpy-2.4.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:529050522e983e00a6c1c6b67411083630de8b57f65e853d7b03d9281b8694d2", size = 16407279, upload-time = "2026-01-10T06:43:41.93Z" },
+ { url = "https://files.pythonhosted.org/packages/86/1c/95c86e17c6b0b31ce6ef219da00f71113b220bcb14938c8d9a05cee0ff53/numpy-2.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2302dc0224c1cbc49bb94f7064f3f923a971bfae45c33870dcbff63a2a550505", size = 16248316, upload-time = "2026-01-10T06:43:44.121Z" },
+ { url = "https://files.pythonhosted.org/packages/30/b4/e7f5ff8697274c9d0fa82398b6a372a27e5cef069b37df6355ccb1f1db1a/numpy-2.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9171a42fcad32dcf3fa86f0a4faa5e9f8facefdb276f54b8b390d90447cff4e2", size = 18329884, upload-time = "2026-01-10T06:43:46.613Z" },
+ { url = "https://files.pythonhosted.org/packages/37/a4/b073f3e9d77f9aec8debe8ca7f9f6a09e888ad1ba7488f0c3b36a94c03ac/numpy-2.4.1-cp313-cp313t-win32.whl", hash = "sha256:382ad67d99ef49024f11d1ce5dcb5ad8432446e4246a4b014418ba3a1175a1f4", size = 6081138, upload-time = "2026-01-10T06:43:48.854Z" },
+ { url = "https://files.pythonhosted.org/packages/16/16/af42337b53844e67752a092481ab869c0523bc95c4e5c98e4dac4e9581ac/numpy-2.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:62fea415f83ad8fdb6c20840578e5fbaf5ddd65e0ec6c3c47eda0f69da172510", size = 12447478, upload-time = "2026-01-10T06:43:50.476Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/f8/fa85b2eac68ec631d0b631abc448552cb17d39afd17ec53dcbcc3537681a/numpy-2.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a7870e8c5fc11aef57d6fea4b4085e537a3a60ad2cdd14322ed531fdca68d261", size = 10382981, upload-time = "2026-01-10T06:43:52.575Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/a7/ef08d25698e0e4b4efbad8d55251d20fe2a15f6d9aa7c9b30cd03c165e6f/numpy-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3869ea1ee1a1edc16c29bbe3a2f2a4e515cc3a44d43903ad41e0cacdbaf733dc", size = 16652046, upload-time = "2026-01-10T06:43:54.797Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/39/e378b3e3ca13477e5ac70293ec027c438d1927f18637e396fe90b1addd72/numpy-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e867df947d427cdd7a60e3e271729090b0f0df80f5f10ab7dd436f40811699c3", size = 12378858, upload-time = "2026-01-10T06:43:57.099Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/74/7ec6154f0006910ed1fdbb7591cf4432307033102b8a22041599935f8969/numpy-2.4.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e3bd2cb07841166420d2fa7146c96ce00cb3410664cbc1a6be028e456c4ee220", size = 5207417, upload-time = "2026-01-10T06:43:59.037Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/b7/053ac11820d84e42f8feea5cb81cc4fcd1091499b45b1ed8c7415b1bf831/numpy-2.4.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:f0a90aba7d521e6954670550e561a4cb925713bd944445dbe9e729b71f6cabee", size = 6542643, upload-time = "2026-01-10T06:44:01.852Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/c4/2e7908915c0e32ca636b92e4e4a3bdec4cb1e7eb0f8aedf1ed3c68a0d8cd/numpy-2.4.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d558123217a83b2d1ba316b986e9248a1ed1971ad495963d555ccd75dcb1556", size = 14418963, upload-time = "2026-01-10T06:44:04.047Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/c0/3ed5083d94e7ffd7c404e54619c088e11f2e1939a9544f5397f4adb1b8ba/numpy-2.4.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f44de05659b67d20499cbc96d49f2650769afcb398b79b324bb6e297bfe3844", size = 16363811, upload-time = "2026-01-10T06:44:06.207Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/68/42b66f1852bf525050a67315a4fb94586ab7e9eaa541b1bef530fab0c5dd/numpy-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:69e7419c9012c4aaf695109564e3387f1259f001b4326dfa55907b098af082d3", size = 16197643, upload-time = "2026-01-10T06:44:08.33Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/40/e8714fc933d85f82c6bfc7b998a0649ad9769a32f3494ba86598aaf18a48/numpy-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffd257026eb1b34352e749d7cc1678b5eeec3e329ad8c9965a797e08ccba205", size = 18289601, upload-time = "2026-01-10T06:44:10.841Z" },
+ { url = "https://files.pythonhosted.org/packages/80/9a/0d44b468cad50315127e884802351723daca7cf1c98d102929468c81d439/numpy-2.4.1-cp314-cp314-win32.whl", hash = "sha256:727c6c3275ddefa0dc078524a85e064c057b4f4e71ca5ca29a19163c607be745", size = 6005722, upload-time = "2026-01-10T06:44:13.332Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/bb/c6513edcce5a831810e2dddc0d3452ce84d208af92405a0c2e58fd8e7881/numpy-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:7d5d7999df434a038d75a748275cd6c0094b0ecdb0837342b332a82defc4dc4d", size = 12438590, upload-time = "2026-01-10T06:44:15.006Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/da/a598d5cb260780cf4d255102deba35c1d072dc028c4547832f45dd3323a8/numpy-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:ce9ce141a505053b3c7bce3216071f3bf5c182b8b28930f14cd24d43932cd2df", size = 10596180, upload-time = "2026-01-10T06:44:17.386Z" },
+ { url = "https://files.pythonhosted.org/packages/de/bc/ea3f2c96fcb382311827231f911723aeff596364eb6e1b6d1d91128aa29b/numpy-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e53170557d37ae404bf8d542ca5b7c629d6efa1117dac6a83e394142ea0a43f", size = 12498774, upload-time = "2026-01-10T06:44:19.467Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/ab/ef9d939fe4a812648c7a712610b2ca6140b0853c5efea361301006c02ae5/numpy-2.4.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:a73044b752f5d34d4232f25f18160a1cc418ea4507f5f11e299d8ac36875f8a0", size = 5327274, upload-time = "2026-01-10T06:44:23.189Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/31/d381368e2a95c3b08b8cf7faac6004849e960f4a042d920337f71cef0cae/numpy-2.4.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:fb1461c99de4d040666ca0444057b06541e5642f800b71c56e6ea92d6a853a0c", size = 6648306, upload-time = "2026-01-10T06:44:25.012Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/e5/0989b44ade47430be6323d05c23207636d67d7362a1796ccbccac6773dd2/numpy-2.4.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423797bdab2eeefbe608d7c1ec7b2b4fd3c58d51460f1ee26c7500a1d9c9ee93", size = 14464653, upload-time = "2026-01-10T06:44:26.706Z" },
+ { url = "https://files.pythonhosted.org/packages/10/a7/cfbe475c35371cae1358e61f20c5f075badc18c4797ab4354140e1d283cf/numpy-2.4.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:52b5f61bdb323b566b528899cc7db2ba5d1015bda7ea811a8bcf3c89c331fa42", size = 16405144, upload-time = "2026-01-10T06:44:29.378Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/a3/0c63fe66b534888fa5177cc7cef061541064dbe2b4b60dcc60ffaf0d2157/numpy-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42d7dd5fa36d16d52a84f821eb96031836fd405ee6955dd732f2023724d0aa01", size = 16247425, upload-time = "2026-01-10T06:44:31.721Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/2b/55d980cfa2c93bd40ff4c290bf824d792bd41d2fe3487b07707559071760/numpy-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7b6b5e28bbd47b7532698e5db2fe1db693d84b58c254e4389d99a27bb9b8f6b", size = 18330053, upload-time = "2026-01-10T06:44:34.617Z" },
+ { url = "https://files.pythonhosted.org/packages/23/12/8b5fc6b9c487a09a7957188e0943c9ff08432c65e34567cabc1623b03a51/numpy-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:5de60946f14ebe15e713a6f22850c2372fa72f4ff9a432ab44aa90edcadaa65a", size = 6152482, upload-time = "2026-01-10T06:44:36.798Z" },
+ { url = "https://files.pythonhosted.org/packages/00/a5/9f8ca5856b8940492fc24fbe13c1bc34d65ddf4079097cf9e53164d094e1/numpy-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:8f085da926c0d491ffff3096f91078cc97ea67e7e6b65e490bc8dcda65663be2", size = 12627117, upload-time = "2026-01-10T06:44:38.828Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/0d/eca3d962f9eef265f01a8e0d20085c6dd1f443cbffc11b6dede81fd82356/numpy-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6436cffb4f2bf26c974344439439c95e152c9a527013f26b3577be6c2ca64295", size = 10667121, upload-time = "2026-01-10T06:44:41.644Z" },
+]
+
[[package]]
name = "packaging"
version = "25.0"
@@ -571,6 +928,75 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" },
]
+[[package]]
+name = "pillow"
+version = "12.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d0/02/d52c733a2452ef1ffcc123b68e6606d07276b0e358db70eabad7e40042b7/pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9", size = 46977283, upload-time = "2026-01-02T09:13:29.892Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/20/31/dc53fe21a2f2996e1b7d92bf671cdb157079385183ef7c1ae08b485db510/pillow-12.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a332ac4ccb84b6dde65dbace8431f3af08874bf9770719d32a635c4ef411b18b", size = 5262642, upload-time = "2026-01-02T09:11:10.138Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/c1/10e45ac9cc79419cedf5121b42dcca5a50ad2b601fa080f58c22fb27626e/pillow-12.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:907bfa8a9cb790748a9aa4513e37c88c59660da3bcfffbd24a7d9e6abf224551", size = 4657464, upload-time = "2026-01-02T09:11:12.319Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/26/7b82c0ab7ef40ebede7a97c72d473bda5950f609f8e0c77b04af574a0ddb/pillow-12.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:efdc140e7b63b8f739d09a99033aa430accce485ff78e6d311973a67b6bf3208", size = 6234878, upload-time = "2026-01-02T09:11:14.096Z" },
+ { url = "https://files.pythonhosted.org/packages/76/25/27abc9792615b5e886ca9411ba6637b675f1b77af3104710ac7353fe5605/pillow-12.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bef9768cab184e7ae6e559c032e95ba8d07b3023c289f79a2bd36e8bf85605a5", size = 8044868, upload-time = "2026-01-02T09:11:15.903Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/ea/f200a4c36d836100e7bc738fc48cd963d3ba6372ebc8298a889e0cfc3359/pillow-12.1.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:742aea052cf5ab5034a53c3846165bc3ce88d7c38e954120db0ab867ca242661", size = 6349468, upload-time = "2026-01-02T09:11:17.631Z" },
+ { url = "https://files.pythonhosted.org/packages/11/8f/48d0b77ab2200374c66d344459b8958c86693be99526450e7aee714e03e4/pillow-12.1.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6dfc2af5b082b635af6e08e0d1f9f1c4e04d17d4e2ca0ef96131e85eda6eb17", size = 7041518, upload-time = "2026-01-02T09:11:19.389Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/23/c281182eb986b5d31f0a76d2a2c8cd41722d6fb8ed07521e802f9bba52de/pillow-12.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:609e89d9f90b581c8d16358c9087df76024cf058fa693dd3e1e1620823f39670", size = 6462829, upload-time = "2026-01-02T09:11:21.28Z" },
+ { url = "https://files.pythonhosted.org/packages/25/ef/7018273e0faac099d7b00982abdcc39142ae6f3bd9ceb06de09779c4a9d6/pillow-12.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:43b4899cfd091a9693a1278c4982f3e50f7fb7cff5153b05174b4afc9593b616", size = 7166756, upload-time = "2026-01-02T09:11:23.559Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/c8/993d4b7ab2e341fe02ceef9576afcf5830cdec640be2ac5bee1820d693d4/pillow-12.1.0-cp312-cp312-win32.whl", hash = "sha256:aa0c9cc0b82b14766a99fbe6084409972266e82f459821cd26997a488a7261a7", size = 6328770, upload-time = "2026-01-02T09:11:25.661Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/87/90b358775a3f02765d87655237229ba64a997b87efa8ccaca7dd3e36e7a7/pillow-12.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:d70534cea9e7966169ad29a903b99fc507e932069a881d0965a1a84bb57f6c6d", size = 7033406, upload-time = "2026-01-02T09:11:27.474Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/cf/881b457eccacac9e5b2ddd97d5071fb6d668307c57cbf4e3b5278e06e536/pillow-12.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:65b80c1ee7e14a87d6a068dd3b0aea268ffcabfe0498d38661b00c5b4b22e74c", size = 2452612, upload-time = "2026-01-02T09:11:29.309Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/c7/2530a4aa28248623e9d7f27316b42e27c32ec410f695929696f2e0e4a778/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1", size = 4062543, upload-time = "2026-01-02T09:11:31.566Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/1f/40b8eae823dc1519b87d53c30ed9ef085506b05281d313031755c1705f73/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179", size = 4138373, upload-time = "2026-01-02T09:11:33.367Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/77/6fa60634cf06e52139fd0e89e5bbf055e8166c691c42fb162818b7fda31d/pillow-12.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0", size = 3601241, upload-time = "2026-01-02T09:11:35.011Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/bf/28ab865de622e14b747f0cd7877510848252d950e43002e224fb1c9ababf/pillow-12.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587", size = 5262410, upload-time = "2026-01-02T09:11:36.682Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/34/583420a1b55e715937a85bd48c5c0991598247a1fd2eb5423188e765ea02/pillow-12.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac", size = 4657312, upload-time = "2026-01-02T09:11:38.535Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/fd/f5a0896839762885b3376ff04878f86ab2b097c2f9a9cdccf4eda8ba8dc0/pillow-12.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b", size = 6232605, upload-time = "2026-01-02T09:11:40.602Z" },
+ { url = "https://files.pythonhosted.org/packages/98/aa/938a09d127ac1e70e6ed467bd03834350b33ef646b31edb7452d5de43792/pillow-12.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea", size = 8041617, upload-time = "2026-01-02T09:11:42.721Z" },
+ { url = "https://files.pythonhosted.org/packages/17/e8/538b24cb426ac0186e03f80f78bc8dc7246c667f58b540bdd57c71c9f79d/pillow-12.1.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c", size = 6346509, upload-time = "2026-01-02T09:11:44.955Z" },
+ { url = "https://files.pythonhosted.org/packages/01/9a/632e58ec89a32738cabfd9ec418f0e9898a2b4719afc581f07c04a05e3c9/pillow-12.1.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc", size = 7038117, upload-time = "2026-01-02T09:11:46.736Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/a2/d40308cf86eada842ca1f3ffa45d0ca0df7e4ab33c83f81e73f5eaed136d/pillow-12.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644", size = 6460151, upload-time = "2026-01-02T09:11:48.625Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/88/f5b058ad6453a085c5266660a1417bdad590199da1b32fb4efcff9d33b05/pillow-12.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c", size = 7164534, upload-time = "2026-01-02T09:11:50.445Z" },
+ { url = "https://files.pythonhosted.org/packages/19/ce/c17334caea1db789163b5d855a5735e47995b0b5dc8745e9a3605d5f24c0/pillow-12.1.0-cp313-cp313-win32.whl", hash = "sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171", size = 6332551, upload-time = "2026-01-02T09:11:52.234Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/07/74a9d941fa45c90a0d9465098fe1ec85de3e2afbdc15cc4766622d516056/pillow-12.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a", size = 7040087, upload-time = "2026-01-02T09:11:54.822Z" },
+ { url = "https://files.pythonhosted.org/packages/88/09/c99950c075a0e9053d8e880595926302575bc742b1b47fe1bbcc8d388d50/pillow-12.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45", size = 2452470, upload-time = "2026-01-02T09:11:56.522Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/ba/970b7d85ba01f348dee4d65412476321d40ee04dcb51cd3735b9dc94eb58/pillow-12.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d", size = 5264816, upload-time = "2026-01-02T09:11:58.227Z" },
+ { url = "https://files.pythonhosted.org/packages/10/60/650f2fb55fdba7a510d836202aa52f0baac633e50ab1cf18415d332188fb/pillow-12.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0", size = 4660472, upload-time = "2026-01-02T09:12:00.798Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/c0/5273a99478956a099d533c4f46cbaa19fd69d606624f4334b85e50987a08/pillow-12.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554", size = 6268974, upload-time = "2026-01-02T09:12:02.572Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/26/0bf714bc2e73d5267887d47931d53c4ceeceea6978148ed2ab2a4e6463c4/pillow-12.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e", size = 8073070, upload-time = "2026-01-02T09:12:04.75Z" },
+ { url = "https://files.pythonhosted.org/packages/43/cf/1ea826200de111a9d65724c54f927f3111dc5ae297f294b370a670c17786/pillow-12.1.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82", size = 6380176, upload-time = "2026-01-02T09:12:06.626Z" },
+ { url = "https://files.pythonhosted.org/packages/03/e0/7938dd2b2013373fd85d96e0f38d62b7a5a262af21ac274250c7ca7847c9/pillow-12.1.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4", size = 7067061, upload-time = "2026-01-02T09:12:08.624Z" },
+ { url = "https://files.pythonhosted.org/packages/86/ad/a2aa97d37272a929a98437a8c0ac37b3cf012f4f8721e1bd5154699b2518/pillow-12.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0", size = 6491824, upload-time = "2026-01-02T09:12:10.488Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/44/80e46611b288d51b115826f136fb3465653c28f491068a72d3da49b54cd4/pillow-12.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b", size = 7190911, upload-time = "2026-01-02T09:12:12.772Z" },
+ { url = "https://files.pythonhosted.org/packages/86/77/eacc62356b4cf81abe99ff9dbc7402750044aed02cfd6a503f7c6fc11f3e/pillow-12.1.0-cp313-cp313t-win32.whl", hash = "sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65", size = 6336445, upload-time = "2026-01-02T09:12:14.775Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/3c/57d81d0b74d218706dafccb87a87ea44262c43eef98eb3b164fd000e0491/pillow-12.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0", size = 7045354, upload-time = "2026-01-02T09:12:16.599Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/82/8b9b97bba2e3576a340f93b044a3a3a09841170ab4c1eb0d5c93469fd32f/pillow-12.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8", size = 2454547, upload-time = "2026-01-02T09:12:18.704Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/87/bdf971d8bbcf80a348cc3bacfcb239f5882100fe80534b0ce67a784181d8/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5cb7bc1966d031aec37ddb9dcf15c2da5b2e9f7cc3ca7c54473a20a927e1eb91", size = 4062533, upload-time = "2026-01-02T09:12:20.791Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/4f/5eb37a681c68d605eb7034c004875c81f86ec9ef51f5be4a63eadd58859a/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:97e9993d5ed946aba26baf9c1e8cf18adbab584b99f452ee72f7ee8acb882796", size = 4138546, upload-time = "2026-01-02T09:12:23.664Z" },
+ { url = "https://files.pythonhosted.org/packages/11/6d/19a95acb2edbace40dcd582d077b991646b7083c41b98da4ed7555b59733/pillow-12.1.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:414b9a78e14ffeb98128863314e62c3f24b8a86081066625700b7985b3f529bd", size = 3601163, upload-time = "2026-01-02T09:12:26.338Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/36/2b8138e51cb42e4cc39c3297713455548be855a50558c3ac2beebdc251dd/pillow-12.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e6bdb408f7c9dd2a5ff2b14a3b0bb6d4deb29fb9961e6eb3ae2031ae9a5cec13", size = 5266086, upload-time = "2026-01-02T09:12:28.782Z" },
+ { url = "https://files.pythonhosted.org/packages/53/4b/649056e4d22e1caa90816bf99cef0884aed607ed38075bd75f091a607a38/pillow-12.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3413c2ae377550f5487991d444428f1a8ae92784aac79caa8b1e3b89b175f77e", size = 4657344, upload-time = "2026-01-02T09:12:31.117Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/6b/c5742cea0f1ade0cd61485dc3d81f05261fc2276f537fbdc00802de56779/pillow-12.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5dcbe95016e88437ecf33544ba5db21ef1b8dd6e1b434a2cb2a3d605299e643", size = 6232114, upload-time = "2026-01-02T09:12:32.936Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/8f/9f521268ce22d63991601aafd3d48d5ff7280a246a1ef62d626d67b44064/pillow-12.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d0a7735df32ccbcc98b98a1ac785cc4b19b580be1bdf0aeb5c03223220ea09d5", size = 8042708, upload-time = "2026-01-02T09:12:34.78Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/eb/257f38542893f021502a1bbe0c2e883c90b5cff26cc33b1584a841a06d30/pillow-12.1.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c27407a2d1b96774cbc4a7594129cc027339fd800cd081e44497722ea1179de", size = 6347762, upload-time = "2026-01-02T09:12:36.748Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/5a/8ba375025701c09b309e8d5163c5a4ce0102fa86bbf8800eb0d7ac87bc51/pillow-12.1.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15c794d74303828eaa957ff8070846d0efe8c630901a1c753fdc63850e19ecd9", size = 7039265, upload-time = "2026-01-02T09:12:39.082Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/dc/cf5e4cdb3db533f539e88a7bbf9f190c64ab8a08a9bc7a4ccf55067872e4/pillow-12.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c990547452ee2800d8506c4150280757f88532f3de2a58e3022e9b179107862a", size = 6462341, upload-time = "2026-01-02T09:12:40.946Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/47/0291a25ac9550677e22eda48510cfc4fa4b2ef0396448b7fbdc0a6946309/pillow-12.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b63e13dd27da389ed9475b3d28510f0f954bca0041e8e551b2a4eb1eab56a39a", size = 7165395, upload-time = "2026-01-02T09:12:42.706Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/4c/e005a59393ec4d9416be06e6b45820403bb946a778e39ecec62f5b2b991e/pillow-12.1.0-cp314-cp314-win32.whl", hash = "sha256:1a949604f73eb07a8adab38c4fe50791f9919344398bdc8ac6b307f755fc7030", size = 6431413, upload-time = "2026-01-02T09:12:44.944Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/af/f23697f587ac5f9095d67e31b81c95c0249cd461a9798a061ed6709b09b5/pillow-12.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f9f6a650743f0ddee5593ac9e954ba1bdbc5e150bc066586d4f26127853ab94", size = 7176779, upload-time = "2026-01-02T09:12:46.727Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/36/6a51abf8599232f3e9afbd16d52829376a68909fe14efe29084445db4b73/pillow-12.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:808b99604f7873c800c4840f55ff389936ef1948e4e87645eaf3fccbc8477ac4", size = 2543105, upload-time = "2026-01-02T09:12:49.243Z" },
+ { url = "https://files.pythonhosted.org/packages/82/54/2e1dd20c8749ff225080d6ba465a0cab4387f5db0d1c5fb1439e2d99923f/pillow-12.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc11908616c8a283cf7d664f77411a5ed2a02009b0097ff8abbba5e79128ccf2", size = 5268571, upload-time = "2026-01-02T09:12:51.11Z" },
+ { url = "https://files.pythonhosted.org/packages/57/61/571163a5ef86ec0cf30d265ac2a70ae6fc9e28413d1dc94fa37fae6bda89/pillow-12.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:896866d2d436563fa2a43a9d72f417874f16b5545955c54a64941e87c1376c61", size = 4660426, upload-time = "2026-01-02T09:12:52.865Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/e1/53ee5163f794aef1bf84243f755ee6897a92c708505350dd1923f4afec48/pillow-12.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8e178e3e99d3c0ea8fc64b88447f7cac8ccf058af422a6cedc690d0eadd98c51", size = 6269908, upload-time = "2026-01-02T09:12:54.884Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/0b/b4b4106ff0ee1afa1dc599fde6ab230417f800279745124f6c50bcffed8e/pillow-12.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:079af2fb0c599c2ec144ba2c02766d1b55498e373b3ac64687e43849fbbef5bc", size = 8074733, upload-time = "2026-01-02T09:12:56.802Z" },
+ { url = "https://files.pythonhosted.org/packages/19/9f/80b411cbac4a732439e629a26ad3ef11907a8c7fc5377b7602f04f6fe4e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdec5e43377761c5dbca620efb69a77f6855c5a379e32ac5b158f54c84212b14", size = 6381431, upload-time = "2026-01-02T09:12:58.823Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/b7/d65c45db463b66ecb6abc17c6ba6917a911202a07662247e1355ce1789e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:565c986f4b45c020f5421a4cea13ef294dde9509a8577f29b2fc5edc7587fff8", size = 7068529, upload-time = "2026-01-02T09:13:00.885Z" },
+ { url = "https://files.pythonhosted.org/packages/50/96/dfd4cd726b4a45ae6e3c669fc9e49deb2241312605d33aba50499e9d9bd1/pillow-12.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43aca0a55ce1eefc0aefa6253661cb54571857b1a7b2964bd8a1e3ef4b729924", size = 6492981, upload-time = "2026-01-02T09:13:03.314Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/1c/b5dc52cf713ae46033359c5ca920444f18a6359ce1020dd3e9c553ea5bc6/pillow-12.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0deedf2ea233722476b3a81e8cdfbad786f7adbed5d848469fa59fe52396e4ef", size = 7191878, upload-time = "2026-01-02T09:13:05.276Z" },
+ { url = "https://files.pythonhosted.org/packages/53/26/c4188248bd5edaf543864fe4834aebe9c9cb4968b6f573ce014cc42d0720/pillow-12.1.0-cp314-cp314t-win32.whl", hash = "sha256:b17fbdbe01c196e7e159aacb889e091f28e61020a8abeac07b68079b6e626988", size = 6438703, upload-time = "2026-01-02T09:13:07.491Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/0e/69ed296de8ea05cb03ee139cee600f424ca166e632567b2d66727f08c7ed/pillow-12.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27b9baecb428899db6c0de572d6d305cfaf38ca1596b5c0542a5182e3e74e8c6", size = 7182927, upload-time = "2026-01-02T09:13:09.841Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/f5/68334c015eed9b5cff77814258717dec591ded209ab5b6fb70e2ae873d1d/pillow-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831", size = 2545104, upload-time = "2026-01-02T09:13:12.068Z" },
+]
+
[[package]]
name = "platformdirs"
version = "4.5.0"
@@ -601,6 +1027,43 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" },
]
+[[package]]
+name = "psutil"
+version = "7.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/73/cb/09e5184fb5fc0358d110fc3ca7f6b1d033800734d34cac10f4136cfac10e/psutil-7.2.1.tar.gz", hash = "sha256:f7583aec590485b43ca601dd9cea0dcd65bd7bb21d30ef4ddbf4ea6b5ed1bdd3", size = 490253, upload-time = "2025-12-29T08:26:00.169Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/77/8e/f0c242053a368c2aa89584ecd1b054a18683f13d6e5a318fc9ec36582c94/psutil-7.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ba9f33bb525b14c3ea563b2fd521a84d2fa214ec59e3e6a2858f78d0844dd60d", size = 129624, upload-time = "2025-12-29T08:26:04.255Z" },
+ { url = "https://files.pythonhosted.org/packages/26/97/a58a4968f8990617decee234258a2b4fc7cd9e35668387646c1963e69f26/psutil-7.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:81442dac7abfc2f4f4385ea9e12ddf5a796721c0f6133260687fec5c3780fa49", size = 130132, upload-time = "2025-12-29T08:26:06.228Z" },
+ { url = "https://files.pythonhosted.org/packages/db/6d/ed44901e830739af5f72a85fa7ec5ff1edea7f81bfbf4875e409007149bd/psutil-7.2.1-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea46c0d060491051d39f0d2cff4f98d5c72b288289f57a21556cc7d504db37fc", size = 180612, upload-time = "2025-12-29T08:26:08.276Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/65/b628f8459bca4efbfae50d4bf3feaab803de9a160b9d5f3bd9295a33f0c2/psutil-7.2.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35630d5af80d5d0d49cfc4d64c1c13838baf6717a13effb35869a5919b854cdf", size = 183201, upload-time = "2025-12-29T08:26:10.622Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/23/851cadc9764edcc18f0effe7d0bf69f727d4cf2442deb4a9f78d4e4f30f2/psutil-7.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:923f8653416604e356073e6e0bccbe7c09990acef442def2f5640dd0faa9689f", size = 139081, upload-time = "2025-12-29T08:26:12.483Z" },
+ { url = "https://files.pythonhosted.org/packages/59/82/d63e8494ec5758029f31c6cb06d7d161175d8281e91d011a4a441c8a43b5/psutil-7.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cfbe6b40ca48019a51827f20d830887b3107a74a79b01ceb8cc8de4ccb17b672", size = 134767, upload-time = "2025-12-29T08:26:14.528Z" },
+ { url = "https://files.pythonhosted.org/packages/05/c2/5fb764bd61e40e1fe756a44bd4c21827228394c17414ade348e28f83cd79/psutil-7.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:494c513ccc53225ae23eec7fe6e1482f1b8a44674241b54561f755a898650679", size = 129716, upload-time = "2025-12-29T08:26:16.017Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/d2/935039c20e06f615d9ca6ca0ab756cf8408a19d298ffaa08666bc18dc805/psutil-7.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fce5f92c22b00cdefd1645aa58ab4877a01679e901555067b1bd77039aa589f", size = 130133, upload-time = "2025-12-29T08:26:18.009Z" },
+ { url = "https://files.pythonhosted.org/packages/77/69/19f1eb0e01d24c2b3eacbc2f78d3b5add8a89bf0bb69465bc8d563cc33de/psutil-7.2.1-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93f3f7b0bb07711b49626e7940d6fe52aa9940ad86e8f7e74842e73189712129", size = 181518, upload-time = "2025-12-29T08:26:20.241Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/6d/7e18b1b4fa13ad370787626c95887b027656ad4829c156bb6569d02f3262/psutil-7.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d34d2ca888208eea2b5c68186841336a7f5e0b990edec929be909353a202768a", size = 184348, upload-time = "2025-12-29T08:26:22.215Z" },
+ { url = "https://files.pythonhosted.org/packages/98/60/1672114392dd879586d60dd97896325df47d9a130ac7401318005aab28ec/psutil-7.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2ceae842a78d1603753561132d5ad1b2f8a7979cb0c283f5b52fb4e6e14b1a79", size = 140400, upload-time = "2025-12-29T08:26:23.993Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/7b/d0e9d4513c46e46897b46bcfc410d51fc65735837ea57a25170f298326e6/psutil-7.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:08a2f175e48a898c8eb8eace45ce01777f4785bc744c90aa2cc7f2fa5462a266", size = 135430, upload-time = "2025-12-29T08:26:25.999Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/cf/5180eb8c8bdf6a503c6919f1da28328bd1e6b3b1b5b9d5b01ae64f019616/psutil-7.2.1-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2e953fcfaedcfbc952b44744f22d16575d3aa78eb4f51ae74165b4e96e55f42", size = 128137, upload-time = "2025-12-29T08:26:27.759Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/2c/78e4a789306a92ade5000da4f5de3255202c534acdadc3aac7b5458fadef/psutil-7.2.1-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:05cc68dbb8c174828624062e73078e7e35406f4ca2d0866c272c2410d8ef06d1", size = 128947, upload-time = "2025-12-29T08:26:29.548Z" },
+ { url = "https://files.pythonhosted.org/packages/29/f8/40e01c350ad9a2b3cb4e6adbcc8a83b17ee50dd5792102b6142385937db5/psutil-7.2.1-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e38404ca2bb30ed7267a46c02f06ff842e92da3bb8c5bfdadbd35a5722314d8", size = 154694, upload-time = "2025-12-29T08:26:32.147Z" },
+ { url = "https://files.pythonhosted.org/packages/06/e4/b751cdf839c011a9714a783f120e6a86b7494eb70044d7d81a25a5cd295f/psutil-7.2.1-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab2b98c9fc19f13f59628d94df5cc4cc4844bc572467d113a8b517d634e362c6", size = 156136, upload-time = "2025-12-29T08:26:34.079Z" },
+ { url = "https://files.pythonhosted.org/packages/44/ad/bbf6595a8134ee1e94a4487af3f132cef7fce43aef4a93b49912a48c3af7/psutil-7.2.1-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f78baafb38436d5a128f837fab2d92c276dfb48af01a240b861ae02b2413ada8", size = 148108, upload-time = "2025-12-29T08:26:36.225Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/15/dd6fd869753ce82ff64dcbc18356093471a5a5adf4f77ed1f805d473d859/psutil-7.2.1-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:99a4cd17a5fdd1f3d014396502daa70b5ec21bf4ffe38393e152f8e449757d67", size = 147402, upload-time = "2025-12-29T08:26:39.21Z" },
+ { url = "https://files.pythonhosted.org/packages/34/68/d9317542e3f2b180c4306e3f45d3c922d7e86d8ce39f941bb9e2e9d8599e/psutil-7.2.1-cp37-abi3-win_amd64.whl", hash = "sha256:b1b0671619343aa71c20ff9767eced0483e4fc9e1f489d50923738caf6a03c17", size = 136938, upload-time = "2025-12-29T08:26:41.036Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/73/2ce007f4198c80fcf2cb24c169884f833fe93fbc03d55d302627b094ee91/psutil-7.2.1-cp37-abi3-win_arm64.whl", hash = "sha256:0d67c1822c355aa6f7314d92018fb4268a76668a536f133599b91edd48759442", size = 133836, upload-time = "2025-12-29T08:26:43.086Z" },
+]
+
+[[package]]
+name = "py-cpuinfo"
+version = "9.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/37/a8/d832f7293ebb21690860d2e01d8115e5ff6f2ae8bbdc953f0eb0fa4bd2c7/py-cpuinfo-9.0.0.tar.gz", hash = "sha256:3cdbbf3fac90dc6f118bfd64384f309edeadd902d7c8fb17f02ffa1fc3f49690", size = 104716, upload-time = "2022-10-25T20:38:06.303Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e0/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5", size = 22335, upload-time = "2022-10-25T20:38:27.636Z" },
+]
+
[[package]]
name = "pycodestyle"
version = "2.14.0"
@@ -663,6 +1126,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/3d/8888e7ca0c6b093b52aa5c6693b0022e66d5958adcc685ed7a6a8ae615e8/pygments_styles-0.2.0-py3-none-any.whl", hash = "sha256:40fb7f1d34ce2b2792aecabc8d3877ca364eb04bb3b7f7747cfc9a7f0569bae9", size = 34200, upload-time = "2025-09-26T08:39:02.262Z" },
]
+[[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]]
name = "pytest"
version = "8.4.2"
@@ -705,6 +1177,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
]
+[[package]]
+name = "python-dateutil"
+version = "2.9.0.post0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "six" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
+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 = "requests"
version = "2.32.5"
@@ -768,6 +1252,23 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/2a/65880dfd0e13f7f13a775998f34703674a4554906167dce02daf7865b954/ruff-0.14.0-py3-none-win_arm64.whl", hash = "sha256:f42c9495f5c13ff841b1da4cb3c2a42075409592825dada7c5885c2c844ac730", size = 12565142, upload-time = "2025-10-07T18:21:53.577Z" },
]
+[[package]]
+name = "scriv"
+version = "1.8.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "attrs" },
+ { name = "click" },
+ { name = "click-log" },
+ { name = "jinja2" },
+ { name = "markdown-it-py" },
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/14/9a/2ef2209e0672b264a2f2574dc88ea3cd9cfc9adfecbfd3165a900980ec8c/scriv-1.8.0.tar.gz", hash = "sha256:7b1a105dd411ac541998250fc8594742419f94cee984ca1257c5ebf5af21918b", size = 98160, upload-time = "2025-12-30T00:01:10.13Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/02/e7/062480ede84ecb56ee0f8f2e5b5a3b2a5bceeb73bbdf909d3c13f5438749/scriv-1.8.0-py3-none-any.whl", hash = "sha256:f00f51325b2f4bc96b16fbb1239d4ab577cc2422301a5dd4f5f9378aae2549e0", size = 39085, upload-time = "2025-12-30T00:01:08.599Z" },
+]
+
[[package]]
name = "shibuya"
version = "2025.9.25"
@@ -781,6 +1282,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/15/d7/e4831122c1cdaa66cf432d50cf24b38dd161aa69187eebfa41295c3a84ec/shibuya-2025.9.25-py3-none-any.whl", hash = "sha256:93e439181167936dd1950874880c23920d551f90ff475888ce278af1d2efa4eb", size = 97695, upload-time = "2025-09-25T02:54:17.351Z" },
]
+[[package]]
+name = "six"
+version = "1.17.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
+]
+
[[package]]
name = "sniffio"
version = "1.3.1"