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"