diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c17483a..7933454 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,76 +3,75 @@ name: CI on: [push, pull_request] jobs: - C_test: - strategy: - matrix: - os: [ubuntu-latest] + # C_test: + # strategy: + # matrix: + # os: [ubuntu-latest] - runs-on: ${{ matrix.os }} - timeout-minutes: 30 + # runs-on: ${{ matrix.os }} + # timeout-minutes: 30 - steps: - - name: Checkout code - uses: actions/checkout@v4 + # steps: + # - name: Checkout code + # uses: actions/checkout@v6.0.2 - - name: Install C dependencies (Ubuntu) - if: matrix.os == 'ubuntu-latest' - run: | - sudo apt-get remove needrestart - sudo apt-get update - sudo apt-get install -y cmake + # - name: Install C dependencies (Ubuntu) + # if: matrix.os == 'ubuntu-latest' + # run: | + # sudo apt-get remove needrestart + # sudo apt-get update + # sudo apt-get install -y cmake - - name: Build C code - run: | - mkdir build - cd build - cmake .. - make + # - name: Build C code + # run: | + # mkdir -p build + # cd build + # cmake .. + # make - - name: Run C tests - run: | - cd build - make test + # - name: Run C tests + # run: | + # cd build + # ctest - Pylint: - strategy: - matrix: - os: [ubuntu-latest] - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + Lint: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout code + uses: actions/checkout@v6.0.2 + - name: Run Ruff linter on Python code + uses: astral-sh/ruff-action@v4.0.0 + with: + args: "check" + continue-on-error: true + - name: Check code formatting + uses: astral-sh/ruff-action@v4.0.0 + with: + args: "format --check" + continue-on-error: true - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - + TypeCheck: + runs-on: ubuntu-latest + timeout-minutes: 5 steps: - - name: Checkout code - uses: actions/checkout@v4 + - uses: actions/checkout@v6.0.2 + - name: Install uv + uses: astral-sh/setup-uv@v8.1.0 - - name: Set up Python - uses: actions/setup-python@v4 + - name: Run ty + run: uvx ty check + continue-on-error: true + Test: + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6.0.2 + - uses: astral-sh/setup-uv@v8.1.0 with: python-version: ${{ matrix.python-version }} - - - name: Install linting tools - run: | - python -m pip install --upgrade pip - pip install pylint flake8 mypy black - python -c "import black, flake8, mypy, pylint; print('All imports OK')" - - # - name: Check Python code formatting - # run: | - # black ./src/ - - - name: Run Python linter - run: | - flake8 ./src/ - continue-on-error: true - - - name: Run python lint score - run: | - pylint --max-line-length=150 ./src/ - continue-on-error: true - - - name: Run Python type checking - run: | - mypy ./src/ - continue-on-error: true + - run: uv sync --group dev + - run: uv run pytest diff --git a/.gitignore b/.gitignore index 7639b99..bff184a 100644 --- a/.gitignore +++ b/.gitignore @@ -12,10 +12,10 @@ _deps CMakeUserPresets.json .vscode/ .vs/ -.test/build/ -.test/venv/ -.test/src/ -.src/__pycache__/ +test/build/ +test/venv/ +test/src/ +*/__pycache__/ # CLion # JetBrains specific template is maintained in a separate JetBrains.gitignore that can diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..46340b6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = "servspy" +version = "0.1.0" +description = "ServSpy is a lightweight tool used for monitoring server status and simply controlling servers, such as through the command lines or the desktops." +requires-python = ">=3.10" +dependencies = [] + +[dependency-groups] +dev = [ + "pytest>=9.0.0", +] + +[tool.ruff] +target-version = "py310" +line-length = 150 +exclude = ["build/", "dist/", ".git/", "__pycache__/"] + +[tool.ruff.lint] +select = ["E", "F", "PL", "I"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" + +[tool.ty.environment] +python-version = "3.10" + +[tool.ty.src] +exclude = ["test/"] + +[tool.pytest.ini_options] +testpaths = ["test"] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index f29df70..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -pytest>=9.0.0 diff --git a/src/command_control_extension_tcp.py b/src/command_control_extension_tcp.py index d22a565..ae621bc 100644 --- a/src/command_control_extension_tcp.py +++ b/src/command_control_extension_tcp.py @@ -13,6 +13,42 @@ client_instance=None command_counter={} command_counter_lock=threading.Lock() + +def _load_json_file(path): + try: + with open(path, 'r', encoding='utf-8') as f: + data = json.load(f) + return data if isinstance(data, dict) else {} + except Exception: + return {} + + +def _merge_log_dicts(base_data, extra_data): + merged = {} + for command, entries in base_data.items(): + merged[command] = list(entries) if isinstance(entries, list) else [entries] + for command, entries in extra_data.items(): + if command not in merged: + merged[command] = [] + if isinstance(entries, list): + merged[command].extend(entries) + else: + merged[command].append(entries) + return merged + + +def _merge_all_logs(log_dir, merged_filename='merged_logs.json'): + merged_data = {} + for filename in os.listdir(log_dir): + if not filename.endswith('.json') or filename == merged_filename: + continue + file_path = os.path.join(log_dir, filename) + if os.path.isfile(file_path): + merged_data = _merge_log_dicts(merged_data, _load_json_file(file_path)) + with open(os.path.join(log_dir, merged_filename), 'w', encoding='utf-8') as f: + json.dump(merged_data, f, ensure_ascii=False, indent=2) + + def _setup_command(): print("Setting up server command...") server_instance.register_command( @@ -149,18 +185,26 @@ def _command_done_dealing_server(sock, addr, cmd): cmd_parts = shlex.split(cmd) log_filename = cmd_parts[1] log_path = cmd_parts[2] - log_dir=os.path.join(os.path.dirname(__file__), 'logs') - if os.path.exists(log_dir): - pass - else: - os.mkdir(log_dir) - received_log_file=os.path.join( - server_instance.file_transfer_dir, log_path) + log_dir = os.path.join(os.path.dirname(__file__), 'logs') + os.makedirs(log_dir, exist_ok=True) + + received_log_file = os.path.join( + server_instance.file_transfer_dir, + os.path.basename(log_path)) + destination_log_file = os.path.join(log_dir, log_filename) try: - shutil.move( - received_log_file, os.path.join(log_dir, log_filename)) + if os.path.exists(destination_log_file): + existing_logs = _load_json_file(destination_log_file) + incoming_logs = _load_json_file(received_log_file) + merged_logs = _merge_log_dicts(existing_logs, incoming_logs) + with open(destination_log_file, 'w', encoding='utf-8') as f: + json.dump(merged_logs, f, ensure_ascii=False, indent=2) + os.remove(received_log_file) + else: + shutil.move(received_log_file, destination_log_file) + _merge_all_logs(log_dir) print("Command Done!") - except: + except Exception: traceback.print_exc() print("ErrorWhileMovingTheLogFile: moving log file failed.") def client_setup(): diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 0000000..ab91fc3 --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,64 @@ +import importlib.util +import os +import sys + +import pytest + +# Only add project root to sys.path if src package is not importable +if importlib.util.find_spec("src") is None: + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import src.command_control_extension_tcp as ctl +from src.connect_tcp import TCP_Client_Base, TCP_Server_Base + +SERVER_PORT = 65001 +CLIENT_PORT = 65000 +SERVER2_PORT = 65002 +CLIENT2_PORT = 65003 + + +@pytest.fixture +def server(monkeypatch): + """Create a TCP_Server_Base and inject into command_control_extension_tcp.server_instance. + + Use this fixture when a test only needs a server instance. + """ + s = TCP_Server_Base(host="127.0.0.1", port=SERVER_PORT, is_extend_command=True) + monkeypatch.setattr(ctl, "server_instance", s) + try: + yield s + finally: + s.stop() + + +@pytest.fixture +def client(monkeypatch): + """Create a TCP_Client_Base and inject into command_control_extension_tcp.client_instance. + + Use this fixture when a test only needs a client instance. + """ + c = TCP_Client_Base(host="127.0.0.1", port=CLIENT_PORT, client_host="127.0.0.1", is_extend_command=True) + monkeypatch.setattr(ctl, "client_instance", c) + try: + yield c + finally: + c.close() + + +@pytest.fixture +def server_client(monkeypatch): + """Create both server and client and inject both module globals. + + Use this when a test requires both sides present. + """ + s = TCP_Server_Base(host="127.0.0.1", port=SERVER2_PORT, is_extend_command=True) + c = TCP_Client_Base(host="127.0.0.1", port=CLIENT2_PORT, client_host="127.0.0.1", is_extend_command=True) + monkeypatch.setattr(ctl, "server_instance", s) + monkeypatch.setattr(ctl, "client_instance", c) + try: + yield s, c + finally: + try: + c.close() + finally: + s.stop() diff --git a/test/test_TCP.py b/test/test_TCP.py deleted file mode 100644 index 6360d3b..0000000 --- a/test/test_TCP.py +++ /dev/null @@ -1,39 +0,0 @@ -import os -import sys -import time -import subprocess -package_dictionary=os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -if package_dictionary not in os.sys.path: - sys.path.insert(0, package_dictionary) -class TestConnect: - def __init__(self): - # breakpoint() - create_tcp_client_amount=int(input()) - if sys.platform.startswith('win'): - self.TCP_server_process=subprocess.Popen( - ["python", os.path.join(os.path.dirname(__file__), 'test_TCP_server.py')], - creationflags=subprocess.CREATE_NEW_CONSOLE) - # self.TCP_server_process=subprocess.Popen( - # ["python", os.path.join(os.path.dirname(__file__), 'test_TCP_server.py')]) - time.sleep(2) - for i in range(create_tcp_client_amount): - time.sleep(0.5) - subprocess.Popen( - ["python", os.path.join(os.path.dirname(__file__), 'test_TCP_client.py')], - creationflags=subprocess.CREATE_NEW_CONSOLE) - # subprocess.Popen( - # ["python", os.path.join(os.path.dirname(__file__), 'test_TCP_client.py')]) - else: - cmd = [ - 'gnome-terminal', '--', 'bash', '-c', "python3.14 {}".format( - os.path.join(os.path.dirname(__file__), 'test_TCP_server.py'))] - self.TCP_server_process=subprocess.Popen(cmd) - time.sleep(2) - for i in range(create_tcp_client_amount): - cmd=[ - 'gnome-terminal', '--', 'bash', '-c', "python3.14 {}".format( - os.path.join(os.path.dirname(__file__), 'test_TCP_client.py'))] - time.sleep(0.5) - subprocess.Popen(cmd) -if __name__=="__main__": - TestConnect() diff --git a/test/test_TCP_client.py b/test/test_TCP_client.py index 36863c8..85cd159 100644 --- a/test/test_TCP_client.py +++ b/test/test_TCP_client.py @@ -1,9 +1,8 @@ -import os -import sys -package_dictionary=os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -if package_dictionary not in os.sys.path: - sys.path.insert(0, package_dictionary) -from src import connect_tcp -def test_TCP_client(): - connect_tcp.TCP_Client_Base(host="127.0.0.1") -test_TCP_client() +from src.connect_tcp import TCP_Client_Base + + +def test_tcp_client_init(): + client = TCP_Client_Base(host="127.0.0.1", port=65003, client_host="127.0.0.1", is_extend_command=True) + assert client.host == "127.0.0.1" + assert client.port == 65003 # noqa: PLR2004 + assert callable(client.connect) diff --git a/test/test_TCP_server.py b/test/test_TCP_server.py index d2e7c9f..657e310 100644 --- a/test/test_TCP_server.py +++ b/test/test_TCP_server.py @@ -1,9 +1,8 @@ -import os -import sys -package_dictionary=os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -if package_dictionary not in os.sys.path: - sys.path.insert(0, package_dictionary) -from src import connect_tcp -def test_TCP_server(): - connect_tcp.TCP_Server_Base() -test_TCP_server() +from src.connect_tcp import TCP_Server_Base + + +def test_tcp_server_init(): + server = TCP_Server_Base(host="127.0.0.1", port=65002, is_extend_command=True) + assert server.host == "127.0.0.1" + assert server.port == 65002 # noqa: PLR2004 + assert callable(server.start_TCP_Server) diff --git a/test/test_command_handlers.py b/test/test_command_handlers.py new file mode 100644 index 0000000..ebeaf02 --- /dev/null +++ b/test/test_command_handlers.py @@ -0,0 +1,111 @@ +import builtins +import io +import json +from types import SimpleNamespace + +import src.command_control_extension_tcp as ctl + + +def test_command_handler_parsing(server): + """Verify server parses /command and forwards to the target client socket. + + NOTE: we force the server.running flag here because tests construct the + server via a fixture with is_extend_command=True (no background loop). + FIXME: prefer a test-friendly public API to start/mark the server running. + """ + server.running = True + + class DummySocket: + def __init__(self): + self.data = b"" + + def sendall(self, b): + self.data += b + + client_addr = ("127.0.0.1", 12345) + dummy = DummySocket() + + # populate only the fields used by the handler + server.clients[client_addr] = { + "socket": dummy, + "address": client_addr, + "connected_time": "now", + } + + # craft command: /command () + # wrap the tuple in quotes so the handler's parser (shlex.split) treats it as one token + # and ast.literal_eval inside the handler can parse it + cmd = "/command echo_test \"('127.0.0.1', 12345)\"" + + ctl._command_handler(None, ("127.0.0.1", 9999), cmd) + + assert dummy.data != b"" + sent = dummy.data.decode("utf-8") + assert "/command" in sent + assert "echo_test" in sent + assert "127.0.0.1" in sent + + +def test_command_handler_server_setup_triggers_file_transfer(server_client, monkeypatch): + """Verify server-side setup handler runs the command and triggers file transfer + /command_done.""" + server, client = server_client + + # Intercept writes to prevent tests creating log files on disk. + # Minimal, deterministic strategy: for any write/append/create open + # return an in-memory stream; keep other opens unchanged. Also avoid + # directory creation by making makedirs a no-op. + orig_open = builtins.open + + def dummy_open(path, mode="r", *a, **kw): + if any(m in mode for m in ("w", "a", "x")): + return io.StringIO() + return orig_open(path, mode, *a, **kw) + + monkeypatch.setattr(builtins, "open", dummy_open) + monkeypatch.setattr(ctl.os, "makedirs", lambda *a, **k: None) + + # mock subprocess.run to avoid executing real commands + monkeypatch.setattr(ctl.subprocess, "run", lambda *a, **kw: SimpleNamespace(stdout="out", stderr="", returncode=0)) + + calls = {} + + def fake_file_transfer(message, file_folder_abspath=None): + calls["file_transfer"] = message + + def fake_send_message(client_socket, message): + calls.setdefault("sent", []).append(message) + + monkeypatch.setattr(client, "file_transfer_client_recv_client_start", fake_file_transfer) + monkeypatch.setattr(client, "send_message", fake_send_message) + + # construct command that _command_handler_server_setup expects + # format: /command + cmd = "/command echo 1 0 ('127.0.0.1', 12345)" + + ctl._command_handler_server_setup(None, ("127.0.0.1", 9999), cmd) + + assert "file_transfer" in calls + assert any("/command_done" in m for m in calls.get("sent", [])) + + +def test_command_done_merges_transferred_logs_into_one_file(server, tmp_path, monkeypatch): + server.file_transfer_dir = str(tmp_path / "received") + (tmp_path / "received").mkdir() + monkeypatch.setattr(ctl.os.path, "dirname", lambda path: str(tmp_path)) + + received_file = tmp_path / "received" / "logs_0.json" + received_file.write_text(json.dumps({"cmd1": [{"output": "ok"}]}), encoding="utf-8") + + logs_dir = tmp_path / "logs" + logs_dir.mkdir() + existing_file = logs_dir / "logs_1.json" + existing_file.write_text(json.dumps({"cmd1": [{"error": "none"}]}), encoding="utf-8") + + cmd = "/command_done \"logs_0.json\" \"/tmp/logs/logs_0.json\"" + ctl._command_done_dealing_server(None, ("127.0.0.1", 9999), cmd) + + merged_file = logs_dir / "merged_logs.json" + assert merged_file.exists() + merged = json.loads(merged_file.read_text(encoding="utf-8")) + assert merged["cmd1"][0] == {"error": "none"} + assert merged["cmd1"][1] == {"output": "ok"} diff --git a/test/test_extension_TCP.py b/test/test_extension_TCP.py deleted file mode 100644 index 238adfe..0000000 --- a/test/test_extension_TCP.py +++ /dev/null @@ -1,39 +0,0 @@ -import os -import sys -import subprocess -import time -package_dictionary=os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -if package_dictionary not in os.sys.path: - sys.path.insert(0, package_dictionary) -class TestConnect: - def __init__(self): - # breakpoint() - create_tcp_client_amount=int(input()) - if sys.platform.startswith('win'): - self.TCP_server_process=subprocess.Popen( - ["python", os.path.join(os.path.dirname(__file__), 'test_extension_TCP_server.py')], - creationflags=subprocess.CREATE_NEW_CONSOLE) - # self.TCP_server_process=subprocess.Popen( - # ["python", os.path.join(os.path.dirname(__file__), 'test_extension_TCP_server.py')]) - time.sleep(2) - for i in range(create_tcp_client_amount): - time.sleep(0.5) - subprocess.Popen( - ["python", os.path.join(os.path.dirname(__file__), 'test_extension_TCP_client.py')], - creationflags=subprocess.CREATE_NEW_CONSOLE) - # subprocess.Popen( - # ["python", os.path.join(os.path.dirname(__file__), 'test_extension_TCP_client.py')]) - else: - cmd = [ - 'gnome-terminal', '--', 'bash', '-c', "python3.14 {}".format( - os.path.join(os.path.dirname(__file__), 'test_extension_TCP_server.py'))] - self.TCP_server_process=subprocess.Popen(cmd) - time.sleep(2) - for i in range(create_tcp_client_amount): - cmd=[ - 'gnome-terminal', '--', 'bash', '-c', "python3.14 {}".format( - os.path.join(os.path.dirname(__file__), 'test_extension_TCP_client.py'))] - time.sleep(0.5) - subprocess.Popen(cmd) -if __name__=="__main__": - TestConnect() diff --git a/test/test_extension_TCP_client.py b/test/test_extension_TCP_client.py deleted file mode 100644 index f2bf6b4..0000000 --- a/test/test_extension_TCP_client.py +++ /dev/null @@ -1,10 +0,0 @@ -import os -import sys -package_dictionary=os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -# package_dictionary=os.path.dirname(os.path.abspath(__file__)) -if package_dictionary not in os.sys.path: - sys.path.insert(0, package_dictionary) -from src.connect_tcp import TCP_Client_Base -from src.command_control_extension_tcp import client_setup - -client_setup() diff --git a/test/test_extension_TCP_server.py b/test/test_extension_TCP_server.py deleted file mode 100644 index ef148e0..0000000 --- a/test/test_extension_TCP_server.py +++ /dev/null @@ -1,10 +0,0 @@ -import os -import sys -package_dictionary=os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -# package_dictionary=os.path.dirname(os.path.abspath(__file__)) -if package_dictionary not in os.sys.path: - sys.path.insert(0, package_dictionary) -from src.connect_tcp import TCP_Server_Base -from src.command_control_extension_tcp import server_setup - -server_setup() diff --git a/test/test_extension_TCP_setup.py b/test/test_extension_TCP_setup.py new file mode 100644 index 0000000..e9ec542 --- /dev/null +++ b/test/test_extension_TCP_setup.py @@ -0,0 +1,14 @@ +import src.command_control_extension_tcp as ctl + + +def test_setup_client_and_server_commands(server_client): + server, client = server_client + + ctl._setup_command() + # server should register '/command' to run on clients (index 1) and '/command_done' on server (index 0) + assert "/command" in server._custom_handlers[1] + assert "/command_done" in server._custom_handlers[0] + + ctl._setup_client_command() + # client should register '/command' to run on server (index 0) + assert "/command" in client._custom_handlers[0] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..9465fcd --- /dev/null +++ b/uv.lock @@ -0,0 +1,156 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +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 = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "servspy" +version = "0.1.0" +source = { virtual = "." } + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=9.0.0" }] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +]