From c2183b573994c4c42141c5c2cf0855d9b4fe86d9 Mon Sep 17 00:00:00 2001 From: Douglas Date: Mon, 30 Jun 2025 07:24:09 -0700 Subject: [PATCH 01/23] Add guardrail in case manifest file does not exist (#93) --- pyproject.toml | 2 +- requirements-dev.txt | 73 +++++++++++++++++++++++++++++++++ requirements.txt | 71 ++++++++++++++++++++++++++++++++ socketsecurity/__init__.py | 2 +- socketsecurity/config.py | 2 +- socketsecurity/core/__init__.py | 7 ++-- socketsecurity/socketcli.py | 4 +- 7 files changed, 153 insertions(+), 8 deletions(-) create mode 100644 requirements-dev.txt create mode 100644 requirements.txt diff --git a/pyproject.toml b/pyproject.toml index 6d9b541..81c00f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.1.14" +version = "2.1.15" requires-python = ">= 3.10" license = {"file" = "LICENSE"} dependencies = [ diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..099e79b --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,73 @@ +# generated by rye +# use `rye lock` or `rye sync` to update this lockfile +# +# last locked with the following flags: +# pre: false +# features: ["test"] +# all-features: false +# with-sources: false +# generate-hashes: false +# universal: false + +hatchling==1.27.0 +hatch==1.14.0 +argparse==1.4.0 + # via socketsecurity +certifi==2024.12.14 + # via requests +charset-normalizer==3.4.1 + # via requests +colorama==0.4.6 + # via pytest-watch +coverage==7.6.10 + # via pytest-cov +docopt==0.6.2 + # via pytest-watch +gitdb==4.0.12 + # via gitpython +gitpython==3.1.44 + # via socketsecurity +idna==3.10 + # via requests +iniconfig==2.0.0 + # via pytest +mdutils==1.6.0 + # via socketsecurity +packaging==24.2 + # via pytest + # via socketsecurity +pluggy==1.5.0 + # via pytest +prettytable==3.12.0 + # via socketsecurity +pytest==8.3.4 + # via pytest-asyncio + # via pytest-cov + # via pytest-mock + # via pytest-watch + # via socketsecurity +pytest-asyncio==0.25.1 + # via socketsecurity +pytest-cov==6.0.0 + # via socketsecurity +pytest-mock==3.14.0 + # via socketsecurity +pytest-watch==4.2.0 + # via socketsecurity +python-dotenv==1.0.1 + # via socketsecurity +requests==2.32.3 + # via socket-sdk-python + # via socketsecurity +smmap==5.0.2 + # via gitdb +socket-sdk-python==2.0.15 + # via socketsecurity +typing-extensions==4.12.2 + # via socket-sdk-python +urllib3==2.3.0 + # via requests +watchdog==6.0.0 + # via pytest-watch +wcwidth==0.2.13 + # via prettytable diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6d0be66 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,71 @@ +# generated by rye +# use `rye lock` or `rye sync` to update this lockfile +# +# last locked with the following flags: +# pre: false +# features: ["test"] +# all-features: false +# with-sources: false +# generate-hashes: false +# universal: false + +argparse==1.4.0 + # via socketsecurity +certifi==2024.12.14 + # via requests +charset-normalizer==3.4.1 + # via requests +colorama==0.4.6 + # via pytest-watch +coverage==7.6.10 + # via pytest-cov +docopt==0.6.2 + # via pytest-watch +gitdb==4.0.12 + # via gitpython +gitpython==3.1.44 + # via socketsecurity +idna==3.10 + # via requests +iniconfig==2.0.0 + # via pytest +mdutils==1.6.0 + # via socketsecurity +packaging==24.2 + # via pytest + # via socketsecurity +pluggy==1.5.0 + # via pytest +prettytable==3.12.0 + # via socketsecurity +pytest==8.3.4 + # via pytest-asyncio + # via pytest-cov + # via pytest-mock + # via pytest-watch + # via socketsecurity +pytest-asyncio==0.25.1 + # via socketsecurity +pytest-cov==6.0.0 + # via socketsecurity +pytest-mock==3.14.0 + # via socketsecurity +pytest-watch==4.2.0 + # via socketsecurity +python-dotenv==1.0.1 + # via socketsecurity +requests==2.32.3 + # via socket-sdk-python + # via socketsecurity +smmap==5.0.2 + # via gitdb +socket-sdk-python==2.0.15 + # via socketsecurity +typing-extensions==4.12.2 + # via socket-sdk-python +urllib3==2.3.0 + # via requests +watchdog==6.0.0 + # via pytest-watch +wcwidth==0.2.13 + # via prettytable diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 12f3c86..137646f 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,2 +1,2 @@ __author__ = 'socket.dev' -__version__ = '2.1.14' +__version__ = '2.1.15' diff --git a/socketsecurity/config.py b/socketsecurity/config.py index 19223d7..408a546 100644 --- a/socketsecurity/config.py +++ b/socketsecurity/config.py @@ -42,7 +42,7 @@ class CliConfig: enable_sarif: bool = False disable_overview: bool = False disable_security_issue: bool = False - files: str = "[]" + files: str = None ignore_commit_files: bool = False disable_blocking: bool = False integration_type: IntegrationType = "api" diff --git a/socketsecurity/core/__init__.py b/socketsecurity/core/__init__.py index c8a566c..4444326 100644 --- a/socketsecurity/core/__init__.py +++ b/socketsecurity/core/__init__.py @@ -860,9 +860,10 @@ def get_source_data(package: Package, packages: dict) -> list: manifests = "" if not hasattr(package, "manifestFiles"): return introduced_by - for manifest_data in package.manifestFiles: - manifest_file = manifest_data.get("file") - manifests += f"{manifest_file};" + if hasattr(package, "manifestFiles"): + for manifest_data in package.manifestFiles: + manifest_file = manifest_data.get("file") + manifests += f"{manifest_file};" manifests = manifests.rstrip(";") source = ("direct", manifests) introduced_by.append(source) diff --git a/socketsecurity/socketcli.py b/socketsecurity/socketcli.py index c228e4e..a7b1acd 100644 --- a/socketsecurity/socketcli.py +++ b/socketsecurity/socketcli.py @@ -130,7 +130,7 @@ def main_code(): files_to_check = set(json.loads(config.files)) # Start with manually specified files # Add git changes if this is a repo and we're not ignoring commit files - if is_repo and not config.ignore_commit_files: + if is_repo and not config.ignore_commit_files and not files_to_check: files_to_check.update(git_repo.changed_files) # Determine if we need to scan based on manifest files @@ -260,7 +260,7 @@ def main_code(): output_handler.handle_output(diff) # Handle license generation - if diff is not None and diff.id != "no_diff_id" and config.generate_license: + if should_skip_scan and diff.id != "no_diff_id" and config.generate_license: all_packages = {} for purl in diff.packages: package = diff.packages[purl] From 30959446fc51046d287a9da28ecda5f994488ea6 Mon Sep 17 00:00:00 2001 From: Douglas Date: Mon, 30 Jun 2025 07:28:32 -0700 Subject: [PATCH 02/23] Revert "Add guardrail in case manifest file does not exist (#93)" (#94) This reverts commit c2183b573994c4c42141c5c2cf0855d9b4fe86d9. --- pyproject.toml | 2 +- requirements-dev.txt | 73 --------------------------------- requirements.txt | 71 -------------------------------- socketsecurity/__init__.py | 2 +- socketsecurity/config.py | 2 +- socketsecurity/core/__init__.py | 7 ++-- socketsecurity/socketcli.py | 4 +- 7 files changed, 8 insertions(+), 153 deletions(-) delete mode 100644 requirements-dev.txt delete mode 100644 requirements.txt diff --git a/pyproject.toml b/pyproject.toml index 81c00f7..6d9b541 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.1.15" +version = "2.1.14" requires-python = ">= 3.10" license = {"file" = "LICENSE"} dependencies = [ diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index 099e79b..0000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,73 +0,0 @@ -# generated by rye -# use `rye lock` or `rye sync` to update this lockfile -# -# last locked with the following flags: -# pre: false -# features: ["test"] -# all-features: false -# with-sources: false -# generate-hashes: false -# universal: false - -hatchling==1.27.0 -hatch==1.14.0 -argparse==1.4.0 - # via socketsecurity -certifi==2024.12.14 - # via requests -charset-normalizer==3.4.1 - # via requests -colorama==0.4.6 - # via pytest-watch -coverage==7.6.10 - # via pytest-cov -docopt==0.6.2 - # via pytest-watch -gitdb==4.0.12 - # via gitpython -gitpython==3.1.44 - # via socketsecurity -idna==3.10 - # via requests -iniconfig==2.0.0 - # via pytest -mdutils==1.6.0 - # via socketsecurity -packaging==24.2 - # via pytest - # via socketsecurity -pluggy==1.5.0 - # via pytest -prettytable==3.12.0 - # via socketsecurity -pytest==8.3.4 - # via pytest-asyncio - # via pytest-cov - # via pytest-mock - # via pytest-watch - # via socketsecurity -pytest-asyncio==0.25.1 - # via socketsecurity -pytest-cov==6.0.0 - # via socketsecurity -pytest-mock==3.14.0 - # via socketsecurity -pytest-watch==4.2.0 - # via socketsecurity -python-dotenv==1.0.1 - # via socketsecurity -requests==2.32.3 - # via socket-sdk-python - # via socketsecurity -smmap==5.0.2 - # via gitdb -socket-sdk-python==2.0.15 - # via socketsecurity -typing-extensions==4.12.2 - # via socket-sdk-python -urllib3==2.3.0 - # via requests -watchdog==6.0.0 - # via pytest-watch -wcwidth==0.2.13 - # via prettytable diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 6d0be66..0000000 --- a/requirements.txt +++ /dev/null @@ -1,71 +0,0 @@ -# generated by rye -# use `rye lock` or `rye sync` to update this lockfile -# -# last locked with the following flags: -# pre: false -# features: ["test"] -# all-features: false -# with-sources: false -# generate-hashes: false -# universal: false - -argparse==1.4.0 - # via socketsecurity -certifi==2024.12.14 - # via requests -charset-normalizer==3.4.1 - # via requests -colorama==0.4.6 - # via pytest-watch -coverage==7.6.10 - # via pytest-cov -docopt==0.6.2 - # via pytest-watch -gitdb==4.0.12 - # via gitpython -gitpython==3.1.44 - # via socketsecurity -idna==3.10 - # via requests -iniconfig==2.0.0 - # via pytest -mdutils==1.6.0 - # via socketsecurity -packaging==24.2 - # via pytest - # via socketsecurity -pluggy==1.5.0 - # via pytest -prettytable==3.12.0 - # via socketsecurity -pytest==8.3.4 - # via pytest-asyncio - # via pytest-cov - # via pytest-mock - # via pytest-watch - # via socketsecurity -pytest-asyncio==0.25.1 - # via socketsecurity -pytest-cov==6.0.0 - # via socketsecurity -pytest-mock==3.14.0 - # via socketsecurity -pytest-watch==4.2.0 - # via socketsecurity -python-dotenv==1.0.1 - # via socketsecurity -requests==2.32.3 - # via socket-sdk-python - # via socketsecurity -smmap==5.0.2 - # via gitdb -socket-sdk-python==2.0.15 - # via socketsecurity -typing-extensions==4.12.2 - # via socket-sdk-python -urllib3==2.3.0 - # via requests -watchdog==6.0.0 - # via pytest-watch -wcwidth==0.2.13 - # via prettytable diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 137646f..12f3c86 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,2 +1,2 @@ __author__ = 'socket.dev' -__version__ = '2.1.15' +__version__ = '2.1.14' diff --git a/socketsecurity/config.py b/socketsecurity/config.py index 408a546..19223d7 100644 --- a/socketsecurity/config.py +++ b/socketsecurity/config.py @@ -42,7 +42,7 @@ class CliConfig: enable_sarif: bool = False disable_overview: bool = False disable_security_issue: bool = False - files: str = None + files: str = "[]" ignore_commit_files: bool = False disable_blocking: bool = False integration_type: IntegrationType = "api" diff --git a/socketsecurity/core/__init__.py b/socketsecurity/core/__init__.py index 4444326..c8a566c 100644 --- a/socketsecurity/core/__init__.py +++ b/socketsecurity/core/__init__.py @@ -860,10 +860,9 @@ def get_source_data(package: Package, packages: dict) -> list: manifests = "" if not hasattr(package, "manifestFiles"): return introduced_by - if hasattr(package, "manifestFiles"): - for manifest_data in package.manifestFiles: - manifest_file = manifest_data.get("file") - manifests += f"{manifest_file};" + for manifest_data in package.manifestFiles: + manifest_file = manifest_data.get("file") + manifests += f"{manifest_file};" manifests = manifests.rstrip(";") source = ("direct", manifests) introduced_by.append(source) diff --git a/socketsecurity/socketcli.py b/socketsecurity/socketcli.py index a7b1acd..c228e4e 100644 --- a/socketsecurity/socketcli.py +++ b/socketsecurity/socketcli.py @@ -130,7 +130,7 @@ def main_code(): files_to_check = set(json.loads(config.files)) # Start with manually specified files # Add git changes if this is a repo and we're not ignoring commit files - if is_repo and not config.ignore_commit_files and not files_to_check: + if is_repo and not config.ignore_commit_files: files_to_check.update(git_repo.changed_files) # Determine if we need to scan based on manifest files @@ -260,7 +260,7 @@ def main_code(): output_handler.handle_output(diff) # Handle license generation - if should_skip_scan and diff.id != "no_diff_id" and config.generate_license: + if diff is not None and diff.id != "no_diff_id" and config.generate_license: all_packages = {} for purl in diff.packages: package = diff.packages[purl] From 31003ad7e6d59f56a41b8686e908fe0f69b4bf7e Mon Sep 17 00:00:00 2001 From: Douglas Date: Mon, 30 Jun 2025 07:38:24 -0700 Subject: [PATCH 03/23] Doug/fix skip logic (#95) * Add guardrail in case manifest file does not exist * Fixed if logic for generate license --- pyproject.toml | 2 +- requirements-dev.txt | 73 +++++++++++++++++++++++++++++++++ requirements.txt | 71 ++++++++++++++++++++++++++++++++ socketsecurity/__init__.py | 2 +- socketsecurity/config.py | 2 +- socketsecurity/core/__init__.py | 7 ++-- socketsecurity/socketcli.py | 4 +- 7 files changed, 153 insertions(+), 8 deletions(-) create mode 100644 requirements-dev.txt create mode 100644 requirements.txt diff --git a/pyproject.toml b/pyproject.toml index 6d9b541..bfce7a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.1.14" +version = "2.1.16" requires-python = ">= 3.10" license = {"file" = "LICENSE"} dependencies = [ diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..099e79b --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,73 @@ +# generated by rye +# use `rye lock` or `rye sync` to update this lockfile +# +# last locked with the following flags: +# pre: false +# features: ["test"] +# all-features: false +# with-sources: false +# generate-hashes: false +# universal: false + +hatchling==1.27.0 +hatch==1.14.0 +argparse==1.4.0 + # via socketsecurity +certifi==2024.12.14 + # via requests +charset-normalizer==3.4.1 + # via requests +colorama==0.4.6 + # via pytest-watch +coverage==7.6.10 + # via pytest-cov +docopt==0.6.2 + # via pytest-watch +gitdb==4.0.12 + # via gitpython +gitpython==3.1.44 + # via socketsecurity +idna==3.10 + # via requests +iniconfig==2.0.0 + # via pytest +mdutils==1.6.0 + # via socketsecurity +packaging==24.2 + # via pytest + # via socketsecurity +pluggy==1.5.0 + # via pytest +prettytable==3.12.0 + # via socketsecurity +pytest==8.3.4 + # via pytest-asyncio + # via pytest-cov + # via pytest-mock + # via pytest-watch + # via socketsecurity +pytest-asyncio==0.25.1 + # via socketsecurity +pytest-cov==6.0.0 + # via socketsecurity +pytest-mock==3.14.0 + # via socketsecurity +pytest-watch==4.2.0 + # via socketsecurity +python-dotenv==1.0.1 + # via socketsecurity +requests==2.32.3 + # via socket-sdk-python + # via socketsecurity +smmap==5.0.2 + # via gitdb +socket-sdk-python==2.0.15 + # via socketsecurity +typing-extensions==4.12.2 + # via socket-sdk-python +urllib3==2.3.0 + # via requests +watchdog==6.0.0 + # via pytest-watch +wcwidth==0.2.13 + # via prettytable diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6d0be66 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,71 @@ +# generated by rye +# use `rye lock` or `rye sync` to update this lockfile +# +# last locked with the following flags: +# pre: false +# features: ["test"] +# all-features: false +# with-sources: false +# generate-hashes: false +# universal: false + +argparse==1.4.0 + # via socketsecurity +certifi==2024.12.14 + # via requests +charset-normalizer==3.4.1 + # via requests +colorama==0.4.6 + # via pytest-watch +coverage==7.6.10 + # via pytest-cov +docopt==0.6.2 + # via pytest-watch +gitdb==4.0.12 + # via gitpython +gitpython==3.1.44 + # via socketsecurity +idna==3.10 + # via requests +iniconfig==2.0.0 + # via pytest +mdutils==1.6.0 + # via socketsecurity +packaging==24.2 + # via pytest + # via socketsecurity +pluggy==1.5.0 + # via pytest +prettytable==3.12.0 + # via socketsecurity +pytest==8.3.4 + # via pytest-asyncio + # via pytest-cov + # via pytest-mock + # via pytest-watch + # via socketsecurity +pytest-asyncio==0.25.1 + # via socketsecurity +pytest-cov==6.0.0 + # via socketsecurity +pytest-mock==3.14.0 + # via socketsecurity +pytest-watch==4.2.0 + # via socketsecurity +python-dotenv==1.0.1 + # via socketsecurity +requests==2.32.3 + # via socket-sdk-python + # via socketsecurity +smmap==5.0.2 + # via gitdb +socket-sdk-python==2.0.15 + # via socketsecurity +typing-extensions==4.12.2 + # via socket-sdk-python +urllib3==2.3.0 + # via requests +watchdog==6.0.0 + # via pytest-watch +wcwidth==0.2.13 + # via prettytable diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 12f3c86..dcb63f2 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,2 +1,2 @@ __author__ = 'socket.dev' -__version__ = '2.1.14' +__version__ = '2.1.16' diff --git a/socketsecurity/config.py b/socketsecurity/config.py index 19223d7..408a546 100644 --- a/socketsecurity/config.py +++ b/socketsecurity/config.py @@ -42,7 +42,7 @@ class CliConfig: enable_sarif: bool = False disable_overview: bool = False disable_security_issue: bool = False - files: str = "[]" + files: str = None ignore_commit_files: bool = False disable_blocking: bool = False integration_type: IntegrationType = "api" diff --git a/socketsecurity/core/__init__.py b/socketsecurity/core/__init__.py index c8a566c..4444326 100644 --- a/socketsecurity/core/__init__.py +++ b/socketsecurity/core/__init__.py @@ -860,9 +860,10 @@ def get_source_data(package: Package, packages: dict) -> list: manifests = "" if not hasattr(package, "manifestFiles"): return introduced_by - for manifest_data in package.manifestFiles: - manifest_file = manifest_data.get("file") - manifests += f"{manifest_file};" + if hasattr(package, "manifestFiles"): + for manifest_data in package.manifestFiles: + manifest_file = manifest_data.get("file") + manifests += f"{manifest_file};" manifests = manifests.rstrip(";") source = ("direct", manifests) introduced_by.append(source) diff --git a/socketsecurity/socketcli.py b/socketsecurity/socketcli.py index c228e4e..068c283 100644 --- a/socketsecurity/socketcli.py +++ b/socketsecurity/socketcli.py @@ -130,7 +130,7 @@ def main_code(): files_to_check = set(json.loads(config.files)) # Start with manually specified files # Add git changes if this is a repo and we're not ignoring commit files - if is_repo and not config.ignore_commit_files: + if is_repo and not config.ignore_commit_files and not files_to_check: files_to_check.update(git_repo.changed_files) # Determine if we need to scan based on manifest files @@ -260,7 +260,7 @@ def main_code(): output_handler.handle_output(diff) # Handle license generation - if diff is not None and diff.id != "no_diff_id" and config.generate_license: + if not should_skip_scan and diff.id != "no_diff_id" and config.generate_license: all_packages = {} for purl in diff.packages: package = diff.packages[purl] From 04b4420baf50d6bf4617976b563268f1635318a8 Mon Sep 17 00:00:00 2001 From: Douglas Date: Mon, 30 Jun 2025 09:34:19 -0700 Subject: [PATCH 04/23] manifestFiles could exist for direct but be none. fixed the check (#96) --- pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- socketsecurity/core/__init__.py | 9 ++++----- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bfce7a9..95058a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.1.16" +version = "2.1.17" requires-python = ">= 3.10" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index dcb63f2..d3f4d18 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,2 +1,2 @@ __author__ = 'socket.dev' -__version__ = '2.1.16' +__version__ = '2.1.17' diff --git a/socketsecurity/core/__init__.py b/socketsecurity/core/__init__.py index 4444326..c60ea38 100644 --- a/socketsecurity/core/__init__.py +++ b/socketsecurity/core/__init__.py @@ -858,12 +858,11 @@ def get_source_data(package: Package, packages: dict) -> list: introduced_by = [] if package.direct: manifests = "" - if not hasattr(package, "manifestFiles"): + if not hasattr(package, "manifestFiles") or package.manifestFiles is None: return introduced_by - if hasattr(package, "manifestFiles"): - for manifest_data in package.manifestFiles: - manifest_file = manifest_data.get("file") - manifests += f"{manifest_file};" + for manifest_data in package.manifestFiles: + manifest_file = manifest_data.get("file") + manifests += f"{manifest_file};" manifests = manifests.rstrip(";") source = ("direct", manifests) introduced_by.append(source) From d96a4099813ed23f78104174509194b5b8a382a7 Mon Sep 17 00:00:00 2001 From: Douglas Coburn Date: Mon, 30 Jun 2025 10:53:10 -0700 Subject: [PATCH 05/23] Fixed diff missing diff_url or report_url --- pyproject.toml | 2 +- requirements.txt | 2 +- socketsecurity/__init__.py | 2 +- socketsecurity/core/__init__.py | 8 ++++++-- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 95058a6..c2d393b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.1.17" +version = "2.1.18" requires-python = ">= 3.10" license = {"file" = "LICENSE"} dependencies = [ diff --git a/requirements.txt b/requirements.txt index 6d0be66..4d7e82c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -59,7 +59,7 @@ requests==2.32.3 # via socketsecurity smmap==5.0.2 # via gitdb -socket-sdk-python==2.0.15 +socket-sdk-python==2.1.5 # via socketsecurity typing-extensions==4.12.2 # via socket-sdk-python diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index d3f4d18..10d169f 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,2 +1,2 @@ __author__ = 'socket.dev' -__version__ = '2.1.17' +__version__ = '2.1.18' diff --git a/socketsecurity/core/__init__.py b/socketsecurity/core/__init__.py index c60ea38..257b014 100644 --- a/socketsecurity/core/__init__.py +++ b/socketsecurity/core/__init__.py @@ -667,13 +667,13 @@ def create_new_diff( """ log.debug(f"starting create_new_diff with no_change: {no_change}") if no_change: - return Diff(id="no_diff_id") + return Diff(id="no_diff_id", diff_url="", report_url="") # Find manifest files files = self.find_files(path) files_for_sending = self.load_files_for_sending(files, path) if not files: - return Diff(id="no_diff_id") + return Diff(id="no_diff_id", diff_url="", report_url="") try: # Get head scan ID @@ -802,6 +802,10 @@ def create_diff_report( diff.new_capabilities = Core.get_capabilities_for_added_packages(added_packages) Core.add_purl_capabilities(diff) + if not hasattr(diff, "diff_url"): + diff.diff_url = None + if not hasattr(diff, "report_url"): + diff.report_url = None return diff From 1717d0ac5365503d08f3d6b52d1cc8a8d721cb2e Mon Sep 17 00:00:00 2001 From: Douglas Date: Mon, 30 Jun 2025 11:27:53 -0700 Subject: [PATCH 06/23] Fix exit code from returning 5 on diff reports with no error alerts and only warn (#99) --- pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- socketsecurity/output.py | 9 +++++---- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c2d393b..5f8e503 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.1.18" +version = "2.1.19" requires-python = ">= 3.10" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 10d169f..0d6d5b0 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,2 +1,2 @@ __author__ = 'socket.dev' -__version__ = '2.1.18' +__version__ = '2.1.19' diff --git a/socketsecurity/output.py b/socketsecurity/output.py index a1f8647..eca30a2 100644 --- a/socketsecurity/output.py +++ b/socketsecurity/output.py @@ -52,10 +52,11 @@ def return_exit_code(self, diff_report: Diff) -> int: if not self.report_pass(diff_report): return 1 - - if len(diff_report.new_alerts) > 0: - # 5 means warning alerts but no blocking alerts - return 5 + + # if there are only warn alerts should be returning 0. This was not intended behavior + # if len(diff_report.new_alerts) > 0: + # # 5 means warning alerts but no blocking alerts + # return 5 return 0 def output_console_comments(self, diff_report: Diff, sbom_file_name: Optional[str] = None) -> None: From 9a1d0306dde31253a73ae67f4a57eb40fe6ed4ba Mon Sep 17 00:00:00 2001 From: Douglas Date: Wed, 2 Jul 2025 22:29:52 -0700 Subject: [PATCH 07/23] =?UTF-8?q?Added=20'api'=20event=20type=20to=20Gitla?= =?UTF-8?q?b=20and=20added=20new=20option=20--license-file-=E2=80=A6=20(#1?= =?UTF-8?q?00)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Added 'api' event type to Gitlab and added new option --license-file-name that defaults to license_output.json instead of a dynamic name that could be a bad filename * Bumped dependencies --- README.md | 13 +++++++------ pyproject.toml | 2 +- requirements-dev.txt | 4 ++-- requirements.txt | 4 ++-- socketsecurity/__init__.py | 2 +- socketsecurity/config.py | 9 +++++++++ socketsecurity/core/scm/gitlab.py | 2 +- socketsecurity/socketcli.py | 6 +----- 8 files changed, 24 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 35f32a0..175422c 100644 --- a/README.md +++ b/README.md @@ -40,12 +40,13 @@ If you don't want to provide the Socket API Token every time then you can use th | --commit-sha | False | "" | Commit SHA | #### Path and File -| Parameter | Required | Default | Description | -|:----------------------|:---------|:--------|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| --target-path | False | ./ | Target path for analysis | -| --sbom-file | False | | SBOM file path | -| --files | False | [] | Files to analyze (JSON array string) | -| --excluded-ecosystems | False | [] | List of ecosystems to exclude from analysis (JSON array string). You can get supported files from the [Supported Files API](https://docs.socket.dev/reference/getsupportedfiles) | +| Parameter | Required | Default | Description | +|:----------------------|:---------|:----------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| --target-path | False | ./ | Target path for analysis | +| --sbom-file | False | | SBOM file path | +| --files | False | [] | Files to analyze (JSON array string) | +| --excluded-ecosystems | False | [] | List of ecosystems to exclude from analysis (JSON array string). You can get supported files from the [Supported Files API](https://docs.socket.dev/reference/getsupportedfiles) | +| --license-file-name | False | `license_output.json` | Name of the file to save the license details to if enabled | #### Branch and Scan Configuration | Parameter | Required | Default | Description | diff --git a/pyproject.toml b/pyproject.toml index 5f8e503..7027425 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.1.19" +version = "2.1.21" requires-python = ">= 3.10" license = {"file" = "LICENSE"} dependencies = [ diff --git a/requirements-dev.txt b/requirements-dev.txt index 099e79b..bef361b 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -56,7 +56,7 @@ pytest-watch==4.2.0 # via socketsecurity python-dotenv==1.0.1 # via socketsecurity -requests==2.32.3 +requests==2.32.4 # via socket-sdk-python # via socketsecurity smmap==5.0.2 @@ -65,7 +65,7 @@ socket-sdk-python==2.0.15 # via socketsecurity typing-extensions==4.12.2 # via socket-sdk-python -urllib3==2.3.0 +urllib3==2.5.0 # via requests watchdog==6.0.0 # via pytest-watch diff --git a/requirements.txt b/requirements.txt index 4d7e82c..9eca071 100644 --- a/requirements.txt +++ b/requirements.txt @@ -54,7 +54,7 @@ pytest-watch==4.2.0 # via socketsecurity python-dotenv==1.0.1 # via socketsecurity -requests==2.32.3 +requests==2.32.4 # via socket-sdk-python # via socketsecurity smmap==5.0.2 @@ -63,7 +63,7 @@ socket-sdk-python==2.1.5 # via socketsecurity typing-extensions==4.12.2 # via socket-sdk-python -urllib3==2.3.0 +urllib3==2.5.0 # via requests watchdog==6.0.0 # via pytest-watch diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 0d6d5b0..dd5d61a 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,2 +1,2 @@ __author__ = 'socket.dev' -__version__ = '2.1.19' +__version__ = '2.1.21' diff --git a/socketsecurity/config.py b/socketsecurity/config.py index 408a546..dae0745 100644 --- a/socketsecurity/config.py +++ b/socketsecurity/config.py @@ -56,6 +56,7 @@ class CliConfig: version: str = __version__ jira_plugin: PluginConfig = field(default_factory=PluginConfig) slack_plugin: PluginConfig = field(default_factory=PluginConfig) + license_file_name: str = "license_output.json" @classmethod def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig': @@ -99,6 +100,7 @@ def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig': 'include_module_folders': args.include_module_folders, 'repo_is_public': args.repo_is_public, "excluded_ecosystems": args.excluded_ecosystems, + 'license_file_name': args.license_file_name, 'version': __version__ } try: @@ -253,6 +255,13 @@ def create_argument_parser() -> argparse.ArgumentParser: dest="sbom_file", help=argparse.SUPPRESS ) + path_group.add_argument( + "--license-file-name", + dest="license_file_name", + default="license_output.json", + metavar="", + help="SBOM file path" + ) path_group.add_argument( "--files", metavar="", diff --git a/socketsecurity/core/scm/gitlab.py b/socketsecurity/core/scm/gitlab.py index 8431cbf..24b1df3 100644 --- a/socketsecurity/core/scm/gitlab.py +++ b/socketsecurity/core/scm/gitlab.py @@ -71,7 +71,7 @@ def __init__(self, client: CliClient, config: Optional[GitlabConfig] = None): def check_event_type(self) -> str: pipeline_source = self.config.pipeline_source.lower() - if pipeline_source in ["web", 'merge_request_event', "push"]: + if pipeline_source in ["web", 'merge_request_event', "push", "api"]: if not self.config.mr_iid: return "main" return "diff" diff --git a/socketsecurity/socketcli.py b/socketsecurity/socketcli.py index 068c283..fc7570b 100644 --- a/socketsecurity/socketcli.py +++ b/socketsecurity/socketcli.py @@ -277,11 +277,7 @@ def main_code(): "purl": package.purl, } all_packages[package.id] = output - license_file = f"{config.repo}" - if config.branch: - license_file += f"_{config.branch}" - license_file += ".json" - core.save_file(license_file, json.dumps(all_packages)) + core.save_file(config.license_file_name, json.dumps(all_packages)) sys.exit(output_handler.return_exit_code(diff)) From 69ec6b9540fe7626f7dad70d74d41c4ebaa4975c Mon Sep 17 00:00:00 2001 From: Douglas Date: Tue, 22 Jul 2025 21:23:20 -0700 Subject: [PATCH 08/23] Refactor file detection and scanning logic to fix commit file handling (#101) * Refactor file detection and scanning logic to fix commit file handling - Fix file argument parsing to handle list, string, and JSON formats more robustly - Clarify git repository detection and file selection logic with better separation of concerns - Add force_api_mode to handle cases where no supported manifest files are found - Replace ambiguous should_skip_scan logic with clearer file detection flow - Add create_full_scan_with_report_url method to Core for API-mode scanning - Improve logging messages and remove unused code (get_all_scores method) - Ensure consistent diff object initialization and ID handling - Automatically enable disable_blocking when no supported files are detected * Add debugging options and lazy file loading to prevent file descriptor exhaustion - Add --save-submitted-files-list option to output JSON with list of scanned files, sizes, and metadata for debugging - Add --save-manifest-tar option to create tar.gz archive of all manifest files with original directory structure - Implement lazy file loading to prevent 'Too many open files' errors when scanning large numbers of manifest files - Add system resource utilities to check file descriptor limits and warn when approaching ulimit -n - Update .gitignore to exclude AI testing files and verification scripts - Update README with comprehensive documentation for new debugging features and examples --- .gitignore | 4 +- README.md | 91 +++++++- pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- socketsecurity/config.py | 16 ++ socketsecurity/core/__init__.py | 276 +++++++++++++++++++++--- socketsecurity/core/lazy_file_loader.py | 165 ++++++++++++++ socketsecurity/core/resource_utils.py | 58 +++++ socketsecurity/socketcli.py | 140 ++++++++---- 9 files changed, 668 insertions(+), 86 deletions(-) create mode 100644 socketsecurity/core/lazy_file_loader.py create mode 100644 socketsecurity/core/resource_utils.py diff --git a/.gitignore b/.gitignore index c481ee5..d6408eb 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,6 @@ file_generator.py .env.local Pipfile test/ -logs \ No newline at end of file +logs +ai_testing/ +verify_find_files_lazy_loading.py diff --git a/README.md b/README.md index 175422c..07c8797 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,10 @@ The Socket Security CLI was created to enable integrations with other tools like ```` shell socketcli [-h] [--api-token API_TOKEN] [--repo REPO] [--integration {api,github,gitlab}] [--owner OWNER] [--branch BRANCH] [--committers [COMMITTERS ...]] [--pr-number PR_NUMBER] [--commit-message COMMIT_MESSAGE] [--commit-sha COMMIT_SHA] - [--target-path TARGET_PATH] [--sbom-file SBOM_FILE] [--files FILES] [--default-branch] [--pending-head] - [--generate-license] [--enable-debug] [--enable-json] [--enable-sarif] [--disable-overview] [--disable-security-issue] - [--allow-unverified] [--ignore-commit-files] [--disable-blocking] [--scm SCM] [--timeout TIMEOUT] - [--exclude-license-details] + [--target-path TARGET_PATH] [--sbom-file SBOM_FILE] [--files FILES] [--save-submitted-files-list SAVE_SUBMITTED_FILES_LIST] + [--default-branch] [--pending-head] [--generate-license] [--enable-debug] [--enable-json] [--enable-sarif] + [--disable-overview] [--disable-security-issue] [--allow-unverified] [--ignore-commit-files] [--disable-blocking] + [--scm SCM] [--timeout TIMEOUT] [--exclude-license-details] ```` If you don't want to provide the Socket API Token every time then you can use the environment variable `SOCKET_SECURITY_API_KEY` @@ -40,13 +40,15 @@ If you don't want to provide the Socket API Token every time then you can use th | --commit-sha | False | "" | Commit SHA | #### Path and File -| Parameter | Required | Default | Description | -|:----------------------|:---------|:----------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| --target-path | False | ./ | Target path for analysis | -| --sbom-file | False | | SBOM file path | -| --files | False | [] | Files to analyze (JSON array string) | -| --excluded-ecosystems | False | [] | List of ecosystems to exclude from analysis (JSON array string). You can get supported files from the [Supported Files API](https://docs.socket.dev/reference/getsupportedfiles) | -| --license-file-name | False | `license_output.json` | Name of the file to save the license details to if enabled | +| Parameter | Required | Default | Description | +|:----------------------------|:---------|:----------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| --target-path | False | ./ | Target path for analysis | +| --sbom-file | False | | SBOM file path | +| --files | False | [] | Files to analyze (JSON array string) | +| --excluded-ecosystems | False | [] | List of ecosystems to exclude from analysis (JSON array string). You can get supported files from the [Supported Files API](https://docs.socket.dev/reference/getsupportedfiles) | +| --license-file-name | False | `license_output.json` | Name of the file to save the license details to if enabled | +| --save-submitted-files-list | False | | Save list of submitted file names to JSON file for debugging purposes | +| --save-manifest-tar | False | | Save all manifest files to a compressed tar.gz archive with original directory structure | #### Branch and Scan Configuration | Parameter | Required | Default | Description | @@ -133,6 +135,73 @@ The CLI determines which files to scan based on the following logic: - **Using `--files`**: If you specify `--files '["package.json"]'`, the CLI will check if this file exists and is a manifest file before triggering a scan. - **Using `--ignore-commit-files`**: This forces a scan of all manifest files in the target path, regardless of what's in your commit. +## Debugging and Troubleshooting + +### Saving Submitted Files List + +The CLI provides a debugging option to save the list of files that were submitted for scanning: + +```bash +socketcli --save-submitted-files-list submitted_files.json +``` + +This will create a JSON file containing: +- Timestamp of when the scan was performed +- Total number of files submitted +- Total size of all files (in bytes and human-readable format) +- Complete list of file paths that were found and submitted for scanning + +Example output file: +```json +{ + "timestamp": "2025-01-22 10:30:45 UTC", + "total_files": 3, + "total_size_bytes": 2048, + "total_size_human": "2.00 KB", + "files": [ + "./package.json", + "./requirements.txt", + "./Pipfile" + ] +} +``` + +This feature is useful for: +- **Debugging**: Understanding which files the CLI found and submitted +- **Verification**: Confirming that expected manifest files are being detected +- **Size Analysis**: Understanding the total size of manifest files being uploaded +- **Troubleshooting**: Identifying why certain files might not be included in scans or if size limits are being hit + +> **Note**: This option works with both differential scans (when git commits are detected) and full scans (API mode). + +### Saving Manifest Files Archive + +For backup, sharing, or analysis purposes, you can save all manifest files to a compressed tar.gz archive: + +```bash +socketcli --save-manifest-tar manifest_files.tar.gz +``` + +This will create a compressed archive containing all the manifest files that were found and submitted for scanning, preserving their original directory structure relative to the scanned directory. + +Example usage with other options: +```bash +# Save both files list and archive +socketcli --save-submitted-files-list files.json --save-manifest-tar backup.tar.gz + +# Use with specific target path +socketcli --target-path ./my-project --save-manifest-tar my-project-manifests.tar.gz +``` + +The manifest archive feature is useful for: +- **Backup**: Creating portable backups of all dependency manifest files +- **Sharing**: Sending the exact files being analyzed to colleagues or support +- **Analysis**: Examining the dependency files offline or with other tools +- **Debugging**: Verifying file discovery and content issues +- **Compliance**: Maintaining records of scanned dependency files + +> **Note**: The tar.gz archive preserves the original directory structure, making it easy to extract and examine the files in their proper context. + ## Development This project uses `pyproject.toml` as the primary dependency specification. diff --git a/pyproject.toml b/pyproject.toml index 7027425..0339b2f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.1.21" +version = "2.1.23" requires-python = ">= 3.10" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index dd5d61a..b2c1d88 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,2 +1,2 @@ __author__ = 'socket.dev' -__version__ = '2.1.21' +__version__ = '2.1.23' diff --git a/socketsecurity/config.py b/socketsecurity/config.py index dae0745..817c7da 100644 --- a/socketsecurity/config.py +++ b/socketsecurity/config.py @@ -57,6 +57,8 @@ class CliConfig: jira_plugin: PluginConfig = field(default_factory=PluginConfig) slack_plugin: PluginConfig = field(default_factory=PluginConfig) license_file_name: str = "license_output.json" + save_submitted_files_list: Optional[str] = None + save_manifest_tar: Optional[str] = None @classmethod def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig': @@ -101,6 +103,8 @@ def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig': 'repo_is_public': args.repo_is_public, "excluded_ecosystems": args.excluded_ecosystems, 'license_file_name': args.license_file_name, + 'save_submitted_files_list': args.save_submitted_files_list, + 'save_manifest_tar': args.save_manifest_tar, 'version': __version__ } try: @@ -262,6 +266,18 @@ def create_argument_parser() -> argparse.ArgumentParser: metavar="", help="SBOM file path" ) + path_group.add_argument( + "--save-submitted-files-list", + dest="save_submitted_files_list", + metavar="", + help="Save list of submitted file names to JSON file for debugging purposes" + ) + path_group.add_argument( + "--save-manifest-tar", + dest="save_manifest_tar", + metavar="", + help="Save all manifest files to a compressed tar.gz archive with original directory structure" + ) path_group.add_argument( "--files", metavar="", diff --git a/socketsecurity/core/__init__.py b/socketsecurity/core/__init__.py index 257b014..7688299 100644 --- a/socketsecurity/core/__init__.py +++ b/socketsecurity/core/__init__.py @@ -1,14 +1,15 @@ import logging import os import sys +import tarfile import time import io +import json from dataclasses import asdict from glob import glob from io import BytesIO from pathlib import PurePath from typing import BinaryIO, Dict, List, Tuple, Set, Union -import re from socketdev import socketdev from socketdev.exceptions import APIFailure from socketdev.fullscans import FullScanParams, SocketArtifact @@ -28,6 +29,8 @@ from socketsecurity.core.exceptions import APIResourceNotFound from .socket_config import SocketConfig from .utils import socket_globs +from .resource_utils import check_file_count_against_ulimit +from .lazy_file_loader import load_files_for_sending_lazy import importlib logging_std = importlib.import_module("logging") @@ -176,6 +179,114 @@ def is_excluded(file_path: str, excluded_dirs: Set[str]) -> bool: return True return False + def save_submitted_files_list(self, files: List[str], output_path: str) -> None: + """ + Save the list of submitted file names to a JSON file for debugging. + + Args: + files: List of file paths that were submitted for scanning + output_path: Path where to save the JSON file + """ + try: + # Calculate total size of all files + total_size_bytes = 0 + valid_files = [] + + for file_path in files: + try: + if os.path.exists(file_path) and os.path.isfile(file_path): + file_size = os.path.getsize(file_path) + total_size_bytes += file_size + valid_files.append(file_path) + else: + log.warning(f"File not found or not accessible: {file_path}") + valid_files.append(file_path) # Still include in list for debugging + except OSError as e: + log.warning(f"Error accessing file {file_path}: {e}") + valid_files.append(file_path) # Still include in list for debugging + + # Convert bytes to human-readable format + def format_bytes(bytes_value): + """Convert bytes to human readable format""" + for unit in ['B', 'KB', 'MB', 'GB']: + if bytes_value < 1024.0: + return f"{bytes_value:.2f} {unit}" + bytes_value /= 1024.0 + return f"{bytes_value:.2f} TB" + + file_data = { + "timestamp": time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime()), + "total_files": len(valid_files), + "total_size_bytes": total_size_bytes, + "total_size_human": format_bytes(total_size_bytes), + "files": sorted(valid_files) + } + + with open(output_path, 'w', encoding='utf-8') as f: + json.dump(file_data, f, indent=2, ensure_ascii=False) + + log.info(f"Saved list of {len(valid_files)} submitted files ({file_data['total_size_human']}) to: {output_path}") + + except Exception as e: + log.error(f"Failed to save submitted files list to {output_path}: {e}") + + def save_manifest_tar(self, files: List[str], output_path: str, base_dir: str) -> None: + """ + Save all manifest files to a compressed tar.gz archive with original directory structure. + + Args: + files: List of file paths to include in the archive + output_path: Path where to save the tar.gz file + base_dir: Base directory to preserve relative structure + """ + try: + # Normalize base directory + base_dir = os.path.abspath(base_dir) + if not base_dir.endswith(os.sep): + base_dir += os.sep + + log.info(f"Creating manifest tar.gz file: {output_path}") + log.debug(f"Base directory: {base_dir}") + + with tarfile.open(output_path, 'w:gz') as tar: + for file_path in files: + if not os.path.exists(file_path): + log.warning(f"File not found, skipping: {file_path}") + continue + + # Calculate relative path within the base directory + abs_file_path = os.path.abspath(file_path) + if abs_file_path.startswith(base_dir): + # File is within base directory - use relative path + arcname = os.path.relpath(abs_file_path, base_dir) + else: + # File is outside base directory - use just the filename + arcname = os.path.basename(abs_file_path) + log.warning(f"File outside base dir, using basename: {file_path} -> {arcname}") + + # Normalize archive name to use forward slashes + arcname = arcname.replace(os.sep, '/') + + log.debug(f"Adding to tar: {file_path} -> {arcname}") + tar.add(file_path, arcname=arcname) + + # Get tar file size for logging + tar_size = os.path.getsize(output_path) + + def format_bytes(bytes_value): + """Convert bytes to human readable format""" + for unit in ['B', 'KB', 'MB', 'GB']: + if bytes_value < 1024.0: + return f"{bytes_value:.2f} {unit}" + bytes_value /= 1024.0 + return f"{bytes_value:.2f} TB" + + tar_size_human = format_bytes(tar_size) + log.info(f"Successfully created tar.gz with {len(files)} files ({tar_size_human}, {tar_size:,} bytes): {output_path}") + + except Exception as e: + log.error(f"Failed to save manifest tar.gz to {output_path}: {e}") + def find_files(self, path: str) -> List[str]: """ Finds supported manifest files in the given path. @@ -196,7 +307,7 @@ def find_files(self, path: str) -> List[str]: for ecosystem in patterns: if ecosystem in self.config.excluded_ecosystems: continue - log.info(f'Scanning ecosystem: {ecosystem}') + log.debug(f'Scanning ecosystem: {ecosystem}') ecosystem_patterns = patterns[ecosystem] for file_name in ecosystem_patterns: original_pattern = ecosystem_patterns[file_name]["pattern"] @@ -219,8 +330,24 @@ def find_files(self, path: str) -> List[str]: glob_end = time.time() log.debug(f"Globbing took {glob_end - glob_start:.4f} seconds") - log.info(f"Total files found: {len(files)}") - return sorted(files) + file_list = sorted(files) + file_count = len(file_list) + log.info(f"Total files found: {file_count}") + + # Check if the number of manifest files might exceed ulimit -n + ulimit_check = check_file_count_against_ulimit(file_count) + if ulimit_check["can_check"]: + if ulimit_check["would_exceed"]: + log.warning(f"Found {file_count} manifest files, which may exceed the file descriptor limit (ulimit -n = {ulimit_check['soft_limit']})") + log.warning(f"Available file descriptors: {ulimit_check['available_fds']} (after {ulimit_check['buffer_size']} buffer)") + log.warning(f"Recommendation: {ulimit_check['recommendation']}") + log.warning("This may cause 'Too many open files' errors during processing") + else: + log.debug(f"File count ({file_count}) is within file descriptor limit ({ulimit_check['soft_limit']})") + else: + log.debug(f"Could not check file descriptor limit: {ulimit_check.get('error', 'Unknown error')}") + + return file_list def get_supported_patterns(self) -> Dict: """ @@ -273,6 +400,18 @@ def has_manifest_files(self, files: list) -> bool: return True return False + def check_file_count_limit(self, file_count: int) -> dict: + """ + Check if the given file count would exceed the system's file descriptor limit. + + Args: + file_count: Number of files to check + + Returns: + Dictionary with check results including recommendations + """ + return check_file_count_against_ulimit(file_count) + @staticmethod def to_case_insensitive_regex(input_string: str) -> str: """ @@ -300,7 +439,10 @@ def empty_head_scan_file() -> list[tuple[str, tuple[str, Union[BinaryIO, BytesIO @staticmethod def load_files_for_sending(files: List[str], workspace: str) -> List[Tuple[str, Tuple[str, BinaryIO]]]: """ - Prepares files for sending to the Socket API. + Prepares files for sending to the Socket API using lazy loading. + + This version uses lazy file loading to prevent "Too many open files" errors + when processing large numbers of manifest files. Args: files: List of file paths from find_files() @@ -310,25 +452,7 @@ def load_files_for_sending(files: List[str], workspace: str) -> List[Tuple[str, List of tuples formatted for requests multipart upload: [(field_name, (filename, file_object)), ...] """ - send_files = [] - if "\\" in workspace: - workspace = workspace.replace("\\", "/") - for file_path in files: - _, name = file_path.rsplit("/", 1) - - if file_path.startswith(workspace): - key = file_path[len(workspace):] - else: - key = file_path - - key = key.lstrip("/") - key = key.lstrip("./") - - f = open(file_path, 'rb') - payload = (key, (name.lstrip(workspace), f)) - send_files.append(payload) - - return send_files + return load_files_for_sending_lazy(files, workspace) def create_full_scan(self, files: list[tuple[str, tuple[str, BytesIO]]], params: FullScanParams) -> FullScan: """ @@ -356,6 +480,85 @@ def create_full_scan(self, files: list[tuple[str, tuple[str, BytesIO]]], params: return full_scan + def create_full_scan_with_report_url( + self, + path: str, + params: FullScanParams, + no_change: bool = False, + save_files_list_path: str = None, + save_manifest_tar_path: str = None + ) -> dict: + """Create a new full scan and return with html_report_url. + + Args: + path: Path to look for manifest files + params: Query params for the Full Scan endpoint + no_change: If True, return empty result + save_files_list_path: Optional path to save submitted files list for debugging + save_manifest_tar_path: Optional path to save manifest files tar.gz archive + + Returns: + Dict with full scan data including html_report_url + """ + log.debug(f"starting create_full_scan_with_report_url with no_change: {no_change}") + if no_change: + return { + "id": "NO_SCAN_RAN", + "html_report_url": "", + "unmatchedFiles": [] + } + + # Find manifest files + files = self.find_files(path) + + # Save submitted files list if requested + if save_files_list_path and files: + self.save_submitted_files_list(files, save_files_list_path) + + # Save manifest tar.gz if requested + if save_manifest_tar_path and files: + self.save_manifest_tar(files, save_manifest_tar_path, path) + + files_for_sending = self.load_files_for_sending(files, path) + if not files: + return { + "id": "NO_SCAN_RAN", + "html_report_url": "", + "unmatchedFiles": [] + } + + try: + # Create new scan + new_scan_start = time.time() + new_full_scan = self.create_full_scan(files_for_sending, params) + new_scan_end = time.time() + log.info(f"Total time to create new full scan: {new_scan_end - new_scan_start:.2f}") + except APIFailure as e: + log.error(f"Failed to create full scan: {e}") + raise + + # Construct report URL + base_socket = "https://socket.dev/dashboard/org" + report_url = f"{base_socket}/{self.config.org_slug}/sbom/{new_full_scan.id}" + if not params.include_license_details: + report_url += "?include_license_details=false" + + # Return result in the format expected by the user + return { + "id": new_full_scan.id, + "created_at": new_full_scan.created_at, + "updated_at": new_full_scan.updated_at, + "organization_id": new_full_scan.organization_id, + "repository_id": new_full_scan.repository_id, + "branch": new_full_scan.branch, + "commit_message": new_full_scan.commit_message, + "commit_hash": new_full_scan.commit_hash, + "pull_request": new_full_scan.pull_request, + "committers": new_full_scan.committers, + "html_report_url": report_url, + "unmatchedFiles": getattr(new_full_scan, 'unmatchedFiles', []) + } + def check_full_scans_status(self, head_full_scan_id: str, new_full_scan_id: str) -> bool: is_ready = False current_timeout = self.config.timeout @@ -656,7 +859,9 @@ def create_new_diff( self, path: str, params: FullScanParams, - no_change: bool = False + no_change: bool = False, + save_files_list_path: str = None, + save_manifest_tar_path: str = None ) -> Diff: """Create a new diff using the Socket SDK. @@ -664,16 +869,27 @@ def create_new_diff( path: Path to look for manifest files params: Query params for the Full Scan endpoint no_change: If True, return empty diff + save_files_list_path: Optional path to save submitted files list for debugging + save_manifest_tar_path: Optional path to save manifest files tar.gz archive """ log.debug(f"starting create_new_diff with no_change: {no_change}") if no_change: - return Diff(id="no_diff_id", diff_url="", report_url="") + return Diff(id="NO_DIFF_RAN", diff_url="", report_url="") # Find manifest files files = self.find_files(path) + + # Save submitted files list if requested + if save_files_list_path and files: + self.save_submitted_files_list(files, save_files_list_path) + + # Save manifest tar.gz if requested + if save_manifest_tar_path and files: + self.save_manifest_tar(files, save_manifest_tar_path, path) + files_for_sending = self.load_files_for_sending(files, path) if not files: - return Diff(id="no_diff_id", diff_url="", report_url="") + return Diff(id="NO_DIFF_RAN", diff_url="", report_url="") try: # Get head scan ID @@ -809,12 +1025,6 @@ def create_diff_report( return diff - def get_all_scores(self, packages: dict[str, Package]) -> dict[str, Package]: - components = [] - for package_id in packages: - package = packages[package_id] - return packages - def create_purl(self, package_id: str, packages: dict[str, Package]) -> Purl: """ Creates the extended PURL data for package identification and tracking. diff --git a/socketsecurity/core/lazy_file_loader.py b/socketsecurity/core/lazy_file_loader.py new file mode 100644 index 0000000..9127652 --- /dev/null +++ b/socketsecurity/core/lazy_file_loader.py @@ -0,0 +1,165 @@ +""" +Lazy file loading utilities for efficient manifest file processing. +""" +import logging +from typing import List, Tuple, Union, BinaryIO +from io import BytesIO +import os + +log = logging.getLogger("socketdev") + + +class LazyFileLoader: + """ + A file-like object that only opens the actual file when needed for reading. + This prevents keeping too many file descriptors open simultaneously. + + This class implements the standard file-like interface that requests library + expects for multipart uploads, making it a drop-in replacement for regular + file objects. + """ + + def __init__(self, file_path: str, name: str): + self.file_path = file_path + self.name = name + self._file = None + self._closed = False + self._position = 0 + + def _ensure_open(self): + """Ensure the file is open and seek to the correct position.""" + if self._closed: + raise ValueError("I/O operation on closed file.") + + if self._file is None: + self._file = open(self.file_path, 'rb') + log.debug(f"Opened file for reading: {self.file_path}") + # Seek to the current position if we've been reading before + if self._position > 0: + self._file.seek(self._position) + + def read(self, size: int = -1): + """Read from the file, opening it if needed.""" + self._ensure_open() + data = self._file.read(size) + self._position = self._file.tell() + return data + + def readline(self, size: int = -1): + """Read a line from the file.""" + self._ensure_open() + data = self._file.readline(size) + self._position = self._file.tell() + return data + + def seek(self, offset: int, whence: int = 0): + """Seek to a position in the file.""" + if self._closed: + raise ValueError("I/O operation on closed file.") + + # Calculate new position for tracking + if whence == 0: # SEEK_SET + self._position = offset + elif whence == 1: # SEEK_CUR + self._position += offset + elif whence == 2: # SEEK_END + # We need to open the file to get its size + self._ensure_open() + result = self._file.seek(offset, whence) + self._position = self._file.tell() + return result + + # If file is already open, seek it too + if self._file is not None: + result = self._file.seek(self._position) + return result + + return self._position + + def tell(self): + """Return current file position.""" + if self._closed: + raise ValueError("I/O operation on closed file.") + + if self._file is not None: + self._position = self._file.tell() + + return self._position + + def close(self): + """Close the file if it was opened.""" + if self._file is not None: + self._file.close() + log.debug(f"Closed file: {self.file_path}") + self._file = None + self._closed = True + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + + @property + def closed(self): + """Check if the file is closed.""" + return self._closed + + @property + def mode(self): + """Return the file mode.""" + return 'rb' + + def readable(self): + """Return whether the file is readable.""" + return not self._closed + + def writable(self): + """Return whether the file is writable.""" + return False + + def seekable(self): + """Return whether the file supports seeking.""" + return True + + +def load_files_for_sending_lazy(files: List[str], workspace: str) -> List[Tuple[str, Tuple[str, LazyFileLoader]]]: + """ + Prepares files for sending to the Socket API using lazy loading. + + This version doesn't open all files immediately, instead it creates + LazyFileLoader objects that only open files when they're actually read. + This prevents "Too many open files" errors when dealing with large numbers + of manifest files. + + Args: + files: List of file paths from find_files() + workspace: Base directory path to make paths relative to + + Returns: + List of tuples formatted for requests multipart upload: + [(field_name, (filename, lazy_file_object)), ...] + """ + send_files = [] + if "\\" in workspace: + workspace = workspace.replace("\\", "/") + + for file_path in files: + _, name = file_path.rsplit("/", 1) + + if file_path.startswith(workspace): + key = file_path[len(workspace):] + else: + key = file_path + + key = key.lstrip("/") + key = key.lstrip("./") + + # Create lazy file loader instead of opening file immediately + # Use the relative path (key) as filename instead of truncated basename + lazy_file = LazyFileLoader(file_path, key) + payload = (key, (key, lazy_file)) + send_files.append(payload) + + log.debug(f"Prepared {len(send_files)} files for lazy loading") + return send_files diff --git a/socketsecurity/core/resource_utils.py b/socketsecurity/core/resource_utils.py new file mode 100644 index 0000000..2652bbf --- /dev/null +++ b/socketsecurity/core/resource_utils.py @@ -0,0 +1,58 @@ +""" +System resource utilities for the Socket Security CLI. +""" +import resource +import logging + +log = logging.getLogger("socketdev") + + +def get_file_descriptor_limit(): + """ + Get the current file descriptor limit (equivalent to ulimit -n) + + Returns: + tuple: (soft_limit, hard_limit) or (None, None) if error + """ + try: + soft_limit, hard_limit = resource.getrlimit(resource.RLIMIT_NOFILE) + return soft_limit, hard_limit + except OSError as e: + log.error(f"Error getting file descriptor limit: {e}") + return None, None + + +def check_file_count_against_ulimit(file_count, buffer_size=100): + """ + Check if the number of files would exceed the file descriptor limit + + Args: + file_count (int): Number of files to check + buffer_size (int): Safety buffer to leave for other file operations + + Returns: + dict: Information about the check + """ + soft_limit, hard_limit = get_file_descriptor_limit() + + if soft_limit is None: + return { + "can_check": False, + "error": "Could not determine file descriptor limit", + "safe_to_process": True # Assume safe if we can't check + } + + available_fds = soft_limit - buffer_size + would_exceed = file_count > available_fds + + return { + "can_check": True, + "file_count": file_count, + "soft_limit": soft_limit, + "hard_limit": hard_limit, + "available_fds": available_fds, + "would_exceed": would_exceed, + "safe_to_process": not would_exceed, + "buffer_size": buffer_size, + "recommendation": "Consider processing files in batches or increasing ulimit" if would_exceed else "Safe to process all files" + } diff --git a/socketsecurity/socketcli.py b/socketsecurity/socketcli.py index fc7570b..97902b7 100644 --- a/socketsecurity/socketcli.py +++ b/socketsecurity/socketcli.py @@ -75,19 +75,49 @@ def main_code(): log.debug("loaded client") core = Core(socket_config, sdk) log.debug("loaded core") - # Load files - files defaults to "[]" in CliConfig + # Parse files argument try: - files = json.loads(config.files) # Will always succeed with empty list by default - is_repo = True # FIXME: This is misleading - JSON parsing success doesn't indicate repo status + if isinstance(config.files, list): + # Already a list, use as-is + specified_files = config.files + elif isinstance(config.files, str): + # Handle different string formats + files_str = config.files.strip() + + # If the string is wrapped in extra quotes, strip them + if ((files_str.startswith('"') and files_str.endswith('"')) or + (files_str.startswith("'") and files_str.endswith("'"))): + # Check if the inner content looks like JSON + inner_str = files_str[1:-1] + if inner_str.startswith('[') and inner_str.endswith(']'): + files_str = inner_str + + # Try to parse as JSON + try: + specified_files = json.loads(files_str) + except json.JSONDecodeError: + # If JSON parsing fails, try replacing single quotes with double quotes + files_str = files_str.replace("'", '"') + specified_files = json.loads(files_str) + else: + # Default to empty list + specified_files = [] except Exception as error: - # Only hits this if files was manually set to invalid JSON - log.error(f"Unable to parse {config.files}") - log.error(error) + log.error(f"Unable to parse files argument: {config.files}") + log.error(f"Error details: {error}") + log.debug(f"Files type: {type(config.files)}") + log.debug(f"Files repr: {repr(config.files)}") sys.exit(3) + # Determine if files were explicitly specified + files_explicitly_specified = config.files != "[]" and len(specified_files) > 0 + # Git setup + is_repo = False + git_repo = None try: git_repo = Git(config.target_path) + is_repo = True if not config.repo: config.repo = git_repo.repo_name if not config.commit_sha: @@ -98,12 +128,10 @@ def main_code(): config.committers = [git_repo.committer] if not config.commit_message: config.commit_message = git_repo.commit_message - if files and not config.ignore_commit_files: # files is empty by default, so this is False unless files manually specified - files = git_repo.changed_files # Only gets git's changed files if files were manually specified - is_repo = True # Redundant since already True except InvalidGitRepositoryError: - is_repo = False # Overwrites previous True - this is the REAL repo status - config.ignore_commit_files = True # Silently changes config - should log this + is_repo = False + log.debug("Not a git repository, setting ignore_commit_files=True") + config.ignore_commit_files = True except NoSuchPathError: raise Exception(f"Unable to find path {config.target_path}") @@ -125,26 +153,43 @@ def main_code(): if scm is not None: config.default_branch = scm.config.is_default_branch + # Determine files to check based on the new logic + files_to_check = [] + force_api_mode = False + + if files_explicitly_specified: + # Case 2: Files are specified - use them and don't check commit details + files_to_check = specified_files + log.debug(f"Using explicitly specified files: {files_to_check}") + elif not config.ignore_commit_files and is_repo: + # Case 1: Files not specified and --ignore-commit-files not set - try to find changed files from commit + files_to_check = git_repo.changed_files + log.debug(f"Using changed files from commit: {files_to_check}") + else: + # ignore_commit_files is set or not a repo - scan everything but force API mode if no supported files + files_to_check = [] + log.debug("No files to check from commit (ignore_commit_files=True or not a repo)") - # Combine manually specified files with git changes if applicable - files_to_check = set(json.loads(config.files)) # Start with manually specified files - - # Add git changes if this is a repo and we're not ignoring commit files - if is_repo and not config.ignore_commit_files and not files_to_check: - files_to_check.update(git_repo.changed_files) - - # Determine if we need to scan based on manifest files - should_skip_scan = True # Default to skipping - if config.ignore_commit_files: - should_skip_scan = False # Force scan if ignoring commit files - elif files_to_check: # If we have any files to check - should_skip_scan = not core.has_manifest_files(list(files_to_check)) - log.debug(f"in elif, should_skip_scan: {should_skip_scan}") - - if should_skip_scan: - log.debug("No manifest files found in changes, skipping scan") + # Check if we have supported manifest files + has_supported_files = files_to_check and core.has_manifest_files(files_to_check) + + # Case 3: If no supported files or files are empty, force API mode (no PR comments) + if not has_supported_files: + force_api_mode = True + log.debug("No supported manifest files found, forcing API mode") + + # Determine scan behavior + should_skip_scan = False # Always perform scan, but behavior changes based on supported files + if config.ignore_commit_files and not files_explicitly_specified: + # Force full scan when ignoring commit files and no explicit files + should_skip_scan = False + log.debug("Forcing full scan due to ignore_commit_files") + elif not has_supported_files: + # No supported files - still scan but in API mode + should_skip_scan = False + log.debug("No supported files but will scan in API mode") else: - log.debug("Found manifest files or forced scan, proceeding") + log.debug("Found supported manifest files, proceeding with normal scan") org_slug = core.config.org_slug if config.repo_is_public: @@ -177,6 +222,8 @@ def main_code(): # Initialize diff diff = Diff() diff.id = "NO_DIFF_RAN" + diff.diff_url = "" + diff.report_url = "" # Handle SCM-specific flows if scm is not None and scm.check_event_type() == "comment": @@ -192,13 +239,11 @@ def main_code(): log.debug("Removing comment alerts") scm.remove_comment_alerts(comments) - elif scm is not None and scm.check_event_type() != "comment": + elif scm is not None and scm.check_event_type() != "comment" and not force_api_mode: log.info("Push initiated flow") - if should_skip_scan: - log.info("No manifest files changes, skipping scan") - elif scm.check_event_type() == "diff": + if scm.check_event_type() == "diff": log.info("Starting comment logic for PR/MR event") - diff = core.create_new_diff(config.target_path, params, no_change=should_skip_scan) + diff = core.create_new_diff(config.target_path, params, no_change=should_skip_scan, save_files_list_path=config.save_submitted_files_list, save_manifest_tar_path=config.save_manifest_tar) comments = scm.get_comments_for_pr() log.debug("Removing comment alerts") @@ -251,16 +296,28 @@ def main_code(): ) else: log.info("Starting non-PR/MR flow") - diff = core.create_new_diff(config.target_path, params, no_change=should_skip_scan) + diff = core.create_new_diff(config.target_path, params, no_change=should_skip_scan, save_files_list_path=config.save_submitted_files_list, save_manifest_tar_path=config.save_manifest_tar) output_handler.handle_output(diff) else: - log.info("API Mode") - diff = core.create_new_diff(config.target_path, params, no_change=should_skip_scan) - output_handler.handle_output(diff) + if force_api_mode: + log.info("No Manifest files changed, creating Socket Report") + else: + log.info("API Mode") + full_scan_result = core.create_full_scan_with_report_url(config.target_path, params, no_change=should_skip_scan, save_files_list_path=config.save_submitted_files_list, save_manifest_tar_path=config.save_manifest_tar) + log.info(f"Full scan created with ID: {full_scan_result['id']}") + log.info(f"Full scan report URL: {full_scan_result['html_report_url']}") + + # Create a minimal diff-like object for compatibility with downstream code + diff = Diff() + diff.id = full_scan_result['id'] + diff.report_url = full_scan_result['html_report_url'] + diff.diff_url = full_scan_result['html_report_url'] + diff.packages = {} # No package data needed for API mode + # No output handling needed for API mode - just creating the scan # Handle license generation - if not should_skip_scan and diff.id != "no_diff_id" and config.generate_license: + if not should_skip_scan and diff.id != "NO_DIFF_RAN" and diff.id != "NO_SCAN_RAN" and config.generate_license: all_packages = {} for purl in diff.packages: package = diff.packages[purl] @@ -279,6 +336,11 @@ def main_code(): all_packages[package.id] = output core.save_file(config.license_file_name, json.dumps(all_packages)) + # If we forced API mode due to no supported files, behave as if --disable-blocking was set + if force_api_mode and not config.disable_blocking: + log.debug("Temporarily enabling disable_blocking due to no supported manifest files") + config.disable_blocking = True + sys.exit(output_handler.return_exit_code(diff)) From 8faf84a99f54f8bfa83dd4c3c68e0f6fd38eae86 Mon Sep 17 00:00:00 2001 From: Douglas Date: Thu, 24 Jul 2025 17:58:18 -0700 Subject: [PATCH 09/23] fix: correct default branch detection and ensure consistent scanning behavior (#102) FIXED BUGS: - Default branch detection was not working properly in git repositories - make_default_branch and set_as_pending_head were inconsistently set - CLI would skip scans when manifest files hadn't changed (should always scan) - --default-branch flag was being overridden by SCM detection - GitLab CI integration wasn't detecting default branch correctly CORRECTED BEHAVIOR: - Always perform scans regardless of manifest file changes (API mode when no manifests changed) - Proper default branch detection priority system: 1. Explicit --default-branch flag (highest priority) 2. CI environment variables (GitHub Actions, GitLab CI) 3. Git repository analysis via git_repo.is_default_branch 4. Fallback to false - Both make_default_branch and set_as_pending_head now synchronized correctly - Force API mode enables non-blocking behavior when no manifest files changed ENHANCED AUTO-DETECTION: - Repository name from git remote origin (was manual) - Branch, commit SHA, message, and committer from git (was manual) - Changed files from git commit (was manual) - Better error handling for non-git repositories SIMPLIFIED CI/CD USAGE: - Most parameters now optional due to git auto-detection - Added production-ready workflow examples: - workflows/github-actions.yml - GitHub Actions with concurrency control - workflows/gitlab-ci.yml - GitLab CI with environment detection - workflows/bitbucket-pipelines.yml - Bitbucket with path filtering - Updated README with corrected parameter documentation The CLI now works as users expected: - GitHub: socketcli --target-path --scm github --pr-number - GitLab: socketcli --target-path --scm gitlab --pr-number - Local: socketcli --target-path ./project --- README.md | 125 ++++++++++++++++---- pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- socketsecurity/core/__init__.py | 41 +++---- socketsecurity/core/git_interface.py | 168 ++++++++++++++++++++++++++- socketsecurity/socketcli.py | 57 ++++++--- workflows/bitbucket-pipelines.yml | 85 ++++++++++++++ workflows/github-actions.yml | 67 +++++++++++ workflows/gitlab-ci.yml | 81 +++++++++++++ 9 files changed, 557 insertions(+), 71 deletions(-) create mode 100644 workflows/bitbucket-pipelines.yml create mode 100644 workflows/github-actions.yml create mode 100644 workflows/gitlab-ci.yml diff --git a/README.md b/README.md index 07c8797..2029a45 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,45 @@ # Socket Security CLI -The Socket Security CLI was created to enable integrations with other tools like GitHub Actions, Gitlab, BitBucket, local use cases and more. The tool will get the head scan for the provided repo from Socket, create a new one, and then report any new alerts detected. If there are new alerts against the Socket security policy it'll exit with a non-Zero exit code. +The Socket Security CLI was created to enable integrations with other tools like GitHub Actions, GitLab, BitBucket, local use cases and more. The tool will get the head scan for the provided repo from Socket, create a new one, and then report any new alerts detected. If there are new alerts against the Socket security policy it'll exit with a non-Zero exit code. + +## Quick Start + +The CLI now features automatic detection of git repository information, making it much simpler to use in CI/CD environments. Most parameters are now optional and will be detected automatically from your git repository. + +### Minimal Usage Examples + +**GitHub Actions:** +```bash +socketcli --target-path $GITHUB_WORKSPACE --scm github --pr-number $PR_NUMBER +``` + +**GitLab CI:** +```bash +socketcli --target-path $CI_PROJECT_DIR --scm gitlab --pr-number ${CI_MERGE_REQUEST_IID:-0} +``` + +**Local Development:** +```bash +socketcli --target-path ./my-project +``` + +The CLI will automatically detect: +- Repository name from git remote +- Branch name from git +- Commit SHA and message from git +- Committer information from git +- Default branch status from git and CI environment +- Changed files from git commit history + +## CI/CD Workflow Examples + +Pre-configured workflow examples are available in the [`workflows/`](workflows/) directory: + +- **[GitHub Actions](workflows/github-actions.yml)** - Complete workflow with concurrency control and automatic PR detection +- **[GitLab CI](workflows/gitlab-ci.yml)** - Pipeline configuration with caching and environment variable handling +- **[Bitbucket Pipelines](workflows/bitbucket-pipelines.yml)** - Basic pipeline setup with optional path filtering + +These examples are production-ready and include best practices for each platform. ## Usage @@ -25,36 +64,36 @@ If you don't want to provide the Socket API Token every time then you can use th #### Repository | Parameter | Required | Default | Description | |:-----------------|:---------|:--------|:------------------------------------------------------------------------| -| --repo | False | | Repository name in owner/repo format | +| --repo | False | *auto* | Repository name in owner/repo format (auto-detected from git remote) | | --integration | False | api | Integration type (api, github, gitlab) | | --owner | False | | Name of the integration owner, defaults to the socket organization slug | -| --branch | False | "" | Branch name | -| --committers | False | | Committer(s) to filter by | +| --branch | False | *auto* | Branch name (auto-detected from git) | +| --committers | False | *auto* | Committer(s) to filter by (auto-detected from git commit) | | --repo-is-public | False | False | If set, flags a new repository creation as public. Defaults to false. | #### Pull Request and Commit -| Parameter | Required | Default | Description | -|:-----------------|:---------|:--------|:--------------------| -| --pr-number | False | "0" | Pull request number | -| --commit-message | False | | Commit message | -| --commit-sha | False | "" | Commit SHA | +| Parameter | Required | Default | Description | +|:-----------------|:---------|:--------|:-----------------------------------------------| +| --pr-number | False | "0" | Pull request number | +| --commit-message | False | *auto* | Commit message (auto-detected from git) | +| --commit-sha | False | *auto* | Commit SHA (auto-detected from git) | #### Path and File | Parameter | Required | Default | Description | |:----------------------------|:---------|:----------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | --target-path | False | ./ | Target path for analysis | | --sbom-file | False | | SBOM file path | -| --files | False | [] | Files to analyze (JSON array string) | +| --files | False | *auto* | Files to analyze (JSON array string). Auto-detected from git commit changes when not specified | | --excluded-ecosystems | False | [] | List of ecosystems to exclude from analysis (JSON array string). You can get supported files from the [Supported Files API](https://docs.socket.dev/reference/getsupportedfiles) | | --license-file-name | False | `license_output.json` | Name of the file to save the license details to if enabled | | --save-submitted-files-list | False | | Save list of submitted file names to JSON file for debugging purposes | | --save-manifest-tar | False | | Save all manifest files to a compressed tar.gz archive with original directory structure | #### Branch and Scan Configuration -| Parameter | Required | Default | Description | -|:-----------------|:---------|:--------|:------------------------------------------------------------| -| --default-branch | False | False | Make this branch the default branch | -| --pending-head | False | False | If true, the new scan will be set as the branch's head scan | +| Parameter | Required | Default | Description | +|:-----------------|:---------|:--------|:------------------------------------------------------------------------------------------------------| +| --default-branch | False | *auto* | Make this branch the default branch (auto-detected from git and CI environment when not specified) | +| --pending-head | False | *auto* | If true, the new scan will be set as the branch's head scan (automatically synced with default-branch) | #### Output Configuration | Parameter | Required | Default | Description | @@ -114,26 +153,66 @@ Example `SOCKET_SLACK_CONFIG_JSON` value {"url": "https://REPLACE_ME_WEBHOOK"} ```` +## Automatic Git Detection + +The CLI now automatically detects repository information from your git environment, significantly simplifying usage in CI/CD pipelines: + +### Auto-Detected Information + +- **Repository name**: Extracted from git remote origin URL +- **Branch name**: Current git branch or CI environment variables +- **Commit SHA**: Latest commit hash or CI-provided commit SHA +- **Commit message**: Latest commit message +- **Committer information**: Git commit author details +- **Default branch status**: Determined from git repository and CI environment +- **Changed files**: Files modified in the current commit (for differential scanning) + +### Default Branch Detection + +The CLI uses intelligent default branch detection with the following priority: + +1. **Explicit `--default-branch` flag**: Takes highest priority when specified +2. **CI environment detection**: Uses CI platform variables (GitHub Actions, GitLab CI) +3. **Git repository analysis**: Compares current branch with repository's default branch +4. **Fallback**: Defaults to `false` if none of the above methods succeed + +Both `--default-branch` and `--pending-head` parameters are automatically synchronized to ensure consistent behavior. + +### Scan Behavior + +The CLI determines scanning behavior intelligently: + +- **Manifest files changed**: Performs differential scan with PR/MR comments when supported +- **No manifest files changed**: Creates full repository scan report without waiting for diff results +- **Force API mode**: When no supported manifest files are detected, automatically enables non-blocking mode + ## File Selection Behavior The CLI determines which files to scan based on the following logic: -1. **Git Commit Files**: By default, the CLI checks files changed in the current git commit first. If any of these files match supported manifest patterns (like package.json, requirements.txt, etc.), a scan is triggered. +1. **Git Commit Files (Default)**: The CLI automatically checks files changed in the current git commit. If any of these files match supported manifest patterns (like package.json, requirements.txt, etc.), a scan is triggered. + +2. **`--files` Parameter Override**: When specified, this parameter takes precedence over git commit detection. It accepts a JSON array of file paths to check for manifest files. + +3. **`--ignore-commit-files` Flag**: When set, git commit files are ignored completely, and the CLI will scan all manifest files in the target directory regardless of what changed. -2. **`--files` Parameter**: If no git commit exists, or no manifest files are found in the commit changes, the CLI checks files specified via the `--files` parameter. This parameter accepts a JSON array of file paths. +4. **Automatic Fallback**: If no manifest files are found in git commit changes and no `--files` are specified, the CLI automatically switches to "API mode" and performs a full repository scan. -3. **`--ignore-commit-files`**: When this flag is set, git commit files are ignored completely, and only files specified in `--files` are considered. This also forces a scan regardless of whether manifest files are present. +> **Important**: The CLI doesn't scan only the specified files - it uses them to determine whether a scan should be performed and what type of scan to run. When triggered, it searches the entire `--target-path` for all supported manifest files. -4. **No Manifest Files**: If no manifest files are found in either git commit changes or `--files` (and `--ignore-commit-files` is not set), the scan is skipped. +### Scanning Modes -> **Note**: The CLI does not scan only the specified files - it uses them to determine whether a scan should be performed. When a scan is triggered, it searches the entire `--target-path` for all supported manifest files. +- **Differential Mode**: When manifest files are detected in changes, performs a diff scan with PR/MR comment integration +- **API Mode**: When no manifest files are in changes, creates a full scan report without PR comments but still scans the entire repository +- **Force Mode**: With `--ignore-commit-files`, always performs a full scan regardless of changes ### Examples -- **Commit with manifest file**: If your commit includes changes to `package.json`, a scan will be triggered automatically. -- **Commit without manifest files**: If your commit only changes non-manifest files (like `.github/workflows/socket.yaml`), no scan will be performed unless you use `--files` or `--ignore-commit-files`. -- **Using `--files`**: If you specify `--files '["package.json"]'`, the CLI will check if this file exists and is a manifest file before triggering a scan. -- **Using `--ignore-commit-files`**: This forces a scan of all manifest files in the target path, regardless of what's in your commit. +- **Commit with manifest file**: If your commit includes changes to `package.json`, a differential scan will be triggered automatically with PR comment integration. +- **Commit without manifest files**: If your commit only changes non-manifest files (like `.github/workflows/socket.yaml`), the CLI automatically switches to API mode and performs a full repository scan. +- **Using `--files`**: If you specify `--files '["package.json"]'`, the CLI will check if this file exists and is a manifest file before determining scan type. +- **Using `--ignore-commit-files`**: This forces a full scan of all manifest files in the target path, regardless of what's in your commit. +- **Auto-detection**: Most CI/CD scenarios now work with just `socketcli --target-path /path/to/repo --scm github --pr-number $PR_NUM` ## Debugging and Troubleshooting diff --git a/pyproject.toml b/pyproject.toml index 0339b2f..1406aad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.1.23" +version = "2.1.24" requires-python = ">= 3.10" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index b2c1d88..b396467 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,2 +1,2 @@ __author__ = 'socket.dev' -__version__ = '2.1.23' +__version__ = '2.1.24' diff --git a/socketsecurity/core/__init__.py b/socketsecurity/core/__init__.py index 7688299..ec85d4e 100644 --- a/socketsecurity/core/__init__.py +++ b/socketsecurity/core/__init__.py @@ -487,7 +487,7 @@ def create_full_scan_with_report_url( no_change: bool = False, save_files_list_path: str = None, save_manifest_tar_path: str = None - ) -> dict: + ) -> Diff: """Create a new full scan and return with html_report_url. Args: @@ -501,12 +501,13 @@ def create_full_scan_with_report_url( Dict with full scan data including html_report_url """ log.debug(f"starting create_full_scan_with_report_url with no_change: {no_change}") + diff = Diff( + id="NO_SCAN_RAN", + report_url="", + diff_url="" + ) if no_change: - return { - "id": "NO_SCAN_RAN", - "html_report_url": "", - "unmatchedFiles": [] - } + return diff # Find manifest files files = self.find_files(path) @@ -521,11 +522,7 @@ def create_full_scan_with_report_url( files_for_sending = self.load_files_for_sending(files, path) if not files: - return { - "id": "NO_SCAN_RAN", - "html_report_url": "", - "unmatchedFiles": [] - } + return diff try: # Create new scan @@ -539,25 +536,13 @@ def create_full_scan_with_report_url( # Construct report URL base_socket = "https://socket.dev/dashboard/org" - report_url = f"{base_socket}/{self.config.org_slug}/sbom/{new_full_scan.id}" - if not params.include_license_details: - report_url += "?include_license_details=false" + diff.report_url = f"{base_socket}/{self.config.org_slug}/sbom/{new_full_scan.id}" + diff.diff_url = diff.report_url + diff.id = new_full_scan.id + diff.packages = {} # Return result in the format expected by the user - return { - "id": new_full_scan.id, - "created_at": new_full_scan.created_at, - "updated_at": new_full_scan.updated_at, - "organization_id": new_full_scan.organization_id, - "repository_id": new_full_scan.repository_id, - "branch": new_full_scan.branch, - "commit_message": new_full_scan.commit_message, - "commit_hash": new_full_scan.commit_hash, - "pull_request": new_full_scan.pull_request, - "committers": new_full_scan.committers, - "html_report_url": report_url, - "unmatchedFiles": getattr(new_full_scan, 'unmatchedFiles', []) - } + return diff def check_full_scans_status(self, head_full_scan_id: str, new_full_scan_id: str) -> bool: is_ready = False diff --git a/socketsecurity/core/git_interface.py b/socketsecurity/core/git_interface.py index 5cf5296..85fa029 100644 --- a/socketsecurity/core/git_interface.py +++ b/socketsecurity/core/git_interface.py @@ -16,16 +16,25 @@ def __init__(self, path: str): assert self.repo self.head = self.repo.head - # Use GITHUB_SHA if available, otherwise fall back to head commit + # Use GITHUB_SHA if available, otherwise fall back to current HEAD commit github_sha = os.getenv('GITHUB_SHA') if github_sha: try: self.commit = self.repo.commit(github_sha) + log.debug(f"Using commit from GITHUB_SHA: {github_sha}") except Exception as error: log.debug(f"Failed to get commit from GITHUB_SHA: {error}") - self.commit = self.head.commit + # Use the actual current HEAD commit, not the head reference's commit + self.commit = self.repo.commit('HEAD') + log.debug(f"Using current HEAD commit: {self.commit.hexsha}") else: - self.commit = self.head.commit + # Use the actual current HEAD commit, not the head reference's commit + self.commit = self.repo.commit('HEAD') + log.debug(f"Using current HEAD commit: {self.commit.hexsha}") + + log.debug(f"Final commit being used: {self.commit.hexsha}") + log.debug(f"Commit author: {self.commit.author.name} <{self.commit.author.email}>") + log.debug(f"Commit committer: {self.commit.committer.name} <{self.commit.committer.email}>") self.repo_name = self.repo.remotes.origin.url.split('.git')[0].split('/')[-1] try: @@ -44,8 +53,161 @@ def __init__(self, path: str): if item != "": full_path = f"{self.path}/{item}" self.changed_files.append(full_path) + + # Determine if this commit is on the default branch + # This considers both GitHub Actions detached HEAD and regular branch situations + self.is_default_branch = self._is_commit_and_branch_default() + + def _is_commit_and_branch_default(self) -> bool: + """ + Check if both the commit is on the default branch AND we're processing the default branch. + This handles GitHub Actions detached HEAD state properly. + + Returns: + True if commit is on default branch and we're processing the default branch + """ + try: + # First check if the commit is reachable from the default branch + if not self.is_commit_on_default_branch(): + log.debug("Commit is not on default branch") + return False + + # Check if we're processing the default branch + github_ref = os.getenv('GITHUB_REF') # e.g., 'refs/heads/main' or 'refs/pull/123/merge' + + if github_ref: + log.debug(f"GitHub ref: {github_ref}") + + # Handle pull requests - they're not on the default branch + if github_ref.startswith('refs/pull/'): + log.debug("Processing a pull request, not default branch") + return False + + # Handle regular branch pushes + if github_ref.startswith('refs/heads/'): + branch_from_ref = github_ref.replace('refs/heads/', '') + default_branch_name = self.get_default_branch_name() + is_default = branch_from_ref == default_branch_name + log.debug(f"Branch from GITHUB_REF: {branch_from_ref}, Default: {default_branch_name}, Is default: {is_default}") + return is_default + + # Handle tags or other refs - not default branch + log.debug(f"Non-branch ref: {github_ref}, not default branch") + return False + else: + # Not in GitHub Actions, use local development logic + # For local development, we consider it "default branch" if: + # 1. Currently on the default branch, OR + # 2. The commit is reachable from the default branch (part of default branch history) + + is_on_default = self.is_on_default_branch() + if is_on_default: + log.debug("Currently on default branch locally") + return True + + # Even if on feature branch, if commit is on default branch, consider it default + # This handles cases where feature branch was created from or merged to default + is_commit_default = self.is_commit_on_default_branch() + log.debug(f"Not on default branch locally, but commit is on default branch: {is_commit_default}") + return is_commit_default + + except Exception as error: + log.debug(f"Error determining if commit and branch are default: {error}") + return False @property def commit_str(self) -> str: """Return commit SHA as a string""" return self.commit.hexsha + + def get_default_branch_name(self) -> str: + """ + Get the default branch name from the remote origin. + + Returns: + Default branch name (e.g., 'main', 'master') + """ + try: + # Try to get the default branch from remote HEAD + remote_head = self.repo.remotes.origin.refs.HEAD + # Extract branch name from refs/remotes/origin/HEAD -> refs/remotes/origin/main + default_branch = str(remote_head.reference).split('/')[-1] + log.debug(f"Default branch detected: {default_branch}") + return default_branch + except Exception as error: + log.debug(f"Could not determine default branch from remote: {error}") + # Fallback: check common default branch names + for branch_name in ['main', 'master']: + try: + if f'origin/{branch_name}' in [str(ref) for ref in self.repo.remotes.origin.refs]: + log.debug(f"Using fallback default branch: {branch_name}") + return branch_name + except: + continue + + # Last fallback: assume 'main' + log.debug("Using final fallback default branch: main") + return 'main' + + def is_commit_on_default_branch(self) -> bool: + """ + Check if the current commit is reachable from the default branch. + + Returns: + True if current commit is on the default branch, False otherwise + """ + try: + default_branch = self.get_default_branch_name() + + # Get the default branch's HEAD commit + try: + # Try remote branch first + default_branch_ref = self.repo.remotes.origin.refs[default_branch] + default_branch_commit = default_branch_ref.commit + except: + # Fallback to local branch + try: + default_branch_ref = self.repo.heads[default_branch] + default_branch_commit = default_branch_ref.commit + except: + log.debug(f"Could not find default branch '{default_branch}' locally or remotely") + return False + + # Check if current commit is the same as default branch HEAD + if self.commit.hexsha == default_branch_commit.hexsha: + log.debug("Current commit is the HEAD of the default branch") + return True + + # Check if current commit is an ancestor of the default branch HEAD + # This means the commit is reachable from the default branch + is_ancestor = self.repo.is_ancestor(self.commit, default_branch_commit) + log.debug(f"Current commit is ancestor of default branch: {is_ancestor}") + return is_ancestor + + except Exception as error: + log.debug(f"Error checking if commit is on default branch: {error}") + return False + + def is_on_default_branch(self) -> bool: + """ + Check if we're currently on the default branch (not just if commit is reachable). + + Returns: + True if currently on the default branch, False otherwise + """ + try: + # If we're in detached HEAD state, we're not "on" any branch + if self.repo.head.is_detached: + log.debug("In detached HEAD state, not on any branch") + return False + + current_branch_name = self.repo.active_branch.name + default_branch_name = self.get_default_branch_name() + + is_default = current_branch_name == default_branch_name + log.debug(f"Current branch: {current_branch_name}, Default branch: {default_branch_name}, Is default: {is_default}") + return is_default + + except Exception as error: + log.debug(f"Error checking if on default branch: {error}") + return False diff --git a/socketsecurity/socketcli.py b/socketsecurity/socketcli.py index 97902b7..c4277cd 100644 --- a/socketsecurity/socketcli.py +++ b/socketsecurity/socketcli.py @@ -125,7 +125,7 @@ def main_code(): if not config.branch: config.branch = git_repo.branch if not config.committers: - config.committers = [git_repo.committer] + config.committers = [git_repo.author] if not config.commit_message: config.commit_message = git_repo.commit_message except InvalidGitRepositoryError: @@ -150,7 +150,9 @@ def main_code(): from socketsecurity.core.scm.gitlab import Gitlab, GitlabConfig gitlab_config = GitlabConfig.from_env() scm = Gitlab(client=client, config=gitlab_config) - if scm is not None: + # Don't override config.default_branch if it was explicitly set via --default-branch flag + # Only use SCM detection if --default-branch wasn't provided + if scm is not None and not config.default_branch: config.default_branch = scm.config.is_default_branch # Determine files to check based on the new logic @@ -203,6 +205,21 @@ def main_code(): except (ValueError, TypeError): pr_number = 0 + # Determine if this should be treated as default branch + # Priority order: + # 1. If --default-branch flag is explicitly set to True, use that + # 2. If SCM detected it's the default branch, use that + # 3. If it's a git repo, use git_repo.is_default_branch + # 4. Otherwise, default to False + if config.default_branch: + is_default_branch = True + elif scm is not None and hasattr(scm.config, 'is_default_branch') and scm.config.is_default_branch: + is_default_branch = True + elif is_repo and git_repo.is_default_branch: + is_default_branch = True + else: + is_default_branch = False + params = FullScanParams( org_slug=org_slug, integration_type=integration_type, @@ -213,8 +230,8 @@ def main_code(): commit_hash=config.commit_sha, pull_request=pr_number, committers=config.committers, - make_default_branch=config.default_branch, - set_as_pending_head=True + make_default_branch=is_default_branch, + set_as_pending_head=is_default_branch ) params.include_license_details = not config.exclude_license_details @@ -302,19 +319,29 @@ def main_code(): else: if force_api_mode: log.info("No Manifest files changed, creating Socket Report") + serializable_params = { + key: value if isinstance(value, (int, float, str, list, dict, bool, type(None))) else str(value) + for key, value in params.__dict__.items() + } + log.debug(f"params={serializable_params}") + diff = core.create_full_scan_with_report_url( + config.target_path, + params, + no_change=should_skip_scan, + save_files_list_path=config.save_submitted_files_list, + save_manifest_tar_path=config.save_manifest_tar + ) + log.info(f"Full scan created with ID: {diff.id}") + log.info(f"Full scan report URL: {diff.report_url}") else: log.info("API Mode") - full_scan_result = core.create_full_scan_with_report_url(config.target_path, params, no_change=should_skip_scan, save_files_list_path=config.save_submitted_files_list, save_manifest_tar_path=config.save_manifest_tar) - log.info(f"Full scan created with ID: {full_scan_result['id']}") - log.info(f"Full scan report URL: {full_scan_result['html_report_url']}") - - # Create a minimal diff-like object for compatibility with downstream code - diff = Diff() - diff.id = full_scan_result['id'] - diff.report_url = full_scan_result['html_report_url'] - diff.diff_url = full_scan_result['html_report_url'] - diff.packages = {} # No package data needed for API mode - # No output handling needed for API mode - just creating the scan + diff = core.create_new_diff( + config.target_path, params, + no_change=should_skip_scan, + save_files_list_path=config.save_submitted_files_list, + save_manifest_tar_path=config.save_manifest_tar + ) + output_handler.handle_output(diff) # Handle license generation if not should_skip_scan and diff.id != "NO_DIFF_RAN" and diff.id != "NO_SCAN_RAN" and config.generate_license: diff --git a/workflows/bitbucket-pipelines.yml b/workflows/bitbucket-pipelines.yml new file mode 100644 index 0000000..d9f1260 --- /dev/null +++ b/workflows/bitbucket-pipelines.yml @@ -0,0 +1,85 @@ +# Socket Security Bitbucket Pipelines +# This pipeline runs Socket Security scans on every commit to any branch +# The CLI automatically detects most information from the git repository + +image: python:3.12-slim + +definitions: + steps: + - step: &socket-scan + name: Socket Security Scan + caches: + - pip + script: + - pip install --upgrade pip + - pip install socketsecurity + # Run Socket CLI with minimal required parameters + # The CLI automatically detects: + # - Repository name from git + # - Branch name from git + # - Commit SHA from git + # - Commit message from git + # - Committer information from git + # - Default branch status from git repository + # - Changed files from git commit + - | + socketcli \ + --target-path $BITBUCKET_CLONE_DIR \ + --scm api \ + --pr-number ${BITBUCKET_PR_ID:-0} + # Repository variables needed (set in Bitbucket repo settings) + # SOCKET_SECURITY_API_KEY: Your Socket Security API token + +pipelines: + # Run on all branches + branches: + '**': + - step: *socket-scan + + # Run on pull requests + pull-requests: + '**': + - step: *socket-scan + +# Optional: More efficient version that only runs when manifest files change +# To use this instead, replace the pipelines section above with: +# +# pipelines: +# branches: +# '**': +# - step: +# <<: *socket-scan +# condition: +# changesets: +# includePaths: +# - "package.json" +# - "package-lock.json" +# - "yarn.lock" +# - "pnpm-lock.yaml" +# - "requirements.txt" +# - "Pipfile" +# - "Pipfile.lock" +# - "pyproject.toml" +# - "poetry.lock" +# - "go.mod" +# - "go.sum" +# - "Cargo.toml" +# - "Cargo.lock" +# - "composer.json" +# - "composer.lock" +# - "Gemfile" +# - "Gemfile.lock" +# - "**/*.csproj" +# - "**/*.fsproj" +# - "**/*.vbproj" +# - "packages.config" +# - "paket.dependencies" +# - "project.json" +# +# pull-requests: +# '**': +# - step: *socket-scan + +# Note: Bitbucket Pipelines doesn't have built-in SCM integration like +# GitHub Actions or GitLab CI, so we use --scm api mode which provides +# basic scanning without PR comment functionality. diff --git a/workflows/github-actions.yml b/workflows/github-actions.yml new file mode 100644 index 0000000..bfbda7a --- /dev/null +++ b/workflows/github-actions.yml @@ -0,0 +1,67 @@ +# Socket Security GitHub Actions Workflow +# This workflow runs Socket Security scans on every commit to any branch +# It automatically detects git repository information and handles different event types + +name: socket-security-workflow +run-name: Socket Security Github Action + +on: + push: + branches: ['**'] # Run on all branches, all commits + pull_request: + types: [opened, synchronize, reopened] + issue_comment: + types: [created] + +# Prevent concurrent runs for the same commit +concurrency: + group: socket-scan-${{ github.ref }}-${{ github.sha }} + cancel-in-progress: true + +jobs: + socket-security: + permissions: + issues: write + contents: read + pull-requests: write + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + # For PRs, fetch one additional commit for proper diff analysis + fetch-depth: ${{ github.event_name == 'pull_request' && 2 || 0 }} + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install Socket CLI + run: pip install socketsecurity --upgrade + + - name: Run Socket Security Scan + env: + SOCKET_SECURITY_API_KEY: ${{ secrets.SOCKET_SECURITY_API_KEY }} + GH_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Determine PR number based on event type + PR_NUMBER=0 + if [ "${{ github.event_name }}" == "pull_request" ]; then + PR_NUMBER=${{ github.event.pull_request.number }} + elif [ "${{ github.event_name }}" == "issue_comment" ]; then + PR_NUMBER=${{ github.event.issue.number }} + fi + + # Run Socket CLI with minimal required parameters + # The CLI automatically detects: + # - Repository name from git + # - Branch name from git + # - Commit SHA from git + # - Commit message from git + # - Committer information from git + # - Default branch status from git and GitHub environment + # - Changed files from git commit + socketcli \ + --target-path $GITHUB_WORKSPACE \ + --scm github \ + --pr-number $PR_NUMBER diff --git a/workflows/gitlab-ci.yml b/workflows/gitlab-ci.yml new file mode 100644 index 0000000..2b96288 --- /dev/null +++ b/workflows/gitlab-ci.yml @@ -0,0 +1,81 @@ +# Socket Security GitLab CI Pipeline +# This pipeline runs Socket Security scans on every commit to any branch +# The CLI automatically detects most information from the git repository + +stages: + - security-scan + +socket-security: + stage: security-scan + image: python:3.12-slim + + # Run on all branches and merge requests + rules: + - if: $CI_PIPELINE_SOURCE == "push" + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + + variables: + # These environment variables are automatically available in GitLab CI + # and are used by the Socket CLI's GitLab SCM integration + PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip" + + cache: + paths: + - .cache/pip/ + + before_script: + - pip install --upgrade pip + - pip install socketsecurity + + script: + # Run Socket CLI with minimal required parameters + # The CLI automatically detects: + # - Repository name from git + # - Branch name from git + # - Commit SHA from git (or CI_COMMIT_SHA) + # - Commit message from git + # - Committer information from git + # - Default branch status from GitLab CI environment variables + # - Changed files from git commit + # - Merge request number from CI_MERGE_REQUEST_IID + - | + socketcli \ + --target-path $CI_PROJECT_DIR \ + --scm gitlab \ + --pr-number ${CI_MERGE_REQUEST_IID:-0} + + # Required for GitLab integration to work properly + variables: + SOCKET_SECURITY_API_KEY: $SOCKET_SECURITY_API_KEY + GITLAB_TOKEN: $CI_JOB_TOKEN + +# Optional: Run only when manifest files change (more efficient) +# To use this version instead, replace the rules section above with: +# +# rules: +# - if: $CI_PIPELINE_SOURCE == "push" +# changes: +# - "package.json" +# - "package-lock.json" +# - "yarn.lock" +# - "pnpm-lock.yaml" +# - "requirements.txt" +# - "Pipfile" +# - "Pipfile.lock" +# - "pyproject.toml" +# - "poetry.lock" +# - "go.mod" +# - "go.sum" +# - "Cargo.toml" +# - "Cargo.lock" +# - "composer.json" +# - "composer.lock" +# - "Gemfile" +# - "Gemfile.lock" +# - "**/*.csproj" +# - "**/*.fsproj" +# - "**/*.vbproj" +# - "packages.config" +# - "paket.dependencies" +# - "project.json" +# - if: $CI_PIPELINE_SOURCE == "merge_request_event" From e901515050023ca5bb7de2a0c62f5b1f36b6e5d2 Mon Sep 17 00:00:00 2001 From: Douglas Date: Thu, 24 Jul 2025 19:32:37 -0700 Subject: [PATCH 10/23] Doug/fix example workflows to use socket container (#103) * Updated workflow examples to use Socket Container * Updated examples not to do an extra version check --- pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- workflows/bitbucket-pipelines.yml | 6 +----- workflows/github-actions.yml | 10 +++------- workflows/gitlab-ci.yml | 6 +----- 5 files changed, 7 insertions(+), 19 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1406aad..bdf1827 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.1.24" +version = "2.1.26" requires-python = ">= 3.10" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index b396467..f4e09ad 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,2 +1,2 @@ __author__ = 'socket.dev' -__version__ = '2.1.24' +__version__ = '2.1.26' diff --git a/workflows/bitbucket-pipelines.yml b/workflows/bitbucket-pipelines.yml index d9f1260..d129560 100644 --- a/workflows/bitbucket-pipelines.yml +++ b/workflows/bitbucket-pipelines.yml @@ -2,17 +2,13 @@ # This pipeline runs Socket Security scans on every commit to any branch # The CLI automatically detects most information from the git repository -image: python:3.12-slim +image: socketdev/cli:latest definitions: steps: - step: &socket-scan name: Socket Security Scan - caches: - - pip script: - - pip install --upgrade pip - - pip install socketsecurity # Run Socket CLI with minimal required parameters # The CLI automatically detects: # - Repository name from git diff --git a/workflows/github-actions.yml b/workflows/github-actions.yml index bfbda7a..8c3d49d 100644 --- a/workflows/github-actions.yml +++ b/workflows/github-actions.yml @@ -26,19 +26,15 @@ jobs: pull-requests: write runs-on: ubuntu-latest + # Option 1: Use the official Socket CLI container (faster, more reliable) + container: socketdev/cli:latest + steps: - uses: actions/checkout@v4 with: # For PRs, fetch one additional commit for proper diff analysis fetch-depth: ${{ github.event_name == 'pull_request' && 2 || 0 }} - - uses: actions/setup-python@v5 - with: - python-version: '3.12' - - - name: Install Socket CLI - run: pip install socketsecurity --upgrade - - name: Run Socket Security Scan env: SOCKET_SECURITY_API_KEY: ${{ secrets.SOCKET_SECURITY_API_KEY }} diff --git a/workflows/gitlab-ci.yml b/workflows/gitlab-ci.yml index 2b96288..4e44580 100644 --- a/workflows/gitlab-ci.yml +++ b/workflows/gitlab-ci.yml @@ -7,7 +7,7 @@ stages: socket-security: stage: security-scan - image: python:3.12-slim + image: socketdev/cli:latest # Run on all branches and merge requests rules: @@ -23,10 +23,6 @@ socket-security: paths: - .cache/pip/ - before_script: - - pip install --upgrade pip - - pip install socketsecurity - script: # Run Socket CLI with minimal required parameters # The CLI automatically detects: From c28a1387390615acbb55db4400fbeb807a3a0907 Mon Sep 17 00:00:00 2001 From: Douglas Date: Fri, 25 Jul 2025 13:08:43 -0700 Subject: [PATCH 11/23] feat: enhance branch detection with CI support and robust fallbacks (#104) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add comprehensive CI environment variable support for GitLab, GitHub, and Bitbucket - Implement priority system: CLI args → CI variables → git properties → defaults - Add robust fallback chain for branch detection in detached HEAD scenarios - Replace hard failures with sensible defaults (socket-default-repo, socket-default-branch) - Fix GitLab CI branch detection issue where branch was showing as None - Support additional commit SHA sources (CI_COMMIT_SHA, BITBUCKET_COMMIT) - Enhance default branch detection logic for all CI platforms - Improve error handling and logging throughout git interface Resolves GitLab pipeline branch detection failures and ensures CLI never fails due to missing repository or branch information. --- pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- socketsecurity/core/git_interface.py | 130 ++++++++++++++++++++++++--- socketsecurity/socketcli.py | 8 +- 4 files changed, 127 insertions(+), 15 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bdf1827..8ae7dd2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.1.26" +version = "2.1.27" requires-python = ">= 3.10" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index f4e09ad..20fd0d8 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,2 +1,2 @@ __author__ = 'socket.dev' -__version__ = '2.1.26' +__version__ = '2.1.27' diff --git a/socketsecurity/core/git_interface.py b/socketsecurity/core/git_interface.py index 85fa029..995634b 100644 --- a/socketsecurity/core/git_interface.py +++ b/socketsecurity/core/git_interface.py @@ -16,14 +16,24 @@ def __init__(self, path: str): assert self.repo self.head = self.repo.head - # Use GITHUB_SHA if available, otherwise fall back to current HEAD commit + # Use CI environment SHA if available, otherwise fall back to current HEAD commit github_sha = os.getenv('GITHUB_SHA') - if github_sha: + gitlab_sha = os.getenv('CI_COMMIT_SHA') + bitbucket_sha = os.getenv('BITBUCKET_COMMIT') + ci_sha = github_sha or gitlab_sha or bitbucket_sha + + if ci_sha: try: - self.commit = self.repo.commit(github_sha) - log.debug(f"Using commit from GITHUB_SHA: {github_sha}") + self.commit = self.repo.commit(ci_sha) + if github_sha: + env_source = "GITHUB_SHA" + elif gitlab_sha: + env_source = "CI_COMMIT_SHA" + else: + env_source = "BITBUCKET_COMMIT" + log.debug(f"Using commit from {env_source}: {ci_sha}") except Exception as error: - log.debug(f"Failed to get commit from GITHUB_SHA: {error}") + log.debug(f"Failed to get commit from CI environment: {error}") # Use the actual current HEAD commit, not the head reference's commit self.commit = self.repo.commit('HEAD') log.debug(f"Using current HEAD commit: {self.commit.hexsha}") @@ -36,13 +46,84 @@ def __init__(self, path: str): log.debug(f"Commit author: {self.commit.author.name} <{self.commit.author.email}>") log.debug(f"Commit committer: {self.commit.committer.name} <{self.commit.committer.email}>") - self.repo_name = self.repo.remotes.origin.url.split('.git')[0].split('/')[-1] + # Extract repository name from git remote, with fallback to default try: - self.branch = self.head.reference - urllib.parse.unquote(str(self.branch)) + remote_url = self.repo.remotes.origin.url + self.repo_name = remote_url.split('.git')[0].split('/')[-1] + log.debug(f"Repository name detected from git remote: {self.repo_name}") except Exception as error: - self.branch = None - log.debug(error) + log.debug(f"Failed to get repository name from git remote: {error}") + self.repo_name = "socket-default-repo" + log.debug(f"Using default repository name: {self.repo_name}") + + # Branch detection with priority: CI Variables -> Git Properties -> Default + # Note: CLI arguments are handled in socketcli.py and take highest priority + + # First, try CI environment variables (most accurate in CI environments) + ci_branch = None + + # GitLab CI variables + gitlab_branch = os.getenv('CI_COMMIT_BRANCH') or os.getenv('CI_MERGE_REQUEST_SOURCE_BRANCH_NAME') + + # GitHub Actions variables + github_ref = os.getenv('GITHUB_REF') # e.g., 'refs/heads/main' + github_branch = None + if github_ref and github_ref.startswith('refs/heads/'): + github_branch = github_ref.replace('refs/heads/', '') + + # Bitbucket Pipelines variables + bitbucket_branch = os.getenv('BITBUCKET_BRANCH') + + # Select CI branch with priority: GitLab -> GitHub -> Bitbucket + ci_branch = gitlab_branch or github_branch or bitbucket_branch + + if ci_branch: + self.branch = ci_branch + if gitlab_branch: + env_source = "GitLab CI" + elif github_branch: + env_source = "GitHub Actions" + else: + env_source = "Bitbucket Pipelines" + log.debug(f"Branch detected from {env_source}: {self.branch}") + else: + # Try to get branch name from git properties + try: + self.branch = self.head.reference + urllib.parse.unquote(str(self.branch)) + log.debug(f"Branch detected from git reference: {self.branch}") + except Exception as error: + log.debug(f"Failed to get branch from git reference: {error}") + + # Fallback: try to detect branch from git commands (works in detached HEAD) + git_detected_branch = None + try: + # Try git name-rev first (most reliable for detached HEAD) + result = self.repo.git.name_rev('--name-only', 'HEAD') + if result and result != 'undefined': + # Clean up the result (remove any prefixes like 'remotes/origin/') + git_detected_branch = result.split('/')[-1] + log.debug(f"Branch detected from git name-rev: {git_detected_branch}") + except Exception as git_error: + log.debug(f"git name-rev failed: {git_error}") + + if not git_detected_branch: + try: + # Fallback: try git describe --all --exact-match + result = self.repo.git.describe('--all', '--exact-match', 'HEAD') + if result and result.startswith('heads/'): + git_detected_branch = result.replace('heads/', '') + log.debug(f"Branch detected from git describe: {git_detected_branch}") + except Exception as git_error: + log.debug(f"git describe failed: {git_error}") + + if git_detected_branch: + self.branch = git_detected_branch + log.debug(f"Branch detected from git commands: {self.branch}") + else: + # Final fallback: use default branch name + self.branch = "socket-default-branch" + log.debug(f"Using default branch name: {self.branch}") self.author = self.commit.author self.commit_sha = self.commit.binsha self.commit_message = self.commit.message @@ -72,9 +153,14 @@ def _is_commit_and_branch_default(self) -> bool: log.debug("Commit is not on default branch") return False - # Check if we're processing the default branch + # Check if we're processing the default branch via CI environment variables github_ref = os.getenv('GITHUB_REF') # e.g., 'refs/heads/main' or 'refs/pull/123/merge' + gitlab_branch = os.getenv('CI_COMMIT_BRANCH') + gitlab_mr_branch = os.getenv('CI_MERGE_REQUEST_SOURCE_BRANCH_NAME') + gitlab_default_branch = os.getenv('CI_DEFAULT_BRANCH', '') + bitbucket_branch = os.getenv('BITBUCKET_BRANCH') + # Handle GitHub Actions if github_ref: log.debug(f"GitHub ref: {github_ref}") @@ -94,6 +180,28 @@ def _is_commit_and_branch_default(self) -> bool: # Handle tags or other refs - not default branch log.debug(f"Non-branch ref: {github_ref}, not default branch") return False + + # Handle GitLab CI + elif gitlab_branch or gitlab_mr_branch: + # If this is a merge request, use the source branch + current_branch = gitlab_mr_branch or gitlab_branch + default_branch_name = gitlab_default_branch or self.get_default_branch_name() + + # For merge requests, they're typically not considered "default branch" + if gitlab_mr_branch: + log.debug(f"Processing GitLab MR from branch: {gitlab_mr_branch}, not default branch") + return False + + is_default = current_branch == default_branch_name + log.debug(f"GitLab branch: {current_branch}, Default: {default_branch_name}, Is default: {is_default}") + return is_default + + # Handle Bitbucket Pipelines + elif bitbucket_branch: + default_branch_name = self.get_default_branch_name() + is_default = bitbucket_branch == default_branch_name + log.debug(f"Bitbucket branch: {bitbucket_branch}, Default: {default_branch_name}, Is default: {is_default}") + return is_default else: # Not in GitHub Actions, use local development logic # For local development, we consider it "default branch" if: diff --git a/socketsecurity/socketcli.py b/socketsecurity/socketcli.py index c4277cd..b1100d1 100644 --- a/socketsecurity/socketcli.py +++ b/socketsecurity/socketcli.py @@ -136,8 +136,12 @@ def main_code(): raise Exception(f"Unable to find path {config.target_path}") if not config.repo: - log.info("Repo name needs to be set") - sys.exit(2) + config.repo = "socket-default-repo" + log.debug(f"Using default repository name: {config.repo}") + + if not config.branch: + config.branch = "socket-default-branch" + log.debug(f"Using default branch name: {config.branch}") scm = None if config.scm == "github": From 20110b3c4847bf52a19767ea10be5a4633d2e535 Mon Sep 17 00:00:00 2001 From: Douglas Date: Mon, 28 Jul 2025 16:37:46 -0700 Subject: [PATCH 12/23] Automatically detect GitHub event action from event payload if not set in environment (#105) - Update the Github logic to read the event action from the GITHUB_EVENT_PATH payload file if EVENT_ACTION is not set. --- pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- socketsecurity/core/scm/github.py | 10 ++++++++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8ae7dd2..5734b33 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.1.27" +version = "2.1.28" requires-python = ">= 3.10" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 20fd0d8..fce5d74 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,2 +1,2 @@ __author__ = 'socket.dev' -__version__ = '2.1.27' +__version__ = '2.1.28' diff --git a/socketsecurity/core/scm/github.py b/socketsecurity/core/scm/github.py index fa40afb..1b4fa0e 100644 --- a/socketsecurity/core/scm/github.py +++ b/socketsecurity/core/scm/github.py @@ -47,7 +47,13 @@ def from_env(cls, pr_number: Optional[str] = None) -> 'GithubConfig': # Add debug logging sha = os.getenv('GITHUB_SHA', '') log.debug(f"Loading SHA from GITHUB_SHA: {sha}") - + event_action = os.getenv('EVENT_ACTION', None) + if not event_action: + event_path = os.getenv('GITHUB_EVENT_PATH') + if event_path and os.path.exists(event_path): + with open(event_path, 'r') as f: + event = json.load(f) + event_action = event.get('action') repository = os.getenv('GITHUB_REPOSITORY', '') owner = os.getenv('GITHUB_REPOSITORY_OWNER', '') if '/' in repository: @@ -74,7 +80,7 @@ def from_env(cls, pr_number: Optional[str] = None) -> 'GithubConfig': env=os.getenv('GITHUB_ENV', ''), token=token, owner=owner, - event_action=os.getenv('EVENT_ACTION'), + event_action=event_action, headers={ 'Authorization': f"Bearer {token}", 'User-Agent': 'SocketPythonScript/0.0.1', From 0546fab77d8b8321bea64d5d7532f39b0d38ae3d Mon Sep 17 00:00:00 2001 From: Douglas Date: Mon, 28 Jul 2025 21:09:49 -0700 Subject: [PATCH 13/23] =?UTF-8?q?fix:=20robust=20changed=20file=20detectio?= =?UTF-8?q?n=20for=20PRs/MRs=20in=20GitHub,=20GitLab,=20and=E2=80=A6=20(#1?= =?UTF-8?q?06)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: robust changed file detection for PRs/MRs in GitHub, GitLab, and Bitbucket - Use git diff with appropriate refs and environment variables to detect changed files in pull/merge requests across GitHub Actions, GitLab CI, and Bitbucket Pipelines. - Fallback to git show for single commit detection. - Ensures manifest and other file changes are correctly picked up in all major * Fixing logic for Github changed files * Add fetch of previous commit * Fix file names for changed files to work with globbing * Fixed detection for files in the commit files change --- pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- socketsecurity/core/__init__.py | 15 +++-- socketsecurity/core/git_interface.py | 96 +++++++++++++++++++++++++++- workflows/github-actions.yml | 4 +- 5 files changed, 107 insertions(+), 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5734b33..d2f79a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.1.28" +version = "2.1.33" requires-python = ">= 3.10" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index fce5d74..c03b717 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,2 +1,2 @@ __author__ = 'socket.dev' -__version__ = '2.1.28' +__version__ = '2.1.33' diff --git a/socketsecurity/core/__init__.py b/socketsecurity/core/__init__.py index ec85d4e..7c43265 100644 --- a/socketsecurity/core/__init__.py +++ b/socketsecurity/core/__init__.py @@ -389,15 +389,20 @@ def has_manifest_files(self, files: list) -> bool: from .utils import socket_globs as fallback_patterns patterns = fallback_patterns + # Normalize all file paths for matching + norm_files = [f.replace('\\', '/').lstrip('./') for f in files] + for ecosystem in patterns: ecosystem_patterns = patterns[ecosystem] for file_name in ecosystem_patterns: pattern_str = ecosystem_patterns[file_name]["pattern"] - for file in files: - if "\\" in file: - file = file.replace("\\", "/") - if PurePath(file).match(pattern_str): - return True + # Expand brace patterns for each manifest pattern + expanded_patterns = Core.expand_brace_pattern(pattern_str) + for exp_pat in expanded_patterns: + for file in norm_files: + # Use PurePath.match for glob-like matching + if PurePath(file).match(exp_pat): + return True return False def check_file_count_limit(self, file_count: int) -> dict: diff --git a/socketsecurity/core/git_interface.py b/socketsecurity/core/git_interface.py index 995634b..93fefb5 100644 --- a/socketsecurity/core/git_interface.py +++ b/socketsecurity/core/git_interface.py @@ -15,6 +15,13 @@ def __init__(self, path: str): self.repo = Repo(path) assert self.repo self.head = self.repo.head + + # Always fetch all remote refs to ensure branches exist for diffing + try: + self.repo.git.fetch('--all') + log.debug("Fetched all remote refs for diffing.") + except Exception as fetch_error: + log.debug(f"Failed to fetch all remote refs: {fetch_error}") # Use CI environment SHA if available, otherwise fall back to current HEAD commit github_sha = os.getenv('GITHUB_SHA') @@ -128,12 +135,95 @@ def __init__(self, path: str): self.commit_sha = self.commit.binsha self.commit_message = self.commit.message self.committer = self.commit.committer - self.show_files = self.repo.git.show(self.commit, name_only=True, format="%n").splitlines() + # Detect changed files in PR/MR context for GitHub, GitLab, Bitbucket; fallback to git show + self.show_files = [] + detected = False + # GitHub Actions PR context + github_base_ref = os.getenv('GITHUB_BASE_REF') + github_head_ref = os.getenv('GITHUB_HEAD_REF') + github_event_name = os.getenv('GITHUB_EVENT_NAME') + github_before_sha = os.getenv('GITHUB_EVENT_BEFORE') # previous commit for push + github_sha = os.getenv('GITHUB_SHA') # current commit + if github_event_name == 'pull_request' and github_base_ref and github_head_ref: + try: + # Fetch both branches individually + self.repo.git.fetch('origin', github_base_ref) + self.repo.git.fetch('origin', github_head_ref) + # Try remote diff first + diff_range = f"origin/{github_base_ref}...origin/{github_head_ref}" + try: + diff_files = self.repo.git.diff('--name-only', diff_range) + self.show_files = diff_files.splitlines() + log.debug(f"Changed files detected via git diff (GitHub PR remote): {self.show_files}") + detected = True + except Exception as remote_error: + log.debug(f"Remote diff failed: {remote_error}") + # Try local branch diff + local_diff_range = f"{github_base_ref}...{github_head_ref}" + try: + diff_files = self.repo.git.diff('--name-only', local_diff_range) + self.show_files = diff_files.splitlines() + log.debug(f"Changed files detected via git diff (GitHub PR local): {self.show_files}") + detected = True + except Exception as local_error: + log.debug(f"Local diff failed: {local_error}") + except Exception as error: + log.debug(f"Failed to fetch branches or diff for GitHub PR: {error}") + # Commits to default branch (push events) + elif github_event_name == 'push' and github_before_sha and github_sha: + try: + diff_files = self.repo.git.diff('--name-only', f'{github_before_sha}..{github_sha}') + self.show_files = diff_files.splitlines() + log.debug(f"Changed files detected via git diff (GitHub push): {self.show_files}") + detected = True + except Exception as error: + log.debug(f"Failed to get changed files via git diff (GitHub push): {error}") + elif github_event_name == 'push': + try: + self.show_files = self.repo.git.show(self.commit, name_only=True, format="%n").splitlines() + log.debug(f"Changed files detected via git show (GitHub push fallback): {self.show_files}") + detected = True + except Exception as error: + log.debug(f"Failed to get changed files via git show (GitHub push fallback): {error}") + # GitLab CI Merge Request context + if not detected: + gitlab_target = os.getenv('CI_MERGE_REQUEST_TARGET_BRANCH_NAME') + gitlab_source = os.getenv('CI_MERGE_REQUEST_SOURCE_BRANCH_NAME') + if gitlab_target and gitlab_source: + try: + self.repo.git.fetch('origin', gitlab_target, gitlab_source) + diff_range = f"origin/{gitlab_target}...origin/{gitlab_source}" + diff_files = self.repo.git.diff('--name-only', diff_range) + self.show_files = diff_files.splitlines() + log.debug(f"Changed files detected via git diff (GitLab): {self.show_files}") + detected = True + except Exception as error: + log.debug(f"Failed to get changed files via git diff (GitLab): {error}") + # Bitbucket Pipelines PR context + if not detected: + bitbucket_pr_id = os.getenv('BITBUCKET_PR_ID') + bitbucket_source = os.getenv('BITBUCKET_BRANCH') + bitbucket_dest = os.getenv('BITBUCKET_PR_DESTINATION_BRANCH') + # BITBUCKET_BRANCH is the source branch in PR builds + if bitbucket_pr_id and bitbucket_source and bitbucket_dest: + try: + self.repo.git.fetch('origin', bitbucket_dest, bitbucket_source) + diff_range = f"origin/{bitbucket_dest}...origin/{bitbucket_source}" + diff_files = self.repo.git.diff('--name-only', diff_range) + self.show_files = diff_files.splitlines() + log.debug(f"Changed files detected via git diff (Bitbucket): {self.show_files}") + detected = True + except Exception as error: + log.debug(f"Failed to get changed files via git diff (Bitbucket): {error}") + # Fallback to git show for single commit + if not detected: + self.show_files = self.repo.git.show(self.commit, name_only=True, format="%n").splitlines() + log.debug(f"Changed files detected via git show: {self.show_files}") self.changed_files = [] for item in self.show_files: if item != "": - full_path = f"{self.path}/{item}" - self.changed_files.append(full_path) + # Use relative path for glob matching + self.changed_files.append(item) # Determine if this commit is on the default branch # This considers both GitHub Actions detached HEAD and regular branch situations diff --git a/workflows/github-actions.yml b/workflows/github-actions.yml index 8c3d49d..422eb25 100644 --- a/workflows/github-actions.yml +++ b/workflows/github-actions.yml @@ -15,7 +15,7 @@ on: # Prevent concurrent runs for the same commit concurrency: - group: socket-scan-${{ github.ref }}-${{ github.sha }} + group: socket-scan-${{ github.sha }} cancel-in-progress: true jobs: @@ -33,7 +33,7 @@ jobs: - uses: actions/checkout@v4 with: # For PRs, fetch one additional commit for proper diff analysis - fetch-depth: ${{ github.event_name == 'pull_request' && 2 || 0 }} + fetch-depth: 0 - name: Run Socket Security Scan env: From 095b0cccda3d149384e3f891537af0db55f48cff Mon Sep 17 00:00:00 2001 From: Douglas Date: Tue, 29 Jul 2025 09:01:18 -0700 Subject: [PATCH 14/23] =?UTF-8?q?Added=20logic=20to=20Ensure=20the=20repo?= =?UTF-8?q?=20is=20marked=20as=20safe=20for=20git=20(prevents=20SH?= =?UTF-8?q?=E2=80=A6=20(#107)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Added logic to Ensure the repo is marked as safe for git (prevents SHA empty/dubious ownership errors) * Fixed calling new function --- pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- socketsecurity/core/git_interface.py | 22 ++++++++++++++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d2f79a4..7f81e51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.1.33" +version = "2.1.35" requires-python = ">= 3.10" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index c03b717..c0d1c92 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,2 +1,2 @@ __author__ = 'socket.dev' -__version__ = '2.1.33' +__version__ = '2.1.35' diff --git a/socketsecurity/core/git_interface.py b/socketsecurity/core/git_interface.py index 93fefb5..a10efc5 100644 --- a/socketsecurity/core/git_interface.py +++ b/socketsecurity/core/git_interface.py @@ -12,6 +12,7 @@ class Git: def __init__(self, path: str): self.path = path + self.ensure_safe_directory(path) self.repo = Repo(path) assert self.repo self.head = self.repo.head @@ -409,3 +410,24 @@ def is_on_default_branch(self) -> bool: except Exception as error: log.debug(f"Error checking if on default branch: {error}") return False + + @staticmethod + def ensure_safe_directory(path: str) -> None: + # Ensure the repo is marked as safe for git (prevents SHA empty/dubious ownership errors) + try : + import subprocess + abs_path = os.path.abspath(path) + # Get all safe directories + result = subprocess.run([ + "git", "config", "--global", "--get-all", "safe.directory" + ], capture_output=True, text=True) + safe_dirs = result.stdout.splitlines() if result.returncode == 0 else [] + if abs_path not in safe_dirs: + subprocess.run([ + "git", "config", "--global", "--add", "safe.directory", abs_path + ], check=True) + log.debug(f"Added {abs_path} to git safe.directory config.") + else: + log.debug(f"{abs_path} already present in git safe.directory config.") + except Exception as safe_error: + log.debug(f"Failed to set safe.directory for git: {safe_error}") \ No newline at end of file From c9df8084e0140249ff5c906a33e6cae4dc9c8b86 Mon Sep 17 00:00:00 2001 From: Douglas Date: Fri, 22 Aug 2025 21:17:16 -0700 Subject: [PATCH 15/23] fix: include license violations in diff results + SDK 2.1.8 upgrade (#111) * feat: upgrade to SDK 2.1.8 with lazy loading and improved committer handling - Upgrade socket-sdk-python dependency to version 2.1.8 to support lazy file loading capabilities - Enable lazy file loading in fullscans.post() with use_lazy_loading=True and max_open_files=50 to prevent "Too many open files" errors when processing large numbers of manifest files - Remove custom lazy_file_loader module as this functionality is now handled by the SDK - Fix committer display format by implementing proper priority order: 1. CLI --committers argument (highest priority) 2. CI/CD SCM username (GITHUB_ACTOR, GITLAB_USER_LOGIN, BITBUCKET_STEP_TRIGGERER_UUID) 3. Git username extracted from email patterns (e.g., GitHub noreply emails) 4. Git email address 5. Git author name (fallback) - Add get_formatted_committer() method to Git class to properly format committer strings instead of displaying raw git.Actor objects - Include license alerts in diff processing by removing licenseSpdxDisj filter condition - Change ulimit warning messages from log.warning to log.debug to reduce noise - Update create_full_scan() method signature to accept file paths directly instead of pre-processed file objects - Remove deprecated load_files_for_sending() method as lazy loading is now handled by the SDK This update improves performance for large repositories, provides better committer identification in CI/CD environments, and ensures license violations are properly reported. * feat: add --enable-diff flag and improve license policy violation handling - Add --enable-diff flag to force differential scanning even when using --integration api - Improve license policy violation grouping and display in PR comments - Fix alert consolidation logic to prevent duplicate alerts based on manifest files - Enhance empty baseline scan creation with proper file cleanup - Add comprehensive test coverage for new enable_diff functionality - Update documentation with new scanning mode examples and usage patterns The --enable-diff flag enables differential mode without SCM integration, useful for getting diff reports while using the API integration type. License policy violations are now properly grouped by package and displayed with consistent formatting in GitHub PR comments. * changes for license processing * Fixing login issues for pushing Docker image * Another docker fix * bumping minor version since the PR ended up having a lot of changes --- .github/workflows/docker-stable.yml | 15 ++-- .github/workflows/pr-preview.yml | 14 ++-- .github/workflows/release.yml | 12 +-- README.md | 3 + pyproject.toml | 4 +- requirements.txt | 2 +- socketsecurity/__init__.py | 2 +- socketsecurity/config.py | 7 ++ socketsecurity/core/__init__.py | 115 +++++++++++++++++---------- socketsecurity/core/git_interface.py | 61 ++++++++++++++ socketsecurity/core/messages.py | 62 ++++++++++++++- socketsecurity/socketcli.py | 29 ++++++- tests/unit/test_cli_config.py | 16 +++- 13 files changed, 268 insertions(+), 74 deletions(-) diff --git a/.github/workflows/docker-stable.yml b/.github/workflows/docker-stable.yml index 4461498..2a4c92d 100644 --- a/.github/workflows/docker-stable.yml +++ b/.github/workflows/docker-stable.yml @@ -21,18 +21,18 @@ jobs: fi echo "Version ${{ inputs.version }} found on PyPI - proceeding with release" - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + - name: Login to Docker Hub with Organization Token + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Build & Push Stable Docker uses: docker/build-push-action@v5 with: @@ -40,4 +40,5 @@ jobs: platforms: linux/amd64,linux/arm64 tags: socketdev/cli:stable build-args: | - CLI_VERSION=${{ inputs.version }} \ No newline at end of file + CLI_VERSION=${{ inputs.version }} + \ No newline at end of file diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index 8c706ac..f3b142a 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -119,19 +119,19 @@ jobs: echo "success=false" >> $GITHUB_OUTPUT exit 1 - - name: Login to Docker Hub - if: steps.verify_package.outputs.success == 'true' - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + - name: Login to Docker Hub with Organization Token + if: steps.verify_package.outputs.success == 'true' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Build & Push Docker Preview if: steps.verify_package.outputs.success == 'true' uses: docker/build-push-action@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0a5d0c6..b70d26e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -68,18 +68,18 @@ jobs: if: steps.version_check.outputs.pypi_exists != 'true' uses: pypa/gh-action-pypi-publish@v1.12.4 - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + - name: Login to Docker Hub with Organization Token + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Verify package is installable id: verify_package env: diff --git a/README.md b/README.md index 2029a45..6f9ca2b 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,7 @@ If you don't want to provide the Socket API Token every time then you can use th |:-------------------------|:---------|:--------|:----------------------------------------------------------------------| | --ignore-commit-files | False | False | Ignore commit files | | --disable-blocking | False | False | Disable blocking mode | +| --enable-diff | False | False | Enable diff mode even when using --integration api (forces diff mode without SCM integration) | | --scm | False | api | Source control management type | | --timeout | False | | Timeout in seconds for API requests | | --include-module-folders | False | False | If enabled will include manifest files from folders like node_modules | @@ -205,6 +206,7 @@ The CLI determines which files to scan based on the following logic: - **Differential Mode**: When manifest files are detected in changes, performs a diff scan with PR/MR comment integration - **API Mode**: When no manifest files are in changes, creates a full scan report without PR comments but still scans the entire repository - **Force Mode**: With `--ignore-commit-files`, always performs a full scan regardless of changes +- **Forced Diff Mode**: With `--enable-diff`, forces differential mode even when using `--integration api` (without SCM integration) ### Examples @@ -212,6 +214,7 @@ The CLI determines which files to scan based on the following logic: - **Commit without manifest files**: If your commit only changes non-manifest files (like `.github/workflows/socket.yaml`), the CLI automatically switches to API mode and performs a full repository scan. - **Using `--files`**: If you specify `--files '["package.json"]'`, the CLI will check if this file exists and is a manifest file before determining scan type. - **Using `--ignore-commit-files`**: This forces a full scan of all manifest files in the target path, regardless of what's in your commit. +- **Using `--enable-diff`**: Forces diff mode without SCM integration - useful when you want differential scanning but are using `--integration api`. For example: `socketcli --integration api --enable-diff --target-path /path/to/repo` - **Auto-detection**: Most CI/CD scenarios now work with just `socketcli --target-path /path/to/repo --scm github --pr-number $PR_NUM` ## Debugging and Troubleshooting diff --git a/pyproject.toml b/pyproject.toml index 7f81e51..2f5c9e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.1.35" +version = "2.2.0" requires-python = ">= 3.10" license = {"file" = "LICENSE"} dependencies = [ @@ -16,7 +16,7 @@ dependencies = [ 'GitPython', 'packaging', 'python-dotenv', - 'socket-sdk-python>=2.1.5,<3' + 'socket-sdk-python>=2.1.8,<3' ] readme = "README.md" description = "Socket Security CLI for CI/CD" diff --git a/requirements.txt b/requirements.txt index 9eca071..b2a6676 100644 --- a/requirements.txt +++ b/requirements.txt @@ -59,7 +59,7 @@ requests==2.32.4 # via socketsecurity smmap==5.0.2 # via gitdb -socket-sdk-python==2.1.5 +socket-sdk-python==2.1.8 # via socketsecurity typing-extensions==4.12.2 # via socket-sdk-python diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index c0d1c92..9309f87 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,2 +1,2 @@ __author__ = 'socket.dev' -__version__ = '2.1.35' +__version__ = '2.2.0' diff --git a/socketsecurity/config.py b/socketsecurity/config.py index 817c7da..a6b5b2d 100644 --- a/socketsecurity/config.py +++ b/socketsecurity/config.py @@ -48,6 +48,7 @@ class CliConfig: integration_type: IntegrationType = "api" integration_org_slug: Optional[str] = None pending_head: bool = False + enable_diff: bool = False timeout: Optional[int] = 1200 exclude_license_details: bool = False include_module_folders: bool = False @@ -421,6 +422,12 @@ def create_argument_parser() -> argparse.ArgumentParser: action="store_true", help=argparse.SUPPRESS ) + advanced_group.add_argument( + "--enable-diff", + dest="enable_diff", + action="store_true", + help="Enable diff mode even when using --integration api (forces diff mode without SCM integration)" + ) advanced_group.add_argument( "--scm", metavar="", diff --git a/socketsecurity/core/__init__.py b/socketsecurity/core/__init__.py index 7c43265..3edd097 100644 --- a/socketsecurity/core/__init__.py +++ b/socketsecurity/core/__init__.py @@ -2,6 +2,7 @@ import os import sys import tarfile +import tempfile import time import io import json @@ -30,7 +31,6 @@ from .socket_config import SocketConfig from .utils import socket_globs from .resource_utils import check_file_count_against_ulimit -from .lazy_file_loader import load_files_for_sending_lazy import importlib logging_std = importlib.import_module("logging") @@ -338,10 +338,10 @@ def find_files(self, path: str) -> List[str]: ulimit_check = check_file_count_against_ulimit(file_count) if ulimit_check["can_check"]: if ulimit_check["would_exceed"]: - log.warning(f"Found {file_count} manifest files, which may exceed the file descriptor limit (ulimit -n = {ulimit_check['soft_limit']})") - log.warning(f"Available file descriptors: {ulimit_check['available_fds']} (after {ulimit_check['buffer_size']} buffer)") - log.warning(f"Recommendation: {ulimit_check['recommendation']}") - log.warning("This may cause 'Too many open files' errors during processing") + log.debug(f"Found {file_count} manifest files, which may exceed the file descriptor limit (ulimit -n = {ulimit_check['soft_limit']})") + log.debug(f"Available file descriptors: {ulimit_check['available_fds']} (after {ulimit_check['buffer_size']} buffer)") + log.debug(f"Recommendation: {ulimit_check['recommendation']}") + log.debug("This may cause 'Too many open files' errors during processing") else: log.debug(f"File count ({file_count}) is within file descriptor limit ({ulimit_check['soft_limit']})") else: @@ -434,37 +434,29 @@ def to_case_insensitive_regex(input_string: str) -> str: return ''.join(f'[{char.lower()}{char.upper()}]' if char.isalpha() else char for char in input_string) @staticmethod - def empty_head_scan_file() -> list[tuple[str, tuple[str, Union[BinaryIO, BytesIO]]]]: - # Create an empty file for when no head full scan so that the diff endpoint can always be used - empty_file_obj = io.BytesIO(b"") - empty_filename = "initial_head_scan" - empty_full_scan_file = [(empty_filename, (empty_filename, empty_file_obj))] - return empty_full_scan_file - - @staticmethod - def load_files_for_sending(files: List[str], workspace: str) -> List[Tuple[str, Tuple[str, BinaryIO]]]: + def empty_head_scan_file() -> List[str]: """ - Prepares files for sending to the Socket API using lazy loading. + Creates a temporary empty file for baseline scans when no head scan exists. - This version uses lazy file loading to prevent "Too many open files" errors - when processing large numbers of manifest files. - - Args: - files: List of file paths from find_files() - workspace: Base directory path to make paths relative to - Returns: - List of tuples formatted for requests multipart upload: - [(field_name, (filename, file_object)), ...] + List containing path to a temporary empty file """ - return load_files_for_sending_lazy(files, workspace) + # Create a temporary empty file + temp_fd, temp_path = tempfile.mkstemp(suffix='.empty', prefix='socket_baseline_') + + # Close the file descriptor since we just need the path + # The file is already created and empty + os.close(temp_fd) + + log.debug(f"Created temporary empty file for baseline scan: {temp_path}") + return [temp_path] - def create_full_scan(self, files: list[tuple[str, tuple[str, BytesIO]]], params: FullScanParams) -> FullScan: + def create_full_scan(self, files: List[str], params: FullScanParams) -> FullScan: """ Creates a new full scan via the Socket API. Args: - files: List of files to scan + files: List of file paths to scan params: Parameters for the full scan Returns: @@ -473,7 +465,7 @@ def create_full_scan(self, files: list[tuple[str, tuple[str, BytesIO]]], params: log.info("Creating new full scan") create_full_start = time.time() - res = self.sdk.fullscans.post(files, params, use_types=True) + res = self.sdk.fullscans.post(files, params, use_types=True, use_lazy_loading=True, max_open_files=50) if not res.success: log.error(f"Error creating full scan: {res.message}, status: {res.status}") raise Exception(f"Error creating full scan: {res.message}, status: {res.status}") @@ -525,14 +517,13 @@ def create_full_scan_with_report_url( if save_manifest_tar_path and files: self.save_manifest_tar(files, save_manifest_tar_path, path) - files_for_sending = self.load_files_for_sending(files, path) if not files: return diff try: # Create new scan new_scan_start = time.time() - new_full_scan = self.create_full_scan(files_for_sending, params) + new_full_scan = self.create_full_scan(files, params) new_scan_end = time.time() log.info(f"Total time to create new full scan: {new_scan_end - new_scan_start:.2f}") except APIFailure as e: @@ -779,7 +770,15 @@ def get_added_and_removed_packages( log.info(f"Comparing scans - Head scan ID: {head_full_scan_id}, New scan ID: {new_full_scan_id}") diff_start = time.time() try: - diff_report = self.sdk.fullscans.stream_diff(self.config.org_slug, head_full_scan_id, new_full_scan_id, use_types=True).data + diff_report = ( + self.sdk.fullscans.stream_diff + ( + self.config.org_slug, + head_full_scan_id, + new_full_scan_id, + use_types=True + ).data + ) except APIFailure as e: log.error(f"API Error: {e}") sys.exit(1) @@ -877,7 +876,6 @@ def create_new_diff( if save_manifest_tar_path and files: self.save_manifest_tar(files, save_manifest_tar_path, path) - files_for_sending = self.load_files_for_sending(files, path) if not files: return Diff(id="NO_DIFF_RAN", diff_url="", report_url="") @@ -887,7 +885,9 @@ def create_new_diff( except APIResourceNotFound: head_full_scan_id = None + # If no head scan exists, create an empty baseline scan if head_full_scan_id is None: + log.info("No previous scan found - creating empty baseline scan") new_params = copy.deepcopy(params.__dict__) new_params.pop('include_license_details') tmp_params = FullScanParams(**new_params) @@ -895,13 +895,34 @@ def create_new_diff( tmp_params.tmp = True tmp_params.set_as_pending_head = False tmp_params.make_default_branch = False - head_full_scan = self.create_full_scan(Core.empty_head_scan_file(), tmp_params) - head_full_scan_id = head_full_scan.id + + # Create baseline scan with empty file + empty_files = Core.empty_head_scan_file() + try: + head_full_scan = self.create_full_scan(empty_files, tmp_params) + head_full_scan_id = head_full_scan.id + log.debug(f"Created empty baseline scan: {head_full_scan_id}") + + # Clean up the temporary empty file + for temp_file in empty_files: + try: + os.unlink(temp_file) + log.debug(f"Cleaned up temporary file: {temp_file}") + except OSError as e: + log.warning(f"Failed to clean up temporary file {temp_file}: {e}") + except Exception as e: + # Clean up temp files even if scan creation fails + for temp_file in empty_files: + try: + os.unlink(temp_file) + except OSError: + pass + raise e # Create new scan try: new_scan_start = time.time() - new_full_scan = self.create_full_scan(files_for_sending, params) + new_full_scan = self.create_full_scan(files, params) new_scan_end = time.time() log.info(f"Total time to create new full scan: {new_scan_end - new_scan_start:.2f}") except APIFailure as e: @@ -913,6 +934,7 @@ def create_new_diff( log.error(f"Stack trace:\n{traceback.format_exc()}") raise + # Handle diff generation - now we always have both scans scans_ready = self.check_full_scans_status(head_full_scan_id, new_full_scan.id) if scans_ready is False: log.error(f"Full scans did not complete within {self.config.timeout} seconds") @@ -1134,6 +1156,12 @@ def add_package_alerts_to_collection(self, package: Package, alerts_collection: alert = Alert(**alert_item) props = getattr(self.config.all_issues, alert.type, default_props) introduced_by = self.get_source_data(package, packages) + + # Handle special case for license policy violations + title = props.title + if alert.type == "licenseSpdxDisj" and not title: + title = "License Policy Violation" + issue_alert = Issue( pkg_type=package.type, pkg_name=package.name, @@ -1144,7 +1172,7 @@ def add_package_alerts_to_collection(self, package: Package, alerts_collection: type=alert.type, severity=alert.severity, description=props.description, - title=props.title, + title=title, suggestion=props.suggestion, next_step_title=props.nextStepTitle, introduced_by=introduced_by, @@ -1156,11 +1184,10 @@ def add_package_alerts_to_collection(self, package: Package, alerts_collection: action = self.config.security_policy[alert.type]['action'] setattr(issue_alert, action, True) - if issue_alert.type != 'licenseSpdxDisj': - if issue_alert.key not in alerts_collection: - alerts_collection[issue_alert.key] = [issue_alert] - else: - alerts_collection[issue_alert.key].append(issue_alert) + if issue_alert.key not in alerts_collection: + alerts_collection[issue_alert.key] = [issue_alert] + else: + alerts_collection[issue_alert.key].append(issue_alert) return alerts_collection @@ -1232,7 +1259,8 @@ def get_new_alerts( if alert_key not in removed_package_alerts: new_alerts = added_package_alerts[alert_key] for alert in new_alerts: - alert_str = f"{alert.purl},{alert.manifests},{alert.type}" + # Consolidate by package and alert type, not by manifest details + alert_str = f"{alert.purl},{alert.type}" if alert.error or alert.warn: if alert_str not in consolidated_alerts: @@ -1243,7 +1271,8 @@ def get_new_alerts( removed_alerts = removed_package_alerts[alert_key] for alert in new_alerts: - alert_str = f"{alert.purl},{alert.manifests},{alert.type}" + # Consolidate by package and alert type, not by manifest details + alert_str = f"{alert.purl},{alert.type}" # Only add if: # 1. Alert isn't in removed packages (or we're not ignoring readded alerts) diff --git a/socketsecurity/core/git_interface.py b/socketsecurity/core/git_interface.py index a10efc5..ed1fc0e 100644 --- a/socketsecurity/core/git_interface.py +++ b/socketsecurity/core/git_interface.py @@ -319,6 +319,67 @@ def commit_str(self) -> str: """Return commit SHA as a string""" return self.commit.hexsha + def get_formatted_committer(self) -> str: + """ + Get the committer in the preferred order: + 1. CLI --committers (handled in socketcli.py) + 2. CI/CD SCM username (GitHub/GitLab/BitBucket environment variables) + 3. Git username (extracted from email patterns like GitHub noreply) + 4. Git email address + 5. Git author name (fallback) + + Returns: + Formatted committer string + """ + # Check for CI/CD environment usernames first + # GitHub Actions + github_actor = os.getenv('GITHUB_ACTOR') + if github_actor: + log.debug(f"Using GitHub actor as committer: {github_actor}") + return github_actor + + # GitLab CI + gitlab_user_login = os.getenv('GITLAB_USER_LOGIN') + if gitlab_user_login: + log.debug(f"Using GitLab user login as committer: {gitlab_user_login}") + return gitlab_user_login + + # Bitbucket Pipelines + bitbucket_step_triggerer_uuid = os.getenv('BITBUCKET_STEP_TRIGGERER_UUID') + if bitbucket_step_triggerer_uuid: + log.debug(f"Using Bitbucket step triggerer as committer: {bitbucket_step_triggerer_uuid}") + return bitbucket_step_triggerer_uuid + + # Fall back to commit author/committer details + # Priority 3: Try to extract git username from email patterns first + if self.author and self.author.email and self.author.email.strip(): + email = self.author.email.strip() + + # If it's a GitHub noreply email, try to extract username + if email.endswith('@users.noreply.github.com'): + # Pattern: number+username@users.noreply.github.com + email_parts = email.split('@')[0] + if '+' in email_parts: + username = email_parts.split('+')[1] + log.debug(f"Extracted GitHub username from noreply email: {username}") + return username + + # Priority 4: Use email if available + if self.author and self.author.email and self.author.email.strip(): + email = self.author.email.strip() + log.debug(f"Using commit author email as committer: {email}") + return email + + # Priority 5: Fall back to author name as last resort + if self.author and self.author.name and self.author.name.strip(): + name = self.author.name.strip() + log.debug(f"Using commit author name as fallback committer: {name}") + return name + + # Ultimate fallback + log.debug("Using fallback committer: unknown") + return "unknown" + def get_default_branch_name(self) -> str: """ Get the default branch name from the remote origin. diff --git a/socketsecurity/core/messages.py b/socketsecurity/core/messages.py index 0b5fc62..42a4fd1 100644 --- a/socketsecurity/core/messages.py +++ b/socketsecurity/core/messages.py @@ -309,13 +309,26 @@ def security_comment_template(diff: Diff) -> str: :param diff: Diff - Contains the detected vulnerabilities and warnings. :return: str - The formatted Markdown/HTML string. """ + # Group license policy violations by PURL (ecosystem/package@version) + license_groups = {} + security_alerts = [] + + for alert in diff.new_alerts: + if alert.type == "licenseSpdxDisj": + purl_key = f"{alert.pkg_type}/{alert.pkg_name}@{alert.pkg_version}" + if purl_key not in license_groups: + license_groups[purl_key] = [] + license_groups[purl_key].append(alert) + else: + security_alerts.append(alert) + # Start of the comment comment = """ > **❗️ Caution** > **Review the following alerts detected in dependencies.** > -> According to your organization’s Security Policy, you **must** resolve all **“Block”** alerts before proceeding. It’s recommended to resolve **“Warn”** alerts too. +> According to your organization's Security Policy, you **must** resolve all **"Block"** alerts before proceeding. It's recommended to resolve **"Warn"** alerts too. > Learn more about [Socket for GitHub](https://socket.dev?utm_medium=gh). @@ -330,8 +343,8 @@ def security_comment_template(diff: Diff) -> str: """ - # Loop through alerts, dynamically generating rows - for alert in diff.new_alerts: + # Loop through security alerts (non-license), dynamically generating rows + for alert in security_alerts: severity_icon = Messages.get_severity_icon(alert.severity) action = "Block" if alert.error else "Warn" details_open = "" @@ -365,7 +378,48 @@ def security_comment_template(diff: Diff) -> str: """ - # Close table and comment + # Add license policy violation entries grouped by PURL + for purl_key, alerts in license_groups.items(): + action = "Block" if any(alert.error for alert in alerts) else "Warn" + first_alert = alerts[0] + + # Use orange diamond for license policy violations + license_icon = "🔶" + + # Build license findings list + license_findings = [] + for alert in alerts: + license_findings.append(alert.title) + + comment += f""" + + + {action} + {license_icon} + +
+ {first_alert.pkg_name}@{first_alert.pkg_version} has a License Policy Violation. +

License findings:

+
    +""" + for finding in license_findings: + comment += f"
  • {finding}
  • \n" + + comment += f"""
+

From: {first_alert.manifests}

+

ℹ️ Read more on: This package | What is a license policy violation?

+
+

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

+

Suggestion: Find a package that does not violate your license policy or adjust your policy to allow this package's license.

+

Mark the package as acceptable risk: To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore {first_alert.pkg_name}@{first_alert.pkg_version}. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

+
+
+ + + + """ + + # Close table comment += """ diff --git a/socketsecurity/socketcli.py b/socketsecurity/socketcli.py index b1100d1..7731a5d 100644 --- a/socketsecurity/socketcli.py +++ b/socketsecurity/socketcli.py @@ -125,7 +125,7 @@ def main_code(): if not config.branch: config.branch = git_repo.branch if not config.committers: - config.committers = [git_repo.author] + config.committers = [git_repo.get_formatted_committer()] if not config.commit_message: config.commit_message = git_repo.commit_message except InvalidGitRepositoryError: @@ -320,6 +320,33 @@ def main_code(): diff = core.create_new_diff(config.target_path, params, no_change=should_skip_scan, save_files_list_path=config.save_submitted_files_list, save_manifest_tar_path=config.save_manifest_tar) output_handler.handle_output(diff) + + elif config.enable_diff and not force_api_mode: + # New logic: --enable-diff forces diff mode even with --integration api (no SCM) + log.info("Diff mode enabled without SCM integration") + diff = core.create_new_diff(config.target_path, params, no_change=should_skip_scan, save_files_list_path=config.save_submitted_files_list, save_manifest_tar_path=config.save_manifest_tar) + output_handler.handle_output(diff) + + elif config.enable_diff and force_api_mode: + # User requested diff mode but no manifest files were detected + log.warning("--enable-diff was specified but no supported manifest files were detected in the changed files. Falling back to full scan mode.") + log.info("Creating Socket Report (full scan)") + serializable_params = { + key: value if isinstance(value, (int, float, str, list, dict, bool, type(None))) else str(value) + for key, value in params.__dict__.items() + } + log.debug(f"params={serializable_params}") + diff = core.create_full_scan_with_report_url( + config.target_path, + params, + no_change=should_skip_scan, + save_files_list_path=config.save_submitted_files_list, + save_manifest_tar_path=config.save_manifest_tar + ) + log.info(f"Full scan created with ID: {diff.id}") + log.info(f"Full scan report URL: {diff.report_url}") + output_handler.handle_output(diff) + else: if force_api_mode: log.info("No Manifest files changed, creating Socket Report") diff --git a/tests/unit/test_cli_config.py b/tests/unit/test_cli_config.py index db7b1f5..40178d3 100644 --- a/tests/unit/test_cli_config.py +++ b/tests/unit/test_cli_config.py @@ -24,8 +24,20 @@ def test_default_values(self): @pytest.mark.parametrize("flag,attr", [ ("--enable-debug", "enable_debug"), ("--disable-blocking", "disable_blocking"), - ("--allow-unverified", "allow_unverified") + ("--allow-unverified", "allow_unverified"), + ("--enable-diff", "enable_diff") ]) def test_boolean_flags(self, flag, attr): config = CliConfig.from_args(["--api-token", "test", flag]) - assert getattr(config, attr) is True \ No newline at end of file + assert getattr(config, attr) is True + + def test_enable_diff_default_false(self): + """Test that enable_diff defaults to False""" + config = CliConfig.from_args(["--api-token", "test"]) + assert config.enable_diff is False + + def test_enable_diff_with_integration_api(self): + """Test that enable_diff can be used with integration api""" + config = CliConfig.from_args(["--api-token", "test", "--integration", "api", "--enable-diff"]) + assert config.enable_diff is True + assert config.integration_type == "api" \ No newline at end of file From a2d97ab2664cb60ca7a07d3e95ecfe1c33549677 Mon Sep 17 00:00:00 2001 From: Douglas Date: Sat, 23 Aug 2025 07:23:56 -0700 Subject: [PATCH 16/23] improve gitlab token usage (#112) * Removing test files * Adding support for both gitlab token styles --- README.md | 74 ++++++++++++ pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- socketsecurity/core/scm/client.py | 47 +++++++- socketsecurity/core/scm/gitlab.py | 120 +++++++++++++++++-- tests/unit/test_gitlab_auth.py | 116 +++++++++++++++++++ tests/unit/test_gitlab_auth_fallback.py | 148 ++++++++++++++++++++++++ workflows/gitlab-ci.yml | 3 + 8 files changed, 500 insertions(+), 12 deletions(-) create mode 100644 tests/unit/test_gitlab_auth.py create mode 100644 tests/unit/test_gitlab_auth_fallback.py diff --git a/README.md b/README.md index 6f9ca2b..f0cf94b 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,74 @@ The CLI uses intelligent default branch detection with the following priority: Both `--default-branch` and `--pending-head` parameters are automatically synchronized to ensure consistent behavior. +## GitLab Token Configuration + +The CLI supports GitLab integration with automatic authentication pattern detection for different token types. + +### Supported Token Types + +GitLab API supports two authentication methods, and the CLI automatically detects which one to use: + +1. **Bearer Token Authentication** (`Authorization: Bearer `) + - GitLab CI Job Tokens (`$CI_JOB_TOKEN`) + - Personal Access Tokens with `glpat-` prefix + - OAuth 2.0 tokens (long alphanumeric tokens) + +2. **Private Token Authentication** (`PRIVATE-TOKEN: `) + - Legacy personal access tokens + - Custom tokens that don't match Bearer patterns + +### Token Detection Logic + +The CLI automatically determines the authentication method using this logic: + +``` +if token == $CI_JOB_TOKEN: + use Bearer authentication +elif token starts with "glpat-": + use Bearer authentication +elif token is long (>40 chars) and alphanumeric: + use Bearer authentication +else: + use PRIVATE-TOKEN authentication +``` + +### Automatic Fallback + +If the initial authentication method fails with a 401 error, the CLI automatically retries with the alternative method: + +- **Bearer → PRIVATE-TOKEN**: If Bearer authentication fails, retry with PRIVATE-TOKEN +- **PRIVATE-TOKEN → Bearer**: If PRIVATE-TOKEN fails, retry with Bearer authentication + +This ensures maximum compatibility across different GitLab configurations and token types. + +### Environment Variables + +| Variable | Description | Example | +|:---------|:------------|:--------| +| `GITLAB_TOKEN` | GitLab API token (required for GitLab integration) | `glpat-xxxxxxxxxxxxxxxxxxxx` | +| `CI_JOB_TOKEN` | GitLab CI job token (automatically used in GitLab CI) | Automatically provided by GitLab CI | + +### Usage Examples + +**GitLab CI with job token (recommended):** +```yaml +variables: + GITLAB_TOKEN: $CI_JOB_TOKEN +``` + +**GitLab CI with personal access token:** +```yaml +variables: + GITLAB_TOKEN: $GITLAB_PERSONAL_ACCESS_TOKEN # Set in GitLab project/group variables +``` + +**Local development:** +```bash +export GITLAB_TOKEN="glpat-your-personal-access-token" +socketcli --integration gitlab --repo owner/repo --pr-number 123 +``` + ### Scan Behavior The CLI determines scanning behavior intelligently: @@ -340,4 +408,10 @@ Implementation targets: ### Environment Variables +#### Core Configuration +- `SOCKET_SECURITY_API_KEY`: Socket Security API token (alternative to --api-token parameter) - `SOCKET_SDK_PATH`: Path to local socket-sdk-python repository (default: ../socket-sdk-python) + +#### GitLab Integration +- `GITLAB_TOKEN`: GitLab API token for GitLab integration (supports both Bearer and PRIVATE-TOKEN authentication) +- `CI_JOB_TOKEN`: GitLab CI job token (automatically provided in GitLab CI environments) diff --git a/pyproject.toml b/pyproject.toml index 2f5c9e7..e4c54e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.2.0" +version = "2.2.2" requires-python = ">= 3.10" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 9309f87..c0d192a 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,2 +1,2 @@ __author__ = 'socket.dev' -__version__ = '2.2.0' +__version__ = '2.2.2' diff --git a/socketsecurity/core/scm/client.py b/socketsecurity/core/scm/client.py index e5bbb73..1033613 100644 --- a/socketsecurity/core/scm/client.py +++ b/socketsecurity/core/scm/client.py @@ -34,8 +34,51 @@ def get_headers(self) -> Dict: class GitlabClient(ScmClient): def get_headers(self) -> Dict: - return { - 'Authorization': f"Bearer {self.token}", + """ + Determine the appropriate authentication headers for GitLab API. + Uses the same logic as GitlabConfig._get_auth_headers() + """ + return self._get_gitlab_auth_headers(self.token) + + @staticmethod + def _get_gitlab_auth_headers(token: str) -> dict: + """ + Determine the appropriate authentication headers for GitLab API. + + GitLab supports two authentication patterns: + 1. Bearer token (OAuth 2.0 tokens, personal access tokens with api scope) + 2. Private token (personal access tokens) + """ + import os + + base_headers = { 'User-Agent': 'SocketPythonScript/0.0.1', "accept": "application/json" } + + # Check if this is a GitLab CI job token + if token == os.getenv('CI_JOB_TOKEN'): + return { + **base_headers, + 'Authorization': f"Bearer {token}" + } + + # Check for personal access token pattern + if token.startswith('glpat-'): + return { + **base_headers, + 'Authorization': f"Bearer {token}" + } + + # Check for OAuth token pattern (typically longer and alphanumeric) + if len(token) > 40 and token.isalnum(): + return { + **base_headers, + 'Authorization': f"Bearer {token}" + } + + # Default to PRIVATE-TOKEN for other token types + return { + **base_headers, + 'PRIVATE-TOKEN': f"{token}" + } diff --git a/socketsecurity/core/scm/gitlab.py b/socketsecurity/core/scm/gitlab.py index 24b1df3..b5f46b7 100644 --- a/socketsecurity/core/scm/gitlab.py +++ b/socketsecurity/core/scm/gitlab.py @@ -42,6 +42,9 @@ def from_env(cls) -> 'GitlabConfig': mr_source_branch = os.getenv('CI_MERGE_REQUEST_SOURCE_BRANCH_NAME') default_branch = os.getenv('CI_DEFAULT_BRANCH', '') + # Determine which authentication pattern to use + headers = cls._get_auth_headers(token) + return cls( commit_sha=os.getenv('CI_COMMIT_SHA', ''), api_url=os.getenv('CI_API_V4_URL', ''), @@ -57,18 +60,119 @@ def from_env(cls) -> 'GitlabConfig': token=token, repository=project_name, is_default_branch=(mr_source_branch == default_branch if mr_source_branch else False), - headers={ - 'Authorization': f"Bearer {token}", - 'User-Agent': 'SocketPythonScript/0.0.1', - "accept": "application/json" - } + headers=headers ) + @staticmethod + def _get_auth_headers(token: str) -> dict: + """ + Determine the appropriate authentication headers for GitLab API. + + GitLab supports two authentication patterns: + 1. Bearer token (OAuth 2.0 tokens, personal access tokens with api scope) + 2. Private token (personal access tokens) + + Logic for token type determination: + - CI_JOB_TOKEN: Always use Bearer (GitLab CI job token) + - Tokens starting with 'glpat-': Personal access tokens, try Bearer first + - OAuth tokens: Use Bearer + - Other tokens: Use PRIVATE-TOKEN as fallback + """ + base_headers = { + 'User-Agent': 'SocketPythonScript/0.0.1', + "accept": "application/json" + } + + # Check if this is a GitLab CI job token + if token == os.getenv('CI_JOB_TOKEN'): + log.debug("Using Bearer authentication for GitLab CI job token") + return { + **base_headers, + 'Authorization': f"Bearer {token}" + } + + # Check for personal access token pattern + if token.startswith('glpat-'): + log.debug("Using Bearer authentication for GitLab personal access token") + return { + **base_headers, + 'Authorization': f"Bearer {token}" + } + + # Check for OAuth token pattern (typically longer and alphanumeric) + if len(token) > 40 and token.isalnum(): + log.debug("Using Bearer authentication for potential OAuth token") + return { + **base_headers, + 'Authorization': f"Bearer {token}" + } + + # Default to PRIVATE-TOKEN for other token types + log.debug("Using PRIVATE-TOKEN authentication for GitLab token") + return { + **base_headers, + 'PRIVATE-TOKEN': f"{token}" + } + class Gitlab: def __init__(self, client: CliClient, config: Optional[GitlabConfig] = None): self.config = config or GitlabConfig.from_env() self.client = client + def _request_with_fallback(self, **kwargs): + """ + Make a request with automatic fallback between Bearer and PRIVATE-TOKEN authentication. + This provides robustness when the initial token type detection is incorrect. + """ + try: + # Try the initial request with the configured headers + return self.client.request(**kwargs) + except Exception as e: + # Check if this is an authentication error (401) + if hasattr(e, 'response') and e.response and e.response.status_code == 401: + log.debug(f"Authentication failed with initial headers, trying fallback method") + + # Determine the fallback headers + original_headers = kwargs.get('headers', self.config.headers) + fallback_headers = self._get_fallback_headers(original_headers) + + if fallback_headers and fallback_headers != original_headers: + log.debug("Retrying request with fallback authentication method") + kwargs['headers'] = fallback_headers + return self.client.request(**kwargs) + + # Re-raise the original exception if it's not an auth error or fallback failed + raise + + def _get_fallback_headers(self, original_headers: dict) -> dict: + """ + Generate fallback authentication headers. + If using Bearer, fallback to PRIVATE-TOKEN and vice versa. + """ + base_headers = { + 'User-Agent': 'SocketPythonScript/0.0.1', + "accept": "application/json" + } + + # If currently using Bearer, try PRIVATE-TOKEN + if 'Authorization' in original_headers and 'Bearer' in original_headers['Authorization']: + log.debug("Falling back from Bearer to PRIVATE-TOKEN authentication") + return { + **base_headers, + 'PRIVATE-TOKEN': f"{self.config.token}" + } + + # If currently using PRIVATE-TOKEN, try Bearer + elif 'PRIVATE-TOKEN' in original_headers: + log.debug("Falling back from PRIVATE-TOKEN to Bearer authentication") + return { + **base_headers, + 'Authorization': f"Bearer {self.config.token}" + } + + # No fallback available + return None + def check_event_type(self) -> str: pipeline_source = self.config.pipeline_source.lower() if pipeline_source in ["web", 'merge_request_event', "push", "api"]: @@ -84,7 +188,7 @@ def check_event_type(self) -> str: def post_comment(self, body: str) -> None: path = f"projects/{self.config.mr_project_id}/merge_requests/{self.config.mr_iid}/notes" payload = {"body": body} - self.client.request( + self._request_with_fallback( path=path, payload=payload, method="POST", @@ -95,7 +199,7 @@ def post_comment(self, body: str) -> None: def update_comment(self, body: str, comment_id: str) -> None: path = f"projects/{self.config.mr_project_id}/merge_requests/{self.config.mr_iid}/notes/{comment_id}" payload = {"body": body} - self.client.request( + self._request_with_fallback( path=path, payload=payload, method="PUT", @@ -106,7 +210,7 @@ def update_comment(self, body: str, comment_id: str) -> None: def get_comments_for_pr(self) -> dict: log.debug(f"Getting Gitlab comments for Repo {self.config.repository} for PR {self.config.mr_iid}") path = f"projects/{self.config.mr_project_id}/merge_requests/{self.config.mr_iid}/notes" - response = self.client.request( + response = self._request_with_fallback( path=path, headers=self.config.headers, base_url=self.config.api_url diff --git a/tests/unit/test_gitlab_auth.py b/tests/unit/test_gitlab_auth.py new file mode 100644 index 0000000..be34224 --- /dev/null +++ b/tests/unit/test_gitlab_auth.py @@ -0,0 +1,116 @@ +"""Tests for GitLab authentication patterns""" +import os +import pytest +from unittest.mock import patch, MagicMock + +from socketsecurity.core.scm.gitlab import GitlabConfig + + +class TestGitlabAuthHeaders: + """Test GitLab authentication header generation""" + + def test_ci_job_token_uses_bearer(self): + """CI_JOB_TOKEN should always use Bearer authentication""" + with patch.dict(os.environ, {'CI_JOB_TOKEN': 'ci-job-token-123'}): + headers = GitlabConfig._get_auth_headers('ci-job-token-123') + assert 'Authorization' in headers + assert headers['Authorization'] == 'Bearer ci-job-token-123' + assert 'PRIVATE-TOKEN' not in headers + + def test_personal_access_token_uses_bearer(self): + """Personal access tokens (glpat-*) should use Bearer authentication""" + token = 'glpat-xxxxxxxxxxxxxxxxxxxx' + headers = GitlabConfig._get_auth_headers(token) + assert 'Authorization' in headers + assert headers['Authorization'] == f'Bearer {token}' + assert 'PRIVATE-TOKEN' not in headers + + def test_oauth_token_uses_bearer(self): + """Long alphanumeric tokens (OAuth) should use Bearer authentication""" + token = 'a' * 50 # 50 character alphanumeric token + headers = GitlabConfig._get_auth_headers(token) + assert 'Authorization' in headers + assert headers['Authorization'] == f'Bearer {token}' + assert 'PRIVATE-TOKEN' not in headers + + def test_short_token_uses_private_token(self): + """Short tokens should use PRIVATE-TOKEN authentication""" + token = 'short-token-123' + headers = GitlabConfig._get_auth_headers(token) + assert 'PRIVATE-TOKEN' in headers + assert headers['PRIVATE-TOKEN'] == token + assert 'Authorization' not in headers + + def test_mixed_alphanumeric_token_uses_private_token(self): + """Tokens with non-alphanumeric characters should use PRIVATE-TOKEN""" + token = 'token-with-dashes-and_underscores' + headers = GitlabConfig._get_auth_headers(token) + assert 'PRIVATE-TOKEN' in headers + assert headers['PRIVATE-TOKEN'] == token + assert 'Authorization' not in headers + + def test_all_headers_include_base_headers(self): + """All authentication patterns should include base headers""" + test_tokens = [ + 'glpat-xxxxxxxxxxxxxxxxxxxx', # Bearer + 'short-token' # PRIVATE-TOKEN + ] + + for token in test_tokens: + headers = GitlabConfig._get_auth_headers(token) + assert headers['User-Agent'] == 'SocketPythonScript/0.0.1' + assert headers['accept'] == 'application/json' + + @patch.dict(os.environ, {'CI_JOB_TOKEN': 'ci-token-123'}) + def test_ci_job_token_detection_priority(self): + """CI_JOB_TOKEN should be detected even if token doesn't match CI_JOB_TOKEN value""" + # This tests the case where GITLAB_TOKEN != CI_JOB_TOKEN + headers = GitlabConfig._get_auth_headers('different-token') + # Should not use Bearer since token doesn't match CI_JOB_TOKEN + assert 'PRIVATE-TOKEN' in headers + assert headers['PRIVATE-TOKEN'] == 'different-token' + + +class TestGitlabConfigFromEnv: + """Test GitlabConfig.from_env() method""" + + @patch.dict(os.environ, { + 'GITLAB_TOKEN': 'glpat-test-token', + 'CI_PROJECT_NAME': 'test-project', + 'CI_API_V4_URL': 'https://gitlab.example.com/api/v4', + 'CI_COMMIT_SHA': 'abc123', + 'CI_PROJECT_DIR': '/builds/test', + 'CI_PIPELINE_SOURCE': 'merge_request_event' + }) + def test_from_env_creates_config_with_correct_headers(self): + """from_env should create config with appropriate auth headers""" + config = GitlabConfig.from_env() + + # Should use Bearer for glpat- token + assert 'Authorization' in config.headers + assert config.headers['Authorization'] == 'Bearer glpat-test-token' + assert 'PRIVATE-TOKEN' not in config.headers + assert config.token == 'glpat-test-token' + + @patch.dict(os.environ, { + 'GITLAB_TOKEN': 'custom-token', + 'CI_PROJECT_NAME': 'test-project' + }, clear=True) + def test_from_env_with_private_token(self): + """from_env should use PRIVATE-TOKEN for non-standard tokens""" + config = GitlabConfig.from_env() + + # Should use PRIVATE-TOKEN for custom token + assert 'PRIVATE-TOKEN' in config.headers + assert config.headers['PRIVATE-TOKEN'] == 'custom-token' + assert 'Authorization' not in config.headers + + def test_from_env_missing_token_exits(self): + """from_env should exit when GITLAB_TOKEN is missing""" + with patch.dict(os.environ, {}, clear=True): + with pytest.raises(SystemExit): + GitlabConfig.from_env() + + +if __name__ == '__main__': + pytest.main([__file__]) diff --git a/tests/unit/test_gitlab_auth_fallback.py b/tests/unit/test_gitlab_auth_fallback.py new file mode 100644 index 0000000..e9e9b0c --- /dev/null +++ b/tests/unit/test_gitlab_auth_fallback.py @@ -0,0 +1,148 @@ +"""Integration test demonstrating GitLab authentication fallback""" +import os +from unittest.mock import patch, MagicMock +import pytest + +from socketsecurity.core.scm.gitlab import Gitlab, GitlabConfig +from socketsecurity.socketcli import CliClient + + +class TestGitlabAuthFallback: + """Test GitLab authentication fallback mechanism""" + + @patch.dict(os.environ, { + 'GITLAB_TOKEN': 'test-token', + 'CI_PROJECT_NAME': 'test-project', + 'CI_API_V4_URL': 'https://gitlab.example.com/api/v4', + 'CI_MERGE_REQUEST_IID': '123', + 'CI_MERGE_REQUEST_PROJECT_ID': '456' + }) + def test_fallback_from_private_token_to_bearer(self): + """Test fallback from PRIVATE-TOKEN to Bearer authentication""" + # Create a mock client that simulates auth failure then success + mock_client = MagicMock(spec=CliClient) + + # First call (with PRIVATE-TOKEN) fails with 401 + auth_error = Exception() + auth_error.response = MagicMock() + auth_error.response.status_code = 401 + + # Second call (with Bearer) succeeds + success_response = {'notes': []} + + mock_client.request.side_effect = [auth_error, success_response] + + # Create GitLab instance with mock client + gitlab = Gitlab(client=mock_client) + + # This should trigger the fallback mechanism + result = gitlab.get_comments_for_pr() + + # Verify two requests were made + assert mock_client.request.call_count == 2 + + # First call should use PRIVATE-TOKEN (default for 'test-token') + first_call_headers = mock_client.request.call_args_list[0][1]['headers'] + assert 'PRIVATE-TOKEN' in first_call_headers + assert first_call_headers['PRIVATE-TOKEN'] == 'test-token' + + # Second call should use Bearer (fallback) + second_call_headers = mock_client.request.call_args_list[1][1]['headers'] + assert 'Authorization' in second_call_headers + assert second_call_headers['Authorization'] == 'Bearer test-token' + + @patch.dict(os.environ, { + 'GITLAB_TOKEN': 'glpat-test-token', + 'CI_PROJECT_NAME': 'test-project', + 'CI_API_V4_URL': 'https://gitlab.example.com/api/v4', + 'CI_MERGE_REQUEST_IID': '123', + 'CI_MERGE_REQUEST_PROJECT_ID': '456' + }) + def test_fallback_from_bearer_to_private_token(self): + """Test fallback from Bearer to PRIVATE-TOKEN authentication""" + # Create a mock client that simulates auth failure then success + mock_client = MagicMock(spec=CliClient) + + # First call (with Bearer) fails with 401 + auth_error = Exception() + auth_error.response = MagicMock() + auth_error.response.status_code = 401 + + # Second call (with PRIVATE-TOKEN) succeeds + success_response = {'notes': []} + + mock_client.request.side_effect = [auth_error, success_response] + + # Create GitLab instance with mock client + gitlab = Gitlab(client=mock_client) + + # This should trigger the fallback mechanism + result = gitlab.get_comments_for_pr() + + # Verify two requests were made + assert mock_client.request.call_count == 2 + + # First call should use Bearer (default for 'glpat-' token) + first_call_headers = mock_client.request.call_args_list[0][1]['headers'] + assert 'Authorization' in first_call_headers + assert first_call_headers['Authorization'] == 'Bearer glpat-test-token' + + # Second call should use PRIVATE-TOKEN (fallback) + second_call_headers = mock_client.request.call_args_list[1][1]['headers'] + assert 'PRIVATE-TOKEN' in second_call_headers + assert second_call_headers['PRIVATE-TOKEN'] == 'glpat-test-token' + + @patch.dict(os.environ, { + 'GITLAB_TOKEN': 'test-token', + 'CI_PROJECT_NAME': 'test-project', + 'CI_API_V4_URL': 'https://gitlab.example.com/api/v4', + 'CI_MERGE_REQUEST_IID': '123', + 'CI_MERGE_REQUEST_PROJECT_ID': '456' + }) + def test_non_auth_error_not_retried(self): + """Test that non-authentication errors are not retried""" + # Create a mock client that simulates a non-auth error + mock_client = MagicMock(spec=CliClient) + + # Simulate a 500 error (not auth-related) + server_error = Exception() + server_error.response = MagicMock() + server_error.response.status_code = 500 + + mock_client.request.side_effect = server_error + + # Create GitLab instance with mock client + gitlab = Gitlab(client=mock_client) + + # This should NOT trigger the fallback mechanism + with pytest.raises(Exception): + gitlab.get_comments_for_pr() + + # Verify only one request was made (no retry) + assert mock_client.request.call_count == 1 + + @patch.dict(os.environ, { + 'GITLAB_TOKEN': 'test-token', + 'CI_PROJECT_NAME': 'test-project', + 'CI_API_V4_URL': 'https://gitlab.example.com/api/v4', + 'CI_MERGE_REQUEST_IID': '123', + 'CI_MERGE_REQUEST_PROJECT_ID': '456' + }) + def test_successful_first_attempt_no_fallback(self): + """Test that successful requests don't trigger fallback""" + # Create a mock client that succeeds on first try + mock_client = MagicMock(spec=CliClient) + mock_client.request.return_value = {'notes': []} + + # Create GitLab instance with mock client + gitlab = Gitlab(client=mock_client) + + # This should succeed on first try + result = gitlab.get_comments_for_pr() + + # Verify only one request was made + assert mock_client.request.call_count == 1 + + +if __name__ == '__main__': + pytest.main([__file__]) diff --git a/workflows/gitlab-ci.yml b/workflows/gitlab-ci.yml index 4e44580..59ea864 100644 --- a/workflows/gitlab-ci.yml +++ b/workflows/gitlab-ci.yml @@ -43,6 +43,9 @@ socket-security: # Required for GitLab integration to work properly variables: SOCKET_SECURITY_API_KEY: $SOCKET_SECURITY_API_KEY + # GitLab token for API access - supports both authentication patterns: + # 1. CI_JOB_TOKEN: Built-in GitLab CI token (automatically uses Bearer auth) + # 2. Personal Access Token: Custom token (auto-detects Bearer vs PRIVATE-TOKEN) GITLAB_TOKEN: $CI_JOB_TOKEN # Optional: Run only when manifest files change (more efficient) From f808583abf3b8f9816922e24391958d29ae0a400 Mon Sep 17 00:00:00 2001 From: Douglas Date: Sat, 23 Aug 2025 17:16:32 -0700 Subject: [PATCH 17/23] Migrate from socket-sdk-python to socketdev>=3.0.0 and switch to uv (#113) * Migrate from socket-sdk-python to socketdev>=3.0.0 and switch to uv - Update pyproject.toml to use socketdev>=3.0.0,<4.0.0 instead of socket-sdk-python - Replace pip-tools with uv for dependency management - Update Makefile to use uv commands (uv pip compile, uv pip sync, etc.) - Update Dockerfile to install socketdev instead of socket-sdk-python - Update deployment scripts to reference socketdev - Update README to reflect uv usage - Regenerate all requirements files with uv - Add requirements-test.txt file - Update SOCKET_SDK_PATH references to point to ../socketdev - Version bump to 2.2.3 * Switch to uv.lock for dependency management - Replace requirements.txt files with uv.lock - Update Makefile to use 'uv sync' instead of pip-compile workflow - Simplify dependency management with 'uv lock' and 'uv sync --all-extras' - Update test and lint commands to use 'uv run' - Remove old requirements.txt, requirements-dev.txt, requirements-test.txt files - Update README documentation to reflect uv.lock workflow - Version bump to 2.2.4 --- Dockerfile | 2 +- Makefile | 51 +- README.md | 18 +- pyproject.toml | 6 +- requirements-dev.lock | 73 -- requirements-dev.txt | 73 -- requirements.lock | 71 -- requirements.txt | 71 -- scripts/deploy-test-docker.sh | 2 +- socketsecurity/__init__.py | 2 +- uv.lock | 1388 +++++++++++++++++++++++++++++++++ 11 files changed, 1424 insertions(+), 333 deletions(-) delete mode 100644 requirements-dev.lock delete mode 100644 requirements-dev.txt delete mode 100644 requirements.lock delete mode 100644 requirements.txt create mode 100644 uv.lock diff --git a/Dockerfile b/Dockerfile index 76d3721..040036d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,5 +18,5 @@ RUN for i in $(seq 1 10); do \ sleep 30; \ done && \ if [ ! -z "$SDK_VERSION" ]; then \ - pip install --index-url ${PIP_INDEX_URL} --extra-index-url ${PIP_EXTRA_INDEX_URL} socket-sdk-python==${SDK_VERSION}; \ + pip install --index-url ${PIP_INDEX_URL} --extra-index-url ${PIP_EXTRA_INDEX_URL} socketdev==${SDK_VERSION}; \ fi \ No newline at end of file diff --git a/Makefile b/Makefile index e1bc1ad..c0fb1b0 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ -.PHONY: setup compile-deps sync-deps clean test lint init-tools local-dev first-time-setup update-deps dev-setup sync-all first-time-local-setup +.PHONY: setup sync clean test lint update-lock local-dev first-time-setup dev-setup sync-all first-time-local-setup # Environment variable for local SDK path (optional) -SOCKET_SDK_PATH ?= ../socket-sdk-python +SOCKET_SDK_PATH ?= ../socketdev # Environment variable to control local development mode USE_LOCAL_SDK ?= false @@ -16,44 +16,37 @@ first-time-local-setup: $(MAKE) clean $(MAKE) USE_LOCAL_SDK=true dev-setup -# Update dependencies after changing pyproject.toml -update-deps: compile-deps sync-deps +# Update lock file after changing pyproject.toml +update-lock: + uv lock # Setup for local development dev-setup: clean local-dev setup # Sync all dependencies after pulling changes -sync-all: sync-deps +sync-all: sync # === Implementation targets === -# Creates virtual environment and installs pip-tools -init-tools: - python -m venv .venv - . .venv/bin/activate && pip install pip-tools - # Installs dependencies needed for local development -# Currently: socket-sdk-python from test PyPI or local path -local-dev: init-tools +# Currently: socketdev from test PyPI or local path +local-dev: ifeq ($(USE_LOCAL_SDK),true) - . .venv/bin/activate && pip install -e $(SOCKET_SDK_PATH) + uv add --editable $(SOCKET_SDK_PATH) endif -# Creates/updates requirements.txt files with locked versions based on pyproject.toml -compile-deps: local-dev - . .venv/bin/activate && pip-compile --output-file=requirements.txt pyproject.toml - . .venv/bin/activate && pip-compile --extra=dev --output-file=requirements-dev.txt pyproject.toml - . .venv/bin/activate && pip-compile --extra=test --output-file=requirements-test.txt pyproject.toml - -# Creates virtual environment and installs dependencies from pyproject.toml -setup: compile-deps - . .venv/bin/activate && pip install -e ".[dev,test]" +# Creates virtual environment and installs dependencies from uv.lock +setup: update-lock + uv sync --all-extras +ifeq ($(USE_LOCAL_SDK),true) + uv add --editable $(SOCKET_SDK_PATH) +endif -# Installs exact versions from requirements.txt into your virtual environment -sync-deps: - . .venv/bin/activate && pip-sync requirements.txt requirements-dev.txt requirements-test.txt +# Installs exact versions from uv.lock into your virtual environment +sync: + uv sync --all-extras ifeq ($(USE_LOCAL_SDK),true) - . .venv/bin/activate && pip install -e $(SOCKET_SDK_PATH) + uv add --editable $(SOCKET_SDK_PATH) endif # Removes virtual environment and cache files @@ -62,8 +55,8 @@ clean: find . -type d -name "__pycache__" -exec rm -rf {} + test: - pytest + uv run pytest lint: - ruff check . - ruff format --check . \ No newline at end of file + uv run ruff check . + uv run ruff format --check . \ No newline at end of file diff --git a/README.md b/README.md index f0cf94b..5295e95 100644 --- a/README.md +++ b/README.md @@ -371,9 +371,9 @@ make first-time-setup 2. Local Development Setup (for SDK development): ```bash pyenv local 3.11 # Ensure correct Python version -SOCKET_SDK_PATH=~/path/to/socket-sdk-python make first-time-local-setup +SOCKET_SDK_PATH=~/path/to/socketdev make first-time-local-setup ``` -The default SDK path is `../socket-sdk-python` if not specified. +The default SDK path is `../socketdev` if not specified. #### Ongoing Development Tasks @@ -392,25 +392,23 @@ make sync-all High-level workflows: - `make first-time-setup`: Complete setup using PyPI packages - `make first-time-local-setup`: Complete setup for local SDK development -- `make update-deps`: Update requirements.txt files and sync dependencies +- `make update-lock`: Update uv.lock file after changing pyproject.toml - `make sync-all`: Sync dependencies after pulling changes - `make dev-setup`: Setup for local development (included in first-time-local-setup) Implementation targets: -- `make init-tools`: Creates virtual environment and installs pip-tools - `make local-dev`: Installs dependencies needed for local development -- `make compile-deps`: Generates requirements.txt files with locked versions -- `make setup`: Creates virtual environment and installs dependencies -- `make sync-deps`: Installs exact versions from requirements.txt +- `make setup`: Creates virtual environment and installs dependencies from uv.lock +- `make sync`: Installs exact versions from uv.lock - `make clean`: Removes virtual environment and cache files -- `make test`: Runs pytest suite -- `make lint`: Runs ruff for code formatting and linting +- `make test`: Runs pytest suite using uv run +- `make lint`: Runs ruff for code formatting and linting using uv run ### Environment Variables #### Core Configuration - `SOCKET_SECURITY_API_KEY`: Socket Security API token (alternative to --api-token parameter) -- `SOCKET_SDK_PATH`: Path to local socket-sdk-python repository (default: ../socket-sdk-python) +- `SOCKET_SDK_PATH`: Path to local socketdev repository (default: ../socketdev) #### GitLab Integration - `GITLAB_TOKEN`: GitLab API token for GitLab integration (supports both Bearer and PRIVATE-TOKEN authentication) diff --git a/pyproject.toml b/pyproject.toml index e4c54e3..1783718 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.2.2" +version = "2.2.4" requires-python = ">= 3.10" license = {"file" = "LICENSE"} dependencies = [ @@ -16,7 +16,7 @@ dependencies = [ 'GitPython', 'packaging', 'python-dotenv', - 'socket-sdk-python>=2.1.8,<3' + 'socketdev>=3.0.0,<4.0.0' ] readme = "README.md" description = "Socket Security CLI for CI/CD" @@ -45,7 +45,7 @@ test = [ dev = [ "ruff>=0.3.0", "twine", # for building - "pip-tools>=7.4.0", # for pip-compile + "uv>=0.1.0", # for dependency management "pre-commit", "hatch" ] diff --git a/requirements-dev.lock b/requirements-dev.lock deleted file mode 100644 index 099e79b..0000000 --- a/requirements-dev.lock +++ /dev/null @@ -1,73 +0,0 @@ -# generated by rye -# use `rye lock` or `rye sync` to update this lockfile -# -# last locked with the following flags: -# pre: false -# features: ["test"] -# all-features: false -# with-sources: false -# generate-hashes: false -# universal: false - -hatchling==1.27.0 -hatch==1.14.0 -argparse==1.4.0 - # via socketsecurity -certifi==2024.12.14 - # via requests -charset-normalizer==3.4.1 - # via requests -colorama==0.4.6 - # via pytest-watch -coverage==7.6.10 - # via pytest-cov -docopt==0.6.2 - # via pytest-watch -gitdb==4.0.12 - # via gitpython -gitpython==3.1.44 - # via socketsecurity -idna==3.10 - # via requests -iniconfig==2.0.0 - # via pytest -mdutils==1.6.0 - # via socketsecurity -packaging==24.2 - # via pytest - # via socketsecurity -pluggy==1.5.0 - # via pytest -prettytable==3.12.0 - # via socketsecurity -pytest==8.3.4 - # via pytest-asyncio - # via pytest-cov - # via pytest-mock - # via pytest-watch - # via socketsecurity -pytest-asyncio==0.25.1 - # via socketsecurity -pytest-cov==6.0.0 - # via socketsecurity -pytest-mock==3.14.0 - # via socketsecurity -pytest-watch==4.2.0 - # via socketsecurity -python-dotenv==1.0.1 - # via socketsecurity -requests==2.32.3 - # via socket-sdk-python - # via socketsecurity -smmap==5.0.2 - # via gitdb -socket-sdk-python==2.0.15 - # via socketsecurity -typing-extensions==4.12.2 - # via socket-sdk-python -urllib3==2.3.0 - # via requests -watchdog==6.0.0 - # via pytest-watch -wcwidth==0.2.13 - # via prettytable diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index bef361b..0000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,73 +0,0 @@ -# generated by rye -# use `rye lock` or `rye sync` to update this lockfile -# -# last locked with the following flags: -# pre: false -# features: ["test"] -# all-features: false -# with-sources: false -# generate-hashes: false -# universal: false - -hatchling==1.27.0 -hatch==1.14.0 -argparse==1.4.0 - # via socketsecurity -certifi==2024.12.14 - # via requests -charset-normalizer==3.4.1 - # via requests -colorama==0.4.6 - # via pytest-watch -coverage==7.6.10 - # via pytest-cov -docopt==0.6.2 - # via pytest-watch -gitdb==4.0.12 - # via gitpython -gitpython==3.1.44 - # via socketsecurity -idna==3.10 - # via requests -iniconfig==2.0.0 - # via pytest -mdutils==1.6.0 - # via socketsecurity -packaging==24.2 - # via pytest - # via socketsecurity -pluggy==1.5.0 - # via pytest -prettytable==3.12.0 - # via socketsecurity -pytest==8.3.4 - # via pytest-asyncio - # via pytest-cov - # via pytest-mock - # via pytest-watch - # via socketsecurity -pytest-asyncio==0.25.1 - # via socketsecurity -pytest-cov==6.0.0 - # via socketsecurity -pytest-mock==3.14.0 - # via socketsecurity -pytest-watch==4.2.0 - # via socketsecurity -python-dotenv==1.0.1 - # via socketsecurity -requests==2.32.4 - # via socket-sdk-python - # via socketsecurity -smmap==5.0.2 - # via gitdb -socket-sdk-python==2.0.15 - # via socketsecurity -typing-extensions==4.12.2 - # via socket-sdk-python -urllib3==2.5.0 - # via requests -watchdog==6.0.0 - # via pytest-watch -wcwidth==0.2.13 - # via prettytable diff --git a/requirements.lock b/requirements.lock deleted file mode 100644 index 6d0be66..0000000 --- a/requirements.lock +++ /dev/null @@ -1,71 +0,0 @@ -# generated by rye -# use `rye lock` or `rye sync` to update this lockfile -# -# last locked with the following flags: -# pre: false -# features: ["test"] -# all-features: false -# with-sources: false -# generate-hashes: false -# universal: false - -argparse==1.4.0 - # via socketsecurity -certifi==2024.12.14 - # via requests -charset-normalizer==3.4.1 - # via requests -colorama==0.4.6 - # via pytest-watch -coverage==7.6.10 - # via pytest-cov -docopt==0.6.2 - # via pytest-watch -gitdb==4.0.12 - # via gitpython -gitpython==3.1.44 - # via socketsecurity -idna==3.10 - # via requests -iniconfig==2.0.0 - # via pytest -mdutils==1.6.0 - # via socketsecurity -packaging==24.2 - # via pytest - # via socketsecurity -pluggy==1.5.0 - # via pytest -prettytable==3.12.0 - # via socketsecurity -pytest==8.3.4 - # via pytest-asyncio - # via pytest-cov - # via pytest-mock - # via pytest-watch - # via socketsecurity -pytest-asyncio==0.25.1 - # via socketsecurity -pytest-cov==6.0.0 - # via socketsecurity -pytest-mock==3.14.0 - # via socketsecurity -pytest-watch==4.2.0 - # via socketsecurity -python-dotenv==1.0.1 - # via socketsecurity -requests==2.32.3 - # via socket-sdk-python - # via socketsecurity -smmap==5.0.2 - # via gitdb -socket-sdk-python==2.0.15 - # via socketsecurity -typing-extensions==4.12.2 - # via socket-sdk-python -urllib3==2.3.0 - # via requests -watchdog==6.0.0 - # via pytest-watch -wcwidth==0.2.13 - # via prettytable diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index b2a6676..0000000 --- a/requirements.txt +++ /dev/null @@ -1,71 +0,0 @@ -# generated by rye -# use `rye lock` or `rye sync` to update this lockfile -# -# last locked with the following flags: -# pre: false -# features: ["test"] -# all-features: false -# with-sources: false -# generate-hashes: false -# universal: false - -argparse==1.4.0 - # via socketsecurity -certifi==2024.12.14 - # via requests -charset-normalizer==3.4.1 - # via requests -colorama==0.4.6 - # via pytest-watch -coverage==7.6.10 - # via pytest-cov -docopt==0.6.2 - # via pytest-watch -gitdb==4.0.12 - # via gitpython -gitpython==3.1.44 - # via socketsecurity -idna==3.10 - # via requests -iniconfig==2.0.0 - # via pytest -mdutils==1.6.0 - # via socketsecurity -packaging==24.2 - # via pytest - # via socketsecurity -pluggy==1.5.0 - # via pytest -prettytable==3.12.0 - # via socketsecurity -pytest==8.3.4 - # via pytest-asyncio - # via pytest-cov - # via pytest-mock - # via pytest-watch - # via socketsecurity -pytest-asyncio==0.25.1 - # via socketsecurity -pytest-cov==6.0.0 - # via socketsecurity -pytest-mock==3.14.0 - # via socketsecurity -pytest-watch==4.2.0 - # via socketsecurity -python-dotenv==1.0.1 - # via socketsecurity -requests==2.32.4 - # via socket-sdk-python - # via socketsecurity -smmap==5.0.2 - # via gitdb -socket-sdk-python==2.1.8 - # via socketsecurity -typing-extensions==4.12.2 - # via socket-sdk-python -urllib3==2.5.0 - # via requests -watchdog==6.0.0 - # via pytest-watch -wcwidth==0.2.13 - # via prettytable diff --git a/scripts/deploy-test-docker.sh b/scripts/deploy-test-docker.sh index c9526e2..9c17eb3 100755 --- a/scripts/deploy-test-docker.sh +++ b/scripts/deploy-test-docker.sh @@ -29,7 +29,7 @@ fi if [ -z "$SDK_VERSION" ]; then echo "No SDK version specified, checking TestPyPI for latest version..." - SDK_VERSION=$(get_latest_version "socket-sdk-python") + SDK_VERSION=$(get_latest_version "socketdev") echo "Latest SDK version on TestPyPI is: $SDK_VERSION" fi diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index c0d192a..c70ff49 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,2 +1,2 @@ __author__ = 'socket.dev' -__version__ = '2.2.2' +__version__ = '2.2.4' diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..80b661a --- /dev/null +++ b/uv.lock @@ -0,0 +1,1388 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "anyio" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252, upload-time = "2025-08-04T08:54:26.451Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "backports-tarfile" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, +] + +[[package]] +name = "certifi" +version = "2025.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, +] + +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", size = 426024, upload-time = "2024-09-04T20:43:34.186Z" }, + { url = "https://files.pythonhosted.org/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", size = 448188, upload-time = "2024-09-04T20:43:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/d3/48/1b9283ebbf0ec065148d8de05d647a986c5f22586b18120020452fff8f5d/cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", size = 455571, upload-time = "2024-09-04T20:43:38.586Z" }, + { url = "https://files.pythonhosted.org/packages/40/87/3b8452525437b40f39ca7ff70276679772ee7e8b394934ff60e63b7b090c/cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", size = 436687, upload-time = "2024-09-04T20:43:40.084Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fb/4da72871d177d63649ac449aec2e8a29efe0274035880c7af59101ca2232/cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", size = 446211, upload-time = "2024-09-04T20:43:41.526Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", size = 461325, upload-time = "2024-09-04T20:43:43.117Z" }, + { url = "https://files.pythonhosted.org/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", size = 438784, upload-time = "2024-09-04T20:43:45.256Z" }, + { url = "https://files.pythonhosted.org/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", size = 461564, upload-time = "2024-09-04T20:43:46.779Z" }, + { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259, upload-time = "2024-09-04T20:43:56.123Z" }, + { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200, upload-time = "2024-09-04T20:43:57.891Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235, upload-time = "2024-09-04T20:44:00.18Z" }, + { url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721, upload-time = "2024-09-04T20:44:01.585Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242, upload-time = "2024-09-04T20:44:03.467Z" }, + { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999, upload-time = "2024-09-04T20:44:05.023Z" }, + { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242, upload-time = "2024-09-04T20:44:06.444Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604, upload-time = "2024-09-04T20:44:08.206Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803, upload-time = "2024-09-04T20:44:15.231Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850, upload-time = "2024-09-04T20:44:17.188Z" }, + { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729, upload-time = "2024-09-04T20:44:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256, upload-time = "2024-09-04T20:44:20.248Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424, upload-time = "2024-09-04T20:44:21.673Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568, upload-time = "2024-09-04T20:44:23.245Z" }, + { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736, upload-time = "2024-09-04T20:44:24.757Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload-time = "2024-09-04T20:44:32.01Z" }, + { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893, upload-time = "2024-09-04T20:44:33.606Z" }, + { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810, upload-time = "2024-09-04T20:44:35.191Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200, upload-time = "2024-09-04T20:44:36.743Z" }, + { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447, upload-time = "2024-09-04T20:44:38.492Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358, upload-time = "2024-09-04T20:44:40.046Z" }, + { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload-time = "2024-09-04T20:44:41.616Z" }, +] + +[[package]] +name = "cfgv" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/98/f3b8013223728a99b908c9344da3aa04ee6e3fa235f19409033eda92fb78/charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72", size = 207695, upload-time = "2025-08-09T07:55:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/21/40/5188be1e3118c82dcb7c2a5ba101b783822cfb413a0268ed3be0468532de/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe", size = 147153, upload-time = "2025-08-09T07:55:38.467Z" }, + { url = "https://files.pythonhosted.org/packages/37/60/5d0d74bc1e1380f0b72c327948d9c2aca14b46a9efd87604e724260f384c/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601", size = 160428, upload-time = "2025-08-09T07:55:40.072Z" }, + { url = "https://files.pythonhosted.org/packages/85/9a/d891f63722d9158688de58d050c59dc3da560ea7f04f4c53e769de5140f5/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c", size = 157627, upload-time = "2025-08-09T07:55:41.706Z" }, + { url = "https://files.pythonhosted.org/packages/65/1a/7425c952944a6521a9cfa7e675343f83fd82085b8af2b1373a2409c683dc/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2", size = 152388, upload-time = "2025-08-09T07:55:43.262Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c9/a2c9c2a355a8594ce2446085e2ec97fd44d323c684ff32042e2a6b718e1d/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0", size = 150077, upload-time = "2025-08-09T07:55:44.903Z" }, + { url = "https://files.pythonhosted.org/packages/3b/38/20a1f44e4851aa1c9105d6e7110c9d020e093dfa5836d712a5f074a12bf7/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0", size = 161631, upload-time = "2025-08-09T07:55:46.346Z" }, + { url = "https://files.pythonhosted.org/packages/a4/fa/384d2c0f57edad03d7bec3ebefb462090d8905b4ff5a2d2525f3bb711fac/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0", size = 159210, upload-time = "2025-08-09T07:55:47.539Z" }, + { url = "https://files.pythonhosted.org/packages/33/9e/eca49d35867ca2db336b6ca27617deed4653b97ebf45dfc21311ce473c37/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a", size = 153739, upload-time = "2025-08-09T07:55:48.744Z" }, + { url = "https://files.pythonhosted.org/packages/2a/91/26c3036e62dfe8de8061182d33be5025e2424002125c9500faff74a6735e/charset_normalizer-3.4.3-cp310-cp310-win32.whl", hash = "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f", size = 99825, upload-time = "2025-08-09T07:55:50.305Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c6/f05db471f81af1fa01839d44ae2a8bfeec8d2a8b4590f16c4e7393afd323/charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669", size = 107452, upload-time = "2025-08-09T07:55:51.461Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b5/991245018615474a60965a7c9cd2b4efbaabd16d582a5547c47ee1c7730b/charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", size = 204483, upload-time = "2025-08-09T07:55:53.12Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2a/ae245c41c06299ec18262825c1569c5d3298fc920e4ddf56ab011b417efd/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", size = 145520, upload-time = "2025-08-09T07:55:54.712Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a4/b3b6c76e7a635748c4421d2b92c7b8f90a432f98bda5082049af37ffc8e3/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", size = 158876, upload-time = "2025-08-09T07:55:56.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e6/63bb0e10f90a8243c5def74b5b105b3bbbfb3e7bb753915fe333fb0c11ea/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f", size = 156083, upload-time = "2025-08-09T07:55:57.582Z" }, + { url = "https://files.pythonhosted.org/packages/87/df/b7737ff046c974b183ea9aa111b74185ac8c3a326c6262d413bd5a1b8c69/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07", size = 150295, upload-time = "2025-08-09T07:55:59.147Z" }, + { url = "https://files.pythonhosted.org/packages/61/f1/190d9977e0084d3f1dc169acd060d479bbbc71b90bf3e7bf7b9927dec3eb/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30", size = 148379, upload-time = "2025-08-09T07:56:00.364Z" }, + { url = "https://files.pythonhosted.org/packages/4c/92/27dbe365d34c68cfe0ca76f1edd70e8705d82b378cb54ebbaeabc2e3029d/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14", size = 160018, upload-time = "2025-08-09T07:56:01.678Z" }, + { url = "https://files.pythonhosted.org/packages/99/04/baae2a1ea1893a01635d475b9261c889a18fd48393634b6270827869fa34/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c", size = 157430, upload-time = "2025-08-09T07:56:02.87Z" }, + { url = "https://files.pythonhosted.org/packages/2f/36/77da9c6a328c54d17b960c89eccacfab8271fdaaa228305330915b88afa9/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae", size = 151600, upload-time = "2025-08-09T07:56:04.089Z" }, + { url = "https://files.pythonhosted.org/packages/64/d4/9eb4ff2c167edbbf08cdd28e19078bf195762e9bd63371689cab5ecd3d0d/charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849", size = 99616, upload-time = "2025-08-09T07:56:05.658Z" }, + { url = "https://files.pythonhosted.org/packages/f4/9c/996a4a028222e7761a96634d1820de8a744ff4327a00ada9c8942033089b/charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c", size = 107108, upload-time = "2025-08-09T07:56:07.176Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655, upload-time = "2025-08-09T07:56:08.475Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223, upload-time = "2025-08-09T07:56:09.708Z" }, + { url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366, upload-time = "2025-08-09T07:56:11.326Z" }, + { url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104, upload-time = "2025-08-09T07:56:13.014Z" }, + { url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830, upload-time = "2025-08-09T07:56:14.428Z" }, + { url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854, upload-time = "2025-08-09T07:56:16.051Z" }, + { url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670, upload-time = "2025-08-09T07:56:17.314Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501, upload-time = "2025-08-09T07:56:18.641Z" }, + { url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173, upload-time = "2025-08-09T07:56:20.289Z" }, + { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" }, + { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" }, + { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" }, + { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" }, + { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" }, + { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" }, + { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" }, + { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" }, + { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" }, + { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" }, + { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" }, + { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" }, + { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" }, +] + +[[package]] +name = "click" +version = "8.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, +] + +[[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 = "coverage" +version = "7.10.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/61/83/153f54356c7c200013a752ce1ed5448573dca546ce125801afca9e1ac1a4/coverage-7.10.5.tar.gz", hash = "sha256:f2e57716a78bc3ae80b2207be0709a3b2b63b9f2dcf9740ee6ac03588a2015b6", size = 821662, upload-time = "2025-08-23T14:42:44.78Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/70/e77b0061a6c7157bfce645c6b9a715a08d4c86b3360a7b3252818080b817/coverage-7.10.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c6a5c3414bfc7451b879141ce772c546985163cf553f08e0f135f0699a911801", size = 216774, upload-time = "2025-08-23T14:40:26.301Z" }, + { url = "https://files.pythonhosted.org/packages/91/08/2a79de5ecf37ee40f2d898012306f11c161548753391cec763f92647837b/coverage-7.10.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bc8e4d99ce82f1710cc3c125adc30fd1487d3cf6c2cd4994d78d68a47b16989a", size = 217175, upload-time = "2025-08-23T14:40:29.142Z" }, + { url = "https://files.pythonhosted.org/packages/64/57/0171d69a699690149a6ba6a4eb702814448c8d617cf62dbafa7ce6bfdf63/coverage-7.10.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:02252dc1216e512a9311f596b3169fad54abcb13827a8d76d5630c798a50a754", size = 243931, upload-time = "2025-08-23T14:40:30.735Z" }, + { url = "https://files.pythonhosted.org/packages/15/06/3a67662c55656702bd398a727a7f35df598eb11104fcb34f1ecbb070291a/coverage-7.10.5-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:73269df37883e02d460bee0cc16be90509faea1e3bd105d77360b512d5bb9c33", size = 245740, upload-time = "2025-08-23T14:40:32.302Z" }, + { url = "https://files.pythonhosted.org/packages/00/f4/f8763aabf4dc30ef0d0012522d312f0b7f9fede6246a1f27dbcc4a1e523c/coverage-7.10.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f8a81b0614642f91c9effd53eec284f965577591f51f547a1cbeb32035b4c2f", size = 247600, upload-time = "2025-08-23T14:40:33.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/31/6632219a9065e1b83f77eda116fed4c76fb64908a6a9feae41816dab8237/coverage-7.10.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6a29f8e0adb7f8c2b95fa2d4566a1d6e6722e0a637634c6563cb1ab844427dd9", size = 245640, upload-time = "2025-08-23T14:40:35.248Z" }, + { url = "https://files.pythonhosted.org/packages/6e/e2/3dba9b86037b81649b11d192bb1df11dde9a81013e434af3520222707bc8/coverage-7.10.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fcf6ab569436b4a647d4e91accba12509ad9f2554bc93d3aee23cc596e7f99c3", size = 243659, upload-time = "2025-08-23T14:40:36.815Z" }, + { url = "https://files.pythonhosted.org/packages/02/b9/57170bd9f3e333837fc24ecc88bc70fbc2eb7ccfd0876854b0c0407078c3/coverage-7.10.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:90dc3d6fb222b194a5de60af8d190bedeeddcbc7add317e4a3cd333ee6b7c879", size = 244537, upload-time = "2025-08-23T14:40:38.737Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1c/93ac36ef1e8b06b8d5777393a3a40cb356f9f3dab980be40a6941e443588/coverage-7.10.5-cp310-cp310-win32.whl", hash = "sha256:414a568cd545f9dc75f0686a0049393de8098414b58ea071e03395505b73d7a8", size = 219285, upload-time = "2025-08-23T14:40:40.342Z" }, + { url = "https://files.pythonhosted.org/packages/30/95/23252277e6e5fe649d6cd3ed3f35d2307e5166de4e75e66aa7f432abc46d/coverage-7.10.5-cp310-cp310-win_amd64.whl", hash = "sha256:e551f9d03347196271935fd3c0c165f0e8c049220280c1120de0084d65e9c7ff", size = 220185, upload-time = "2025-08-23T14:40:42.026Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f2/336d34d2fc1291ca7c18eeb46f64985e6cef5a1a7ef6d9c23720c6527289/coverage-7.10.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c177e6ffe2ebc7c410785307758ee21258aa8e8092b44d09a2da767834f075f2", size = 216890, upload-time = "2025-08-23T14:40:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/92448b07cc1cf2b429d0ce635f59cf0c626a5d8de21358f11e92174ff2a6/coverage-7.10.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:14d6071c51ad0f703d6440827eaa46386169b5fdced42631d5a5ac419616046f", size = 217287, upload-time = "2025-08-23T14:40:45.214Z" }, + { url = "https://files.pythonhosted.org/packages/96/ba/ad5b36537c5179c808d0ecdf6e4aa7630b311b3c12747ad624dcd43a9b6b/coverage-7.10.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:61f78c7c3bc272a410c5ae3fde7792b4ffb4acc03d35a7df73ca8978826bb7ab", size = 247683, upload-time = "2025-08-23T14:40:46.791Z" }, + { url = "https://files.pythonhosted.org/packages/28/e5/fe3bbc8d097029d284b5fb305b38bb3404895da48495f05bff025df62770/coverage-7.10.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f39071caa126f69d63f99b324fb08c7b1da2ec28cbb1fe7b5b1799926492f65c", size = 249614, upload-time = "2025-08-23T14:40:48.082Z" }, + { url = "https://files.pythonhosted.org/packages/69/9c/a1c89a8c8712799efccb32cd0a1ee88e452f0c13a006b65bb2271f1ac767/coverage-7.10.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:343a023193f04d46edc46b2616cdbee68c94dd10208ecd3adc56fcc54ef2baa1", size = 251719, upload-time = "2025-08-23T14:40:49.349Z" }, + { url = "https://files.pythonhosted.org/packages/e9/be/5576b5625865aa95b5633315f8f4142b003a70c3d96e76f04487c3b5cc95/coverage-7.10.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:585ffe93ae5894d1ebdee69fc0b0d4b7c75d8007983692fb300ac98eed146f78", size = 249411, upload-time = "2025-08-23T14:40:50.624Z" }, + { url = "https://files.pythonhosted.org/packages/94/0a/e39a113d4209da0dbbc9385608cdb1b0726a4d25f78672dc51c97cfea80f/coverage-7.10.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b0ef4e66f006ed181df29b59921bd8fc7ed7cd6a9289295cd8b2824b49b570df", size = 247466, upload-time = "2025-08-23T14:40:52.362Z" }, + { url = "https://files.pythonhosted.org/packages/40/cb/aebb2d8c9e3533ee340bea19b71c5b76605a0268aa49808e26fe96ec0a07/coverage-7.10.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:eb7b0bbf7cc1d0453b843eca7b5fa017874735bef9bfdfa4121373d2cc885ed6", size = 248104, upload-time = "2025-08-23T14:40:54.064Z" }, + { url = "https://files.pythonhosted.org/packages/08/e6/26570d6ccce8ff5de912cbfd268e7f475f00597cb58da9991fa919c5e539/coverage-7.10.5-cp311-cp311-win32.whl", hash = "sha256:1d043a8a06987cc0c98516e57c4d3fc2c1591364831e9deb59c9e1b4937e8caf", size = 219327, upload-time = "2025-08-23T14:40:55.424Z" }, + { url = "https://files.pythonhosted.org/packages/79/79/5f48525e366e518b36e66167e3b6e5db6fd54f63982500c6a5abb9d3dfbd/coverage-7.10.5-cp311-cp311-win_amd64.whl", hash = "sha256:fefafcca09c3ac56372ef64a40f5fe17c5592fab906e0fdffd09543f3012ba50", size = 220213, upload-time = "2025-08-23T14:40:56.724Z" }, + { url = "https://files.pythonhosted.org/packages/40/3c/9058128b7b0bf333130c320b1eb1ae485623014a21ee196d68f7737f8610/coverage-7.10.5-cp311-cp311-win_arm64.whl", hash = "sha256:7e78b767da8b5fc5b2faa69bb001edafcd6f3995b42a331c53ef9572c55ceb82", size = 218893, upload-time = "2025-08-23T14:40:58.011Z" }, + { url = "https://files.pythonhosted.org/packages/27/8e/40d75c7128f871ea0fd829d3e7e4a14460cad7c3826e3b472e6471ad05bd/coverage-7.10.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c2d05c7e73c60a4cecc7d9b60dbfd603b4ebc0adafaef371445b47d0f805c8a9", size = 217077, upload-time = "2025-08-23T14:40:59.329Z" }, + { url = "https://files.pythonhosted.org/packages/18/a8/f333f4cf3fb5477a7f727b4d603a2eb5c3c5611c7fe01329c2e13b23b678/coverage-7.10.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:32ddaa3b2c509778ed5373b177eb2bf5662405493baeff52278a0b4f9415188b", size = 217310, upload-time = "2025-08-23T14:41:00.628Z" }, + { url = "https://files.pythonhosted.org/packages/ec/2c/fbecd8381e0a07d1547922be819b4543a901402f63930313a519b937c668/coverage-7.10.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dd382410039fe062097aa0292ab6335a3f1e7af7bba2ef8d27dcda484918f20c", size = 248802, upload-time = "2025-08-23T14:41:02.012Z" }, + { url = "https://files.pythonhosted.org/packages/3f/bc/1011da599b414fb6c9c0f34086736126f9ff71f841755786a6b87601b088/coverage-7.10.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7fa22800f3908df31cea6fb230f20ac49e343515d968cc3a42b30d5c3ebf9b5a", size = 251550, upload-time = "2025-08-23T14:41:03.438Z" }, + { url = "https://files.pythonhosted.org/packages/4c/6f/b5c03c0c721c067d21bc697accc3642f3cef9f087dac429c918c37a37437/coverage-7.10.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f366a57ac81f5e12797136552f5b7502fa053c861a009b91b80ed51f2ce651c6", size = 252684, upload-time = "2025-08-23T14:41:04.85Z" }, + { url = "https://files.pythonhosted.org/packages/f9/50/d474bc300ebcb6a38a1047d5c465a227605d6473e49b4e0d793102312bc5/coverage-7.10.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5f1dc8f1980a272ad4a6c84cba7981792344dad33bf5869361576b7aef42733a", size = 250602, upload-time = "2025-08-23T14:41:06.719Z" }, + { url = "https://files.pythonhosted.org/packages/4a/2d/548c8e04249cbba3aba6bd799efdd11eee3941b70253733f5d355d689559/coverage-7.10.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2285c04ee8676f7938b02b4936d9b9b672064daab3187c20f73a55f3d70e6b4a", size = 248724, upload-time = "2025-08-23T14:41:08.429Z" }, + { url = "https://files.pythonhosted.org/packages/e2/96/a7c3c0562266ac39dcad271d0eec8fc20ab576e3e2f64130a845ad2a557b/coverage-7.10.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2492e4dd9daab63f5f56286f8a04c51323d237631eb98505d87e4c4ff19ec34", size = 250158, upload-time = "2025-08-23T14:41:09.749Z" }, + { url = "https://files.pythonhosted.org/packages/f3/75/74d4be58c70c42ef0b352d597b022baf12dbe2b43e7cb1525f56a0fb1d4b/coverage-7.10.5-cp312-cp312-win32.whl", hash = "sha256:38a9109c4ee8135d5df5505384fc2f20287a47ccbe0b3f04c53c9a1989c2bbaf", size = 219493, upload-time = "2025-08-23T14:41:11.095Z" }, + { url = "https://files.pythonhosted.org/packages/4f/08/364e6012d1d4d09d1e27437382967efed971d7613f94bca9add25f0c1f2b/coverage-7.10.5-cp312-cp312-win_amd64.whl", hash = "sha256:6b87f1ad60b30bc3c43c66afa7db6b22a3109902e28c5094957626a0143a001f", size = 220302, upload-time = "2025-08-23T14:41:12.449Z" }, + { url = "https://files.pythonhosted.org/packages/db/d5/7c8a365e1f7355c58af4fe5faf3f90cc8e587590f5854808d17ccb4e7077/coverage-7.10.5-cp312-cp312-win_arm64.whl", hash = "sha256:672a6c1da5aea6c629819a0e1461e89d244f78d7b60c424ecf4f1f2556c041d8", size = 218936, upload-time = "2025-08-23T14:41:13.872Z" }, + { url = "https://files.pythonhosted.org/packages/9f/08/4166ecfb60ba011444f38a5a6107814b80c34c717bc7a23be0d22e92ca09/coverage-7.10.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ef3b83594d933020f54cf65ea1f4405d1f4e41a009c46df629dd964fcb6e907c", size = 217106, upload-time = "2025-08-23T14:41:15.268Z" }, + { url = "https://files.pythonhosted.org/packages/25/d7/b71022408adbf040a680b8c64bf6ead3be37b553e5844f7465643979f7ca/coverage-7.10.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2b96bfdf7c0ea9faebce088a3ecb2382819da4fbc05c7b80040dbc428df6af44", size = 217353, upload-time = "2025-08-23T14:41:16.656Z" }, + { url = "https://files.pythonhosted.org/packages/74/68/21e0d254dbf8972bb8dd95e3fe7038f4be037ff04ba47d6d1b12b37510ba/coverage-7.10.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:63df1fdaffa42d914d5c4d293e838937638bf75c794cf20bee12978fc8c4e3bc", size = 248350, upload-time = "2025-08-23T14:41:18.128Z" }, + { url = "https://files.pythonhosted.org/packages/90/65/28752c3a896566ec93e0219fc4f47ff71bd2b745f51554c93e8dcb659796/coverage-7.10.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8002dc6a049aac0e81ecec97abfb08c01ef0c1fbf962d0c98da3950ace89b869", size = 250955, upload-time = "2025-08-23T14:41:19.577Z" }, + { url = "https://files.pythonhosted.org/packages/a5/eb/ca6b7967f57f6fef31da8749ea20417790bb6723593c8cd98a987be20423/coverage-7.10.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:63d4bb2966d6f5f705a6b0c6784c8969c468dbc4bcf9d9ded8bff1c7e092451f", size = 252230, upload-time = "2025-08-23T14:41:20.959Z" }, + { url = "https://files.pythonhosted.org/packages/bc/29/17a411b2a2a18f8b8c952aa01c00f9284a1fbc677c68a0003b772ea89104/coverage-7.10.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1f672efc0731a6846b157389b6e6d5d5e9e59d1d1a23a5c66a99fd58339914d5", size = 250387, upload-time = "2025-08-23T14:41:22.644Z" }, + { url = "https://files.pythonhosted.org/packages/c7/89/97a9e271188c2fbb3db82235c33980bcbc733da7da6065afbaa1d685a169/coverage-7.10.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3f39cef43d08049e8afc1fde4a5da8510fc6be843f8dea350ee46e2a26b2f54c", size = 248280, upload-time = "2025-08-23T14:41:24.061Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c6/0ad7d0137257553eb4706b4ad6180bec0a1b6a648b092c5bbda48d0e5b2c/coverage-7.10.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2968647e3ed5a6c019a419264386b013979ff1fb67dd11f5c9886c43d6a31fc2", size = 249894, upload-time = "2025-08-23T14:41:26.165Z" }, + { url = "https://files.pythonhosted.org/packages/84/56/fb3aba936addb4c9e5ea14f5979393f1c2466b4c89d10591fd05f2d6b2aa/coverage-7.10.5-cp313-cp313-win32.whl", hash = "sha256:0d511dda38595b2b6934c2b730a1fd57a3635c6aa2a04cb74714cdfdd53846f4", size = 219536, upload-time = "2025-08-23T14:41:27.694Z" }, + { url = "https://files.pythonhosted.org/packages/fc/54/baacb8f2f74431e3b175a9a2881feaa8feb6e2f187a0e7e3046f3c7742b2/coverage-7.10.5-cp313-cp313-win_amd64.whl", hash = "sha256:9a86281794a393513cf117177fd39c796b3f8e3759bb2764259a2abba5cce54b", size = 220330, upload-time = "2025-08-23T14:41:29.081Z" }, + { url = "https://files.pythonhosted.org/packages/64/8a/82a3788f8e31dee51d350835b23d480548ea8621f3effd7c3ba3f7e5c006/coverage-7.10.5-cp313-cp313-win_arm64.whl", hash = "sha256:cebd8e906eb98bb09c10d1feed16096700b1198d482267f8bf0474e63a7b8d84", size = 218961, upload-time = "2025-08-23T14:41:30.511Z" }, + { url = "https://files.pythonhosted.org/packages/d8/a1/590154e6eae07beee3b111cc1f907c30da6fc8ce0a83ef756c72f3c7c748/coverage-7.10.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0520dff502da5e09d0d20781df74d8189ab334a1e40d5bafe2efaa4158e2d9e7", size = 217819, upload-time = "2025-08-23T14:41:31.962Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ff/436ffa3cfc7741f0973c5c89405307fe39b78dcf201565b934e6616fc4ad/coverage-7.10.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d9cd64aca68f503ed3f1f18c7c9174cbb797baba02ca8ab5112f9d1c0328cd4b", size = 218040, upload-time = "2025-08-23T14:41:33.472Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ca/5787fb3d7820e66273913affe8209c534ca11241eb34ee8c4fd2aaa9dd87/coverage-7.10.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0913dd1613a33b13c4f84aa6e3f4198c1a21ee28ccb4f674985c1f22109f0aae", size = 259374, upload-time = "2025-08-23T14:41:34.914Z" }, + { url = "https://files.pythonhosted.org/packages/b5/89/21af956843896adc2e64fc075eae3c1cadb97ee0a6960733e65e696f32dd/coverage-7.10.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1b7181c0feeb06ed8a02da02792f42f829a7b29990fef52eff257fef0885d760", size = 261551, upload-time = "2025-08-23T14:41:36.333Z" }, + { url = "https://files.pythonhosted.org/packages/e1/96/390a69244ab837e0ac137989277879a084c786cf036c3c4a3b9637d43a89/coverage-7.10.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36d42b7396b605f774d4372dd9c49bed71cbabce4ae1ccd074d155709dd8f235", size = 263776, upload-time = "2025-08-23T14:41:38.25Z" }, + { url = "https://files.pythonhosted.org/packages/00/32/cfd6ae1da0a521723349f3129b2455832fc27d3f8882c07e5b6fefdd0da2/coverage-7.10.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b4fdc777e05c4940b297bf47bf7eedd56a39a61dc23ba798e4b830d585486ca5", size = 261326, upload-time = "2025-08-23T14:41:40.343Z" }, + { url = "https://files.pythonhosted.org/packages/4c/c4/bf8d459fb4ce2201e9243ce6c015936ad283a668774430a3755f467b39d1/coverage-7.10.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:42144e8e346de44a6f1dbd0a56575dd8ab8dfa7e9007da02ea5b1c30ab33a7db", size = 259090, upload-time = "2025-08-23T14:41:42.106Z" }, + { url = "https://files.pythonhosted.org/packages/f4/5d/a234f7409896468e5539d42234016045e4015e857488b0b5b5f3f3fa5f2b/coverage-7.10.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:66c644cbd7aed8fe266d5917e2c9f65458a51cfe5eeff9c05f15b335f697066e", size = 260217, upload-time = "2025-08-23T14:41:43.591Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ad/87560f036099f46c2ddd235be6476dd5c1d6be6bb57569a9348d43eeecea/coverage-7.10.5-cp313-cp313t-win32.whl", hash = "sha256:2d1b73023854068c44b0c554578a4e1ef1b050ed07cf8b431549e624a29a66ee", size = 220194, upload-time = "2025-08-23T14:41:45.051Z" }, + { url = "https://files.pythonhosted.org/packages/36/a8/04a482594fdd83dc677d4a6c7e2d62135fff5a1573059806b8383fad9071/coverage-7.10.5-cp313-cp313t-win_amd64.whl", hash = "sha256:54a1532c8a642d8cc0bd5a9a51f5a9dcc440294fd06e9dda55e743c5ec1a8f14", size = 221258, upload-time = "2025-08-23T14:41:46.44Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ad/7da28594ab66fe2bc720f1bc9b131e62e9b4c6e39f044d9a48d18429cc21/coverage-7.10.5-cp313-cp313t-win_arm64.whl", hash = "sha256:74d5b63fe3f5f5d372253a4ef92492c11a4305f3550631beaa432fc9df16fcff", size = 219521, upload-time = "2025-08-23T14:41:47.882Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7f/c8b6e4e664b8a95254c35a6c8dd0bf4db201ec681c169aae2f1256e05c85/coverage-7.10.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:68c5e0bc5f44f68053369fa0d94459c84548a77660a5f2561c5e5f1e3bed7031", size = 217090, upload-time = "2025-08-23T14:41:49.327Z" }, + { url = "https://files.pythonhosted.org/packages/44/74/3ee14ede30a6e10a94a104d1d0522d5fb909a7c7cac2643d2a79891ff3b9/coverage-7.10.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cf33134ffae93865e32e1e37df043bef15a5e857d8caebc0099d225c579b0fa3", size = 217365, upload-time = "2025-08-23T14:41:50.796Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/06ac21bf87dfb7620d1f870dfa3c2cae1186ccbcdc50b8b36e27a0d52f50/coverage-7.10.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ad8fa9d5193bafcf668231294241302b5e683a0518bf1e33a9a0dfb142ec3031", size = 248413, upload-time = "2025-08-23T14:41:52.5Z" }, + { url = "https://files.pythonhosted.org/packages/21/bc/cc5bed6e985d3a14228539631573f3863be6a2587381e8bc5fdf786377a1/coverage-7.10.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:146fa1531973d38ab4b689bc764592fe6c2f913e7e80a39e7eeafd11f0ef6db2", size = 250943, upload-time = "2025-08-23T14:41:53.922Z" }, + { url = "https://files.pythonhosted.org/packages/8d/43/6a9fc323c2c75cd80b18d58db4a25dc8487f86dd9070f9592e43e3967363/coverage-7.10.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6013a37b8a4854c478d3219ee8bc2392dea51602dd0803a12d6f6182a0061762", size = 252301, upload-time = "2025-08-23T14:41:56.528Z" }, + { url = "https://files.pythonhosted.org/packages/69/7c/3e791b8845f4cd515275743e3775adb86273576596dc9f02dca37357b4f2/coverage-7.10.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eb90fe20db9c3d930fa2ad7a308207ab5b86bf6a76f54ab6a40be4012d88fcae", size = 250302, upload-time = "2025-08-23T14:41:58.171Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bc/5099c1e1cb0c9ac6491b281babea6ebbf999d949bf4aa8cdf4f2b53505e8/coverage-7.10.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:384b34482272e960c438703cafe63316dfbea124ac62006a455c8410bf2a2262", size = 248237, upload-time = "2025-08-23T14:41:59.703Z" }, + { url = "https://files.pythonhosted.org/packages/7e/51/d346eb750a0b2f1e77f391498b753ea906fde69cc11e4b38dca28c10c88c/coverage-7.10.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:467dc74bd0a1a7de2bedf8deaf6811f43602cb532bd34d81ffd6038d6d8abe99", size = 249726, upload-time = "2025-08-23T14:42:01.343Z" }, + { url = "https://files.pythonhosted.org/packages/a3/85/eebcaa0edafe427e93286b94f56ea7e1280f2c49da0a776a6f37e04481f9/coverage-7.10.5-cp314-cp314-win32.whl", hash = "sha256:556d23d4e6393ca898b2e63a5bca91e9ac2d5fb13299ec286cd69a09a7187fde", size = 219825, upload-time = "2025-08-23T14:42:03.263Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f7/6d43e037820742603f1e855feb23463979bf40bd27d0cde1f761dcc66a3e/coverage-7.10.5-cp314-cp314-win_amd64.whl", hash = "sha256:f4446a9547681533c8fa3e3c6cf62121eeee616e6a92bd9201c6edd91beffe13", size = 220618, upload-time = "2025-08-23T14:42:05.037Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b0/ed9432e41424c51509d1da603b0393404b828906236fb87e2c8482a93468/coverage-7.10.5-cp314-cp314-win_arm64.whl", hash = "sha256:5e78bd9cf65da4c303bf663de0d73bf69f81e878bf72a94e9af67137c69b9fe9", size = 219199, upload-time = "2025-08-23T14:42:06.662Z" }, + { url = "https://files.pythonhosted.org/packages/2f/54/5a7ecfa77910f22b659c820f67c16fc1e149ed132ad7117f0364679a8fa9/coverage-7.10.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:5661bf987d91ec756a47c7e5df4fbcb949f39e32f9334ccd3f43233bbb65e508", size = 217833, upload-time = "2025-08-23T14:42:08.262Z" }, + { url = "https://files.pythonhosted.org/packages/4e/0e/25672d917cc57857d40edf38f0b867fb9627115294e4f92c8fcbbc18598d/coverage-7.10.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a46473129244db42a720439a26984f8c6f834762fc4573616c1f37f13994b357", size = 218048, upload-time = "2025-08-23T14:42:10.247Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7c/0b2b4f1c6f71885d4d4b2b8608dcfc79057adb7da4143eb17d6260389e42/coverage-7.10.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1f64b8d3415d60f24b058b58d859e9512624bdfa57a2d1f8aff93c1ec45c429b", size = 259549, upload-time = "2025-08-23T14:42:11.811Z" }, + { url = "https://files.pythonhosted.org/packages/94/73/abb8dab1609abec7308d83c6aec547944070526578ee6c833d2da9a0ad42/coverage-7.10.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:44d43de99a9d90b20e0163f9770542357f58860a26e24dc1d924643bd6aa7cb4", size = 261715, upload-time = "2025-08-23T14:42:13.505Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d1/abf31de21ec92731445606b8d5e6fa5144653c2788758fcf1f47adb7159a/coverage-7.10.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a931a87e5ddb6b6404e65443b742cb1c14959622777f2a4efd81fba84f5d91ba", size = 263969, upload-time = "2025-08-23T14:42:15.422Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b3/ef274927f4ebede96056173b620db649cc9cb746c61ffc467946b9d0bc67/coverage-7.10.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9559b906a100029274448f4c8b8b0a127daa4dade5661dfd821b8c188058842", size = 261408, upload-time = "2025-08-23T14:42:16.971Z" }, + { url = "https://files.pythonhosted.org/packages/20/fc/83ca2812be616d69b4cdd4e0c62a7bc526d56875e68fd0f79d47c7923584/coverage-7.10.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b08801e25e3b4526ef9ced1aa29344131a8f5213c60c03c18fe4c6170ffa2874", size = 259168, upload-time = "2025-08-23T14:42:18.512Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/e0779e5716f72d5c9962e709d09815d02b3b54724e38567308304c3fc9df/coverage-7.10.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ed9749bb8eda35f8b636fb7632f1c62f735a236a5d4edadd8bbcc5ea0542e732", size = 260317, upload-time = "2025-08-23T14:42:20.005Z" }, + { url = "https://files.pythonhosted.org/packages/2b/fe/4247e732f2234bb5eb9984a0888a70980d681f03cbf433ba7b48f08ca5d5/coverage-7.10.5-cp314-cp314t-win32.whl", hash = "sha256:609b60d123fc2cc63ccee6d17e4676699075db72d14ac3c107cc4976d516f2df", size = 220600, upload-time = "2025-08-23T14:42:22.027Z" }, + { url = "https://files.pythonhosted.org/packages/a7/a0/f294cff6d1034b87839987e5b6ac7385bec599c44d08e0857ac7f164ad0c/coverage-7.10.5-cp314-cp314t-win_amd64.whl", hash = "sha256:0666cf3d2c1626b5a3463fd5b05f5e21f99e6aec40a3192eee4d07a15970b07f", size = 221714, upload-time = "2025-08-23T14:42:23.616Z" }, + { url = "https://files.pythonhosted.org/packages/23/18/fa1afdc60b5528d17416df440bcbd8fd12da12bfea9da5b6ae0f7a37d0f7/coverage-7.10.5-cp314-cp314t-win_arm64.whl", hash = "sha256:bc85eb2d35e760120540afddd3044a5bf69118a91a296a8b3940dfc4fdcfe1e2", size = 219735, upload-time = "2025-08-23T14:42:25.156Z" }, + { url = "https://files.pythonhosted.org/packages/08/b6/fff6609354deba9aeec466e4bcaeb9d1ed3e5d60b14b57df2a36fb2273f2/coverage-7.10.5-py3-none-any.whl", hash = "sha256:0be24d35e4db1d23d0db5c0f6a74a962e2ec83c426b5cac09f4234aadef38e4a", size = 208736, upload-time = "2025-08-23T14:42:43.145Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "cryptography" +version = "45.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d6/0d/d13399c94234ee8f3df384819dc67e0c5ce215fb751d567a55a1f4b028c7/cryptography-45.0.6.tar.gz", hash = "sha256:5c966c732cf6e4a276ce83b6e4c729edda2df6929083a952cc7da973c539c719", size = 744949, upload-time = "2025-08-05T23:59:27.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/b6/cabd07410f222f32c8d55486c464f432808abaa1f12af9afcbe8f2f19030/cryptography-45.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:44647c5d796f5fc042bbc6d61307d04bf29bccb74d188f18051b635f20a9c75f", size = 4206483, upload-time = "2025-08-05T23:58:27.132Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9e/f9c7d36a38b1cfeb1cc74849aabe9bf817990f7603ff6eb485e0d70e0b27/cryptography-45.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e40b80ecf35ec265c452eea0ba94c9587ca763e739b8e559c128d23bff7ebbbf", size = 4429679, upload-time = "2025-08-05T23:58:29.152Z" }, + { url = "https://files.pythonhosted.org/packages/9c/2a/4434c17eb32ef30b254b9e8b9830cee4e516f08b47fdd291c5b1255b8101/cryptography-45.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:00e8724bdad672d75e6f069b27970883179bd472cd24a63f6e620ca7e41cc0c5", size = 4210553, upload-time = "2025-08-05T23:58:30.596Z" }, + { url = "https://files.pythonhosted.org/packages/ef/1d/09a5df8e0c4b7970f5d1f3aff1b640df6d4be28a64cae970d56c6cf1c772/cryptography-45.0.6-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a3085d1b319d35296176af31c90338eeb2ddac8104661df79f80e1d9787b8b2", size = 3894499, upload-time = "2025-08-05T23:58:32.03Z" }, + { url = "https://files.pythonhosted.org/packages/79/62/120842ab20d9150a9d3a6bdc07fe2870384e82f5266d41c53b08a3a96b34/cryptography-45.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1b7fa6a1c1188c7ee32e47590d16a5a0646270921f8020efc9a511648e1b2e08", size = 4458484, upload-time = "2025-08-05T23:58:33.526Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/1bc3634d45ddfed0871bfba52cf8f1ad724761662a0c792b97a951fb1b30/cryptography-45.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:275ba5cc0d9e320cd70f8e7b96d9e59903c815ca579ab96c1e37278d231fc402", size = 4210281, upload-time = "2025-08-05T23:58:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fe/ffb12c2d83d0ee625f124880a1f023b5878f79da92e64c37962bbbe35f3f/cryptography-45.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f4028f29a9f38a2025abedb2e409973709c660d44319c61762202206ed577c42", size = 4456890, upload-time = "2025-08-05T23:58:36.923Z" }, + { url = "https://files.pythonhosted.org/packages/8c/8e/b3f3fe0dc82c77a0deb5f493b23311e09193f2268b77196ec0f7a36e3f3e/cryptography-45.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ee411a1b977f40bd075392c80c10b58025ee5c6b47a822a33c1198598a7a5f05", size = 4333247, upload-time = "2025-08-05T23:58:38.781Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a6/c3ef2ab9e334da27a1d7b56af4a2417d77e7806b2e0f90d6267ce120d2e4/cryptography-45.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e2a21a8eda2d86bb604934b6b37691585bd095c1f788530c1fcefc53a82b3453", size = 4565045, upload-time = "2025-08-05T23:58:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/98/c6/ea5173689e014f1a8470899cd5beeb358e22bb3cf5a876060f9d1ca78af4/cryptography-45.0.6-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0d9ef57b6768d9fa58e92f4947cea96ade1233c0e236db22ba44748ffedca394", size = 4198169, upload-time = "2025-08-05T23:58:47.121Z" }, + { url = "https://files.pythonhosted.org/packages/ba/73/b12995edc0c7e2311ffb57ebd3b351f6b268fed37d93bfc6f9856e01c473/cryptography-45.0.6-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ea3c42f2016a5bbf71825537c2ad753f2870191134933196bee408aac397b3d9", size = 4421273, upload-time = "2025-08-05T23:58:48.557Z" }, + { url = "https://files.pythonhosted.org/packages/f7/6e/286894f6f71926bc0da67408c853dd9ba953f662dcb70993a59fd499f111/cryptography-45.0.6-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:20ae4906a13716139d6d762ceb3e0e7e110f7955f3bc3876e3a07f5daadec5f3", size = 4199211, upload-time = "2025-08-05T23:58:50.139Z" }, + { url = "https://files.pythonhosted.org/packages/de/34/a7f55e39b9623c5cb571d77a6a90387fe557908ffc44f6872f26ca8ae270/cryptography-45.0.6-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dac5ec199038b8e131365e2324c03d20e97fe214af051d20c49db129844e8b3", size = 3883732, upload-time = "2025-08-05T23:58:52.253Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b9/c6d32edbcba0cd9f5df90f29ed46a65c4631c4fbe11187feb9169c6ff506/cryptography-45.0.6-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:18f878a34b90d688982e43f4b700408b478102dd58b3e39de21b5ebf6509c301", size = 4450655, upload-time = "2025-08-05T23:58:53.848Z" }, + { url = "https://files.pythonhosted.org/packages/77/2d/09b097adfdee0227cfd4c699b3375a842080f065bab9014248933497c3f9/cryptography-45.0.6-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5bd6020c80c5b2b2242d6c48487d7b85700f5e0038e67b29d706f98440d66eb5", size = 4198956, upload-time = "2025-08-05T23:58:55.209Z" }, + { url = "https://files.pythonhosted.org/packages/55/66/061ec6689207d54effdff535bbdf85cc380d32dd5377173085812565cf38/cryptography-45.0.6-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:eccddbd986e43014263eda489abbddfbc287af5cddfd690477993dbb31e31016", size = 4449859, upload-time = "2025-08-05T23:58:56.639Z" }, + { url = "https://files.pythonhosted.org/packages/41/ff/e7d5a2ad2d035e5a2af116e1a3adb4d8fcd0be92a18032917a089c6e5028/cryptography-45.0.6-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:550ae02148206beb722cfe4ef0933f9352bab26b087af00e48fdfb9ade35c5b3", size = 4320254, upload-time = "2025-08-05T23:58:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/82/27/092d311af22095d288f4db89fcaebadfb2f28944f3d790a4cf51fe5ddaeb/cryptography-45.0.6-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5b64e668fc3528e77efa51ca70fadcd6610e8ab231e3e06ae2bab3b31c2b8ed9", size = 4554815, upload-time = "2025-08-05T23:59:00.283Z" }, + { url = "https://files.pythonhosted.org/packages/ec/24/55fc238fcaa122855442604b8badb2d442367dfbd5a7ca4bb0bd346e263a/cryptography-45.0.6-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:826b46dae41a1155a0c0e66fafba43d0ede1dc16570b95e40c4d83bfcf0a451d", size = 4141694, upload-time = "2025-08-05T23:59:06.66Z" }, + { url = "https://files.pythonhosted.org/packages/f9/7e/3ea4fa6fbe51baf3903806a0241c666b04c73d2358a3ecce09ebee8b9622/cryptography-45.0.6-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:cc4d66f5dc4dc37b89cfef1bd5044387f7a1f6f0abb490815628501909332d5d", size = 4375010, upload-time = "2025-08-05T23:59:08.14Z" }, + { url = "https://files.pythonhosted.org/packages/50/42/ec5a892d82d2a2c29f80fc19ced4ba669bca29f032faf6989609cff1f8dc/cryptography-45.0.6-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:f68f833a9d445cc49f01097d95c83a850795921b3f7cc6488731e69bde3288da", size = 4141377, upload-time = "2025-08-05T23:59:09.584Z" }, + { url = "https://files.pythonhosted.org/packages/e7/d7/246c4c973a22b9c2931999da953a2c19cae7c66b9154c2d62ffed811225e/cryptography-45.0.6-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3b5bf5267e98661b9b888a9250d05b063220dfa917a8203744454573c7eb79db", size = 4374609, upload-time = "2025-08-05T23:59:11.923Z" }, + { url = "https://files.pythonhosted.org/packages/e3/fe/deea71e9f310a31fe0a6bfee670955152128d309ea2d1c79e2a5ae0f0401/cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:3de77e4df42ac8d4e4d6cdb342d989803ad37707cf8f3fbf7b088c9cbdd46427", size = 4153022, upload-time = "2025-08-05T23:59:16.954Z" }, + { url = "https://files.pythonhosted.org/packages/60/45/a77452f5e49cb580feedba6606d66ae7b82c128947aa754533b3d1bd44b0/cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:599c8d7df950aa68baa7e98f7b73f4f414c9f02d0e8104a30c0182a07732638b", size = 4386802, upload-time = "2025-08-05T23:59:18.55Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b9/a2f747d2acd5e3075fdf5c145c7c3568895daaa38b3b0c960ef830db6cdc/cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:31a2b9a10530a1cb04ffd6aa1cd4d3be9ed49f7d77a4dafe198f3b382f41545c", size = 4152706, upload-time = "2025-08-05T23:59:20.044Z" }, + { url = "https://files.pythonhosted.org/packages/81/ec/381b3e8d0685a3f3f304a382aa3dfce36af2d76467da0fd4bb21ddccc7b2/cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:e5b3dda1b00fb41da3af4c5ef3f922a200e33ee5ba0f0bc9ecf0b0c173958385", size = 4386740, upload-time = "2025-08-05T23:59:21.525Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "docopt" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/55/8f8cab2afd404cf578136ef2cc5dfb50baa1761b68c9da1fb1e4eed343c9/docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491", size = 25901, upload-time = "2014-06-16T11:18:57.406Z" } + +[[package]] +name = "docutils" +version = "0.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/86/5b41c32ecedcfdb4c77b28b6cb14234f252075f8cdb254531727a35547dd/docutils-0.22.tar.gz", hash = "sha256:ba9d57750e92331ebe7c08a1bbf7a7f8143b86c476acd51528b042216a6aad0f", size = 2277984, upload-time = "2025-07-29T15:20:31.06Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/57/8db39bc5f98f042e0153b1de9fb88e1a409a33cda4dd7f723c2ed71e01f6/docutils-0.22-py3-none-any.whl", hash = "sha256:4ed966a0e96a0477d852f7af31bdcb3adc049fbb35ccba358c2ea8a03287615e", size = 630709, upload-time = "2025-07-29T15:20:28.335Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, +] + +[[package]] +name = "filelock" +version = "3.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/bb/0ab3e58d22305b6f5440629d20683af28959bf793d98d11950e305c1c326/filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58", size = 17687, upload-time = "2025-08-14T16:56:03.016Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" }, +] + +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.45" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/c8/dd58967d119baab745caec2f9d853297cec1989ec1d63f677d3880632b88/gitpython-3.1.45.tar.gz", hash = "sha256:85b0ee964ceddf211c41b9f27a49086010a190fd8132a24e21f362a4b36a791c", size = 215076, upload-time = "2025-07-24T03:45:54.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/61/d4b89fec821f72385526e1b9d9a3a0385dda4a72b206d28049e2c7cd39b8/gitpython-3.1.45-py3-none-any.whl", hash = "sha256:8908cb2e02fb3b93b7eb0f2827125cb699869470432cc885f019b8fd0fccff77", size = 208168, upload-time = "2025-07-24T03:45:52.517Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "hatch" +version = "1.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "hatchling" }, + { name = "httpx" }, + { name = "hyperlink" }, + { name = "keyring" }, + { name = "packaging" }, + { name = "pexpect" }, + { name = "platformdirs" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "tomli-w" }, + { name = "tomlkit" }, + { name = "userpath" }, + { name = "uv" }, + { name = "virtualenv" }, + { name = "zstandard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/43/c0b37db0e857a44ce5ffdb7e8a9b8fa6425d0b74dea698fafcd9bddb50d1/hatch-1.14.1.tar.gz", hash = "sha256:ca1aff788f8596b0dd1f8f8dfe776443d2724a86b1976fabaf087406ba3d0713", size = 5188180, upload-time = "2025-04-07T04:16:04.522Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/40/19c0935bf9f25808541a0e3144ac459de696c5b6b6d4511a98d456c69604/hatch-1.14.1-py3-none-any.whl", hash = "sha256:39cdaa59e47ce0c5505d88a951f4324a9c5aafa17e4a877e2fde79b36ab66c21", size = 125770, upload-time = "2025-04-07T04:16:02.525Z" }, +] + +[[package]] +name = "hatchling" +version = "1.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pathspec" }, + { name = "pluggy" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "trove-classifiers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/8a/cc1debe3514da292094f1c3a700e4ca25442489731ef7c0814358816bb03/hatchling-1.27.0.tar.gz", hash = "sha256:971c296d9819abb3811112fc52c7a9751c8d381898f36533bb16f9791e941fd6", size = 54983, upload-time = "2024-12-15T17:08:11.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/e7/ae38d7a6dfba0533684e0b2136817d667588ae3ec984c1a4e5df5eb88482/hatchling-1.27.0-py3-none-any.whl", hash = "sha256:d3a2f3567c4f926ea39849cdf924c7e99e6686c9c8e288ae1037c8fa2a5d937b", size = 75794, upload-time = "2024-12-15T17:08:10.364Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "hyperlink" +version = "21.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/51/1947bd81d75af87e3bb9e34593a4cf118115a8feb451ce7a69044ef1412e/hyperlink-21.0.0.tar.gz", hash = "sha256:427af957daa58bc909471c6c40f74c5450fa123dd093fc53efd2e91d2705a56b", size = 140743, upload-time = "2021-01-08T05:51:20.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/aa/8caf6a0a3e62863cbb9dab27135660acba46903b703e224f14f447e57934/hyperlink-21.0.0-py2.py3-none-any.whl", hash = "sha256:e6b14c37ecb73e89c77d78cdb4c2cc8f3fb59a885c5b3f819ff4ed80f25af1b4", size = 74638, upload-time = "2021-01-08T05:51:22.906Z" }, +] + +[[package]] +name = "id" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/11/102da08f88412d875fa2f1a9a469ff7ad4c874b0ca6fed0048fe385bdb3d/id-1.5.0.tar.gz", hash = "sha256:292cb8a49eacbbdbce97244f47a97b4c62540169c976552e497fd57df0734c1d", size = 15237, upload-time = "2024-12-04T19:53:05.575Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/cb/18326d2d89ad3b0dd143da971e77afd1e6ca6674f1b1c3df4b6bec6279fc/id-1.5.0-py3-none-any.whl", hash = "sha256:f1434e1cef91f2cbb8a4ec64663d5a23b9ed43ef44c4c957d02583d61714c658", size = 13611, upload-time = "2024-12-04T19:53:03.02Z" }, +] + +[[package]] +name = "identify" +version = "2.6.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ca/ffbabe3635bb839aa36b3a893c91a9b0d368cb4d8073e03a12896970af82/identify-2.6.13.tar.gz", hash = "sha256:da8d6c828e773620e13bfa86ea601c5a5310ba4bcd65edf378198b56a1f9fb32", size = 99243, upload-time = "2025-08-09T19:35:00.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/ce/461b60a3ee109518c055953729bf9ed089a04db895d47e95444071dcdef2/identify-2.6.13-py2.py3-none-any.whl", hash = "sha256:60381139b3ae39447482ecc406944190f690d4a2997f2584062089848361b33b", size = 99153, upload-time = "2025-08-09T19:34:59.1Z" }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/ad/f3777b81bf0b6e7bc7514a1656d3e637b2e8e15fab2ce3235730b3e7a4e6/jaraco_context-6.0.1.tar.gz", hash = "sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3", size = 13912, upload-time = "2024-08-20T03:39:27.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/db/0c52c4cf5e4bd9f5d7135ec7669a3a767af21b3a308e1ed3674881e52b62/jaraco.context-6.0.1-py3-none-any.whl", hash = "sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4", size = 6825, upload-time = "2024-08-20T03:39:25.966Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/ed/1aa2d585304ec07262e1a83a9889880701079dde796ac7b1d1826f40c63d/jaraco_functools-4.3.0.tar.gz", hash = "sha256:cfd13ad0dd2c47a3600b439ef72d8615d482cedcff1632930d6f28924d92f294", size = 19755, upload-time = "2025-08-18T20:05:09.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/09/726f168acad366b11e420df31bf1c702a54d373a83f968d94141a8c3fde0/jaraco_functools-4.3.0-py3-none-any.whl", hash = "sha256:227ff8ed6f7b8f62c56deff101545fa7543cf2c8e7b82a7c2116e672f29c26e8", size = 10408, upload-time = "2025-08-18T20:05:08.69Z" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + +[[package]] +name = "keyring" +version = "25.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/09/d904a6e96f76ff214be59e7aa6ef7190008f52a0ab6689760a98de0bf37d/keyring-25.6.0.tar.gz", hash = "sha256:0b39998aa941431eb3d9b0d4b2460bc773b9df6fed7621c2dfb291a7e0187a66", size = 62750, upload-time = "2024-12-25T15:26:45.782Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/32/da7f44bcb1105d3e88a0b74ebdca50c59121d2ddf71c9e34ba47df7f3a56/keyring-25.6.0-py3-none-any.whl", hash = "sha256:552a3f7af126ece7ed5c89753650eec89c7eaae8617d0aa4d9ad2b75111266bd", size = 39085, upload-time = "2024-12-25T15:26:44.377Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mdutils" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/28/4b/df40d441a280f20fa173f224577fbbb2477851a73736080c89a61ff8a4dd/mdutils-1.8.0.tar.gz", hash = "sha256:091b605b4b550465a304f1c66d37647b6008b4a19e7b5154a7d39a148e903f6d", size = 23909, upload-time = "2025-07-10T19:09:41.194Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/6b/d4c6e92daf6123696797e9914e4a3d36e2ba24a2ff1aad00c2d6d2afb82d/mdutils-1.8.0-py3-none-any.whl", hash = "sha256:0d3cf9af4a958f3bbd184c1097985bb1d1f9489887e1f7329942ada14359c7ba", size = 21647, upload-time = "2025-07-10T19:09:40.063Z" }, +] + +[[package]] +name = "more-itertools" +version = "10.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/a0/834b0cebabbfc7e311f30b46c8188790a37f89fc8d756660346fe5abfd09/more_itertools-10.7.0.tar.gz", hash = "sha256:9fddd5403be01a94b204faadcff459ec3568cf110265d3c54323e1e866ad29d3", size = 127671, upload-time = "2025-04-22T14:17:41.838Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/9f/7ba6f94fc1e9ac3d2b853fdff3035fb2fa5afbed898c4a72b8a020610594/more_itertools-10.7.0-py3-none-any.whl", hash = "sha256:d43980384673cb07d2f7d2d918c616b30c659c089ee23953f601d6609c67510e", size = 65278, upload-time = "2025-04-22T14:17:40.49Z" }, +] + +[[package]] +name = "nh3" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/a4/96cff0977357f60f06ec4368c4c7a7a26cccfe7c9fcd54f5378bf0428fd3/nh3-0.3.0.tar.gz", hash = "sha256:d8ba24cb31525492ea71b6aac11a4adac91d828aadeff7c4586541bf5dc34d2f", size = 19655, upload-time = "2025-07-17T14:43:37.05Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/11/340b7a551916a4b2b68c54799d710f86cf3838a4abaad8e74d35360343bb/nh3-0.3.0-cp313-cp313t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:a537ece1bf513e5a88d8cff8a872e12fe8d0f42ef71dd15a5e7520fecd191bbb", size = 1427992, upload-time = "2025-07-17T14:43:06.848Z" }, + { url = "https://files.pythonhosted.org/packages/ad/7f/7c6b8358cf1222921747844ab0eef81129e9970b952fcb814df417159fb9/nh3-0.3.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7c915060a2c8131bef6a29f78debc29ba40859b6dbe2362ef9e5fd44f11487c2", size = 798194, upload-time = "2025-07-17T14:43:08.263Z" }, + { url = "https://files.pythonhosted.org/packages/63/da/c5fd472b700ba37d2df630a9e0d8cc156033551ceb8b4c49cc8a5f606b68/nh3-0.3.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba0caa8aa184196daa6e574d997a33867d6d10234018012d35f86d46024a2a95", size = 837884, upload-time = "2025-07-17T14:43:09.233Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3c/cba7b26ccc0ef150c81646478aa32f9c9535234f54845603c838a1dc955c/nh3-0.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:80fe20171c6da69c7978ecba33b638e951b85fb92059259edd285ff108b82a6d", size = 996365, upload-time = "2025-07-17T14:43:10.243Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ba/59e204d90727c25b253856e456ea61265ca810cda8ee802c35f3fadaab00/nh3-0.3.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:e90883f9f85288f423c77b3f5a6f4486375636f25f793165112679a7b6363b35", size = 1071042, upload-time = "2025-07-17T14:43:11.57Z" }, + { url = "https://files.pythonhosted.org/packages/10/71/2fb1834c10fab6d9291d62c95192ea2f4c7518bd32ad6c46aab5d095cb87/nh3-0.3.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0649464ac8eee018644aacbc103874ccbfac80e3035643c3acaab4287e36e7f5", size = 995737, upload-time = "2025-07-17T14:43:12.659Z" }, + { url = "https://files.pythonhosted.org/packages/33/c1/8f8ccc2492a000b6156dce68a43253fcff8b4ce70ab4216d08f90a2ac998/nh3-0.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1adeb1062a1c2974bc75b8d1ecb014c5fd4daf2df646bbe2831f7c23659793f9", size = 980552, upload-time = "2025-07-17T14:43:13.763Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d6/f1c6e091cbe8700401c736c2bc3980c46dca770a2cf6a3b48a175114058e/nh3-0.3.0-cp313-cp313t-win32.whl", hash = "sha256:7275fdffaab10cc5801bf026e3c089d8de40a997afc9e41b981f7ac48c5aa7d5", size = 593618, upload-time = "2025-07-17T14:43:15.098Z" }, + { url = "https://files.pythonhosted.org/packages/23/1e/80a8c517655dd40bb13363fc4d9e66b2f13245763faab1a20f1df67165a7/nh3-0.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:423201bbdf3164a9e09aa01e540adbb94c9962cc177d5b1cbb385f5e1e79216e", size = 598948, upload-time = "2025-07-17T14:43:16.064Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e0/af86d2a974c87a4ba7f19bc3b44a8eaa3da480de264138fec82fe17b340b/nh3-0.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:16f8670201f7e8e0e05ed1a590eb84bfa51b01a69dd5caf1d3ea57733de6a52f", size = 580479, upload-time = "2025-07-17T14:43:17.038Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e0/cf1543e798ba86d838952e8be4cb8d18e22999be2a24b112a671f1c04fd6/nh3-0.3.0-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:ec6cfdd2e0399cb79ba4dcffb2332b94d9696c52272ff9d48a630c5dca5e325a", size = 1442218, upload-time = "2025-07-17T14:43:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/5c/86/a96b1453c107b815f9ab8fac5412407c33cc5c7580a4daf57aabeb41b774/nh3-0.3.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5e7185599f89b0e391e2f29cc12dc2e206167380cea49b33beda4891be2fe1", size = 823791, upload-time = "2025-07-17T14:43:19.721Z" }, + { url = "https://files.pythonhosted.org/packages/97/33/11e7273b663839626f714cb68f6eb49899da5a0d9b6bc47b41fe870259c2/nh3-0.3.0-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:389d93d59b8214d51c400fb5b07866c2a4f79e4e14b071ad66c92184fec3a392", size = 811143, upload-time = "2025-07-17T14:43:20.779Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1b/b15bd1ce201a1a610aeb44afd478d55ac018b4475920a3118ffd806e2483/nh3-0.3.0-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:e9e6a7e4d38f7e8dda9edd1433af5170c597336c1a74b4693c5cb75ab2b30f2a", size = 1064661, upload-time = "2025-07-17T14:43:21.839Z" }, + { url = "https://files.pythonhosted.org/packages/8f/14/079670fb2e848c4ba2476c5a7a2d1319826053f4f0368f61fca9bb4227ae/nh3-0.3.0-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7852f038a054e0096dac12b8141191e02e93e0b4608c4b993ec7d4ffafea4e49", size = 997061, upload-time = "2025-07-17T14:43:23.179Z" }, + { url = "https://files.pythonhosted.org/packages/a3/e5/ac7fc565f5d8bce7f979d1afd68e8cb415020d62fa6507133281c7d49f91/nh3-0.3.0-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af5aa8127f62bbf03d68f67a956627b1bd0469703a35b3dad28d0c1195e6c7fb", size = 924761, upload-time = "2025-07-17T14:43:24.23Z" }, + { url = "https://files.pythonhosted.org/packages/39/2c/6394301428b2017a9d5644af25f487fa557d06bc8a491769accec7524d9a/nh3-0.3.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f416c35efee3e6a6c9ab7716d9e57aa0a49981be915963a82697952cba1353e1", size = 803959, upload-time = "2025-07-17T14:43:26.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/9a/344b9f9c4bd1c2413a397f38ee6a3d5db30f1a507d4976e046226f12b297/nh3-0.3.0-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37d3003d98dedca6cd762bf88f2e70b67f05100f6b949ffe540e189cc06887f9", size = 844073, upload-time = "2025-07-17T14:43:27.375Z" }, + { url = "https://files.pythonhosted.org/packages/66/3f/cd37f76c8ca277b02a84aa20d7bd60fbac85b4e2cbdae77cb759b22de58b/nh3-0.3.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:634e34e6162e0408e14fb61d5e69dbaea32f59e847cfcfa41b66100a6b796f62", size = 1000680, upload-time = "2025-07-17T14:43:28.452Z" }, + { url = "https://files.pythonhosted.org/packages/ee/db/7aa11b44bae4e7474feb1201d8dee04fabe5651c7cb51409ebda94a4ed67/nh3-0.3.0-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:b0612ccf5de8a480cf08f047b08f9d3fecc12e63d2ee91769cb19d7290614c23", size = 1076613, upload-time = "2025-07-17T14:43:30.031Z" }, + { url = "https://files.pythonhosted.org/packages/97/03/03f79f7e5178eb1ad5083af84faff471e866801beb980cc72943a4397368/nh3-0.3.0-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:c7a32a7f0d89f7d30cb8f4a84bdbd56d1eb88b78a2434534f62c71dac538c450", size = 1001418, upload-time = "2025-07-17T14:43:31.429Z" }, + { url = "https://files.pythonhosted.org/packages/ce/55/1974bcc16884a397ee699cebd3914e1f59be64ab305533347ca2d983756f/nh3-0.3.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3f1b4f8a264a0c86ea01da0d0c390fe295ea0bcacc52c2103aca286f6884f518", size = 986499, upload-time = "2025-07-17T14:43:32.459Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/76936ec021fe1f3270c03278b8af5f2079038116b5d0bfe8538ffe699d69/nh3-0.3.0-cp38-abi3-win32.whl", hash = "sha256:6d68fa277b4a3cf04e5c4b84dd0c6149ff7d56c12b3e3fab304c525b850f613d", size = 599000, upload-time = "2025-07-17T14:43:33.852Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ae/324b165d904dc1672eee5f5661c0a68d4bab5b59fbb07afb6d8d19a30b45/nh3-0.3.0-cp38-abi3-win_amd64.whl", hash = "sha256:bae63772408fd63ad836ec569a7c8f444dd32863d0c67f6e0b25ebbd606afa95", size = 604530, upload-time = "2025-07-17T14:43:34.95Z" }, + { url = "https://files.pythonhosted.org/packages/5b/76/3165e84e5266d146d967a6cc784ff2fbf6ddd00985a55ec006b72bc39d5d/nh3-0.3.0-cp38-abi3-win_arm64.whl", hash = "sha256:d97d3efd61404af7e5721a0e74d81cdbfc6e5f97e11e731bb6d090e30a7b62b2", size = 585971, upload-time = "2025-07-17T14:43:35.936Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +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 = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.3.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362, upload-time = "2025-05-07T22:47:42.121Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" }, +] + +[[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 = "pre-commit" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/29/7cf5bbc236333876e4b41f56e06857a87937ce4bf91e117a6991a2dbb02a/pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16", size = 193792, upload-time = "2025-08-09T18:56:14.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965, upload-time = "2025-08-09T18:56:13.192Z" }, +] + +[[package]] +name = "prettytable" +version = "3.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/b1/85e18ac92afd08c533603e3393977b6bc1443043115a47bb094f3b98f94f/prettytable-3.16.0.tar.gz", hash = "sha256:3c64b31719d961bf69c9a7e03d0c1e477320906a98da63952bc6698d6164ff57", size = 66276, upload-time = "2025-03-24T19:39:04.008Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/c7/5613524e606ea1688b3bdbf48aa64bafb6d0a4ac3750274c43b6158a390f/prettytable-3.16.0-py3-none-any.whl", hash = "sha256:b5eccfabb82222f5aa46b798ff02a8452cf530a352c31bddfa29be41242863aa", size = 33863, upload-time = "2025-03-24T19:39:02.359Z" }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, +] + +[[package]] +name = "pycparser" +version = "2.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736, upload-time = "2024-03-30T13:22:22.564Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.1" +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/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/51/f8794af39eeb870e87a8c8068642fc07bce0c854d6865d7dd0f2a9d338c2/pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea", size = 46652, upload-time = "2025-07-16T04:29:26.393Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/9d/bf86eddabf8c6c9cb1ea9a869d6873b46f105a5d292d3a6f7071f5b07935/pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf", size = 15157, upload-time = "2025-07-16T04:29:24.929Z" }, +] + +[[package]] +name = "pytest-cov" +version = "6.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/99/668cade231f434aaa59bbfbf49469068d2ddd945000621d3d165d2e7dd7b/pytest_cov-6.2.1.tar.gz", hash = "sha256:25cc6cc0a5358204b8108ecedc51a9b57b34cc6b8c967cc2c01a4e00d8a67da2", size = 69432, upload-time = "2025-06-12T10:47:47.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/16/4ea354101abb1287856baa4af2732be351c7bee728065aed451b678153fd/pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5", size = 24644, upload-time = "2025-06-12T10:47:45.932Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/28/67172c96ba684058a4d24ffe144d64783d2a270d0af0d9e792737bddc75c/pytest_mock-3.14.1.tar.gz", hash = "sha256:159e9edac4c451ce77a5cdb9fc5d1100708d2dd4ba3c3df572f14097351af80e", size = 33241, upload-time = "2025-05-26T13:58:45.167Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/05/77b60e520511c53d1c1ca75f1930c7dd8e971d0c4379b7f4b3f9644685ba/pytest_mock-3.14.1-py3-none-any.whl", hash = "sha256:178aefcd11307d874b4cd3100344e7e2d888d9791a6a1d9bfe90fbc1b74fd1d0", size = 9923, upload-time = "2025-05-26T13:58:43.487Z" }, +] + +[[package]] +name = "pytest-watch" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama" }, + { name = "docopt" }, + { name = "pytest" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/47/ab65fc1d682befc318c439940f81a0de1026048479f732e84fe714cd69c0/pytest-watch-4.2.0.tar.gz", hash = "sha256:06136f03d5b361718b8d0d234042f7b2f203910d8568f63df2f866b547b3d4b9", size = 16340, upload-time = "2018-05-20T19:52:16.194Z" } + +[[package]] +name = "python-dotenv" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199, upload-time = "2024-08-06T20:31:40.178Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758, upload-time = "2024-08-06T20:31:42.173Z" }, + { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463, upload-time = "2024-08-06T20:31:44.263Z" }, + { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280, upload-time = "2024-08-06T20:31:50.199Z" }, + { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239, upload-time = "2024-08-06T20:31:52.292Z" }, + { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802, upload-time = "2024-08-06T20:31:53.836Z" }, + { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527, upload-time = "2024-08-06T20:31:55.565Z" }, + { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052, upload-time = "2024-08-06T20:31:56.914Z" }, + { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774, upload-time = "2024-08-06T20:31:58.304Z" }, + { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612, upload-time = "2024-08-06T20:32:03.408Z" }, + { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040, upload-time = "2024-08-06T20:32:04.926Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829, upload-time = "2024-08-06T20:32:06.459Z" }, + { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167, upload-time = "2024-08-06T20:32:08.338Z" }, + { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952, upload-time = "2024-08-06T20:32:14.124Z" }, + { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301, upload-time = "2024-08-06T20:32:16.17Z" }, + { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638, upload-time = "2024-08-06T20:32:18.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850, upload-time = "2024-08-06T20:32:19.889Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980, upload-time = "2024-08-06T20:32:21.273Z" }, + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, +] + +[[package]] +name = "readme-renderer" +version = "44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "nh3" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/a9/104ec9234c8448c4379768221ea6df01260cd6c2ce13182d4eac531c8342/readme_renderer-44.0.tar.gz", hash = "sha256:8712034eabbfa6805cacf1402b4eeb2a73028f72d1166d6f5cb7f9c047c5d1e1", size = 32056, upload-time = "2024-07-08T15:00:57.805Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/67/921ec3024056483db83953ae8e48079ad62b92db7880013ca77632921dd0/readme_renderer-44.0-py3-none-any.whl", hash = "sha256:2fbca89b81a08526aadf1357a8c2ae889ec05fb03f5da67f9769c9a592166151", size = 13310, upload-time = "2024-07-08T15:00:56.577Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, +] + +[[package]] +name = "rfc3986" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/40/1520d68bfa07ab5a6f065a186815fb6610c86fe957bc065754e47f7b0840/rfc3986-2.0.0.tar.gz", hash = "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c", size = 49026, upload-time = "2022-01-10T00:52:30.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl", hash = "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", size = 31326, upload-time = "2022-01-10T00:52:29.594Z" }, +] + +[[package]] +name = "rich" +version = "14.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/75/af448d8e52bf1d8fa6a9d089ca6c07ff4453d86c65c145d0a300bb073b9b/rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8", size = 224441, upload-time = "2025-07-25T07:32:58.125Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f", size = 243368, upload-time = "2025-07-25T07:32:56.73Z" }, +] + +[[package]] +name = "ruff" +version = "0.12.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/eb/8c073deb376e46ae767f4961390d17545e8535921d2f65101720ed8bd434/ruff-0.12.10.tar.gz", hash = "sha256:189ab65149d11ea69a2d775343adf5f49bb2426fc4780f65ee33b423ad2e47f9", size = 5310076, upload-time = "2025-08-21T18:23:22.595Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/e7/560d049d15585d6c201f9eeacd2fd130def3741323e5ccf123786e0e3c95/ruff-0.12.10-py3-none-linux_armv6l.whl", hash = "sha256:8b593cb0fb55cc8692dac7b06deb29afda78c721c7ccfed22db941201b7b8f7b", size = 11935161, upload-time = "2025-08-21T18:22:26.965Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b0/ad2464922a1113c365d12b8f80ed70fcfb39764288ac77c995156080488d/ruff-0.12.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ebb7333a45d56efc7c110a46a69a1b32365d5c5161e7244aaf3aa20ce62399c1", size = 12660884, upload-time = "2025-08-21T18:22:30.925Z" }, + { url = "https://files.pythonhosted.org/packages/d7/f1/97f509b4108d7bae16c48389f54f005b62ce86712120fd8b2d8e88a7cb49/ruff-0.12.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d59e58586829f8e4a9920788f6efba97a13d1fa320b047814e8afede381c6839", size = 11872754, upload-time = "2025-08-21T18:22:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/12/ad/44f606d243f744a75adc432275217296095101f83f966842063d78eee2d3/ruff-0.12.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:822d9677b560f1fdeab69b89d1f444bf5459da4aa04e06e766cf0121771ab844", size = 12092276, upload-time = "2025-08-21T18:22:36.764Z" }, + { url = "https://files.pythonhosted.org/packages/06/1f/ed6c265e199568010197909b25c896d66e4ef2c5e1c3808caf461f6f3579/ruff-0.12.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:37b4a64f4062a50c75019c61c7017ff598cb444984b638511f48539d3a1c98db", size = 11734700, upload-time = "2025-08-21T18:22:39.822Z" }, + { url = "https://files.pythonhosted.org/packages/63/c5/b21cde720f54a1d1db71538c0bc9b73dee4b563a7dd7d2e404914904d7f5/ruff-0.12.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2c6f4064c69d2542029b2a61d39920c85240c39837599d7f2e32e80d36401d6e", size = 13468783, upload-time = "2025-08-21T18:22:42.559Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/39369e6ac7f2a1848f22fb0b00b690492f20811a1ac5c1fd1d2798329263/ruff-0.12.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:059e863ea3a9ade41407ad71c1de2badfbe01539117f38f763ba42a1206f7559", size = 14436642, upload-time = "2025-08-21T18:22:45.612Z" }, + { url = "https://files.pythonhosted.org/packages/e3/03/5da8cad4b0d5242a936eb203b58318016db44f5c5d351b07e3f5e211bb89/ruff-0.12.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bef6161e297c68908b7218fa6e0e93e99a286e5ed9653d4be71e687dff101cf", size = 13859107, upload-time = "2025-08-21T18:22:48.886Z" }, + { url = "https://files.pythonhosted.org/packages/19/19/dd7273b69bf7f93a070c9cec9494a94048325ad18fdcf50114f07e6bf417/ruff-0.12.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4f1345fbf8fb0531cd722285b5f15af49b2932742fc96b633e883da8d841896b", size = 12886521, upload-time = "2025-08-21T18:22:51.567Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1d/b4207ec35e7babaee62c462769e77457e26eb853fbdc877af29417033333/ruff-0.12.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f68433c4fbc63efbfa3ba5db31727db229fa4e61000f452c540474b03de52a9", size = 13097528, upload-time = "2025-08-21T18:22:54.609Z" }, + { url = "https://files.pythonhosted.org/packages/ff/00/58f7b873b21114456e880b75176af3490d7a2836033779ca42f50de3b47a/ruff-0.12.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:141ce3d88803c625257b8a6debf4a0473eb6eed9643a6189b68838b43e78165a", size = 13080443, upload-time = "2025-08-21T18:22:57.413Z" }, + { url = "https://files.pythonhosted.org/packages/12/8c/9e6660007fb10189ccb78a02b41691288038e51e4788bf49b0a60f740604/ruff-0.12.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f3fc21178cd44c98142ae7590f42ddcb587b8e09a3b849cbc84edb62ee95de60", size = 11896759, upload-time = "2025-08-21T18:23:00.473Z" }, + { url = "https://files.pythonhosted.org/packages/67/4c/6d092bb99ea9ea6ebda817a0e7ad886f42a58b4501a7e27cd97371d0ba54/ruff-0.12.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7d1a4e0bdfafcd2e3e235ecf50bf0176f74dd37902f241588ae1f6c827a36c56", size = 11701463, upload-time = "2025-08-21T18:23:03.211Z" }, + { url = "https://files.pythonhosted.org/packages/59/80/d982c55e91df981f3ab62559371380616c57ffd0172d96850280c2b04fa8/ruff-0.12.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e67d96827854f50b9e3e8327b031647e7bcc090dbe7bb11101a81a3a2cbf1cc9", size = 12691603, upload-time = "2025-08-21T18:23:06.935Z" }, + { url = "https://files.pythonhosted.org/packages/ad/37/63a9c788bbe0b0850611669ec6b8589838faf2f4f959647f2d3e320383ae/ruff-0.12.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ae479e1a18b439c59138f066ae79cc0f3ee250712a873d00dbafadaad9481e5b", size = 13164356, upload-time = "2025-08-21T18:23:10.225Z" }, + { url = "https://files.pythonhosted.org/packages/47/d4/1aaa7fb201a74181989970ebccd12f88c0fc074777027e2a21de5a90657e/ruff-0.12.10-py3-none-win32.whl", hash = "sha256:9de785e95dc2f09846c5e6e1d3a3d32ecd0b283a979898ad427a9be7be22b266", size = 11896089, upload-time = "2025-08-21T18:23:14.232Z" }, + { url = "https://files.pythonhosted.org/packages/ad/14/2ad38fd4037daab9e023456a4a40ed0154e9971f8d6aed41bdea390aabd9/ruff-0.12.10-py3-none-win_amd64.whl", hash = "sha256:7837eca8787f076f67aba2ca559cefd9c5cbc3a9852fd66186f4201b87c1563e", size = 13004616, upload-time = "2025-08-21T18:23:17.422Z" }, + { url = "https://files.pythonhosted.org/packages/24/3c/21cf283d67af33a8e6ed242396863af195a8a6134ec581524fd22b9811b6/ruff-0.12.10-py3-none-win_arm64.whl", hash = "sha256:cc138cc06ed9d4bfa9d667a65af7172b47840e1a98b02ce7011c391e54635ffc", size = 12074225, upload-time = "2025-08-21T18:23:20.137Z" }, +] + +[[package]] +name = "secretstorage" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/a4/f48c9d79cb507ed1373477dbceaba7401fd8a23af63b837fa61f1dcd3691/SecretStorage-3.3.3.tar.gz", hash = "sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77", size = 19739, upload-time = "2022-08-13T16:22:46.976Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/24/b4293291fa1dd830f353d2cb163295742fa87f179fcc8a20a306a81978b7/SecretStorage-3.3.3-py3-none-any.whl", hash = "sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99", size = 15221, upload-time = "2022-08-13T16:22:44.457Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "smmap" +version = "5.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/cd/a040c4b3119bbe532e5b0732286f805445375489fceaec1f48306068ee3b/smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5", size = 22329, upload-time = "2025-01-02T07:14:40.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303, upload-time = "2025-01-02T07:14:38.724Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "socketdev" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b6/4f/07cb8e4e827931527a3c04e3520dabed8f20ece5a5fb91e5a012e6bb2446/socketdev-3.0.0.tar.gz", hash = "sha256:27c22d3a016e06b916f373f78edd34dc6d7612da0ae845e8e383d58d7425e5bb", size = 101362, upload-time = "2025-08-23T22:59:02.855Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/c4/ed98ab0022f19c8e7ded5a2eaea0f0cabf829c6e7001bb7cf8ae112e964f/socketdev-3.0.0-py3-none-any.whl", hash = "sha256:f142f3b0d22a32479cf73bd35f9a0bdcd4896e494c60fdeb2999c0daa9682611", size = 48942, upload-time = "2025-08-23T22:59:01.134Z" }, +] + +[[package]] +name = "socketsecurity" +version = "2.2.3" +source = { editable = "." } +dependencies = [ + { name = "gitpython" }, + { name = "mdutils" }, + { name = "packaging" }, + { name = "prettytable" }, + { name = "python-dotenv" }, + { name = "requests" }, + { name = "socketdev" }, +] + +[package.optional-dependencies] +dev = [ + { name = "hatch" }, + { name = "pre-commit" }, + { name = "ruff" }, + { name = "twine" }, + { name = "uv" }, +] +test = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, + { name = "pytest-watch" }, +] + +[package.metadata] +requires-dist = [ + { name = "gitpython" }, + { name = "hatch", marker = "extra == 'dev'" }, + { name = "mdutils" }, + { name = "packaging" }, + { name = "pre-commit", marker = "extra == 'dev'" }, + { name = "prettytable" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=7.4.0" }, + { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.23.0" }, + { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=4.1.0" }, + { name = "pytest-mock", marker = "extra == 'test'", specifier = ">=3.12.0" }, + { name = "pytest-watch", marker = "extra == 'test'", specifier = ">=4.2.0" }, + { name = "python-dotenv" }, + { name = "requests" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.3.0" }, + { name = "socketdev", specifier = ">=3.0.0,<4.0.0" }, + { name = "twine", marker = "extra == 'dev'" }, + { name = "uv", marker = "extra == 'dev'", specifier = ">=0.1.0" }, +] +provides-extras = ["test", "dev"] + +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload-time = "2024-11-27T22:37:54.956Z" }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload-time = "2024-11-27T22:37:56.698Z" }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload-time = "2024-11-27T22:37:57.63Z" }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload-time = "2024-11-27T22:37:59.344Z" }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload-time = "2024-11-27T22:38:00.429Z" }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload-time = "2024-11-27T22:38:02.094Z" }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload-time = "2024-11-27T22:38:03.206Z" }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload-time = "2024-11-27T22:38:04.217Z" }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload-time = "2024-11-27T22:38:05.908Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload-time = "2024-11-27T22:38:06.812Z" }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, +] + +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] + +[[package]] +name = "tomlkit" +version = "0.13.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/18/0bbf3884e9eaa38819ebe46a7bd25dcd56b67434402b66a58c4b8e552575/tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1", size = 185207, upload-time = "2025-06-05T07:13:44.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/75/8539d011f6be8e29f339c42e633aae3cb73bffa95dd0f9adec09b9c58e85/tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0", size = 38901, upload-time = "2025-06-05T07:13:43.546Z" }, +] + +[[package]] +name = "trove-classifiers" +version = "2025.8.6.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/21/707af14daa638b0df15b5d5700349e0abdd3e5140069f9ab6e0ccb922806/trove_classifiers-2025.8.6.13.tar.gz", hash = "sha256:5a0abad839d2ed810f213ab133d555d267124ddea29f1d8a50d6eca12a50ae6e", size = 16932, upload-time = "2025-08-06T13:26:26.479Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/44/323a87d78f04d5329092aada803af3612dd004a64b69ba8b13046601a8c9/trove_classifiers-2025.8.6.13-py3-none-any.whl", hash = "sha256:c4e7fc83012770d80b3ae95816111c32b085716374dccee0d3fbf5c235495f9f", size = 14121, upload-time = "2025-08-06T13:26:25.063Z" }, +] + +[[package]] +name = "twine" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "id" }, + { name = "keyring", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" }, + { name = "packaging" }, + { name = "readme-renderer" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "rfc3986" }, + { name = "rich" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c8/a2/6df94fc5c8e2170d21d7134a565c3a8fb84f9797c1dd65a5976aaf714418/twine-6.1.0.tar.gz", hash = "sha256:be324f6272eff91d07ee93f251edf232fc647935dd585ac003539b42404a8dbd", size = 168404, upload-time = "2025-01-21T18:45:26.758Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/b6/74e927715a285743351233f33ea3c684528a0d374d2e43ff9ce9585b73fe/twine-6.1.0-py3-none-any.whl", hash = "sha256:a47f973caf122930bf0fbbf17f80b83bc1602c9ce393c7845f289a3001dc5384", size = 40791, upload-time = "2025-01-21T18:45:24.584Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + +[[package]] +name = "userpath" +version = "1.9.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/b7/30753098208505d7ff9be5b3a32112fb8a4cb3ddfccbbb7ba9973f2e29ff/userpath-1.9.2.tar.gz", hash = "sha256:6c52288dab069257cc831846d15d48133522455d4677ee69a9781f11dbefd815", size = 11140, upload-time = "2024-02-29T21:39:08.742Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/99/3ec6335ded5b88c2f7ed25c56ffd952546f7ed007ffb1e1539dc3b57015a/userpath-1.9.2-py3-none-any.whl", hash = "sha256:2cbf01a23d655a1ff8fc166dfb78da1b641d1ceabf0fe5f970767d380b14e89d", size = 9065, upload-time = "2024-02-29T21:39:07.551Z" }, +] + +[[package]] +name = "uv" +version = "0.8.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/f3/4f947303fb68daa553fc44ea5f2bcdfb1d11c4390ab52b6e3829f72b6b69/uv-0.8.13.tar.gz", hash = "sha256:a4438eca3d301183c52994a6d2baff70fd1840421a83446f3cabb1d0d0b50aff", size = 3529020, upload-time = "2025-08-21T19:20:17.329Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/1d/98c7985f05c1dfa4a1d9bfebfdc75fd5c86633acb3bd9a65a5233a267d0e/uv-0.8.13-py3-none-linux_armv6l.whl", hash = "sha256:3b5c6e44238007ec1d25212cafe1b37a8506d425d1dd74a267cb9072a61930f9", size = 18712142, upload-time = "2025-08-21T19:19:30.882Z" }, + { url = "https://files.pythonhosted.org/packages/32/a2/2fc23b2fb14316fafafcd918dd4bf3456aecfeae95d71deaf9d7d59e9720/uv-0.8.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2945c32b8fcf23807ef1f74c390795e2b00371c53b94c015cc6e7b0cfbab9d94", size = 18752276, upload-time = "2025-08-21T19:19:34.658Z" }, + { url = "https://files.pythonhosted.org/packages/95/e4/c8d4963271e3a9ea0886be1082c96b16881d58d6d260957e76bd41f4c991/uv-0.8.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:73459fe1403b1089853071db6770450dc03e4058848f7146d88cff5f1c352743", size = 17403078, upload-time = "2025-08-21T19:19:36.882Z" }, + { url = "https://files.pythonhosted.org/packages/9b/25/3985330034df1f99b63b947f5de37059859aede74883fc14e4500a983daf/uv-0.8.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:854c4e75024a4894477bf61684b2872b83c77ca87d1bad62692bcc31200619c3", size = 18075030, upload-time = "2025-08-21T19:19:41.992Z" }, + { url = "https://files.pythonhosted.org/packages/68/f2/5956b44e8b77e777b1c15c011e43a7bb29c4346908f569c474db67baab02/uv-0.8.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:28c8d4560c673ff5c798f2f4422281840728f46ebf1946345b65d065f8344c03", size = 18334387, upload-time = "2025-08-21T19:19:44.543Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/5feda4185ea21e7dc79f73ba1f9ebbd2ac5da22ed5c40d7c6f6a310d4738/uv-0.8.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f6c508aa9c5210577008e1919b532e38356fe68712179399f00462b3e78fd845", size = 19097001, upload-time = "2025-08-21T19:19:47.363Z" }, + { url = "https://files.pythonhosted.org/packages/02/f8/1b6e258907afdb008855418f7a0aa41568f8b7389994f62b7992352a5146/uv-0.8.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3bac51ea503d97f371222f23e845fc4ab95465ac3e958c7589d6743c75445b71", size = 20501591, upload-time = "2025-08-21T19:19:49.976Z" }, + { url = "https://files.pythonhosted.org/packages/39/17/8a9f979bb6329b74320514797bfaf0a2865e25c2edf7506fde5080ad071c/uv-0.8.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a6d37547947fcae57244b4d1f3b62fba55f4a85d3e45e7284a93b6cd5bedca4", size = 20156528, upload-time = "2025-08-21T19:19:52.096Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ca/bc42b2ae9dd0eae8b5bba34020bc3893cf7370c4125164075643a1da86c8/uv-0.8.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3735a452cdc3168932d128891d7e8866b4a2d052283c6da5ccfe0b038d1cf8bd", size = 19491271, upload-time = "2025-08-21T19:19:54.302Z" }, + { url = "https://files.pythonhosted.org/packages/82/6b/81387a715dd045f7edea452fb76a5896dcfc11b8ecf0db5106f4b0f633ec/uv-0.8.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2113cd877974b68ea2af64a2f2cc23708ba97066046e78efb72ba94e5fef617a", size = 19452076, upload-time = "2025-08-21T19:19:56.608Z" }, + { url = "https://files.pythonhosted.org/packages/59/2e/eae2eff5876576b6e1d43010924446da22f4fe33140124a4f2ae936c457d/uv-0.8.13-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:4c2c5e5962239ecaff6444d5bc22422a9bd2da25a80adc6ab14cb42e4461b1cf", size = 18329700, upload-time = "2025-08-21T19:19:58.784Z" }, + { url = "https://files.pythonhosted.org/packages/5e/6e/9a88c83a1d8845c04b911433bbe61989b9d40b4feb7a51ab1ca595107bf5/uv-0.8.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:eb90089624d92d57b8582f708973db8988e09dba6bae83991dba20731d82eb6a", size = 19218874, upload-time = "2025-08-21T19:20:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/df/f5/8f818016a1704a185dd0087cacbe0797b0ca2ef859922814e26322427756/uv-0.8.13-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:cf3ce98404ddc1e11cd2c2604668f8f81219cf00bb1227b792fdf5dbb4faf31a", size = 18310085, upload-time = "2025-08-21T19:20:03.219Z" }, + { url = "https://files.pythonhosted.org/packages/64/d2/b0d16ec65efb8b5877256e14da808a0db20c4bf3e9960d807e4c0db5f71c/uv-0.8.13-py3-none-musllinux_1_1_i686.whl", hash = "sha256:8a3739540f8b0b5258869b1671185d55daacfa4609eaffd235573ac938ec01a6", size = 18604881, upload-time = "2025-08-21T19:20:05.413Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ba/f96a34b2aec7c38d0512b10fea182eaf0d416e2825cf4ac00fde1e375019/uv-0.8.13-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:18a502328545af511039c7b7c602a0aa89eeff23b1221a1f56d99b3a3fecfddd", size = 19569585, upload-time = "2025-08-21T19:20:08.008Z" }, + { url = "https://files.pythonhosted.org/packages/6d/7d/a57198784a1dca07668007837cfd00346cde2da079697f67daaedbfb1376/uv-0.8.13-py3-none-win32.whl", hash = "sha256:d22fa55580b224779279b98e0b23cbc45e51837e1fac616d7c5d03aff668a998", size = 18661160, upload-time = "2025-08-21T19:20:10.393Z" }, + { url = "https://files.pythonhosted.org/packages/3f/14/65ba1f45c27ff975497f2db965cd2b989fdda031afa76b978ddc72a66032/uv-0.8.13-py3-none-win_amd64.whl", hash = "sha256:20862f612de38f6dea55d40467a29f3cb621b256a4b5891ae55debbbdf1db2b4", size = 20556536, upload-time = "2025-08-21T19:20:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/47/16a2eb25166861af1a139e506e5595cb92c7f7e5aae6e71feec135093394/uv-0.8.13-py3-none-win_arm64.whl", hash = "sha256:404ca19b2d860ab661e1d78633f594e994f8422af8772ad237d763fe353da2ab", size = 19028395, upload-time = "2025-08-21T19:20:15.035Z" }, +] + +[[package]] +name = "virtualenv" +version = "20.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/14/37fcdba2808a6c615681cd216fecae00413c9dab44fb2e57805ecf3eaee3/virtualenv-20.34.0.tar.gz", hash = "sha256:44815b2c9dee7ed86e387b842a84f20b93f7f417f95886ca1996a72a4138eb1a", size = 6003808, upload-time = "2025-08-13T14:24:07.464Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/06/04c8e804f813cf972e3262f3f8584c232de64f0cde9f703b46cf53a45090/virtualenv-20.34.0-py3-none-any.whl", hash = "sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026", size = 5983279, upload-time = "2025-08-13T14:24:05.111Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, + { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.2.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5", size = 101301, upload-time = "2024-01-06T02:10:57.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166, upload-time = "2024-01-06T02:10:55.763Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +] + +[[package]] +name = "zstandard" +version = "0.24.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/1b/c20b2ef1d987627765dcd5bf1dadb8ef6564f00a87972635099bb76b7a05/zstandard-0.24.0.tar.gz", hash = "sha256:fe3198b81c00032326342d973e526803f183f97aa9e9a98e3f897ebafe21178f", size = 905681, upload-time = "2025-08-17T18:36:36.352Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/9d/d1ca1e7bff6a7938e81180322c053c080ae9e31b0e3b393434deae7a1ae5/zstandard-0.24.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:af1394c2c5febc44e0bbf0fc6428263fa928b50d1b1982ce1d870dc793a8e5f4", size = 795228, upload-time = "2025-08-17T18:21:12.444Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ba/a40ddfbbb9f0773127701a802338f215211b018f9222b9fab1e2d498f9cd/zstandard-0.24.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5e941654cef13a1d53634ec30933722eda11f44f99e1d0bc62bbce3387580d50", size = 640522, upload-time = "2025-08-17T18:21:14.133Z" }, + { url = "https://files.pythonhosted.org/packages/3e/7c/edeee3ef8d469a1345edd86f8d123a3825d60df033bcbbd16df417bdb9e7/zstandard-0.24.0-cp310-cp310-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:561123d05681197c0e24eb8ab3cfdaf299e2b59c293d19dad96e1610ccd8fbc6", size = 5344625, upload-time = "2025-08-17T18:21:16.067Z" }, + { url = "https://files.pythonhosted.org/packages/bf/2c/2f76e5058435d96ab0187303d4e9663372893cdcc95d64fdb60824951162/zstandard-0.24.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0f6d9a146e07458cb41423ca2d783aefe3a3a97fe72838973c13b8f1ecc7343a", size = 5055074, upload-time = "2025-08-17T18:21:18.483Z" }, + { url = "https://files.pythonhosted.org/packages/e4/87/3962530a568d38e64f287e11b9a38936d873617120589611c49c29af94a8/zstandard-0.24.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf02f915fa7934ea5dfc8d96757729c99a8868b7c340b97704795d6413cf5fe6", size = 5401308, upload-time = "2025-08-17T18:21:20.859Z" }, + { url = "https://files.pythonhosted.org/packages/f1/69/85e65f0fb05b4475130888cf7934ff30ac14b5979527e8f1ccb6f56e21ec/zstandard-0.24.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:35f13501a8accf834457d8e40e744568287a215818778bc4d79337af2f3f0d97", size = 5448948, upload-time = "2025-08-17T18:21:23.015Z" }, + { url = "https://files.pythonhosted.org/packages/2b/2f/1b607274bf20ea8bcd13bea3edc0a48f984c438c09d0a050b9667dadcaed/zstandard-0.24.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:92be52ca4e6e604f03d5daa079caec9e04ab4cbf6972b995aaebb877d3d24e13", size = 5555870, upload-time = "2025-08-17T18:21:24.985Z" }, + { url = "https://files.pythonhosted.org/packages/a0/9a/fadd5ffded6ab113b26704658a40444865b914de072fb460b6b51aa5fa2f/zstandard-0.24.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0c9c3cba57f5792532a3df3f895980d47d78eda94b0e5b800651b53e96e0b604", size = 5044917, upload-time = "2025-08-17T18:21:27.082Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/c5edc3b00e070d0b4156993bd7bef9cba58c5f2571bd0003054cbe90005c/zstandard-0.24.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:dd91b0134a32dfcd8be504e8e46de44ad0045a569efc25101f2a12ccd41b5759", size = 5571834, upload-time = "2025-08-17T18:21:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/1f/7e/9e353ed08c3d7a93050bbadbebe2f5f783b13393e0e8e08e970ef3396390/zstandard-0.24.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d6975f2d903bc354916a17b91a7aaac7299603f9ecdb788145060dde6e573a16", size = 4959108, upload-time = "2025-08-17T18:21:31.228Z" }, + { url = "https://files.pythonhosted.org/packages/af/28/135dffba375ab1f4d2c569de804647eba8bd682f36d3c01b5a012c560ff2/zstandard-0.24.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:7ac6e4d727521d86d20ec291a3f4e64a478e8a73eaee80af8f38ec403e77a409", size = 5265997, upload-time = "2025-08-17T18:21:33.369Z" }, + { url = "https://files.pythonhosted.org/packages/cc/7a/702e7cbc51c39ce104c198ea6d069fb6a918eb24c5709ac79fe9371f7a55/zstandard-0.24.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:87ae1684bc3c02d5c35884b3726525eda85307073dbefe68c3c779e104a59036", size = 5440015, upload-time = "2025-08-17T18:21:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/77/40/4a2d0faa2ae6f4c847c7f77ec626abed80873035891c4a4349b735a36fb4/zstandard-0.24.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:7de5869e616d426b56809be7dc6dba4d37b95b90411ccd3de47f421a42d4d42c", size = 5819056, upload-time = "2025-08-17T18:21:39.661Z" }, + { url = "https://files.pythonhosted.org/packages/3e/fc/580504a2d7c71411a8e403b83f2388ee083819a68e0e740bf974e78839f8/zstandard-0.24.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:388aad2d693707f4a0f6cc687eb457b33303d6b57ecf212c8ff4468c34426892", size = 5362621, upload-time = "2025-08-17T18:21:42.605Z" }, + { url = "https://files.pythonhosted.org/packages/70/66/97f6b38eeda955eaa6b5e7cfc0528039bfcb9eb8338016aacf6d83d8a75e/zstandard-0.24.0-cp310-cp310-win32.whl", hash = "sha256:962ea3aecedcc944f8034812e23d7200d52c6e32765b8da396eeb8b8ffca71ce", size = 435575, upload-time = "2025-08-17T18:21:45.477Z" }, + { url = "https://files.pythonhosted.org/packages/68/a2/5814bdd22d879b10fcc5dc37366e39603767063f06ae970f2a657f76ddac/zstandard-0.24.0-cp310-cp310-win_amd64.whl", hash = "sha256:869bf13f66b124b13be37dd6e08e4b728948ff9735308694e0b0479119e08ea7", size = 505115, upload-time = "2025-08-17T18:21:44.011Z" }, + { url = "https://files.pythonhosted.org/packages/01/1f/5c72806f76043c0ef9191a2b65281dacdf3b65b0828eb13bb2c987c4fb90/zstandard-0.24.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:addfc23e3bd5f4b6787b9ca95b2d09a1a67ad5a3c318daaa783ff90b2d3a366e", size = 795228, upload-time = "2025-08-17T18:21:46.978Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ba/3059bd5cd834666a789251d14417621b5c61233bd46e7d9023ea8bc1043a/zstandard-0.24.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6b005bcee4be9c3984b355336283afe77b2defa76ed6b89332eced7b6fa68b68", size = 640520, upload-time = "2025-08-17T18:21:48.162Z" }, + { url = "https://files.pythonhosted.org/packages/57/07/f0e632bf783f915c1fdd0bf68614c4764cae9dd46ba32cbae4dd659592c3/zstandard-0.24.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:3f96a9130171e01dbb6c3d4d9925d604e2131a97f540e223b88ba45daf56d6fb", size = 5347682, upload-time = "2025-08-17T18:21:50.266Z" }, + { url = "https://files.pythonhosted.org/packages/a6/4c/63523169fe84773a7462cd090b0989cb7c7a7f2a8b0a5fbf00009ba7d74d/zstandard-0.24.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd0d3d16e63873253bad22b413ec679cf6586e51b5772eb10733899832efec42", size = 5057650, upload-time = "2025-08-17T18:21:52.634Z" }, + { url = "https://files.pythonhosted.org/packages/c6/16/49013f7ef80293f5cebf4c4229535a9f4c9416bbfd238560edc579815dbe/zstandard-0.24.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b7a8c30d9bf4bd5e4dcfe26900bef0fcd9749acde45cdf0b3c89e2052fda9a13", size = 5404893, upload-time = "2025-08-17T18:21:54.54Z" }, + { url = "https://files.pythonhosted.org/packages/4d/38/78e8bcb5fc32a63b055f2b99e0be49b506f2351d0180173674f516cf8a7a/zstandard-0.24.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:52cd7d9fa0a115c9446abb79b06a47171b7d916c35c10e0c3aa6f01d57561382", size = 5452389, upload-time = "2025-08-17T18:21:56.822Z" }, + { url = "https://files.pythonhosted.org/packages/55/8a/81671f05619edbacd49bd84ce6899a09fc8299be20c09ae92f6618ccb92d/zstandard-0.24.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a0f6fc2ea6e07e20df48752e7700e02e1892c61f9a6bfbacaf2c5b24d5ad504b", size = 5558888, upload-time = "2025-08-17T18:21:58.68Z" }, + { url = "https://files.pythonhosted.org/packages/49/cc/e83feb2d7d22d1f88434defbaeb6e5e91f42a4f607b5d4d2d58912b69d67/zstandard-0.24.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e46eb6702691b24ddb3e31e88b4a499e31506991db3d3724a85bd1c5fc3cfe4e", size = 5048038, upload-time = "2025-08-17T18:22:00.642Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/7a5c57ff49ef8943877f85c23368c104c2aea510abb339a2dc31ad0a27c3/zstandard-0.24.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d5e3b9310fd7f0d12edc75532cd9a56da6293840c84da90070d692e0bb15f186", size = 5573833, upload-time = "2025-08-17T18:22:02.402Z" }, + { url = "https://files.pythonhosted.org/packages/f9/00/64519983cd92535ba4bdd4ac26ac52db00040a52d6c4efb8d1764abcc343/zstandard-0.24.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:76cdfe7f920738ea871f035568f82bad3328cbc8d98f1f6988264096b5264efd", size = 4961072, upload-time = "2025-08-17T18:22:04.384Z" }, + { url = "https://files.pythonhosted.org/packages/72/ab/3a08a43067387d22994fc87c3113636aa34ccd2914a4d2d188ce365c5d85/zstandard-0.24.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:3f2fe35ec84908dddf0fbf66b35d7c2878dbe349552dd52e005c755d3493d61c", size = 5268462, upload-time = "2025-08-17T18:22:06.095Z" }, + { url = "https://files.pythonhosted.org/packages/49/cf/2abb3a1ad85aebe18c53e7eca73223f1546ddfa3bf4d2fb83fc5a064c5ca/zstandard-0.24.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:aa705beb74ab116563f4ce784fa94771f230c05d09ab5de9c397793e725bb1db", size = 5443319, upload-time = "2025-08-17T18:22:08.572Z" }, + { url = "https://files.pythonhosted.org/packages/40/42/0dd59fc2f68f1664cda11c3b26abdf987f4e57cb6b6b0f329520cd074552/zstandard-0.24.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:aadf32c389bb7f02b8ec5c243c38302b92c006da565e120dfcb7bf0378f4f848", size = 5822355, upload-time = "2025-08-17T18:22:10.537Z" }, + { url = "https://files.pythonhosted.org/packages/99/c0/ea4e640fd4f7d58d6f87a1e7aca11fb886ac24db277fbbb879336c912f63/zstandard-0.24.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e40cd0fc734aa1d4bd0e7ad102fd2a1aefa50ce9ef570005ffc2273c5442ddc3", size = 5365257, upload-time = "2025-08-17T18:22:13.159Z" }, + { url = "https://files.pythonhosted.org/packages/27/a9/92da42a5c4e7e4003271f2e1f0efd1f37cfd565d763ad3604e9597980a1c/zstandard-0.24.0-cp311-cp311-win32.whl", hash = "sha256:cda61c46343809ecda43dc620d1333dd7433a25d0a252f2dcc7667f6331c7b61", size = 435559, upload-time = "2025-08-17T18:22:17.29Z" }, + { url = "https://files.pythonhosted.org/packages/e2/8e/2c8e5c681ae4937c007938f954a060fa7c74f36273b289cabdb5ef0e9a7e/zstandard-0.24.0-cp311-cp311-win_amd64.whl", hash = "sha256:3b95fc06489aa9388400d1aab01a83652bc040c9c087bd732eb214909d7fb0dd", size = 505070, upload-time = "2025-08-17T18:22:14.808Z" }, + { url = "https://files.pythonhosted.org/packages/52/10/a2f27a66bec75e236b575c9f7b0d7d37004a03aa2dcde8e2decbe9ed7b4d/zstandard-0.24.0-cp311-cp311-win_arm64.whl", hash = "sha256:ad9fd176ff6800a0cf52bcf59c71e5de4fa25bf3ba62b58800e0f84885344d34", size = 461507, upload-time = "2025-08-17T18:22:15.964Z" }, + { url = "https://files.pythonhosted.org/packages/26/e9/0bd281d9154bba7fc421a291e263911e1d69d6951aa80955b992a48289f6/zstandard-0.24.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a2bda8f2790add22773ee7a4e43c90ea05598bffc94c21c40ae0a9000b0133c3", size = 795710, upload-time = "2025-08-17T18:22:19.189Z" }, + { url = "https://files.pythonhosted.org/packages/36/26/b250a2eef515caf492e2d86732e75240cdac9d92b04383722b9753590c36/zstandard-0.24.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cc76de75300f65b8eb574d855c12518dc25a075dadb41dd18f6322bda3fe15d5", size = 640336, upload-time = "2025-08-17T18:22:20.466Z" }, + { url = "https://files.pythonhosted.org/packages/79/bf/3ba6b522306d9bf097aac8547556b98a4f753dc807a170becaf30dcd6f01/zstandard-0.24.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:d2b3b4bda1a025b10fe0269369475f420177f2cb06e0f9d32c95b4873c9f80b8", size = 5342533, upload-time = "2025-08-17T18:22:22.326Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ec/22bc75bf054e25accdf8e928bc68ab36b4466809729c554ff3a1c1c8bce6/zstandard-0.24.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b84c6c210684286e504022d11ec294d2b7922d66c823e87575d8b23eba7c81f", size = 5062837, upload-time = "2025-08-17T18:22:24.416Z" }, + { url = "https://files.pythonhosted.org/packages/48/cc/33edfc9d286e517fb5b51d9c3210e5bcfce578d02a675f994308ca587ae1/zstandard-0.24.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c59740682a686bf835a1a4d8d0ed1eefe31ac07f1c5a7ed5f2e72cf577692b00", size = 5393855, upload-time = "2025-08-17T18:22:26.786Z" }, + { url = "https://files.pythonhosted.org/packages/73/36/59254e9b29da6215fb3a717812bf87192d89f190f23817d88cb8868c47ac/zstandard-0.24.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6324fde5cf5120fbf6541d5ff3c86011ec056e8d0f915d8e7822926a5377193a", size = 5451058, upload-time = "2025-08-17T18:22:28.885Z" }, + { url = "https://files.pythonhosted.org/packages/9a/c7/31674cb2168b741bbbe71ce37dd397c9c671e73349d88ad3bca9e9fae25b/zstandard-0.24.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51a86bd963de3f36688553926a84e550d45d7f9745bd1947d79472eca27fcc75", size = 5546619, upload-time = "2025-08-17T18:22:31.115Z" }, + { url = "https://files.pythonhosted.org/packages/e6/01/1a9f22239f08c00c156f2266db857545ece66a6fc0303d45c298564bc20b/zstandard-0.24.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d82ac87017b734f2fb70ff93818c66f0ad2c3810f61040f077ed38d924e19980", size = 5046676, upload-time = "2025-08-17T18:22:33.077Z" }, + { url = "https://files.pythonhosted.org/packages/a7/91/6c0cf8fa143a4988a0361380ac2ef0d7cb98a374704b389fbc38b5891712/zstandard-0.24.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:92ea7855d5bcfb386c34557516c73753435fb2d4a014e2c9343b5f5ba148b5d8", size = 5576381, upload-time = "2025-08-17T18:22:35.391Z" }, + { url = "https://files.pythonhosted.org/packages/e2/77/1526080e22e78871e786ccf3c84bf5cec9ed25110a9585507d3c551da3d6/zstandard-0.24.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3adb4b5414febf074800d264ddf69ecade8c658837a83a19e8ab820e924c9933", size = 4953403, upload-time = "2025-08-17T18:22:37.266Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d0/a3a833930bff01eab697eb8abeafb0ab068438771fa066558d96d7dafbf9/zstandard-0.24.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6374feaf347e6b83ec13cc5dcfa70076f06d8f7ecd46cc71d58fac798ff08b76", size = 5267396, upload-time = "2025-08-17T18:22:39.757Z" }, + { url = "https://files.pythonhosted.org/packages/f3/5e/90a0db9a61cd4769c06374297ecfcbbf66654f74cec89392519deba64d76/zstandard-0.24.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:13fc548e214df08d896ee5f29e1f91ee35db14f733fef8eabea8dca6e451d1e2", size = 5433269, upload-time = "2025-08-17T18:22:42.131Z" }, + { url = "https://files.pythonhosted.org/packages/ce/58/fc6a71060dd67c26a9c5566e0d7c99248cbe5abfda6b3b65b8f1a28d59f7/zstandard-0.24.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:0a416814608610abf5488889c74e43ffa0343ca6cf43957c6b6ec526212422da", size = 5814203, upload-time = "2025-08-17T18:22:44.017Z" }, + { url = "https://files.pythonhosted.org/packages/5c/6a/89573d4393e3ecbfa425d9a4e391027f58d7810dec5cdb13a26e4cdeef5c/zstandard-0.24.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0d66da2649bb0af4471699aeb7a83d6f59ae30236fb9f6b5d20fb618ef6c6777", size = 5359622, upload-time = "2025-08-17T18:22:45.802Z" }, + { url = "https://files.pythonhosted.org/packages/60/ff/2cbab815d6f02a53a9d8d8703bc727d8408a2e508143ca9af6c3cca2054b/zstandard-0.24.0-cp312-cp312-win32.whl", hash = "sha256:ff19efaa33e7f136fe95f9bbcc90ab7fb60648453b03f95d1de3ab6997de0f32", size = 435968, upload-time = "2025-08-17T18:22:49.493Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a3/8f96b8ddb7ad12344218fbd0fd2805702dafd126ae9f8a1fb91eef7b33da/zstandard-0.24.0-cp312-cp312-win_amd64.whl", hash = "sha256:bc05f8a875eb651d1cc62e12a4a0e6afa5cd0cc231381adb830d2e9c196ea895", size = 505195, upload-time = "2025-08-17T18:22:47.193Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4a/bfca20679da63bfc236634ef2e4b1b4254203098b0170e3511fee781351f/zstandard-0.24.0-cp312-cp312-win_arm64.whl", hash = "sha256:b04c94718f7a8ed7cdd01b162b6caa1954b3c9d486f00ecbbd300f149d2b2606", size = 461605, upload-time = "2025-08-17T18:22:48.317Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ef/db949de3bf81ed122b8ee4db6a8d147a136fe070e1015f5a60d8a3966748/zstandard-0.24.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e4ebb000c0fe24a6d0f3534b6256844d9dbf042fdf003efe5cf40690cf4e0f3e", size = 795700, upload-time = "2025-08-17T18:22:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/99/56/fc04395d6f5eabd2fe6d86c0800d198969f3038385cb918bfbe94f2b0c62/zstandard-0.24.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:498f88f5109666c19531f0243a90d2fdd2252839cd6c8cc6e9213a3446670fa8", size = 640343, upload-time = "2025-08-17T18:22:51.999Z" }, + { url = "https://files.pythonhosted.org/packages/9b/0f/0b0e0d55f2f051d5117a0d62f4f9a8741b3647440c0ee1806b7bd47ed5ae/zstandard-0.24.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0a9e95ceb180ccd12a8b3437bac7e8a8a089c9094e39522900a8917745542184", size = 5342571, upload-time = "2025-08-17T18:22:53.734Z" }, + { url = "https://files.pythonhosted.org/packages/5d/43/d74e49f04fbd62d4b5d89aeb7a29d693fc637c60238f820cd5afe6ca8180/zstandard-0.24.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bcf69e0bcddbf2adcfafc1a7e864edcc204dd8171756d3a8f3340f6f6cc87b7b", size = 5062723, upload-time = "2025-08-17T18:22:55.624Z" }, + { url = "https://files.pythonhosted.org/packages/8e/97/df14384d4d6a004388e6ed07ded02933b5c7e0833a9150c57d0abc9545b7/zstandard-0.24.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:10e284748a7e7fbe2815ca62a9d6e84497d34cfdd0143fa9e8e208efa808d7c4", size = 5393282, upload-time = "2025-08-17T18:22:57.655Z" }, + { url = "https://files.pythonhosted.org/packages/7e/09/8f5c520e59a4d41591b30b7568595eda6fd71c08701bb316d15b7ed0613a/zstandard-0.24.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:1bda8a85e5b9d5e73af2e61b23609a8cc1598c1b3b2473969912979205a1ff25", size = 5450895, upload-time = "2025-08-17T18:22:59.749Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3d/02aba892327a67ead8cba160ee835cfa1fc292a9dcb763639e30c07da58b/zstandard-0.24.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1b14bc92af065d0534856bf1b30fc48753163ea673da98857ea4932be62079b1", size = 5546353, upload-time = "2025-08-17T18:23:01.457Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6e/96c52afcde44da6a5313a1f6c356349792079808f12d8b69a7d1d98ef353/zstandard-0.24.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:b4f20417a4f511c656762b001ec827500cbee54d1810253c6ca2df2c0a307a5f", size = 5046404, upload-time = "2025-08-17T18:23:03.418Z" }, + { url = "https://files.pythonhosted.org/packages/da/b6/eefee6b92d341a7db7cd1b3885d42d30476a093720fb5c181e35b236d695/zstandard-0.24.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:337572a7340e1d92fd7fb5248c8300d0e91071002d92e0b8cabe8d9ae7b58159", size = 5576095, upload-time = "2025-08-17T18:23:05.331Z" }, + { url = "https://files.pythonhosted.org/packages/a3/29/743de3131f6239ba6611e17199581e6b5e0f03f268924d42468e29468ca0/zstandard-0.24.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df4be1cf6e8f0f2bbe2a3eabfff163ef592c84a40e1a20a8d7db7f27cfe08fc2", size = 4953448, upload-time = "2025-08-17T18:23:07.225Z" }, + { url = "https://files.pythonhosted.org/packages/c9/11/bd36ef49fba82e307d69d93b5abbdcdc47d6a0bcbc7ffbbfe0ef74c2fec5/zstandard-0.24.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6885ae4b33aee8835dbdb4249d3dfec09af55e705d74d9b660bfb9da51baaa8b", size = 5267388, upload-time = "2025-08-17T18:23:09.127Z" }, + { url = "https://files.pythonhosted.org/packages/c0/23/a4cfe1b871d3f1ce1f88f5c68d7e922e94be0043f3ae5ed58c11578d1e21/zstandard-0.24.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:663848a8bac4fdbba27feea2926049fdf7b55ec545d5b9aea096ef21e7f0b079", size = 5433383, upload-time = "2025-08-17T18:23:11.343Z" }, + { url = "https://files.pythonhosted.org/packages/77/26/f3fb85f00e732cca617d4b9cd1ffa6484f613ea07fad872a8bdc3a0ce753/zstandard-0.24.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:05d27c953f2e0a3ecc8edbe91d6827736acc4c04d0479672e0400ccdb23d818c", size = 5813988, upload-time = "2025-08-17T18:23:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8c/d7e3b424b73f3ce66e754595cbcb6d94ff49790c9ac37d50e40e8145cd44/zstandard-0.24.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:77b8b7b98893eaf47da03d262816f01f251c2aa059c063ed8a45c50eada123a5", size = 5359756, upload-time = "2025-08-17T18:23:15.021Z" }, + { url = "https://files.pythonhosted.org/packages/90/6c/f1f0e11f1b295138f9da7e7ae22dcd9a1bb96a9544fa3b31507e431288f5/zstandard-0.24.0-cp313-cp313-win32.whl", hash = "sha256:cf7fbb4e54136e9a03c7ed7691843c4df6d2ecc854a2541f840665f4f2bb2edd", size = 435957, upload-time = "2025-08-17T18:23:18.835Z" }, + { url = "https://files.pythonhosted.org/packages/9f/03/ab8b82ae5eb49eca4d3662705399c44442666cc1ce45f44f2d263bb1ae31/zstandard-0.24.0-cp313-cp313-win_amd64.whl", hash = "sha256:d64899cc0f33a8f446f1e60bffc21fa88b99f0e8208750d9144ea717610a80ce", size = 505171, upload-time = "2025-08-17T18:23:16.44Z" }, + { url = "https://files.pythonhosted.org/packages/db/12/89a2ecdea4bc73a934a30b66a7cfac5af352beac94d46cf289e103b65c34/zstandard-0.24.0-cp313-cp313-win_arm64.whl", hash = "sha256:57be3abb4313e0dd625596376bbb607f40059d801d51c1a1da94d7477e63b255", size = 461596, upload-time = "2025-08-17T18:23:17.603Z" }, + { url = "https://files.pythonhosted.org/packages/c9/56/f3d2c4d64aacee4aab89e788783636884786b6f8334c819f09bff1aa207b/zstandard-0.24.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b7fa260dd2731afd0dfa47881c30239f422d00faee4b8b341d3e597cface1483", size = 795747, upload-time = "2025-08-17T18:23:19.968Z" }, + { url = "https://files.pythonhosted.org/packages/32/2d/9d3e5f6627e4cb5e511803788be1feee2f0c3b94594591e92b81db324253/zstandard-0.24.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e05d66239d14a04b4717998b736a25494372b1b2409339b04bf42aa4663bf251", size = 640475, upload-time = "2025-08-17T18:23:21.5Z" }, + { url = "https://files.pythonhosted.org/packages/be/5d/48e66abf8c146d95330e5385633a8cfdd556fa8bd14856fe721590cbab2b/zstandard-0.24.0-cp314-cp314-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:622e1e04bd8a085994e02313ba06fbcf4f9ed9a488c6a77a8dbc0692abab6a38", size = 5343866, upload-time = "2025-08-17T18:23:23.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/6c/65fe7ba71220a551e082e4a52790487f1d6bb8dfc2156883e088f975ad6d/zstandard-0.24.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:55872e818598319f065e8192ebefecd6ac05f62a43f055ed71884b0a26218f41", size = 5062719, upload-time = "2025-08-17T18:23:25.192Z" }, + { url = "https://files.pythonhosted.org/packages/cb/68/15ed0a813ff91be80cc2a610ac42e0fc8d29daa737de247bbf4bab9429a1/zstandard-0.24.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bb2446a55b3a0fd8aa02aa7194bd64740015464a2daaf160d2025204e1d7c282", size = 5393090, upload-time = "2025-08-17T18:23:27.145Z" }, + { url = "https://files.pythonhosted.org/packages/d4/89/e560427b74fa2da6a12b8f3af8ee29104fe2bb069a25e7d314c35eec7732/zstandard-0.24.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2825a3951f945fb2613ded0f517d402b1e5a68e87e0ee65f5bd224a8333a9a46", size = 5450383, upload-time = "2025-08-17T18:23:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/a3/95/0498328cbb1693885509f2fc145402b108b750a87a3af65b7250b10bd896/zstandard-0.24.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:09887301001e7a81a3618156bc1759e48588de24bddfdd5b7a4364da9a8fbc20", size = 5546142, upload-time = "2025-08-17T18:23:31.281Z" }, + { url = "https://files.pythonhosted.org/packages/8a/8a/64aa15a726594df3bf5d8decfec14fe20cd788c60890f44fcfc74d98c2cc/zstandard-0.24.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:98ca91dc9602cf351497d5600aa66e6d011a38c085a8237b370433fcb53e3409", size = 4953456, upload-time = "2025-08-17T18:23:33.234Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/e94879c5cd6017af57bcba08519ed1228b1ebb15681efd949f4a00199449/zstandard-0.24.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e69f8e534b4e254f523e2f9d4732cf9c169c327ca1ce0922682aac9a5ee01155", size = 5268287, upload-time = "2025-08-17T18:23:35.145Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e5/1a3b3a93f953dbe9e77e2a19be146e9cd2af31b67b1419d6cc8e8898d409/zstandard-0.24.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:444633b487a711e34f4bccc46a0c5dfbe1aee82c1a511e58cdc16f6bd66f187c", size = 5433197, upload-time = "2025-08-17T18:23:36.969Z" }, + { url = "https://files.pythonhosted.org/packages/39/83/b6eb1e1181de994b29804e1e0d2dc677bece4177f588c71653093cb4f6d5/zstandard-0.24.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f7d3fe9e1483171e9183ffdb1fab07c5fef80a9c3840374a38ec2ab869ebae20", size = 5813161, upload-time = "2025-08-17T18:23:38.812Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d3/2fb4166561591e9d75e8e35c79182aa9456644e2f4536f29e51216d1c513/zstandard-0.24.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:27b6fa72b57824a3f7901fc9cc4ce1c1c834b28f3a43d1d4254c64c8f11149d4", size = 5359831, upload-time = "2025-08-17T18:23:41.162Z" }, + { url = "https://files.pythonhosted.org/packages/11/94/6a9227315b774f64a67445f62152c69b4e5e49a52a3c7c4dad8520a55e20/zstandard-0.24.0-cp314-cp314-win32.whl", hash = "sha256:fdc7a52a4cdaf7293e10813fd6a3abc0c7753660db12a3b864ab1fb5a0c60c16", size = 444448, upload-time = "2025-08-17T18:23:45.151Z" }, + { url = "https://files.pythonhosted.org/packages/fc/de/67acaba311013e0798cb96d1a2685cb6edcdfc1cae378b297ea7b02c319f/zstandard-0.24.0-cp314-cp314-win_amd64.whl", hash = "sha256:656ed895b28c7e42dd5b40dfcea3217cfc166b6b7eef88c3da2f5fc62484035b", size = 516075, upload-time = "2025-08-17T18:23:42.8Z" }, + { url = "https://files.pythonhosted.org/packages/10/ae/45fd8921263cea0228b20aa31bce47cc66016b2aba1afae1c6adcc3dbb1f/zstandard-0.24.0-cp314-cp314-win_arm64.whl", hash = "sha256:0101f835da7de08375f380192ff75135527e46e3f79bef224e3c49cb640fef6a", size = 476847, upload-time = "2025-08-17T18:23:43.892Z" }, +] From 7af45bc95b5c4fb9d17580ab90f6cfd8382d9dd6 Mon Sep 17 00:00:00 2001 From: Douglas Date: Sun, 24 Aug 2025 17:02:03 -0700 Subject: [PATCH 18/23] Fixed an issue where when there were new alerts the CLI wasn't correctly failing (#114) --- pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- socketsecurity/core/messages.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1783718..7598d6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.2.4" +version = "2.2.5" requires-python = ">= 3.10" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index c70ff49..7c2fa0f 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,2 +1,2 @@ __author__ = 'socket.dev' -__version__ = '2.2.4' +__version__ = '2.2.5' diff --git a/socketsecurity/core/messages.py b/socketsecurity/core/messages.py index 42a4fd1..f062114 100644 --- a/socketsecurity/core/messages.py +++ b/socketsecurity/core/messages.py @@ -283,7 +283,7 @@ def create_security_comment_sarif(diff) -> dict: @staticmethod def create_security_comment_json(diff: Diff) -> dict: scan_failed = False - if len(diff.new_alerts) == 0: + if len(diff.new_alerts) > 0: for alert in diff.new_alerts: alert: Issue if alert.error: From 461f495b09e08db25d0317b02607d0be28ec4802 Mon Sep 17 00:00:00 2001 From: Tommy Ho Date: Wed, 3 Sep 2025 21:20:47 -0500 Subject: [PATCH 19/23] feat: add merge commit detection support (#116) --- pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- socketsecurity/core/git_interface.py | 80 +++++++++++++++++++++++++++- 3 files changed, 80 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7598d6f..37ad7cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.2.5" +version = "2.2.6" requires-python = ">= 3.10" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 7c2fa0f..3a4e477 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,2 +1,2 @@ __author__ = 'socket.dev' -__version__ = '2.2.5' +__version__ = '2.2.6' diff --git a/socketsecurity/core/git_interface.py b/socketsecurity/core/git_interface.py index ed1fc0e..84eec25 100644 --- a/socketsecurity/core/git_interface.py +++ b/socketsecurity/core/git_interface.py @@ -218,8 +218,21 @@ def __init__(self, path: str): log.debug(f"Failed to get changed files via git diff (Bitbucket): {error}") # Fallback to git show for single commit if not detected: - self.show_files = self.repo.git.show(self.commit, name_only=True, format="%n").splitlines() - log.debug(f"Changed files detected via git show: {self.show_files}") + # Check if this is a merge commit first + if self._is_merge_commit(): + # For merge commits, use git diff with parent + if self._detect_merge_commit_changes(): + detected = True + else: + # Fallback to git show if merge detection fails + self.show_files = self.repo.git.show(self.commit, name_only=True, format="%n").splitlines() + log.debug(f"Changed files detected via git show (merge commit fallback): {self.show_files}") + detected = True + else: + # Regular single commit + self.show_files = self.repo.git.show(self.commit, name_only=True, format="%n").splitlines() + log.debug(f"Changed files detected via git show: {self.show_files}") + detected = True self.changed_files = [] for item in self.show_files: if item != "": @@ -380,6 +393,69 @@ def get_formatted_committer(self) -> str: log.debug("Using fallback committer: unknown") return "unknown" + def _is_merge_commit(self) -> bool: + """ + Check if the current commit is a merge commit. + + Returns: + True if this is a merge commit (has multiple parents), False otherwise + """ + try: + # A merge commit has multiple parents + is_merge = len(self.commit.parents) > 1 + log.debug(f"Commit {self.commit.hexsha[:8]} has {len(self.commit.parents)} parents, is_merge: {is_merge}") + return is_merge + except Exception as error: + log.debug(f"Error checking if commit is merge commit: {error}") + return False + + def _detect_merge_commit_changes(self) -> bool: + """ + Detect changed files in a merge commit using git diff with parent. + + This method handles the case where git show --name-only doesn't work + for merge commits (expected Git behavior). + + Returns: + True if detection was successful, False otherwise + """ + try: + if not self._is_merge_commit(): + log.debug("Not a merge commit, skipping merge commit detection") + return False + + # For merge commits, we need to diff against a parent + # We'll use the first parent (typically the target branch) + if not self.commit.parents: + log.debug("Merge commit has no parents - cannot perform merge-aware diff") + return False + + parent_commit = self.commit.parents[0] + + # Verify parent commit is accessible + try: + parent_sha = parent_commit.hexsha + # Quick validation that parent exists + self.repo.commit(parent_sha) + except Exception as parent_error: + log.error(f"Cannot resolve parent commit {parent_sha}: {parent_error}") + return False + + # Use git diff to show changes from parent to merge commit + diff_range = f'{parent_sha}..{self.commit.hexsha}' + log.debug(f"Attempting merge commit diff: git diff --name-only {diff_range}") + + diff_files = self.repo.git.diff('--name-only', diff_range) + self.show_files = diff_files.splitlines() + + log.debug(f"Changed files detected via git diff (merge commit): {self.show_files}") + log.info(f"Changed file detection: method=merge-diff, source=merge-commit-fallback, files={len(self.show_files)}") + return True + + except Exception as error: + log.debug(f"Failed to detect merge commit changes: {error}") + return False + def get_default_branch_name(self) -> str: """ Get the default branch name from the remote origin. From 8bd8b837afab0f1c37cf013647a077178ee593f2 Mon Sep 17 00:00:00 2001 From: Tommy Ho Date: Wed, 3 Sep 2025 21:33:00 -0500 Subject: [PATCH 20/23] chore: add slack debugging (#117) * chore: add slack webhook debugging * Version bump --------- Co-authored-by: Douglas Co-authored-by: Douglas Coburn --- pyproject.toml | 3 ++- socketsecurity/__init__.py | 2 +- socketsecurity/output.py | 16 ++++++++++++++++ socketsecurity/plugins/slack.py | 12 ++++++++++++ 4 files changed, 31 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 37ad7cb..1311b95 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.2.6" +version = "2.2.7" requires-python = ">= 3.10" license = {"file" = "LICENSE"} dependencies = [ @@ -52,6 +52,7 @@ dev = [ [project.scripts] socketcli = "socketsecurity.socketcli:cli" +socketclidev = "socketsecurity.socketcli:cli" [project.urls] Homepage = "https://socket.dev" diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 3a4e477..8210ef3 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,2 +1,2 @@ __author__ = 'socket.dev' -__version__ = '2.2.6' +__version__ = '2.2.7' diff --git a/socketsecurity/output.py b/socketsecurity/output.py index eca30a2..2948bb2 100644 --- a/socketsecurity/output.py +++ b/socketsecurity/output.py @@ -34,6 +34,22 @@ def handle_output(self, diff_report: Diff) -> None: plugin_mgr = PluginManager({"jira": jira_config}) plugin_mgr.send(diff_report, config=self.config) + # Debug Slack webhook configuration when debug is enabled (always show when debug is on) + if self.config.enable_debug: + import os + slack_enabled_env = os.getenv("SOCKET_SLACK_ENABLED", "Not set") + slack_config_env = os.getenv("SOCKET_SLACK_CONFIG_JSON", "Not set") + slack_url = "Not configured" + if self.config.slack_plugin.config and self.config.slack_plugin.config.get("url"): + slack_url = self.config.slack_plugin.config.get("url") + self.logger.debug("=== Slack Webhook Debug Information ===") + self.logger.debug(f"Slack Plugin Enabled: {self.config.slack_plugin.enabled}") + self.logger.debug(f"SOCKET_SLACK_ENABLED environment variable: {slack_enabled_env}") + self.logger.debug(f"SOCKET_SLACK_CONFIG_JSON environment variable: {slack_config_env}") + self.logger.debug(f"Slack Webhook URL: {slack_url}") + self.logger.debug(f"Slack Alert Levels: {self.config.slack_plugin.levels}") + self.logger.debug("=====================================") + if self.config.slack_plugin.enabled: slack_config = { "enabled": self.config.slack_plugin.enabled, diff --git a/socketsecurity/plugins/slack.py b/socketsecurity/plugins/slack.py index 0c592dc..a1d1c43 100644 --- a/socketsecurity/plugins/slack.py +++ b/socketsecurity/plugins/slack.py @@ -15,9 +15,13 @@ def get_name(): def send(self, diff, config: CliConfig): if not self.config.get("enabled", False): + if config.enable_debug: + logger.debug("Slack plugin is disabled - skipping webhook notification") return if not self.config.get("url"): logger.warning("Slack webhook URL not configured.") + if config.enable_debug: + logger.debug("Slack webhook URL is missing from configuration") return else: url = self.config.get("url") @@ -31,6 +35,12 @@ def send(self, diff, config: CliConfig): message = self.create_slack_blocks_from_diff(diff, config) logger.debug(f"Sending message to {url}") + + if config.enable_debug: + logger.debug(f"Slack webhook URL: {url}") + logger.debug(f"Number of alerts to send: {len(diff.new_alerts)}") + logger.debug(f"Message blocks count: {len(message)}") + response = requests.post( url, json={"blocks": message} @@ -38,6 +48,8 @@ def send(self, diff, config: CliConfig): if response.status_code >= 400: logger.error("Slack error %s: %s", response.status_code, response.text) + elif config.enable_debug: + logger.debug(f"Slack webhook response: {response.status_code}") @staticmethod def create_slack_blocks_from_diff(diff: Diff, config: CliConfig): From 656a458793f13f81a8f37b143172175d69c74039 Mon Sep 17 00:00:00 2001 From: Douglas Date: Tue, 9 Sep 2025 11:55:34 -0700 Subject: [PATCH 21/23] feat: Add SCM-aware manifest file URL generation and fix report links (#119) - Add get_manifest_file_url() method with GitHub/GitLab/Bitbucket support - Support environment variables for custom SCM servers (GitHub Enterprise, self-hosted GitLab, Bitbucket Server) - Fix manifest file links in security comments to use proper SCM URLs instead of Socket dashboard URLs - Fix 'View full report' links to use diff_url for PRs and report_url for non-PR scans - Add base_path parameter to create_full_scan() for improved path handling - Update socketdev dependency to >=3.0.5 for latest features - Add os module import for environment variable access - Update type hints for better code clarity --- pyproject.toml | 4 +- socketsecurity/__init__.py | 2 +- socketsecurity/core/__init__.py | 11 +-- socketsecurity/core/messages.py | 115 ++++++++++++++++++++++++++++++-- socketsecurity/socketcli.py | 2 +- uv.lock | 10 +-- 6 files changed, 123 insertions(+), 21 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1311b95..ff7c2cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.2.7" +version = "2.2.8" requires-python = ">= 3.10" license = {"file" = "LICENSE"} dependencies = [ @@ -16,7 +16,7 @@ dependencies = [ 'GitPython', 'packaging', 'python-dotenv', - 'socketdev>=3.0.0,<4.0.0' + 'socketdev>=3.0.5,<4.0.0' ] readme = "README.md" description = "Socket Security CLI for CI/CD" diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 8210ef3..8625d2e 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,2 +1,2 @@ __author__ = 'socket.dev' -__version__ = '2.2.7' +__version__ = '2.2.8' diff --git a/socketsecurity/core/__init__.py b/socketsecurity/core/__init__.py index 3edd097..30275b9 100644 --- a/socketsecurity/core/__init__.py +++ b/socketsecurity/core/__init__.py @@ -451,13 +451,14 @@ def empty_head_scan_file() -> List[str]: log.debug(f"Created temporary empty file for baseline scan: {temp_path}") return [temp_path] - def create_full_scan(self, files: List[str], params: FullScanParams) -> FullScan: + def create_full_scan(self, files: List[str], params: FullScanParams, base_path: str = None) -> FullScan: """ Creates a new full scan via the Socket API. Args: files: List of file paths to scan params: Parameters for the full scan + base_path: Base path for the scan (optional) Returns: FullScan object with scan results @@ -465,7 +466,7 @@ def create_full_scan(self, files: List[str], params: FullScanParams) -> FullScan log.info("Creating new full scan") create_full_start = time.time() - res = self.sdk.fullscans.post(files, params, use_types=True, use_lazy_loading=True, max_open_files=50) + res = self.sdk.fullscans.post(files, params, use_types=True, use_lazy_loading=True, max_open_files=50, base_path=base_path) if not res.success: log.error(f"Error creating full scan: {res.message}, status: {res.status}") raise Exception(f"Error creating full scan: {res.message}, status: {res.status}") @@ -523,7 +524,7 @@ def create_full_scan_with_report_url( try: # Create new scan new_scan_start = time.time() - new_full_scan = self.create_full_scan(files, params) + new_full_scan = self.create_full_scan(files, params, base_path=path) new_scan_end = time.time() log.info(f"Total time to create new full scan: {new_scan_end - new_scan_start:.2f}") except APIFailure as e: @@ -899,7 +900,7 @@ def create_new_diff( # Create baseline scan with empty file empty_files = Core.empty_head_scan_file() try: - head_full_scan = self.create_full_scan(empty_files, tmp_params) + head_full_scan = self.create_full_scan(empty_files, tmp_params, base_path=path) head_full_scan_id = head_full_scan.id log.debug(f"Created empty baseline scan: {head_full_scan_id}") @@ -922,7 +923,7 @@ def create_new_diff( # Create new scan try: new_scan_start = time.time() - new_full_scan = self.create_full_scan(files, params) + new_full_scan = self.create_full_scan(files, params, base_path=path) new_scan_end = time.time() log.info(f"Total time to create new full scan: {new_scan_end - new_scan_start:.2f}") except APIFailure as e: diff --git a/socketsecurity/core/messages.py b/socketsecurity/core/messages.py index f062114..6412b4d 100644 --- a/socketsecurity/core/messages.py +++ b/socketsecurity/core/messages.py @@ -1,5 +1,6 @@ import json import logging +import os import re from pathlib import Path from mdutils import MdUtils @@ -29,6 +30,92 @@ def map_severity_to_sarif(severity: str) -> str: } return severity_mapping.get(severity.lower(), "note") + @staticmethod + def get_manifest_file_url(diff: Diff, manifest_path: str, config=None) -> str: + """ + Generate proper URL for manifest file based on the repository type and diff URL. + + :param diff: Diff object containing diff_url and report_url + :param manifest_path: Path to the manifest file (can contain multiple files separated by ';') + :param config: Configuration object to determine SCM type + :return: Properly formatted URL for the manifest file + """ + if not manifest_path: + return "" + + # Handle multiple manifest files separated by ';' - use the first one + first_manifest = manifest_path.split(';')[0] if ';' in manifest_path else manifest_path + + # Clean up the manifest path - remove build agent paths and normalize + clean_path = first_manifest + + # Remove common build agent path prefixes + prefixes_to_remove = [ + 'opt/buildagent/work/', + '/opt/buildagent/work/', + 'home/runner/work/', + '/home/runner/work/', + ] + + for prefix in prefixes_to_remove: + if clean_path.startswith(prefix): + # Find the part after the build ID (usually a hash) + parts = clean_path[len(prefix):].split('/', 2) + if len(parts) >= 3: + clean_path = parts[2] # Take everything after build ID and repo name + break + + # Remove leading slashes + clean_path = clean_path.lstrip('/') + + # Determine SCM type from config or diff_url + scm_type = "api" # Default to API + if config and hasattr(config, 'scm'): + scm_type = config.scm.lower() + elif hasattr(diff, 'diff_url') and diff.diff_url: + diff_url = diff.diff_url.lower() + if 'github.com' in diff_url or 'github' in diff_url: + scm_type = "github" + elif 'gitlab' in diff_url: + scm_type = "gitlab" + elif 'bitbucket' in diff_url: + scm_type = "bitbucket" + + # Generate URL based on SCM type using config information + # NEVER use diff.diff_url for SCM URLs - those are Socket URLs for "View report" links + if scm_type == "github": + if config and hasattr(config, 'repo') and config.repo: + # Get branch from config, default to main + branch = getattr(config, 'branch', 'main') if hasattr(config, 'branch') and config.branch else 'main' + # Construct GitHub URL from repo info (could be github.com or GitHub Enterprise) + github_server = os.getenv('GITHUB_SERVER_URL', 'https://github.com') + return f"{github_server}/{config.repo}/blob/{branch}/{clean_path}" + + elif scm_type == "gitlab": + if config and hasattr(config, 'repo') and config.repo: + # Get branch from config, default to main + branch = getattr(config, 'branch', 'main') if hasattr(config, 'branch') and config.branch else 'main' + # Construct GitLab URL from repo info (could be gitlab.com or self-hosted GitLab) + gitlab_server = os.getenv('CI_SERVER_URL', 'https://gitlab.com') + return f"{gitlab_server}/{config.repo}/-/blob/{branch}/{clean_path}" + + elif scm_type == "bitbucket": + if config and hasattr(config, 'repo') and config.repo: + # Get branch from config, default to main + branch = getattr(config, 'branch', 'main') if hasattr(config, 'branch') and config.branch else 'main' + # Construct Bitbucket URL from repo info (could be bitbucket.org or Bitbucket Server) + bitbucket_server = os.getenv('BITBUCKET_SERVER_URL', 'https://bitbucket.org') + return f"{bitbucket_server}/{config.repo}/src/{branch}/{clean_path}" + + # Fallback to Socket file view for API or unknown repository types + if hasattr(diff, 'report_url') and diff.report_url: + # Strip leading slash and URL encode for Socket dashboard + socket_path = clean_path.lstrip('/') + encoded_path = socket_path.replace('/', '%2F') + return f"{diff.report_url}?tab=files&file={encoded_path}" + + return "" + @staticmethod def find_line_in_file(packagename: str, packageversion: str, manifest_file: str) -> tuple: """ @@ -301,12 +388,13 @@ def create_security_comment_json(diff: Diff) -> dict: return output @staticmethod - def security_comment_template(diff: Diff) -> str: + def security_comment_template(diff: Diff, config=None) -> str: """ Generates the security comment template in the new required format. Dynamically determines placement of the alerts table if markers like `` are used. :param diff: Diff - Contains the detected vulnerabilities and warnings. + :param config: Optional configuration object to determine SCM type. :return: str - The formatted Markdown/HTML string. """ # Group license policy violations by PURL (ecosystem/package@version) @@ -348,6 +436,8 @@ def security_comment_template(diff: Diff) -> str: severity_icon = Messages.get_severity_icon(alert.severity) action = "Block" if alert.error else "Warn" details_open = "" + # Generate proper manifest URL + manifest_url = Messages.get_manifest_file_url(diff, alert.manifests, config) # Generate a table row for each alert comment += f""" @@ -360,7 +450,7 @@ def security_comment_template(diff: Diff) -> str:
{alert.pkg_name}@{alert.pkg_version} - {alert.title}

Note: {alert.description}

-

Source: Manifest File

+

Source: Manifest File

ℹ️ Read more on: This package | This alert | @@ -405,8 +495,12 @@ def security_comment_template(diff: Diff) -> str: for finding in license_findings: comment += f"

  • {finding}
  • \n" + + # Generate proper manifest URL for license violations + license_manifest_url = Messages.get_manifest_file_url(diff, first_alert.manifests, config) + comment += f""" -

    From: {first_alert.manifests}

    +

    From: Manifest File

    ℹ️ Read more on: This package | What is a license policy violation?

    Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

    @@ -420,12 +514,19 @@ def security_comment_template(diff: Diff) -> str: """ # Close table - comment += """ + # Use diff_url for PRs, report_url for non-PR scans + view_report_url = "" + if hasattr(diff, 'diff_url') and diff.diff_url: + view_report_url = diff.diff_url + elif hasattr(diff, 'report_url') and diff.report_url: + view_report_url = diff.report_url + + comment += f""" -[View full report](https://socket.dev/...&action=error%2Cwarn) +[View full report]({view_report_url}?action=error%2Cwarn) """ return comment @@ -519,7 +620,7 @@ def create_acceptable_risk(md: MdUtils, ignore_commands: list) -> MdUtils: return md @staticmethod - def create_security_alert_table(diff: Diff, md: MdUtils) -> (MdUtils, list, dict): + def create_security_alert_table(diff: Diff, md: MdUtils) -> tuple[MdUtils, list, dict]: """ Creates the detected issues table based on the Security Policy :param diff: Diff - Diff report with the detected issues @@ -730,7 +831,7 @@ def create_console_security_alert_table(diff: Diff) -> PrettyTable: return alert_table @staticmethod - def create_sources(alert: Issue, style="md") -> [str, str]: + def create_sources(alert: Issue, style="md") -> tuple[str, str]: sources = [] manifests = [] diff --git a/socketsecurity/socketcli.py b/socketsecurity/socketcli.py index 7731a5d..a0d071e 100644 --- a/socketsecurity/socketcli.py +++ b/socketsecurity/socketcli.py @@ -275,7 +275,7 @@ def main_code(): overview_comment = Messages.dependency_overview_template(diff) log.debug("Creating Security Issues Comment") - security_comment = Messages.security_comment_template(diff) + security_comment = Messages.security_comment_template(diff, config) new_security_comment = True new_overview_comment = True diff --git a/uv.lock b/uv.lock index 80b661a..3375542 100644 --- a/uv.lock +++ b/uv.lock @@ -1027,20 +1027,20 @@ wheels = [ [[package]] name = "socketdev" -version = "3.0.0" +version = "3.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b6/4f/07cb8e4e827931527a3c04e3520dabed8f20ece5a5fb91e5a012e6bb2446/socketdev-3.0.0.tar.gz", hash = "sha256:27c22d3a016e06b916f373f78edd34dc6d7612da0ae845e8e383d58d7425e5bb", size = 101362, upload-time = "2025-08-23T22:59:02.855Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/b7/fe90d55105df76e9ff3af025f64b2d2b515c30ac0866a9973a093f25c5ed/socketdev-3.0.5.tar.gz", hash = "sha256:58cbe8613c3c892cdbae4941cb53f065051f8e991500d9d61618b214acf4ffc2", size = 129576, upload-time = "2025-09-09T07:15:48.232Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/58/c4/ed98ab0022f19c8e7ded5a2eaea0f0cabf829c6e7001bb7cf8ae112e964f/socketdev-3.0.0-py3-none-any.whl", hash = "sha256:f142f3b0d22a32479cf73bd35f9a0bdcd4896e494c60fdeb2999c0daa9682611", size = 48942, upload-time = "2025-08-23T22:59:01.134Z" }, + { url = "https://files.pythonhosted.org/packages/de/05/c3fc7d0418c2598302ad4b0baf111fa492b31a8fa14acfa394af6f55b373/socketdev-3.0.5-py3-none-any.whl", hash = "sha256:e050f50d2c6b4447107edd3368b56b053e1df62056d424cc1616e898303638ef", size = 55083, upload-time = "2025-09-09T07:15:46.52Z" }, ] [[package]] name = "socketsecurity" -version = "2.2.3" +version = "2.2.7" source = { editable = "." } dependencies = [ { name = "gitpython" }, @@ -1084,7 +1084,7 @@ requires-dist = [ { name = "python-dotenv" }, { name = "requests" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.3.0" }, - { name = "socketdev", specifier = ">=3.0.0,<4.0.0" }, + { name = "socketdev", specifier = ">=3.0.5,<4.0.0" }, { name = "twine", marker = "extra == 'dev'" }, { name = "uv", marker = "extra == 'dev'", specifier = ">=0.1.0" }, ] From 40fc69e6c699e0f23e05ef59e44cc6248c195051 Mon Sep 17 00:00:00 2001 From: Douglas Date: Thu, 11 Sep 2025 21:28:50 -0700 Subject: [PATCH 22/23] feat: add monorepo workspace support with --sub-path and --workspace-name (#120) - Add --sub-path option to scan manifest files in a subdirectory while preserving git context from target-path - Add --workspace-name option to append suffix to repository name (repo-name-workspace_name) - Require both options to be used together with validation - Update scanning logic to use combined target_path + sub_path for manifest file detection - Modify repository naming to include workspace suffix when provided - Preserve git repository context (commits, branches, etc.) from main target-path - Enable Socket CLI to work with monorepo structures where manifests are in subdirectories This allows users to scan specific workspaces within a monorepo while maintaining proper git context and --- pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- socketsecurity/config.py | 24 +++++++++++++++++ socketsecurity/socketcli.py | 51 ++++++++++++++++++++++++++++++++----- 4 files changed, 70 insertions(+), 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ff7c2cb..d8747c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.2.8" +version = "2.2.9" requires-python = ">= 3.10" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 8625d2e..2ba817a 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,2 +1,2 @@ __author__ = 'socket.dev' -__version__ = '2.2.8' +__version__ = '2.2.9' diff --git a/socketsecurity/config.py b/socketsecurity/config.py index a6b5b2d..72a2327 100644 --- a/socketsecurity/config.py +++ b/socketsecurity/config.py @@ -60,6 +60,8 @@ class CliConfig: license_file_name: str = "license_output.json" save_submitted_files_list: Optional[str] = None save_manifest_tar: Optional[str] = None + sub_path: Optional[str] = None + workspace_name: Optional[str] = None @classmethod def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig': @@ -106,6 +108,8 @@ def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig': 'license_file_name': args.license_file_name, 'save_submitted_files_list': args.save_submitted_files_list, 'save_manifest_tar': args.save_manifest_tar, + 'sub_path': args.sub_path, + 'workspace_name': args.workspace_name, 'version': __version__ } try: @@ -129,6 +133,14 @@ def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig': if args.owner: config_args['integration_org_slug'] = args.owner + # Validate that sub_path and workspace_name are used together + if args.sub_path and not args.workspace_name: + logging.error("--sub-path requires --workspace-name to be specified") + exit(1) + if args.workspace_name and not args.sub_path: + logging.error("--workspace-name requires --sub-path to be specified") + exit(1) + return cls(**config_args) def to_dict(self) -> dict: @@ -285,6 +297,18 @@ def create_argument_parser() -> argparse.ArgumentParser: default="[]", help="Files to analyze (JSON array string)" ) + path_group.add_argument( + "--sub-path", + dest="sub_path", + metavar="", + help="Sub-path within target-path for manifest file scanning (while preserving git context from target-path)" + ) + path_group.add_argument( + "--workspace-name", + dest="workspace_name", + metavar="", + help="Workspace name suffix to append to repository name (repo-name-workspace_name)" + ) path_group.add_argument( "--excluded-ecosystems", diff --git a/socketsecurity/socketcli.py b/socketsecurity/socketcli.py index a0d071e..15ae755 100644 --- a/socketsecurity/socketcli.py +++ b/socketsecurity/socketcli.py @@ -136,13 +136,35 @@ def main_code(): raise Exception(f"Unable to find path {config.target_path}") if not config.repo: - config.repo = "socket-default-repo" + base_repo_name = "socket-default-repo" + if config.workspace_name: + config.repo = f"{base_repo_name}-{config.workspace_name}" + else: + config.repo = base_repo_name log.debug(f"Using default repository name: {config.repo}") if not config.branch: config.branch = "socket-default-branch" log.debug(f"Using default branch name: {config.branch}") + # Calculate the scan path - combine target_path with sub_path if provided + scan_path = config.target_path + if config.sub_path: + import os + scan_path = os.path.join(config.target_path, config.sub_path) + log.debug(f"Using sub-path for scanning: {scan_path}") + # Verify the scan path exists + if not os.path.exists(scan_path): + raise Exception(f"Sub-path does not exist: {scan_path}") + + # Modify repository name if workspace_name is provided + if config.workspace_name and config.repo: + config.repo = f"{config.repo}-{config.workspace_name}" + log.debug(f"Modified repository name with workspace suffix: {config.repo}") + elif config.workspace_name and not config.repo: + # If no repo name was set but workspace_name is provided, we'll use it later + log.debug(f"Workspace name provided: {config.workspace_name}") + scm = None if config.scm == "github": from socketsecurity.core.scm.github import Github, GithubConfig @@ -179,6 +201,21 @@ def main_code(): # Check if we have supported manifest files has_supported_files = files_to_check and core.has_manifest_files(files_to_check) + # If using sub_path, we need to check if manifest files exist in the scan path + if config.sub_path and not files_explicitly_specified: + # Override file checking to look in the scan path instead + import os + from pathlib import Path + + # Get manifest files from the scan path + try: + scan_files = core.find_files(scan_path) + has_supported_files = len(scan_files) > 0 + log.debug(f"Found {len(scan_files)} manifest files in scan path: {scan_path}") + except Exception as e: + log.debug(f"Error finding files in scan path {scan_path}: {e}") + has_supported_files = False + # Case 3: If no supported files or files are empty, force API mode (no PR comments) if not has_supported_files: force_api_mode = True @@ -264,7 +301,7 @@ def main_code(): log.info("Push initiated flow") if scm.check_event_type() == "diff": log.info("Starting comment logic for PR/MR event") - diff = core.create_new_diff(config.target_path, params, no_change=should_skip_scan, save_files_list_path=config.save_submitted_files_list, save_manifest_tar_path=config.save_manifest_tar) + diff = core.create_new_diff(scan_path, params, no_change=should_skip_scan, save_files_list_path=config.save_submitted_files_list, save_manifest_tar_path=config.save_manifest_tar) comments = scm.get_comments_for_pr() log.debug("Removing comment alerts") @@ -317,14 +354,14 @@ def main_code(): ) else: log.info("Starting non-PR/MR flow") - diff = core.create_new_diff(config.target_path, params, no_change=should_skip_scan, save_files_list_path=config.save_submitted_files_list, save_manifest_tar_path=config.save_manifest_tar) + diff = core.create_new_diff(scan_path, params, no_change=should_skip_scan, save_files_list_path=config.save_submitted_files_list, save_manifest_tar_path=config.save_manifest_tar) output_handler.handle_output(diff) elif config.enable_diff and not force_api_mode: # New logic: --enable-diff forces diff mode even with --integration api (no SCM) log.info("Diff mode enabled without SCM integration") - diff = core.create_new_diff(config.target_path, params, no_change=should_skip_scan, save_files_list_path=config.save_submitted_files_list, save_manifest_tar_path=config.save_manifest_tar) + diff = core.create_new_diff(scan_path, params, no_change=should_skip_scan, save_files_list_path=config.save_submitted_files_list, save_manifest_tar_path=config.save_manifest_tar) output_handler.handle_output(diff) elif config.enable_diff and force_api_mode: @@ -337,7 +374,7 @@ def main_code(): } log.debug(f"params={serializable_params}") diff = core.create_full_scan_with_report_url( - config.target_path, + scan_path, params, no_change=should_skip_scan, save_files_list_path=config.save_submitted_files_list, @@ -356,7 +393,7 @@ def main_code(): } log.debug(f"params={serializable_params}") diff = core.create_full_scan_with_report_url( - config.target_path, + scan_path, params, no_change=should_skip_scan, save_files_list_path=config.save_submitted_files_list, @@ -367,7 +404,7 @@ def main_code(): else: log.info("API Mode") diff = core.create_new_diff( - config.target_path, params, + scan_path, params, no_change=should_skip_scan, save_files_list_path=config.save_submitted_files_list, save_manifest_tar_path=config.save_manifest_tar From 4672706514e8a3def2eb0fc127f21ecb120259c2 Mon Sep 17 00:00:00 2001 From: Douglas Date: Fri, 12 Sep 2025 12:29:37 -0700 Subject: [PATCH 23/23] Updated readme and updated sdk requirements (#121) * Updated readme and updated sdk requirements * feat: enhance monorepo support with multiple --sub-path options - Add support for multiple --sub-path arguments to scan different directories within a single workspace - Require --workspace-name when using --sub-path for proper workspace identification - Optimize base_paths parameter to pass target_path for simplified SDK integration - Update argument validation to enforce sub_paths and workspace_name pairing - Add comprehensive README documentation with monorepo examples and usage patterns - Include GitHub Actions workflow examples for monorepo CI/CD scenarios - Extend parameter table with detailed descriptions of new CLI options This enhancement enables scanning multiple directories (e.g., frontend, backend, services/api) as a single workspace while preserving git context from the repository root, making it ideal for organizations with monorepo structures. --- README.md | 78 ++++++++++++++++++++++++++------- pyproject.toml | 4 +- socketsecurity/__init__.py | 2 +- socketsecurity/config.py | 15 ++++--- socketsecurity/core/__init__.py | 66 ++++++++++++++++------------ socketsecurity/socketcli.py | 63 +++++++++++++++----------- 6 files changed, 150 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index 5295e95..89ff153 100644 --- a/README.md +++ b/README.md @@ -41,15 +41,60 @@ Pre-configured workflow examples are available in the [`workflows/`](workflows/) These examples are production-ready and include best practices for each platform. +## Monorepo Workspace Support + +The Socket CLI supports scanning specific workspaces within monorepo structures while preserving git context from the repository root. This is useful for organizations that maintain multiple applications or services in a single repository. + +### Key Features + +- **Multiple Sub-paths**: Specify multiple `--sub-path` options to scan different directories within your monorepo +- **Combined Workspace**: All sub-paths are scanned together as a single workspace in Socket +- **Git Context Preserved**: Repository metadata (commits, branches, etc.) comes from the main target-path +- **Workspace Naming**: Use `--workspace-name` to differentiate scans from different parts of your monorepo + +### Usage Examples + +**Scan multiple frontend and backend workspaces:** +```bash +socketcli --target-path /path/to/monorepo \ + --sub-path frontend \ + --sub-path backend \ + --sub-path services/api \ + --workspace-name main-app +``` + +**GitHub Actions for monorepo workspace:** +```bash +socketcli --target-path $GITHUB_WORKSPACE \ + --sub-path packages/web \ + --sub-path packages/mobile \ + --workspace-name mobile-web \ + --scm github \ + --pr-number $PR_NUMBER +``` + +This will: +- Scan manifest files in `./packages/web/` and `./packages/mobile/` +- Combine them into a single workspace scan +- Create a repository in Socket named like `my-repo-mobile-web` +- Preserve git context (commits, branch info) from the repository root + +### Requirements + +- Both `--sub-path` and `--workspace-name` must be specified together +- `--sub-path` can be used multiple times to include multiple directories +- All specified sub-paths must exist within the target-path + ## Usage ```` shell -socketcli [-h] [--api-token API_TOKEN] [--repo REPO] [--integration {api,github,gitlab}] [--owner OWNER] [--branch BRANCH] - [--committers [COMMITTERS ...]] [--pr-number PR_NUMBER] [--commit-message COMMIT_MESSAGE] [--commit-sha COMMIT_SHA] - [--target-path TARGET_PATH] [--sbom-file SBOM_FILE] [--files FILES] [--save-submitted-files-list SAVE_SUBMITTED_FILES_LIST] - [--default-branch] [--pending-head] [--generate-license] [--enable-debug] [--enable-json] [--enable-sarif] - [--disable-overview] [--disable-security-issue] [--allow-unverified] [--ignore-commit-files] [--disable-blocking] - [--scm SCM] [--timeout TIMEOUT] [--exclude-license-details] +socketcli [-h] [--api-token API_TOKEN] [--repo REPO] [--repo-is-public] [--branch BRANCH] [--integration {api,github,gitlab,azure,bitbucket}] + [--owner OWNER] [--pr-number PR_NUMBER] [--commit-message COMMIT_MESSAGE] [--commit-sha COMMIT_SHA] [--committers [COMMITTERS ...]] + [--target-path TARGET_PATH] [--sbom-file SBOM_FILE] [--license-file-name LICENSE_FILE_NAME] [--save-submitted-files-list SAVE_SUBMITTED_FILES_LIST] + [--save-manifest-tar SAVE_MANIFEST_TAR] [--files FILES] [--sub-path SUB_PATH] [--workspace-name WORKSPACE_NAME] + [--excluded-ecosystems EXCLUDED_ECOSYSTEMS] [--default-branch] [--pending-head] [--generate-license] [--enable-debug] + [--enable-json] [--enable-sarif] [--disable-overview] [--exclude-license-details] [--allow-unverified] [--disable-security-issue] + [--ignore-commit-files] [--disable-blocking] [--enable-diff] [--scm SCM] [--timeout TIMEOUT] [--include-module-folders] [--version] ```` If you don't want to provide the Socket API Token every time then you can use the environment variable `SOCKET_SECURITY_API_KEY` @@ -65,11 +110,11 @@ If you don't want to provide the Socket API Token every time then you can use th | Parameter | Required | Default | Description | |:-----------------|:---------|:--------|:------------------------------------------------------------------------| | --repo | False | *auto* | Repository name in owner/repo format (auto-detected from git remote) | -| --integration | False | api | Integration type (api, github, gitlab) | +| --repo-is-public | False | False | If set, flags a new repository creation as public. Defaults to false. | +| --integration | False | api | Integration type (api, github, gitlab, azure, bitbucket) | | --owner | False | | Name of the integration owner, defaults to the socket organization slug | | --branch | False | *auto* | Branch name (auto-detected from git) | | --committers | False | *auto* | Committer(s) to filter by (auto-detected from git commit) | -| --repo-is-public | False | False | If set, flags a new repository creation as public. Defaults to false. | #### Pull Request and Commit | Parameter | Required | Default | Description | @@ -83,17 +128,20 @@ If you don't want to provide the Socket API Token every time then you can use th |:----------------------------|:---------|:----------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | --target-path | False | ./ | Target path for analysis | | --sbom-file | False | | SBOM file path | -| --files | False | *auto* | Files to analyze (JSON array string). Auto-detected from git commit changes when not specified | -| --excluded-ecosystems | False | [] | List of ecosystems to exclude from analysis (JSON array string). You can get supported files from the [Supported Files API](https://docs.socket.dev/reference/getsupportedfiles) | | --license-file-name | False | `license_output.json` | Name of the file to save the license details to if enabled | | --save-submitted-files-list | False | | Save list of submitted file names to JSON file for debugging purposes | | --save-manifest-tar | False | | Save all manifest files to a compressed tar.gz archive with original directory structure | +| --files | False | *auto* | Files to analyze (JSON array string). Auto-detected from git commit changes when not specified | +| --sub-path | False | | Sub-path within target-path for manifest file scanning (can be specified multiple times). All sub-paths are combined into a single workspace scan while preserving git context from target-path. Must be used with --workspace-name | +| --workspace-name | False | | Workspace name suffix to append to repository name (repo-name-workspace_name). Must be used with --sub-path | +| --excluded-ecosystems | False | [] | List of ecosystems to exclude from analysis (JSON array string). You can get supported files from the [Supported Files API](https://docs.socket.dev/reference/getsupportedfiles) | #### Branch and Scan Configuration -| Parameter | Required | Default | Description | -|:-----------------|:---------|:--------|:------------------------------------------------------------------------------------------------------| -| --default-branch | False | *auto* | Make this branch the default branch (auto-detected from git and CI environment when not specified) | -| --pending-head | False | *auto* | If true, the new scan will be set as the branch's head scan (automatically synced with default-branch) | +| Parameter | Required | Default | Description | +|:-------------------------|:---------|:--------|:------------------------------------------------------------------------------------------------------| +| --default-branch | False | *auto* | Make this branch the default branch (auto-detected from git and CI environment when not specified) | +| --pending-head | False | *auto* | If true, the new scan will be set as the branch's head scan (automatically synced with default-branch) | +| --include-module-folders | False | False | If enabled will include manifest files from folders like node_modules | #### Output Configuration | Parameter | Required | Default | Description | @@ -104,6 +152,7 @@ If you don't want to provide the Socket API Token every time then you can use th | --enable-sarif | False | False | Enable SARIF output of results instead of table or JSON format | | --disable-overview | False | False | Disable overview output | | --exclude-license-details | False | False | Exclude license details from the diff report (boosts performance for large repos) | +| --version | False | False | Show program's version number and exit | #### Security Configuration | Parameter | Required | Default | Description | @@ -119,7 +168,6 @@ If you don't want to provide the Socket API Token every time then you can use th | --enable-diff | False | False | Enable diff mode even when using --integration api (forces diff mode without SCM integration) | | --scm | False | api | Source control management type | | --timeout | False | | Timeout in seconds for API requests | -| --include-module-folders | False | False | If enabled will include manifest files from folders like node_modules | #### Plugins diff --git a/pyproject.toml b/pyproject.toml index d8747c3..22811e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.2.9" +version = "2.2.11" requires-python = ">= 3.10" license = {"file" = "LICENSE"} dependencies = [ @@ -16,7 +16,7 @@ dependencies = [ 'GitPython', 'packaging', 'python-dotenv', - 'socketdev>=3.0.5,<4.0.0' + 'socketdev>=3.0.6,<4.0.0' ] readme = "README.md" description = "Socket Security CLI for CI/CD" diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 2ba817a..9ea1adb 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,2 +1,2 @@ __author__ = 'socket.dev' -__version__ = '2.2.9' +__version__ = '2.2.11' diff --git a/socketsecurity/config.py b/socketsecurity/config.py index 72a2327..bbfb4bc 100644 --- a/socketsecurity/config.py +++ b/socketsecurity/config.py @@ -60,7 +60,7 @@ class CliConfig: license_file_name: str = "license_output.json" save_submitted_files_list: Optional[str] = None save_manifest_tar: Optional[str] = None - sub_path: Optional[str] = None + sub_paths: List[str] = field(default_factory=list) workspace_name: Optional[str] = None @classmethod @@ -108,7 +108,7 @@ def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig': 'license_file_name': args.license_file_name, 'save_submitted_files_list': args.save_submitted_files_list, 'save_manifest_tar': args.save_manifest_tar, - 'sub_path': args.sub_path, + 'sub_paths': args.sub_paths or [], 'workspace_name': args.workspace_name, 'version': __version__ } @@ -133,11 +133,11 @@ def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig': if args.owner: config_args['integration_org_slug'] = args.owner - # Validate that sub_path and workspace_name are used together - if args.sub_path and not args.workspace_name: + # Validate that sub_paths and workspace_name are used together + if args.sub_paths and not args.workspace_name: logging.error("--sub-path requires --workspace-name to be specified") exit(1) - if args.workspace_name and not args.sub_path: + if args.workspace_name and not args.sub_paths: logging.error("--workspace-name requires --sub-path to be specified") exit(1) @@ -299,9 +299,10 @@ def create_argument_parser() -> argparse.ArgumentParser: ) path_group.add_argument( "--sub-path", - dest="sub_path", + dest="sub_paths", metavar="", - help="Sub-path within target-path for manifest file scanning (while preserving git context from target-path)" + action="append", + help="Sub-path within target-path for manifest file scanning (can be specified multiple times). All sub-paths will be combined into a single workspace scan while preserving git context from target-path" ) path_group.add_argument( "--workspace-name", diff --git a/socketsecurity/core/__init__.py b/socketsecurity/core/__init__.py index 30275b9..6dd6ecb 100644 --- a/socketsecurity/core/__init__.py +++ b/socketsecurity/core/__init__.py @@ -451,14 +451,14 @@ def empty_head_scan_file() -> List[str]: log.debug(f"Created temporary empty file for baseline scan: {temp_path}") return [temp_path] - def create_full_scan(self, files: List[str], params: FullScanParams, base_path: str = None) -> FullScan: + def create_full_scan(self, files: List[str], params: FullScanParams, base_paths: List[str] = None) -> FullScan: """ Creates a new full scan via the Socket API. Args: files: List of file paths to scan params: Parameters for the full scan - base_path: Base path for the scan (optional) + base_paths: List of base paths for the scan (optional) Returns: FullScan object with scan results @@ -466,7 +466,7 @@ def create_full_scan(self, files: List[str], params: FullScanParams, base_path: log.info("Creating new full scan") create_full_start = time.time() - res = self.sdk.fullscans.post(files, params, use_types=True, use_lazy_loading=True, max_open_files=50, base_path=base_path) + res = self.sdk.fullscans.post(files, params, use_types=True, use_lazy_loading=True, max_open_files=50, base_paths=base_paths) if not res.success: log.error(f"Error creating full scan: {res.message}, status: {res.status}") raise Exception(f"Error creating full scan: {res.message}, status: {res.status}") @@ -480,20 +480,22 @@ def create_full_scan(self, files: List[str], params: FullScanParams, base_path: def create_full_scan_with_report_url( self, - path: str, + paths: List[str], params: FullScanParams, no_change: bool = False, save_files_list_path: str = None, - save_manifest_tar_path: str = None + save_manifest_tar_path: str = None, + base_paths: List[str] = None ) -> Diff: """Create a new full scan and return with html_report_url. Args: - path: Path to look for manifest files + paths: List of paths to look for manifest files params: Query params for the Full Scan endpoint no_change: If True, return empty result save_files_list_path: Optional path to save submitted files list for debugging save_manifest_tar_path: Optional path to save manifest files tar.gz archive + base_paths: List of base paths for the scan (optional) Returns: Dict with full scan data including html_report_url @@ -507,24 +509,27 @@ def create_full_scan_with_report_url( if no_change: return diff - # Find manifest files - files = self.find_files(path) + # Find manifest files from all paths + all_files = [] + for path in paths: + files = self.find_files(path) + all_files.extend(files) # Save submitted files list if requested - if save_files_list_path and files: - self.save_submitted_files_list(files, save_files_list_path) + if save_files_list_path and all_files: + self.save_submitted_files_list(all_files, save_files_list_path) - # Save manifest tar.gz if requested - if save_manifest_tar_path and files: - self.save_manifest_tar(files, save_manifest_tar_path, path) + # Save manifest tar.gz if requested (use first path as base) + if save_manifest_tar_path and all_files and paths: + self.save_manifest_tar(all_files, save_manifest_tar_path, paths[0]) - if not files: + if not all_files: return diff try: # Create new scan new_scan_start = time.time() - new_full_scan = self.create_full_scan(files, params, base_path=path) + new_full_scan = self.create_full_scan(all_files, params, base_paths=base_paths) new_scan_end = time.time() log.info(f"Total time to create new full scan: {new_scan_end - new_scan_start:.2f}") except APIFailure as e: @@ -847,37 +852,42 @@ def get_added_and_removed_packages( def create_new_diff( self, - path: str, + paths: List[str], params: FullScanParams, no_change: bool = False, save_files_list_path: str = None, - save_manifest_tar_path: str = None + save_manifest_tar_path: str = None, + base_paths: List[str] = None ) -> Diff: """Create a new diff using the Socket SDK. Args: - path: Path to look for manifest files + paths: List of paths to look for manifest files params: Query params for the Full Scan endpoint no_change: If True, return empty diff save_files_list_path: Optional path to save submitted files list for debugging save_manifest_tar_path: Optional path to save manifest files tar.gz archive + base_paths: List of base paths for the scan (optional) """ log.debug(f"starting create_new_diff with no_change: {no_change}") if no_change: return Diff(id="NO_DIFF_RAN", diff_url="", report_url="") - # Find manifest files - files = self.find_files(path) + # Find manifest files from all paths + all_files = [] + for path in paths: + files = self.find_files(path) + all_files.extend(files) # Save submitted files list if requested - if save_files_list_path and files: - self.save_submitted_files_list(files, save_files_list_path) + if save_files_list_path and all_files: + self.save_submitted_files_list(all_files, save_files_list_path) - # Save manifest tar.gz if requested - if save_manifest_tar_path and files: - self.save_manifest_tar(files, save_manifest_tar_path, path) + # Save manifest tar.gz if requested (use first path as base) + if save_manifest_tar_path and all_files and paths: + self.save_manifest_tar(all_files, save_manifest_tar_path, paths[0]) - if not files: + if not all_files: return Diff(id="NO_DIFF_RAN", diff_url="", report_url="") try: @@ -900,7 +910,7 @@ def create_new_diff( # Create baseline scan with empty file empty_files = Core.empty_head_scan_file() try: - head_full_scan = self.create_full_scan(empty_files, tmp_params, base_path=path) + head_full_scan = self.create_full_scan(empty_files, tmp_params, base_paths=base_paths) head_full_scan_id = head_full_scan.id log.debug(f"Created empty baseline scan: {head_full_scan_id}") @@ -923,7 +933,7 @@ def create_new_diff( # Create new scan try: new_scan_start = time.time() - new_full_scan = self.create_full_scan(files, params, base_path=path) + new_full_scan = self.create_full_scan(all_files, params, base_paths=base_paths) new_scan_end = time.time() log.info(f"Total time to create new full scan: {new_scan_end - new_scan_start:.2f}") except APIFailure as e: diff --git a/socketsecurity/socketcli.py b/socketsecurity/socketcli.py index 15ae755..2813790 100644 --- a/socketsecurity/socketcli.py +++ b/socketsecurity/socketcli.py @@ -147,15 +147,22 @@ def main_code(): config.branch = "socket-default-branch" log.debug(f"Using default branch name: {config.branch}") - # Calculate the scan path - combine target_path with sub_path if provided - scan_path = config.target_path - if config.sub_path: + # Calculate the scan paths - combine target_path with sub_paths if provided + scan_paths = [] + base_paths = [config.target_path] # Always use target_path as the single base path + + if config.sub_paths: import os - scan_path = os.path.join(config.target_path, config.sub_path) - log.debug(f"Using sub-path for scanning: {scan_path}") - # Verify the scan path exists - if not os.path.exists(scan_path): - raise Exception(f"Sub-path does not exist: {scan_path}") + for sub_path in config.sub_paths: + full_scan_path = os.path.join(config.target_path, sub_path) + log.debug(f"Using sub-path for scanning: {full_scan_path}") + # Verify the scan path exists + if not os.path.exists(full_scan_path): + raise Exception(f"Sub-path does not exist: {full_scan_path}") + scan_paths.append(full_scan_path) + else: + # Use the target path as the single scan path + scan_paths = [config.target_path] # Modify repository name if workspace_name is provided if config.workspace_name and config.repo: @@ -201,19 +208,22 @@ def main_code(): # Check if we have supported manifest files has_supported_files = files_to_check and core.has_manifest_files(files_to_check) - # If using sub_path, we need to check if manifest files exist in the scan path - if config.sub_path and not files_explicitly_specified: - # Override file checking to look in the scan path instead + # If using sub_paths, we need to check if manifest files exist in the scan paths + if config.sub_paths and not files_explicitly_specified: + # Override file checking to look in the scan paths instead import os from pathlib import Path - # Get manifest files from the scan path + # Get manifest files from all scan paths try: - scan_files = core.find_files(scan_path) - has_supported_files = len(scan_files) > 0 - log.debug(f"Found {len(scan_files)} manifest files in scan path: {scan_path}") + all_scan_files = [] + for scan_path in scan_paths: + scan_files = core.find_files(scan_path) + all_scan_files.extend(scan_files) + has_supported_files = len(all_scan_files) > 0 + log.debug(f"Found {len(all_scan_files)} manifest files across {len(scan_paths)} scan paths") except Exception as e: - log.debug(f"Error finding files in scan path {scan_path}: {e}") + log.debug(f"Error finding files in scan paths: {e}") has_supported_files = False # Case 3: If no supported files or files are empty, force API mode (no PR comments) @@ -301,7 +311,7 @@ def main_code(): log.info("Push initiated flow") if scm.check_event_type() == "diff": log.info("Starting comment logic for PR/MR event") - diff = core.create_new_diff(scan_path, params, no_change=should_skip_scan, save_files_list_path=config.save_submitted_files_list, save_manifest_tar_path=config.save_manifest_tar) + diff = core.create_new_diff(scan_paths, params, no_change=should_skip_scan, save_files_list_path=config.save_submitted_files_list, save_manifest_tar_path=config.save_manifest_tar, base_paths=base_paths) comments = scm.get_comments_for_pr() log.debug("Removing comment alerts") @@ -354,14 +364,14 @@ def main_code(): ) else: log.info("Starting non-PR/MR flow") - diff = core.create_new_diff(scan_path, params, no_change=should_skip_scan, save_files_list_path=config.save_submitted_files_list, save_manifest_tar_path=config.save_manifest_tar) + diff = core.create_new_diff(scan_paths, params, no_change=should_skip_scan, save_files_list_path=config.save_submitted_files_list, save_manifest_tar_path=config.save_manifest_tar, base_paths=base_paths) output_handler.handle_output(diff) elif config.enable_diff and not force_api_mode: # New logic: --enable-diff forces diff mode even with --integration api (no SCM) log.info("Diff mode enabled without SCM integration") - diff = core.create_new_diff(scan_path, params, no_change=should_skip_scan, save_files_list_path=config.save_submitted_files_list, save_manifest_tar_path=config.save_manifest_tar) + diff = core.create_new_diff(scan_paths, params, no_change=should_skip_scan, save_files_list_path=config.save_submitted_files_list, save_manifest_tar_path=config.save_manifest_tar, base_paths=base_paths) output_handler.handle_output(diff) elif config.enable_diff and force_api_mode: @@ -374,11 +384,12 @@ def main_code(): } log.debug(f"params={serializable_params}") diff = core.create_full_scan_with_report_url( - scan_path, + scan_paths, params, no_change=should_skip_scan, save_files_list_path=config.save_submitted_files_list, - save_manifest_tar_path=config.save_manifest_tar + save_manifest_tar_path=config.save_manifest_tar, + base_paths=base_paths ) log.info(f"Full scan created with ID: {diff.id}") log.info(f"Full scan report URL: {diff.report_url}") @@ -393,21 +404,23 @@ def main_code(): } log.debug(f"params={serializable_params}") diff = core.create_full_scan_with_report_url( - scan_path, + scan_paths, params, no_change=should_skip_scan, save_files_list_path=config.save_submitted_files_list, - save_manifest_tar_path=config.save_manifest_tar + save_manifest_tar_path=config.save_manifest_tar, + base_paths=base_paths ) log.info(f"Full scan created with ID: {diff.id}") log.info(f"Full scan report URL: {diff.report_url}") else: log.info("API Mode") diff = core.create_new_diff( - scan_path, params, + scan_paths, params, no_change=should_skip_scan, save_files_list_path=config.save_submitted_files_list, - save_manifest_tar_path=config.save_manifest_tar + save_manifest_tar_path=config.save_manifest_tar, + base_paths=base_paths ) output_handler.handle_output(diff)