From 5f200f0392da80010fe20eda3ba2147beb66a497 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 23:29:03 -0400 Subject: [PATCH 1/7] ci(deps): bump pypa/gh-action-pypi-publish from ab69e431e9c9f48a3310be0a56527c679f56e04d to dc37677b2e1c63e2034f94d8a5b11f265b73ba33 (#91) * ci(deps): bump pypa/gh-action-pypi-publish Bumps [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) from ab69e431e9c9f48a3310be0a56527c679f56e04d to ba38be9e461d3875417946c167d0b5f3d385a247. - [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases) - [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/ab69e431e9c9f48a3310be0a56527c679f56e04d...ba38be9e461d3875417946c167d0b5f3d385a247) --- updated-dependencies: - dependency-name: pypa/gh-action-pypi-publish dependency-version: ba38be9e461d3875417946c167d0b5f3d385a247 dependency-type: direct:production ... Signed-off-by: dependabot[bot] * Apply suggestions from code review Co-authored-by: lelia <2418071+lelia@users.noreply.github.com> --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lelia <2418071+lelia@users.noreply.github.com> --- .github/workflows/pr-preview.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index 73849d8..14710ce 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -67,7 +67,7 @@ jobs: - name: Publish to Test PyPI if: steps.version_check.outputs.exists != 'true' - uses: pypa/gh-action-pypi-publish@ab69e431e9c9f48a3310be0a56527c679f56e04d + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: repository-url: https://test.pypi.org/legacy/ verbose: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 322ae27..1be63e1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,7 +53,7 @@ jobs: - name: Publish to PyPI if: steps.version_check.outputs.pypi_exists != 'true' - uses: pypa/gh-action-pypi-publish@ab69e431e9c9f48a3310be0a56527c679f56e04d + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 - name: Verify package is installable id: verify_package From 1c1f1f21e2d6cea5da183540b170080ec78d1fdc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 23:30:40 -0400 Subject: [PATCH 2/7] ci(deps): bump actions/checkout from 6.0.2 to 7.0.1 (#96) Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.2 to 7.0.1. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/de0fac2e4500dabe0009e67214ff5f5447ce83dd...3d3c42e5aac5ba805825da76410c181273ba90b1) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 7.0.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lelia <2418071+lelia@users.noreply.github.com> --- .github/workflows/dependency-review.yml | 6 +++--- .github/workflows/pr-preview.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/version-check.yml | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index fd1d8fc..f2aabec 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -40,7 +40,7 @@ jobs: workflow_or_action_changed: ${{ steps.diff.outputs.workflow_or_action_changed }} is_trusted: ${{ steps.trust.outputs.is_trusted }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false @@ -124,7 +124,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 1 persist-credentials: false @@ -197,7 +197,7 @@ jobs: timeout-minutes: 15 environment: socket-firewall steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 1 persist-credentials: false diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index 14710ce..27f7711 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -24,7 +24,7 @@ jobs: contents: read pull-requests: write steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1be63e1..daae060 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,7 +10,7 @@ jobs: id-token: write contents: read steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/version-check.yml b/.github/workflows/version-check.yml index b7f2871..b24786d 100644 --- a/.github/workflows/version-check.yml +++ b/.github/workflows/version-check.yml @@ -20,7 +20,7 @@ jobs: if: github.event.pull_request.user.login != 'dependabot[bot]' runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 # Fetch all history for all branches persist-credentials: false From 82cf3918cb6b48aba604eda03290b804c1128b4c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 23:41:17 -0400 Subject: [PATCH 3/7] chore(deps): bump the python-minor-patch group across 1 directory with 2 updates (#95) Bumps the python-minor-patch group with 2 updates in the / directory: [typing-extensions](https://github.com/python/typing_extensions) and [ruff](https://github.com/astral-sh/ruff). Updates `typing-extensions` from 4.15.0 to 4.16.0 - [Release notes](https://github.com/python/typing_extensions/releases) - [Changelog](https://github.com/python/typing_extensions/blob/main/CHANGELOG.md) - [Commits](https://github.com/python/typing_extensions/compare/4.15.0...4.16.0) Updates `ruff` from 0.15.14 to 0.15.20 - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.15.14...0.15.20) --- updated-dependencies: - dependency-name: typing-extensions dependency-version: 4.16.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: python-minor-patch - dependency-name: ruff dependency-version: 0.15.20 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python-minor-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lelia <2418071+lelia@users.noreply.github.com> --- uv.lock | 48 ++++++++++++++++++++++++------------------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/uv.lock b/uv.lock index 7397d8f..ae0b374 100644 --- a/uv.lock +++ b/uv.lock @@ -1286,27 +1286,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.14" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/dc/8a/8bce2894573e9dae6ff4d77fe34ad727d79b9e6238ad288c5638990d90f6/ruff-0.15.14.tar.gz", hash = "sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f", size = 4700910, upload-time = "2026-05-21T14:34:55.177Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/c8/74a92c6ff9fcfb4f1f947126d3ebee8389276e161ecc85de5bda7cda51bd/ruff-0.15.14-py3-none-linux_armv6l.whl", hash = "sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108", size = 10739177, upload-time = "2026-05-21T14:34:37.332Z" }, - { url = "https://files.pythonhosted.org/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b", size = 11144969, upload-time = "2026-05-21T14:34:43.978Z" }, - { url = "https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f", size = 10478207, upload-time = "2026-05-21T14:34:48.378Z" }, - { url = "https://files.pythonhosted.org/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d", size = 10818459, upload-time = "2026-05-21T14:34:22.318Z" }, - { url = "https://files.pythonhosted.org/packages/45/33/53d651177f84f94b400a0e27f8824eeada3dddc9d5ee8aeb048f4352a520/ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4", size = 10541800, upload-time = "2026-05-21T14:34:20.209Z" }, - { url = "https://files.pythonhosted.org/packages/b8/a6/868f87e0bf9786ed24b5d0d0ad8676b8a94fd1912f42cddf9cfc7857818a/ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542", size = 11342149, upload-time = "2026-05-21T14:34:46.365Z" }, - { url = "https://files.pythonhosted.org/packages/a7/8b/38cd5c19faffdcc05a408d2b78edccc69492ab9720eadb49ea15ef80d768/ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f", size = 12212563, upload-time = "2026-05-21T14:34:28.579Z" }, - { url = "https://files.pythonhosted.org/packages/3e/4d/a3c5b874a556d5731e3e657aaf04311bb76f0a5c3ec220ed43051be6b64b/ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf", size = 11493299, upload-time = "2026-05-21T14:34:41.836Z" }, - { url = "https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba", size = 11455931, upload-time = "2026-05-21T14:34:57.276Z" }, - { url = "https://files.pythonhosted.org/packages/2c/4a/e2e7b4d8dbf233d4eace59c75bc3435fa6d8bd3bae82d351d4e4300c0fd1/ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f", size = 11400794, upload-time = "2026-05-21T14:34:39.773Z" }, - { url = "https://files.pythonhosted.org/packages/97/c7/83c0539fe34c3e09136204d1e75d6052492364e0b3cb05e9465423f567d7/ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581", size = 10804759, upload-time = "2026-05-21T14:34:31.045Z" }, - { url = "https://files.pythonhosted.org/packages/86/a6/18f2bfc095a2ab4a78745644e428205532ce6653a5d0fa8501572891534d/ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93", size = 10539517, upload-time = "2026-05-21T14:34:53.064Z" }, - { url = "https://files.pythonhosted.org/packages/54/3a/5a8b3b69c654d4e4bf1d246ac5b49cbcdac6eaab6905925f8915f31e3b80/ruff-0.15.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61", size = 11065169, upload-time = "2026-05-21T14:34:24.484Z" }, - { url = "https://files.pythonhosted.org/packages/ed/c5/8864e4e7925b836ea354b31d57641ec03830564e281a8b6f061f8c3e0ec1/ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553", size = 11560214, upload-time = "2026-05-21T14:34:50.975Z" }, - { url = "https://files.pythonhosted.org/packages/36/38/012bf76752e1f89ed50b77b99532d90f3a3e287bc7918e1fc0948ac866ac/ruff-0.15.14-py3-none-win32.whl", hash = "sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6", size = 10805548, upload-time = "2026-05-21T14:34:33.453Z" }, - { url = "https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl", hash = "sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902", size = 11939523, upload-time = "2026-05-21T14:34:18.077Z" }, - { url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" }, +version = "0.15.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, + { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, + { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, ] [[package]] @@ -1487,11 +1487,11 @@ wheels = [ [[package]] name = "typing-extensions" -version = "4.15.0" +version = "4.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] [[package]] From 4d66c3b64e8f5f17b066c71584eca16f626db6f1 Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:11:55 -0400 Subject: [PATCH 4/7] Make SDK preview publication opt-in (#100) * Make package previews opt-in * Document contributing and preview workflows * Harden opt-in preview publishing --- .github/workflows/package-check.yml | 58 +++++++ .github/workflows/pr-preview.yml | 241 +++++++++++++++++----------- .hooks/sync_version.py | 31 ++++ CONTRIBUTING.md | 49 ++++++ 4 files changed, 287 insertions(+), 92 deletions(-) create mode 100644 .github/workflows/package-check.yml create mode 100644 CONTRIBUTING.md diff --git a/.github/workflows/package-check.yml b/.github/workflows/package-check.yml new file mode 100644 index 0000000..b5336d2 --- /dev/null +++ b/.github/workflows/package-check.yml @@ -0,0 +1,58 @@ +name: Package Check + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: package-check-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + package-check: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 1 + persist-credentials: false + + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.12" + + - name: Install build tooling + uses: ./.github/actions/setup-hatch + + - name: Install test and distribution tooling + run: python -m pip install ".[test]" "twine>=4.0.0" + + - name: Run unit tests + run: python -m pytest -q tests/unit + + - name: Build distributions + run: hatch build + + - name: Validate distributions + run: python -m twine check dist/* + + - name: Install and smoke-test wheel + run: | + python -m venv "$RUNNER_TEMP/package-check" + "$RUNNER_TEMP/package-check/bin/pip" install --upgrade pip + "$RUNNER_TEMP/package-check/bin/pip" install dist/*.whl + "$RUNNER_TEMP/package-check/bin/python" -c "import socketdev; from socketdev.version import __version__; print('wheel smoke OK', __version__)" + + - name: Upload distributions + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: socketdev-${{ github.sha }} + path: dist/* + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index 27f7711..0493175 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -1,141 +1,198 @@ -name: PR Preview +name: Publish PR Preview + on: pull_request: - types: [opened, synchronize, ready_for_review] + types: [labeled] + workflow_dispatch: + inputs: + pr_number: + description: Pull request number to publish + required: true + type: string -# Cancel an in-flight preview when the PR is pushed again -- previews publish -# to Test PyPI, so superseded runs shouldn't keep churning. concurrency: - group: pr-preview-${{ github.event.pull_request.number }} - cancel-in-progress: true + group: publish-pr-preview-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: false jobs: - preview: - # Skip on: - # - PRs from forks (no access to publish secrets / OIDC) - # - Dependabot PRs: preview-publishing a dependency bump to Test PyPI is - # pointless (no package version bump) and would fail the version check. + context: if: >- - github.event.pull_request.head.repo.full_name == github.repository && - github.event.pull_request.user.login != 'dependabot[bot]' + github.event_name == 'workflow_dispatch' || + (github.event.label.name == 'publish-preview' && + github.event.pull_request.head.repo.full_name == github.repository) runs-on: ubuntu-latest + timeout-minutes: 5 permissions: - id-token: write contents: read - pull-requests: write + pull-requests: read + outputs: + pr_number: ${{ steps.context.outputs.pr_number }} + head_sha: ${{ steps.context.outputs.head_sha }} + steps: + - name: Validate pull request context + id: context + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + EVENT_PR_NUMBER: ${{ github.event.pull_request.number }} + INPUT_PR_NUMBER: ${{ inputs.pr_number }} + WORKFLOW_REF: ${{ github.ref }} + with: + script: | + const rawPrNumber = context.eventName === 'workflow_dispatch' + ? process.env.INPUT_PR_NUMBER + : process.env.EVENT_PR_NUMBER; + if (!/^[1-9][0-9]*$/.test(rawPrNumber || '')) { + core.setFailed('Pull request number must contain ASCII digits only.'); + return; + } + + if (context.eventName === 'workflow_dispatch') { + const defaultRef = `refs/heads/${process.env.DEFAULT_BRANCH}`; + if (process.env.WORKFLOW_REF !== defaultRef) { + core.setFailed(`Run manual previews from ${defaultRef}.`); + return; + } + } + + const prNumber = Number(rawPrNumber); + if (!Number.isSafeInteger(prNumber)) { + core.setFailed('Pull request number is outside the supported range.'); + return; + } + const {data: pullRequest} = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + }); + if (pullRequest.state !== 'open') { + core.setFailed(`Pull request #${prNumber} is not open.`); + return; + } + if (pullRequest.head.repo?.full_name !== `${context.repo.owner}/${context.repo.repo}`) { + core.setFailed('Preview publication is limited to branches in this repository.'); + return; + } + + core.setOutput('pr_number', String(prNumber)); + core.setOutput('head_sha', pullRequest.head.sha); + + build: + needs: context + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + outputs: + preview_version: ${{ steps.version.outputs.preview_version }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ needs.context.outputs.head_sha }} fetch-depth: 0 persist-credentials: false + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: - python-version: '3.13' + python-version: "3.12" - name: Install build tooling uses: ./.github/actions/setup-hatch - - name: Inject full dynamic version - run: python .hooks/sync_version.py --dev + - name: Install distribution validator + run: python -m pip install "twine>=4.0.0" - - name: Check if version exists on Test PyPI - id: version_check + - name: Inject deterministic preview version + env: + PREVIEW_ID: ${{ github.run_id }} + RUN_ATTEMPT: ${{ github.run_attempt }} run: | - VERSION=$(hatch version | cut -d+ -f1) - echo "VERSION=$VERSION" >> $GITHUB_ENV - if curl -s -f https://test.pypi.org/pypi/socketdev/$VERSION/json > /dev/null; then - echo "Version ${VERSION} already exists on Test PyPI" - echo "exists=true" >> $GITHUB_OUTPUT - else - echo "Version ${VERSION} not found on Test PyPI - proceeding with test deployment" - echo "exists=false" >> $GITHUB_OUTPUT - fi - - - name: Clean previous builds - run: rm -rf dist/ build/ *.egg-info - - - name: Get Hatch version + PREVIEW_ID=$((PREVIEW_ID * 100 + RUN_ATTEMPT)) + python .hooks/sync_version.py --dev --preview-id "$PREVIEW_ID" --skip-lock + + - name: Read preview version id: version - run: | - VERSION=$(hatch version | cut -d+ -f1) - echo "VERSION=$VERSION" >> $GITHUB_ENV + run: echo "preview_version=$(hatch version)" >> "$GITHUB_OUTPUT" - - name: Build package - if: steps.version_check.outputs.exists != 'true' + - name: Build and validate distributions run: | hatch build + python -m twine check dist/* + + - name: Install and smoke-test wheel locally + run: | + python -m venv "$RUNNER_TEMP/preview-check" + "$RUNNER_TEMP/preview-check/bin/pip" install --upgrade pip + "$RUNNER_TEMP/preview-check/bin/pip" install dist/*.whl + "$RUNNER_TEMP/preview-check/bin/python" -c "import socketdev; from socketdev.version import __version__; print('preview wheel smoke OK', __version__)" + + - name: Upload preview distributions + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: socketdev-preview-${{ github.run_id }}-${{ github.run_attempt }} + path: dist/* + if-no-files-found: error + retention-days: 14 + + publish: + needs: [context, build] + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + id-token: write + pull-requests: write + steps: + - name: Download preview distributions + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: socketdev-preview-${{ github.run_id }}-${{ github.run_attempt }} + path: dist - - name: Publish to Test PyPI - if: steps.version_check.outputs.exists != 'true' + - name: Publish to TestPyPI uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: repository-url: https://test.pypi.org/legacy/ verbose: true - - name: Comment on PR - if: steps.version_check.outputs.exists != 'true' + - name: Comment on pull request uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - VERSION: ${{ env.VERSION }} + PREVIEW_VERSION: ${{ needs.build.outputs.preview_version }} + PR_NUMBER: ${{ needs.context.outputs.pr_number }} with: script: | - const version = process.env.VERSION; - const prNumber = context.payload.pull_request.number; - const owner = context.repo.owner; - const repo = context.repo.repo; - // Find existing bot comments - const comments = await github.rest.issues.listComments({ + const marker = ''; + const prNumber = Number(process.env.PR_NUMBER); + const version = process.env.PREVIEW_VERSION; + const body = `${marker} + 🚀 SDK preview published: \`socketdev==${version}\` + + \`\`\`bash + pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple socketdev==${version} + \`\`\` + + TestPyPI's package index can take several minutes to expose a newly uploaded version.`; + const {data: comments} = await github.rest.issues.listComments({ owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, }); - - const botComment = comments.data.find(comment => - comment.user.type === 'Bot' && - comment.body.includes('🚀 Preview package published!') + const existing = comments.find(comment => + comment.user.type === 'Bot' && comment.body.includes(marker) ); - - const comment = ` - 🚀 Preview package published! - - Install with: - \`\`\`bash - pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple socketdev==${version} - \`\`\``; - - if (botComment) { - // Update existing comment + if (existing) { await github.rest.issues.updateComment({ - owner: owner, - repo: repo, - comment_id: botComment.id, - body: comment + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, }); } else { - // Create new comment await github.rest.issues.createComment({ - owner: owner, - repo: repo, + owner: context.repo.owner, + repo: context.repo.repo, issue_number: prNumber, - body: comment + body, }); } - - - name: Verify package is available - if: steps.version_check.outputs.exists != 'true' - id: verify_package - env: - VERSION: ${{ env.VERSION }} - run: | - for i in {1..30}; do - if pip install --index-url 'https://test.pypi.org/simple/' --extra-index-url 'https://pypi.org/simple' socketdev==${VERSION}; then - echo "Package ${VERSION} is now available and installable on Test PyPI" - pip uninstall -y socketdev - echo "success=true" >> $GITHUB_OUTPUT - exit 0 - fi - echo "Attempt $i: Package not yet installable, waiting 20s... (${i}/30)" - sleep 20 - done - echo "success=false" >> $GITHUB_OUTPUT - exit 1 diff --git a/.hooks/sync_version.py b/.hooks/sync_version.py index 7a8ab24..32869c3 100755 --- a/.hooks/sync_version.py +++ b/.hooks/sync_version.py @@ -124,13 +124,44 @@ def run_uv_lock() -> bool: return before != after +def read_preview_id(): + if "--preview-id" not in sys.argv: + return None + + option_index = sys.argv.index("--preview-id") + try: + preview_id = sys.argv[option_index + 1] + except IndexError: + print("❌ `--preview-id` requires a numeric value.") + sys.exit(1) + + if not preview_id.isascii() or not preview_id.isdigit(): + print("❌ `--preview-id` must contain ASCII digits only.") + sys.exit(1) + return preview_id + + def main(): dev_mode = "--dev" in sys.argv + skip_lock = "--skip-lock" in sys.argv + preview_id = read_preview_id() current_version = read_version_from_version_file(VERSION_FILE) previous_version = read_version_from_git("socketdev/version.py") print(f"Current: {current_version}, Previous: {previous_version}") + if preview_id is not None: + if not dev_mode: + print("❌ `--preview-id` can only be used with `--dev`.") + sys.exit(1) + base_version = current_version.split(".dev")[0] + new_version = f"{base_version}.dev{preview_id}" + inject_version(new_version) + if not skip_lock: + run_uv_lock() + print(f"✅ Prepared deterministic preview version {new_version}.") + sys.exit(0) + if current_version == previous_version: if dev_mode: base_version = current_version.split(".dev")[0] if ".dev" in current_version else current_version diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5775220 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,49 @@ +# Contributing + +## Development setup + +Use Python 3.9 or newer. Create and activate a virtual environment, then +install the package with its development and test dependencies: + +```bash +python -m venv .venv +source .venv/bin/activate +python -m pip install -e ".[dev,test]" +``` + +Before opening a pull request, run the focused checks for your change. The +complete local check is: + +```bash +python -m pytest +hatch build +python -m twine check dist/* +``` + +## Pull request validation + +The `Package Check` workflow runs automatically for pull requests. It runs the +unit tests, builds and validates the distributions, smoke-tests the wheel, and +uploads the distributions as workflow artifacts. It does not publish a package. + +## Publishing a pull request preview + +Preview publication is intentionally opt-in. Only request a preview for code +that is trusted to run with the repository's publishing permissions. + +For a pull request from this repository, apply the `publish-preview` label. The +`Publish PR Preview` workflow will build and validate a uniquely versioned +`socketdev` prerelease, publish it to TestPyPI, and add or update a pull request +comment with the exact version and installation command. Both label-triggered +and manually dispatched previews are limited to open pull requests whose +branches belong to this repository. + +The workflow reacts when the label is added; pushing another commit while the +label remains on the pull request does not publish a new preview. To publish the +new pull request head or retry a failed publication, remove `publish-preview` +and apply it again. + +Maintainers can also open **Actions > Publish PR Preview > Run workflow**, run +it from the repository's default branch, and enter the pull request number. +Manual dispatch is useful when a label should remain unchanged or a +publication needs to be retried. From d5a7f516625514b2899238bc04cfec717f630457 Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:31:15 -0400 Subject: [PATCH 5/7] Add missing purl types and per-artifact parse resilience to full-scan stream (#101) * fix(fullscans): tolerate unknown purl types and skip unparseable artifacts (CE-362) The full-scan stream can include artifacts whose purl type is not in SocketPURL_Type (e.g. "generic"), and a single such artifact failed the entire FullScanStreamResponse parse, leaving consumers with zero packages and alerts for an otherwise-successful scan. - Add the standard purl types (generic, maven, gem, nuget, cargo, ...) to SocketPURL_Type - Fall back to UNKNOWN with a warning for unrecognized purl types, the same forward-compat approach SocketCategory uses (#78) - Skip individual artifacts that fail to parse in FullScanStreamResponse.from_dict instead of discarding the response - Bump version to 3.4.0 Co-Authored-By: Claude Fable 5 * fix(fullscans): skip artifacts without usable ids --------- Co-authored-by: Claude Fable 5 --- pyproject.toml | 2 +- socketdev/fullscans/__init__.py | 69 +++++++++++++- socketdev/version.py | 2 +- tests/unit/test_socket_purl_type.py | 141 ++++++++++++++++++++++++++++ uv.lock | 2 +- 5 files changed, 208 insertions(+), 8 deletions(-) create mode 100644 tests/unit/test_socket_purl_type.py diff --git a/pyproject.toml b/pyproject.toml index 609daa2..43db636 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "socketdev" -version = "3.3.0" +version = "3.4.0" requires-python = ">= 3.9" dependencies = [ 'requests', diff --git a/socketdev/fullscans/__init__.py b/socketdev/fullscans/__init__.py index dd4c6b7..2fe6f09 100644 --- a/socketdev/fullscans/__init__.py +++ b/socketdev/fullscans/__init__.py @@ -12,9 +12,46 @@ class SocketPURL_Type(str, Enum): UNKNOWN = "unknown" + APK = "apk" + BITBUCKET = "bitbucket" + CARGO = "cargo" + COCOAPODS = "cocoapods" + COMPOSER = "composer" + CONAN = "conan" + CONDA = "conda" + CRAN = "cran" + DEB = "deb" + DOCKER = "docker" + GEM = "gem" + GENERIC = "generic" + GITHUB = "github" + GOLANG = "golang" + HACKAGE = "hackage" + HEX = "hex" + HUGGINGFACE = "huggingface" + LUAROCKS = "luarocks" + MAVEN = "maven" + MLFLOW = "mlflow" NPM = "npm" + NUGET = "nuget" + OCI = "oci" + PUB = "pub" PYPI = "pypi" - GOLANG = "golang" + RPM = "rpm" + SWIFT = "swift" + + @classmethod + def _missing_(cls, value): + # The API can emit purl types this SDK does not know about yet. Fall + # back to UNKNOWN instead of raising so one artifact cannot fail an + # entire response parse (same forward-compat approach as + # SocketCategory, https://github.com/SocketDev/socket-sdk-python/issues/78). + log.warning( + "Unknown SocketPURL_Type %r; falling back to UNKNOWN. " + "Upgrade socketdev to pick up newer purl types.", + value, + ) + return cls.UNKNOWN class SocketIssueSeverity(str, Enum): @@ -726,13 +763,24 @@ def to_dict(self): @classmethod def from_dict(cls, data: dict) -> "FullScanStreamResponse": + artifacts = None + if data.get("artifacts"): + artifacts = {} + for artifact_id, raw in data["artifacts"].items(): + try: + artifacts[artifact_id] = SocketArtifact.from_dict(raw) + except Exception: + # One malformed artifact should not fail the whole stream. + log.warning( + "Skipping artifact %s that could not be parsed", + artifact_id, + exc_info=True, + ) return cls( success=data["success"], status=data["status"], message=data.get("message"), - artifacts={k: SocketArtifact.from_dict(v) for k, v in data["artifacts"].items()} - if data.get("artifacts") - else None, + artifacts=artifacts, ) @@ -907,7 +955,18 @@ def stream(self, org_slug: str, full_scan_id: str, use_types: bool = False) -> U stream_str.append(item) stream_deduped = Dedupe.dedupe(stream_str, batched=False) for batch in stream_deduped: - artifacts[batch["id"]] = batch + try: + artifact_id = batch["id"] + if not isinstance(artifact_id, str) or not artifact_id: + raise TypeError("artifact id must be a non-empty string") + artifacts[artifact_id] = batch + except (KeyError, TypeError): + # A malformed artifact should not discard valid stream results + # before FullScanStreamResponse can parse them individually. + log.warning( + "Skipping artifact without a usable id", + exc_info=True, + ) if use_types: return FullScanStreamResponse.from_dict({"success": True, "status": 200, "artifacts": artifacts}) return artifacts diff --git a/socketdev/version.py b/socketdev/version.py index 88c513e..903a158 100644 --- a/socketdev/version.py +++ b/socketdev/version.py @@ -1 +1 @@ -__version__ = "3.3.0" +__version__ = "3.4.0" diff --git a/tests/unit/test_socket_purl_type.py b/tests/unit/test_socket_purl_type.py new file mode 100644 index 0000000..6454fbc --- /dev/null +++ b/tests/unit/test_socket_purl_type.py @@ -0,0 +1,141 @@ +""" +Unit tests for lenient SocketPURL_Type parsing (CE-362). + +The Socket API can emit purl types the SDK does not yet know about (e.g. +``"generic"``, which was missing from the enum entirely). Strict enum parsing +turned one such artifact into a hard failure for the whole full-scan stream: +``FullScanStreamResponse.from_dict`` raised, ``FullScans.stream`` returned +``success=False`` with no artifacts, and consumers (notably socketsecurity) +produced empty reports for otherwise-successful scans. + +These tests pin two behaviors: + +1. ``SocketPURL_Type`` resolves known purl types (including ``generic``) and + falls back to ``UNKNOWN`` with a warning for unrecognized values, mirroring + the ``SocketCategory`` forward-compat approach from issue #78. +2. ``FullScanStreamResponse.from_dict`` skips individual artifacts that fail to + parse instead of discarding the entire response. +""" + +import json +import logging +import unittest + +from socketdev.fullscans import ( + FullScans, + FullScanStreamResponse, + SocketArtifact, + SocketPURL, + SocketPURL_Type, +) + + +def _artifact_payload(artifact_id: str, purl_type: str) -> dict: + return { + "id": artifact_id, + "type": purl_type, + "name": "example-package", + "version": "1.0.0", + "alerts": [], + } + + +class TestSocketPURLTypeParsing(unittest.TestCase): + """SocketPURL_Type should tolerate unknown purl type values.""" + + def test_generic_is_recognized(self): + self.assertEqual(SocketPURL_Type("generic"), SocketPURL_Type.GENERIC) + + def test_common_ecosystems_are_recognized(self): + for value in ("npm", "pypi", "golang", "maven", "gem", "nuget", "cargo"): + self.assertEqual(SocketPURL_Type(value).value, value) + + def test_unknown_type_falls_back_to_unknown(self): + self.assertEqual( + SocketPURL_Type("someFutureEcosystem"), SocketPURL_Type.UNKNOWN + ) + + def test_unknown_type_emits_warning(self): + with self.assertLogs("socketdev", level=logging.WARNING) as captured: + SocketPURL_Type("someFutureEcosystem") + self.assertTrue( + any("Unknown SocketPURL_Type" in message for message in captured.output), + f"expected a warning about the unknown purl type, got: {captured.output}", + ) + + def test_socket_purl_from_dict_does_not_raise(self): + purl = SocketPURL.from_dict({"type": "someFutureEcosystem", "name": "pkg"}) + self.assertEqual(purl.type, SocketPURL_Type.UNKNOWN) + + def test_socket_artifact_from_dict_with_generic_type(self): + artifact = SocketArtifact.from_dict(_artifact_payload("a1", "generic")) + self.assertEqual(artifact.type, SocketPURL_Type.GENERIC) + self.assertEqual(artifact.name, "example-package") + + +class TestFullScanStreamResponseResilience(unittest.TestCase): + """One bad artifact should not empty out the whole stream response.""" + + def test_generic_artifact_is_kept(self): + response = FullScanStreamResponse.from_dict( + { + "success": True, + "status": 200, + "artifacts": { + "a1": _artifact_payload("a1", "npm"), + "a2": _artifact_payload("a2", "generic"), + }, + } + ) + self.assertEqual(set(response.artifacts), {"a1", "a2"}) + self.assertEqual(response.artifacts["a2"].type, SocketPURL_Type.GENERIC) + + def test_malformed_artifact_is_skipped_not_fatal(self): + payload = { + "success": True, + "status": 200, + "artifacts": { + "good": _artifact_payload("good", "npm"), + # Missing required "id" field, so SocketArtifact.from_dict raises. + "bad": {"type": "npm", "alerts": []}, + }, + } + with self.assertLogs("socketdev", level=logging.WARNING) as captured: + response = FullScanStreamResponse.from_dict(payload) + self.assertEqual(list(response.artifacts), ["good"]) + self.assertTrue( + any("Skipping artifact bad" in message for message in captured.output), + f"expected a warning about the skipped artifact, got: {captured.output}", + ) + + def test_full_scans_stream_skips_artifact_without_id(self): + class Response: + status_code = 200 + text = "\n".join( + json.dumps(artifact) + for artifact in ( + _artifact_payload("good", "npm"), + {"type": "npm", "name": "bad", "alerts": []}, + ) + ) + + class API: + def do_request(self, **kwargs): + return Response() + + with self.assertLogs("socketdev", level=logging.WARNING) as captured: + response = FullScans(API()).stream("org", "scan", use_types=True) + + self.assertTrue(response.success) + self.assertEqual(list(response.artifacts), ["good"]) + self.assertTrue( + any( + "Skipping artifact without a usable id" in message + for message in captured.output + ), + f"expected a warning about the skipped artifact, got: {captured.output}", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/uv.lock b/uv.lock index ae0b374..1db031d 100644 --- a/uv.lock +++ b/uv.lock @@ -1353,7 +1353,7 @@ wheels = [ [[package]] name = "socketdev" -version = "3.3.0" +version = "3.4.0" source = { editable = "." } dependencies = [ { name = "requests" }, From 2ae50f46d3cb775f96bd53784b808f8831f7236f Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:23:15 -0400 Subject: [PATCH 6/7] Add cached diff-scan polling support to DiffScans.get (#99) * Add cached/query param support and 202 handling to diffscans.get DiffScans.get now accepts optional query params (cached, omit_unchanged, omit_license_details) and returns a {"status": "processing", "id": ...} dict on HTTP 202 instead of logging an error, so clients can poll GET /orgs/{org}/diff-scans/{id}?cached=true until the computed diff is ready rather than holding a single idle connection open while the backend computes (which idle-timeout middleboxes like Azure NAT gateways kill after ~4 minutes). Also encode list-valued query params (e.g. committers) as repeated params in create_from_repo/create_from_ids via urlencode(doseq=True). Co-Authored-By: Claude Fable 5 * Harden diff-scan processing sentinel --------- Co-authored-by: Claude Fable 5 --- pyproject.toml | 2 +- socketdev/diffscans/__init__.py | 42 +++++++++++++-- socketdev/version.py | 2 +- tests/unit/test_all_endpoints_unit.py | 76 +++++++++++++++++++++++++++ uv.lock | 2 +- 5 files changed, 117 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 43db636..fdb3000 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "socketdev" -version = "3.4.0" +version = "3.4.1" requires-python = ">= 3.9" dependencies = [ 'requests', diff --git a/socketdev/diffscans/__init__.py b/socketdev/diffscans/__init__.py index 28075f2..a1dd2af 100644 --- a/socketdev/diffscans/__init__.py +++ b/socketdev/diffscans/__init__.py @@ -21,12 +21,45 @@ def list(self, org_slug: str, params: Optional[Dict[str, Any]] = None) -> dict: log.error(f"Error listing diff scans: {response.status_code}, message: {response.text}") return {} - def get(self, org_slug: str, diff_scan_id: str) -> dict: - """Fetch a diff scan by ID.""" + def get(self, org_slug: str, diff_scan_id: str, params: Optional[Dict[str, Any]] = None) -> dict: + """Fetch a diff scan by ID. + + Args: + org_slug: Organization slug + diff_scan_id: The ID of the diff scan to fetch + params: Optional query parameters. Supports: + cached: When "true", return pre-computed results immediately + (200) or a processing status (202) instead of holding + the connection open while the diff is computed. + omit_unchanged: When "true", omit unchanged artifacts. + omit_license_details: When "true", omit license details. + + Returns: + dict: On 200, the API response containing the diff_scan object. + On 202 (results still processing when cached=true), a dict of + {"status": "processing", "id": diff_scan_id} so callers can + poll until the diff scan is ready. Empty dict on error. + """ + import urllib.parse path = f"orgs/{org_slug}/diff-scans/{diff_scan_id}" + if params: + path += "?" + urllib.parse.urlencode(params, doseq=True) response = self.api.do_request(path=path, method="GET") if response.status_code == 200: return response.json() + if response.status_code == 202: + result = {} + try: + body = response.json() + if isinstance(body, dict): + result.update(body) + except ValueError: + pass + # HTTP 202 always means the requested diff is still processing. + # Keep any additional response fields, but make the polling + # sentinel and requested resource ID authoritative for callers. + result.update({"status": "processing", "id": diff_scan_id}) + return result log.error(f"Error fetching diff scan: {response.status_code}, message: {response.text}") return {} @@ -61,7 +94,8 @@ def create_from_repo(self, org_slug: str, repo_slug: str, files: list, params: O import urllib.parse path = f"orgs/{org_slug}/diff-scans/from-repo/{repo_slug}" if params: - path += "?" + urllib.parse.urlencode(params) + # doseq=True so list values (e.g. committers) become repeated params + path += "?" + urllib.parse.urlencode(params, doseq=True) # Use lazy loading if requested if use_lazy_loading: @@ -80,7 +114,7 @@ def create_from_ids(self, org_slug: str, params: Dict[str, Any]) -> dict: import urllib.parse path = f"orgs/{org_slug}/diff-scans/from-ids" if params: - path += "?" + urllib.parse.urlencode(params) + path += "?" + urllib.parse.urlencode(params, doseq=True) response = self.api.do_request(path=path, method="POST") if response.status_code in (200, 201): return response.json() diff --git a/socketdev/version.py b/socketdev/version.py index 903a158..a5cfdf5 100644 --- a/socketdev/version.py +++ b/socketdev/version.py @@ -1 +1 @@ -__version__ = "3.4.0" +__version__ = "3.4.1" diff --git a/tests/unit/test_all_endpoints_unit.py b/tests/unit/test_all_endpoints_unit.py index 64895ee..5782497 100644 --- a/tests/unit/test_all_endpoints_unit.py +++ b/tests/unit/test_all_endpoints_unit.py @@ -113,6 +113,59 @@ def test_diffscans_get_unit(self): self.assertEqual(call_args[0][0], "GET") self.assertIn("/orgs/test-org/diff-scans/diff-123", call_args[0][1]) + def test_diffscans_get_cached_unit(self): + """Test diffscans get passes cached/omit params through as a query string.""" + expected_data = { + "diff_scan": { + "id": "diff-123", + "artifacts": {"added": [], "removed": [], "unchanged": [], "replaced": [], "updated": []}, + } + } + self._mock_response(expected_data) + + result = self.sdk.diffscans.get("test-org", "diff-123", params={"cached": "true"}) + + self.assertEqual(result, expected_data) + call_args = self.mock_requests.request.call_args + self.assertEqual(call_args[0][0], "GET") + self.assertIn("/orgs/test-org/diff-scans/diff-123?cached=true", call_args[0][1]) + + def test_diffscans_get_processing_unit(self): + """Test diffscans get surfaces 202 processing status instead of an error.""" + self._mock_response({"status": "processing", "id": "diff-123"}, 202) + + result = self.sdk.diffscans.get("test-org", "diff-123", params={"cached": "true"}) + + self.assertEqual(result.get("status"), "processing") + self.assertEqual(result.get("id"), "diff-123") + + def test_diffscans_get_processing_empty_body_unit(self): + """Test diffscans get synthesizes the processing status when the 202 body is empty.""" + mock_response = Mock() + mock_response.status_code = 202 + mock_response.headers = {} + mock_response.json.side_effect = ValueError("no body") + mock_response.text = "" + self.mock_requests.request.return_value = mock_response + + result = self.sdk.diffscans.get("test-org", "diff-123", params={"cached": "true"}) + + self.assertEqual(result, {"status": "processing", "id": "diff-123"}) + + def test_diffscans_get_processing_sentinel_wins_unit(self): + """Test a 202 body cannot override the SDK's polling sentinel or requested ID.""" + self._mock_response( + {"status": "pending", "id": "wrong-id", "retry_after": 5}, + 202, + ) + + result = self.sdk.diffscans.get("test-org", "diff-123", params={"cached": "true"}) + + self.assertEqual( + result, + {"status": "processing", "id": "diff-123", "retry_after": 5}, + ) + def test_diffscans_create_from_ids_unit(self): """Test diffscans creation from scan IDs.""" expected_data = {"id": "new-diff-scan", "status": "queued"} @@ -153,6 +206,29 @@ def test_diffscans_create_from_repo_unit(self): finally: os.unlink(f.name) + def test_diffscans_create_from_repo_committers_list_unit(self): + """Test list-valued params (committers) encode as repeated query params.""" + self._mock_response({"id": "repo-diff-scan"}, 201) + + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + json.dump({"name": "test", "version": "1.0.0"}, f) + f.flush() + + try: + with open(f.name, "rb") as file_obj: + files = [("file", ("package.json", file_obj))] + params = {"committers": ["alice", "bob"], "branch": "main"} + self.sdk.diffscans.create_from_repo("test-org", "test-repo", files, params) + + call_args = self.mock_requests.request.call_args + url = call_args[0][1] + self.assertIn("committers=alice", url) + self.assertIn("committers=bob", url) + self.assertIn("branch=main", url) + + finally: + os.unlink(f.name) + def test_diffscans_gfm_unit(self): """Test diffscans GitHub Flavored Markdown export.""" expected_data = {"markdown": "# Diff Report\n\n## Summary\n- Added: 0\n- Removed: 0"} diff --git a/uv.lock b/uv.lock index 1db031d..79c3589 100644 --- a/uv.lock +++ b/uv.lock @@ -1353,7 +1353,7 @@ wheels = [ [[package]] name = "socketdev" -version = "3.4.0" +version = "3.4.1" source = { editable = "." } dependencies = [ { name = "requests" }, From 8ad17aeb46dfc88913ac3d5e57a6a51ac252073d Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Wed, 5 Aug 2026 19:27:21 -0400 Subject: [PATCH 7/7] fix(purl): expose fail-open batch params and harden dedupe (#98) * fix(purl): expose fail-open batch params and harden dedupe (CE-360) purl.post() defaulted to the batch API's fail-open behavior with no way to opt out: unresolved input purls are silently omitted from the response, so callers could not tell "clean" from "dropped". Add typed poll/timeout_sec/ alerts/purl_errors params (None => omit, preserving the fail-open default for existing callers) plus a strict=True guard that raises APIPartialResponse when requested purls are missing from the response. Also harden Dedupe.consolidate_and_merge_alerts to use .get() for key/type/severity/action so synthetic pendingScan/notFound status rows (built server-side from a minimal {type, key} base) no longer raise KeyError. Bump 3.3.0 -> 3.4.0. Co-Authored-By: Claude Opus 4.8 Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> * fix(purl): preserve error stream records * fix(purl): define strict response semantics * fix(purl): preserve legacy boolean strings --------- Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 --- pyproject.toml | 2 +- socketdev/core/dedupe.py | 21 ++-- socketdev/exceptions.py | 28 +++++ socketdev/purl/__init__.py | 127 +++++++++++++++++++- socketdev/version.py | 2 +- tests/unit/test_all_endpoints_unit.py | 163 ++++++++++++++++++++++++++ tests/unit/test_exceptions.py | 6 + uv.lock | 2 +- 8 files changed, 336 insertions(+), 15 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fdb3000..56dd376 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "socketdev" -version = "3.4.1" +version = "3.4.2" requires-python = ">= 3.9" dependencies = [ 'requests', diff --git a/socketdev/core/dedupe.py b/socketdev/core/dedupe.py index b3b29da..2e6e5c4 100644 --- a/socketdev/core/dedupe.py +++ b/socketdev/core/dedupe.py @@ -11,8 +11,8 @@ def normalize_file_path(path: str) -> str: @staticmethod def alert_key(alert: dict) -> tuple: return ( - alert["type"], - alert["severity"], + alert.get("type"), + alert.get("severity"), alert.get("category"), Dedupe.normalize_file_path(alert.get("file")), alert.get("start"), @@ -23,8 +23,8 @@ def alert_key(alert: dict) -> tuple: def consolidate_and_merge_alerts(package_group: List[Dict[str, Any]]) -> Dict[str, Any]: def alert_identity(alert: dict) -> tuple: return ( - alert["type"], - alert["severity"], + alert.get("type"), + alert.get("severity"), alert.get("category"), Dedupe.normalize_file_path(alert.get("file")), alert.get("start"), @@ -41,14 +41,17 @@ def alert_identity(alert: dict) -> tuple: identity = alert_identity(alert) if identity not in alert_map: - # Build alert dict with only fields that exist in the original alert + # Build alert dict with only fields that exist in the original alert. + # Use .get() for key/type/severity/action so synthetic status rows + # (e.g. pendingScan/notFound), which are built server-side from a + # minimal {type, key} base, don't raise KeyError here. consolidated_alert = { - "key": alert["key"], # keep the first key seen - "type": alert["type"], - "severity": alert["severity"], + "key": alert.get("key"), # keep the first key seen + "type": alert.get("type"), + "severity": alert.get("severity"), "releases": [release], "props": alert.get("props", []), - "action": alert["action"] + "action": alert.get("action") } # Only include optional fields if they exist in the original alert diff --git a/socketdev/exceptions.py b/socketdev/exceptions.py index 980aaf9..b9c62db 100644 --- a/socketdev/exceptions.py +++ b/socketdev/exceptions.py @@ -78,3 +78,31 @@ class APIBadGateway(APIFailure): def __init__(self, *args): super().__init__(*args, status_code=502) + + +class APIPartialResponse(APIFailure): + """Raised by ``purl.post(strict=True)`` when the batch response omits requested inputs. + + The batch purl API is fail-open: input purls whose resolution/analysis has not + completed are silently dropped from the response unless the caller opts in via + ``alerts=True`` (synthetic ``pendingScan``/``notFound`` rows) or ``poll=True`` (a + bounded fail-closed wait). ``strict=True`` turns that silent omission into this + explicit error so callers get a first-class "partial batch" signal without having + to diff the response themselves. + + The ``missing`` attribute holds the requested purls that were absent from the + response (the HTTP call itself succeeded, so there is no status code). A missing + row may reflect pending analysis, a malformed or unknown purl, or a response contract + failure, so blindly retrying is not guaranteed to succeed. Callers that need a bounded + wait should use ``poll=True``; callers that need omission reasons should request + ``alerts=True`` and/or ``purl_errors=True``. + """ + + def __init__(self, *args, missing=None): + super().__init__(*args) + self.missing = list(missing or []) + + def is_transient_error(self) -> bool: + # The HTTP request completed successfully, and the omission reason may be + # permanent. Server-side polling is the explicit bounded retry mechanism. + return False diff --git a/socketdev/purl/__init__.py b/socketdev/purl/__init__.py index 50118ed..3c01897 100644 --- a/socketdev/purl/__init__.py +++ b/socketdev/purl/__init__.py @@ -1,10 +1,19 @@ import json import urllib.parse import warnings +from typing import Optional from socketdev.log import log +from socketdev.exceptions import APIPartialResponse from ..core.dedupe import Dedupe +def _encode_bool_query_value(value) -> str: + """Encode typed bools while preserving legacy string query values.""" + if isinstance(value, bool): + return "true" if value else "false" + return str(value) + + class Purl: def __init__(self, api): self.api = api @@ -14,8 +23,60 @@ def post( license: str = "false", components: list = None, org_slug: str = None, + poll: Optional[bool] = None, + timeout_sec: Optional[int] = None, + alerts: Optional[bool] = None, + purl_errors: Optional[bool] = None, + strict: bool = False, **kwargs, ) -> list: + """POST a batch of purls to the Socket batch purl endpoint and return deduped rows. + + The batch purl API (``POST /v0/purl`` and ``POST /v0/orgs/{slug}/purl``) defaults + to **fail-open**: any input purl whose resolution/analysis has not finished is + **silently omitted** from the response. A naive caller therefore cannot tell + "this version is clean" apart from "this version was dropped from the response". + The parameters below opt into the server behaviors that make omissions visible. + + Args: + license: ``"true"``/``"false"`` — request license information (stringly-typed + to match the query param the API expects). + components: list of component dicts to score, e.g. ``[{"purl": "pkg:npm/lodash@4.18.1"}]``. + org_slug: organization slug. When provided, routes to the org-scoped endpoint + ``POST /v0/orgs/{org_slug}/purl``; otherwise the deprecated ``POST /v0/purl``. + poll: opt into a fail-closed bounded wait for pending analysis (``poll=True`` → + ``poll=true`` query param). ``None`` omits the param (server default). + timeout_sec: bound in seconds for the ``poll`` wait (``→ timeoutSec``). The + server may cap this via a feature flag. ``None`` omits the param. + alerts: when ``True`` (``→ alerts=true``), the server emits synthetic + ``pendingScan``/``notFound`` status rows instead of silently omitting + unresolved inputs, so callers can distinguish "no data yet" from "clean". + purl_errors: when ``True`` (``→ purlErrors``), the server includes per-purl + error rows for malformed/unresolvable inputs. ``None`` omits the param. + For backward compatibility, legacy string values passed to the promoted + Boolean parameters are forwarded unchanged. + strict: client-side guard. When ``True``, compares the exact ``purl`` string + of each requested component against the returned ``inputPurl`` (or the + ``purl`` fallback). The API defines ``inputPurl`` as the original, + unmodified input before server normalization, so canonicalized ``purl`` + values do not cause false omissions. Raises + :class:`~socketdev.exceptions.APIPartialResponse` (with a ``missing`` + list) if any requested purl is absent from the response. This surfaces + partial batches even without ``alerts=True``. Only components that carry + a ``purl`` string are checked. + **kwargs: forwarded verbatim into the query string (back-compat passthrough for + any params not yet promoted to first-class arguments). + + Returns: + A deduped list of result rows. When ``alerts=True``, unresolved inputs appear + as synthetic rows carrying ``pendingScan``/``notFound`` alerts rather than being + omitted. On a non-200 response, logs the error and returns ``[]`` (callers that + need to fail closed should treat ``[]`` as an error). + + Raises: + APIPartialResponse: if ``strict=True`` and one or more requested component purls + are missing from the response. + """ if org_slug is None: warnings.warn( "Calling purl.post() without org_slug uses the deprecated POST /v0/purl endpoint. " @@ -31,25 +92,85 @@ def post( query_args = { "license": license, } + # Promote the typed params into query args only when explicitly set, so existing + # callers keep the server's fail-open default (None => omit the param entirely). + if poll is not None: + query_args["poll"] = _encode_bool_query_value(poll) + if timeout_sec is not None: + query_args["timeoutSec"] = str(timeout_sec) + if alerts is not None: + query_args["alerts"] = _encode_bool_query_value(alerts) + if purl_errors is not None: + query_args["purlErrors"] = _encode_bool_query_value(purl_errors) if kwargs: query_args.update(kwargs) params = urllib.parse.urlencode(query_args) path += params response = self.api.do_request(path=path, payload=purls, method="POST") if response.status_code == 200: - purl = [] + artifact_rows = [] + stream_records = [] result = response.text result = result.strip('"').strip() for line in result.split("\n"): if line and line != '"': try: item = json.loads(line) - purl.append(item) + if isinstance(item, dict) and item.get("_type") in { + "purlError", + "summary", + }: + stream_records.append(item) + else: + artifact_rows.append(item) except json.JSONDecodeError: continue - purl_deduped = Dedupe.dedupe(purl, batched=True) + purl_deduped = Dedupe.dedupe(artifact_rows, batched=True) + purl_deduped.extend(stream_records) + if strict: + self._raise_on_missing(components, purl_deduped) return purl_deduped log.error(f"Error posting {components} to the Purl API: {response.status_code}") log.error(response.text) return [] + + @staticmethod + def _raise_on_missing(components: list, results: list) -> None: + """Raise APIPartialResponse if any requested component purl is absent from results. + + Only components exposing a ``purl`` string are checked. The batch API contract + defines ``inputPurl`` as the original, unmodified input string before server-side + normalization, so matching it exactly preserves the caller's identity even when + the response's canonical ``purl`` differs. ``purl`` is retained as a fallback, + and typed ``purlError`` stream records carry ``inputPurl`` under ``value``. + """ + requested = [ + c["purl"] + for c in components + if isinstance(c, dict) and isinstance(c.get("purl"), str) + ] + if not requested: + return + returned = set() + for row in results: + if not isinstance(row, dict): + continue + for field in ("inputPurl", "purl"): + value = row.get(field) + if isinstance(value, str): + returned.add(value) + record_value = row.get("value") + if isinstance(record_value, dict): + input_purl = record_value.get("inputPurl") + if isinstance(input_purl, str): + returned.add(input_purl) + missing = [purl for purl in requested if purl not in returned] + if missing: + raise APIPartialResponse( + "purl.post(strict=True): the batch response omitted " + f"{len(missing)} of {len(requested)} requested purls " + "(fail-open: unresolved inputs are dropped unless alerts=True/poll=True): " + f"{missing}", + missing=missing, + ) diff --git a/socketdev/version.py b/socketdev/version.py index a5cfdf5..46aa803 100644 --- a/socketdev/version.py +++ b/socketdev/version.py @@ -1 +1 @@ -__version__ = "3.4.1" +__version__ = "3.4.2" diff --git a/tests/unit/test_all_endpoints_unit.py b/tests/unit/test_all_endpoints_unit.py index 5782497..7e0d36b 100644 --- a/tests/unit/test_all_endpoints_unit.py +++ b/tests/unit/test_all_endpoints_unit.py @@ -477,6 +477,169 @@ def test_purl_post_unit_legacy_path(self): self.assertIn("/purl", call_args[0][1]) self.assertNotIn("/orgs/", call_args[0][1]) + def _mock_purl_ndjson(self, ndjson): + """Mock a 200 NDJSON purl response and return the mock.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.headers = {'content-type': 'application/x-ndjson'} + mock_response.text = ndjson + self.mock_requests.request.return_value = mock_response + return mock_response + + def test_purl_post_first_class_params_query_string(self): + """poll/timeout_sec/alerts/purl_errors map to the expected query params.""" + self._mock_purl_ndjson( + '{"inputPurl": "pkg:npm/lodash@4.18.1", "purl": "pkg:npm/lodash@4.18.1", ' + '"type": "npm", "name": "lodash", "version": "4.18.1", "valid": true, "alerts": []}' + ) + + self.sdk.purl.post( + license="false", + components=[{"purl": "pkg:npm/lodash@4.18.1"}], + org_slug="test-org", + poll=True, + timeout_sec=120, + alerts=True, + purl_errors=False, + ) + + url = self.mock_requests.request.call_args[0][1] + self.assertIn("poll=true", url) + self.assertIn("timeoutSec=120", url) + self.assertIn("alerts=true", url) + self.assertIn("purlErrors=false", url) + + def test_purl_post_omits_unset_params(self): + """None-valued typed params are omitted so the API's fail-open default is preserved.""" + self._mock_purl_ndjson( + '{"inputPurl": "pkg:npm/lodash@4.18.1", "purl": "pkg:npm/lodash@4.18.1", ' + '"type": "npm", "name": "lodash", "version": "4.18.1", "valid": true, "alerts": []}' + ) + + self.sdk.purl.post(components=[{"purl": "pkg:npm/lodash@4.18.1"}], org_slug="test-org") + + url = self.mock_requests.request.call_args[0][1] + self.assertNotIn("poll=", url) + self.assertNotIn("timeoutSec=", url) + self.assertNotIn("alerts=", url) + self.assertNotIn("purlErrors=", url) + + def test_purl_post_preserves_legacy_string_boolean_params(self): + """Promoted params retain the pre-existing stringly kwargs behavior.""" + self._mock_purl_ndjson( + '{"inputPurl": "pkg:npm/lodash@4.18.1", ' + '"purl": "pkg:npm/lodash@4.18.1", "type": "npm", ' + '"name": "lodash", "version": "4.18.1", "valid": true, "alerts": []}' + ) + + self.sdk.purl.post( + components=[{"purl": "pkg:npm/lodash@4.18.1"}], + org_slug="test-org", + poll="false", + alerts="false", + purl_errors="false", + ) + + url = self.mock_requests.request.call_args[0][1] + self.assertIn("poll=false", url) + self.assertIn("alerts=false", url) + self.assertIn("purlErrors=false", url) + + def test_purl_post_synthetic_pending_scan_row(self): + """A synthetic pendingScan row (no severity/action) parses without raising KeyError.""" + # Synthetic status alerts are built server-side from a minimal {type, key} base. + self._mock_purl_ndjson( + '{"inputPurl": "pkg:npm/newpkg@0.0.1", "purl": "pkg:npm/newpkg@0.0.1", ' + '"type": "npm", "name": "newpkg", "version": "0.0.1", ' + '"alerts": [{"type": "pendingScan", "key": "abc123"}]}' + ) + + result = self.sdk.purl.post( + components=[{"purl": "pkg:npm/newpkg@0.0.1"}], + org_slug="test-org", + alerts=True, + ) + + self.assertEqual(len(result), 1) + alert = result[0]["alerts"][0] + self.assertEqual(alert["type"], "pendingScan") + self.assertEqual(alert["key"], "abc123") + # Missing fields are surfaced as None rather than raising. + self.assertIsNone(alert["severity"]) + self.assertIsNone(alert["action"]) + + def test_purl_post_preserves_purl_error_record(self): + """purlError stream records bypass artifact deduplication.""" + error_row = { + "_type": "purlError", + "value": { + "error": "package_not_found", + "inputPurl": "pkg:npm/missing@1.0.0", + }, + } + self._mock_purl_ndjson(json.dumps(error_row)) + + result = self.sdk.purl.post( + components=[{"purl": "pkg:npm/missing@1.0.0"}], + org_slug="test-org", + purl_errors=True, + strict=True, + ) + + self.assertEqual(result, [error_row]) + url = self.mock_requests.request.call_args[0][1] + self.assertIn("purlErrors=true", url) + + def test_purl_post_strict_raises_on_missing(self): + """strict=True raises APIPartialResponse listing purls dropped from the response.""" + from socketdev.exceptions import APIPartialResponse + + # Requested two purls; the fail-open API only returned one. + self._mock_purl_ndjson( + '{"inputPurl": "pkg:npm/lodash@4.18.1", "purl": "pkg:npm/lodash@4.18.1", ' + '"type": "npm", "name": "lodash", "version": "4.18.1", "valid": true, "alerts": []}' + ) + + with self.assertRaises(APIPartialResponse) as ctx: + self.sdk.purl.post( + components=[ + {"purl": "pkg:npm/lodash@4.18.1"}, + {"purl": "pkg:npm/dropped@0.0.1"}, + ], + org_slug="test-org", + strict=True, + ) + self.assertEqual(ctx.exception.missing, ["pkg:npm/dropped@0.0.1"]) + + def test_purl_post_strict_passes_when_complete(self): + """strict=True returns normally when every requested purl is present.""" + self._mock_purl_ndjson( + '{"inputPurl": "pkg:npm/lodash@4.18.1", "purl": "pkg:npm/lodash@4.18.1", ' + '"type": "npm", "name": "lodash", "version": "4.18.1", "valid": true, "alerts": []}' + ) + + result = self.sdk.purl.post( + components=[{"purl": "pkg:npm/lodash@4.18.1"}], + org_slug="test-org", + strict=True, + ) + self.assertEqual(len(result), 1) + + def test_purl_post_strict_matches_original_input_before_normalization(self): + """strict=True matches exact inputPurl even when the canonical purl differs.""" + requested_purl = "pkg:npm/%40scope/pkg@1.0.0" + server_rows = [ + { + "inputPurl": requested_purl, + "purl": "pkg:npm/@scope/pkg@1.0.0", + } + ] + + self.sdk.purl._raise_on_missing( + [{"purl": requested_purl}], + server_rows, + ) + # Quota endpoints def test_quota_get_unit(self): """Test quota retrieval.""" diff --git a/tests/unit/test_exceptions.py b/tests/unit/test_exceptions.py index df621ad..5b305d6 100644 --- a/tests/unit/test_exceptions.py +++ b/tests/unit/test_exceptions.py @@ -25,6 +25,7 @@ APIInsufficientPermissions, APIInsufficientQuota, APIOrganizationNotAllowed, + APIPartialResponse, APIResourceNotFound, APITimeout, ) @@ -51,6 +52,11 @@ def test_connection_level_classes_are_transient(self): self.assertTrue(APIConnectionError().is_transient_error()) self.assertTrue(APIBadGateway().is_transient_error()) + def test_partial_response_is_not_transient(self): + error = APIPartialResponse("incomplete", missing=["pkg:npm/missing@1.0.0"]) + self.assertFalse(error.is_transient_error()) + self.assertEqual(error.missing, ["pkg:npm/missing@1.0.0"]) + def test_bad_gateway_carries_502_by_default(self): self.assertEqual(APIBadGateway().status_code, 502) diff --git a/uv.lock b/uv.lock index 79c3589..bf4ff76 100644 --- a/uv.lock +++ b/uv.lock @@ -1353,7 +1353,7 @@ wheels = [ [[package]] name = "socketdev" -version = "3.4.1" +version = "3.4.2" source = { editable = "." } dependencies = [ { name = "requests" },