Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .git-blame-ignore-revs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Revisions that only reformat or mechanically re-lint code.
# Configure once per clone:
# git config blame.ignoreRevsFile .git-blame-ignore-revs
# (GitHub applies this file automatically in its blame view.)
48 changes: 48 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: Lint

env:
PYTHON_VERSION: "3.12"

# Deliberately not path-filtered. Ruff finishes in well under a minute, and its
# trigger surface is every Python file in the repository -- including the ones
# outside the Unit Tests filters (.hooks/, benchmarks/, tests/e2e/). Running
# unconditionally also keeps this usable as a required status check: a
# path-filtered workflow reports as "not run" rather than "passed", which blocks
# any pull request that does not happen to touch the filtered paths.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:

permissions:
contents: read

concurrency:
group: lint-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
ruff:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 1
persist-credentials: false
- name: 🐍 setup python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: 🛠️ install deps
run: |
python -m pip install --upgrade pip
pip install uv
uv sync --extra dev
# Same ruff version the pre-commit hook uses (pinned in pyproject.toml,
# locked in uv.lock), so a clean commit locally stays clean here.
- name: 🧹 ruff check
run: uv run ruff check
- name: 🎨 ruff format
run: uv run ruff format --check
20 changes: 0 additions & 20 deletions .github/workflows/python-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,26 +67,6 @@ jobs:
uv export --no-hashes --no-emit-project --format requirements-txt > /tmp/req-audit.txt
uvx pip-audit --strict --progress-spinner off --disable-pip --no-deps -r /tmp/req-audit.txt

ruff:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 1
persist-credentials: false
- name: 🐍 setup python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: 🛠️ install deps
run: |
python -m pip install --upgrade pip
pip install uv
uv sync --extra dev
- name: 🧹 run ruff
run: uv run ruff check

unsupported-python-install:
runs-on: ubuntu-latest
timeout-minutes: 10
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/version-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,11 @@ jobs:
pip install packaging

# Get version from current PR
PR_VERSION=$(grep -o "__version__.*" socketsecurity/__init__.py | awk '{print $3}' | tr -d "'")
PR_VERSION=$(grep -o "__version__.*" socketsecurity/__init__.py | awk '{print $3}' | tr -d "\"'")
echo "PR_VERSION=$PR_VERSION" >> $GITHUB_ENV

# Get version from main branch
MAIN_VERSION=$(git show origin/main:socketsecurity/__init__.py | grep -o "__version__.*" | awk '{print $3}' | tr -d "'")
MAIN_VERSION=$(git show origin/main:socketsecurity/__init__.py | grep -o "__version__.*" | awk '{print $3}' | tr -d "\"'")
echo "MAIN_VERSION=$MAIN_VERSION" >> $GITHUB_ENV

export PR_VERSION
Expand Down
17 changes: 14 additions & 3 deletions .hooks/sync_version.py
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
PYPI_PROD_API = "https://pypi.org/pypi/socketsecurity/json"
PYPI_TEST_API = "https://test.pypi.org/pypi/socketsecurity/json"


def read_version_from_init(path: pathlib.Path) -> str:
content = path.read_text()
match = VERSION_PATTERN.search(content)
Expand All @@ -24,6 +25,7 @@ def read_version_from_init(path: pathlib.Path) -> str:
sys.exit(1)
return match.group(1)


def read_version_from_git(path: str) -> str:
try:
output = subprocess.check_output(["git", "show", f"HEAD:{path}"], text=True)
Expand All @@ -34,13 +36,15 @@ def read_version_from_git(path: str) -> str:
except subprocess.CalledProcessError:
return None


def bump_patch_version(version: str) -> str:
if ".dev" in version:
version = version.split(".dev")[0]
parts = version.split(".")
parts[-1] = str(int(parts[-1]) + 1)
return ".".join(parts)


def parse_stable_version(version: str):
if not STABLE_VERSION_PATTERN.fullmatch(version):
return None
Expand Down Expand Up @@ -72,6 +76,7 @@ def fetch_latest_stable_pypi_version():
return None
return max(stable_versions)


def find_next_available_dev_version(base_version: str) -> str:
existing_versions = fetch_existing_versions(PYPI_TEST_API)
for i in range(1, 100):
Expand All @@ -94,12 +99,13 @@ def find_next_stable_patch_version(current_version: str) -> str:
next_parts = (base_parts[0], base_parts[1], base_parts[2] + 1)
return format_stable_version(next_parts)


def inject_version(version: str):
print(f"🔁 Updating version to: {version}")

# Update __init__.py
init_content = INIT_FILE.read_text()
new_init_content = VERSION_PATTERN.sub(f"__version__ = '{version}'", init_content)
new_init_content = VERSION_PATTERN.sub(f'__version__ = "{version}"', init_content)
INIT_FILE.write_text(new_init_content)

# Update pyproject.toml
Expand Down Expand Up @@ -190,16 +196,21 @@ def main():
inject_version(new_version)
uv_lock_changed = run_uv_lock()
lock_hint = " and uv.lock" if uv_lock_changed else ""
print(f"⚠️ Version {current_version} is already published on PyPI — auto-bumped to {new_version}. Please git add{lock_hint} + commit again.")
print(
f"⚠️ Version {current_version} is already published on PyPI — auto-bumped to {new_version}. Please git add{lock_hint} + commit again."
)
sys.exit(1)

uv_lock_changed = run_uv_lock()
if uv_lock_changed:
print("⚠️ Version already bumped, but uv.lock was out of date and has been updated. Please git add uv.lock + commit again.")
print(
"⚠️ Version already bumped, but uv.lock was out of date and has been updated. Please git add uv.lock + commit again."
)
sys.exit(1)

print("✅ Version already bumped and uv.lock is up to date — proceeding.")
sys.exit(0)


if __name__ == "__main__":
main()
27 changes: 26 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,29 @@ repos:
entry: python .hooks/sync_version.py
language: python
always_run: true
pass_filenames: false
pass_filenames: false

# Ruff runs out of the project environment rather than the upstream
# astral-sh/ruff-pre-commit mirror so its version is pinned in exactly one
# place: `ruff==0.16.4` under [project.optional-dependencies].dev, locked
# in uv.lock and used verbatim by the Lint workflow. Dependabot has no
# pre-commit ecosystem and will not touch a mirror's `rev:`, so a mirror
# would drift out of step with CI and produce the worst failure mode for a
# hook -- clean locally, red on the pull request.
#
# `--fix` applies only ruff's fixes marked safe. When it changes a file
# pre-commit aborts the commit and leaves the edit in the working tree, so
# nothing lands without being looked at.
- id: ruff-check
name: ruff check
entry: uv run --extra dev ruff check --force-exclude --fix
language: system
types_or: [python, pyi]
require_serial: true

- id: ruff-format
name: ruff format
entry: uv run --extra dev ruff format --force-exclude
language: system
types_or: [python, pyi]
require_serial: true
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,40 @@
# Changelog

## 2.7.2

### Fixed: package timestamps were truncated

- `Package.created_at` stripped its `" (Coordinated Universal Time)"` suffix with
`str.strip()`, which treats its argument as a set of characters rather than a
suffix. Timestamps beginning with `Tue` lost their leading `T`, and timestamps
that carried no such suffix lost a trailing `T`. The suffix is now removed with
`str.removesuffix()`.

### Fixed: notification delivery could hang a pipeline indefinitely

- Slack, Teams, Jira, generic webhook and GitLab commit-status requests were sent
without a timeout. `requests` blocks forever by default, so an unresponsive
endpoint could hold a run open until the CI job itself timed out. All of these
calls now use an explicit 30 second timeout.

### Fixed: two internal guards did nothing under `python -O`

- A manifest upload checked its organization slug with `assert`, which the
interpreter removes entirely in optimised mode. It is now an explicit check that
raises with a readable message. A second, redundant `assert` was removed.

### Fixed: a debug message was written to stdout

- Duplicate packages in a scan's SBOM artifacts printed to stdout, which also
carries machine-readable output such as SARIF. The message is now logged at
debug level.

### Changed: configuration messages follow the CLI logger

- `config.py` logged through the root logger, so its warnings and errors ignored
the configured log level and format. They now use the `socketcli` logger like
the rest of the CLI.

## 2.7.1

### Changed: bump pinned @coana-tech/cli to 15.10.36
Expand Down
63 changes: 63 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,16 @@ dependencies:
uv sync --all-extras
```

Install the git hooks once per clone:

```bash
make hooks
```

Before opening a pull request, run:

```bash
make lint
make test
uv run hatch build
uv run python -m twine check dist/*
Expand All @@ -22,6 +29,62 @@ uv run python -m twine check dist/*
To develop against a local SDK checkout, set `SOCKET_SDK_PATH` if it is not at
`../socketdev`, then run `make first-time-local-setup`.

## Linting

Ruff is the only linter. It runs in three places, all reading the same
configuration from `pyproject.toml` and the same version pinned in
`[project.optional-dependencies].dev`:

- `make lint` locally,
- the `ruff-check` pre-commit hook, on the files a commit touches,
- the `Lint` workflow, on every pull request and every push to `main`.

The pre-commit hook applies ruff's safe fixes and then fails the commit, leaving
the edits unstaged so they get read before they land. CI is the backstop for
commits made with `--no-verify` or without hooks installed.

`ruff format` is enforced the same way. It owns line length (120) and
whitespace, so the linter does not duplicate those checks: `E501` and `W291`/
`W293` are deliberately not selected. Everything the formatter cannot reflow is
a string literal -- argparse help text, log messages, the Markdown used to build
pull request comments -- where rewrapping risks silently changing user-visible
text. The PR-comment markup in particular relies on trailing double-spaces as
Markdown hard line breaks.

### One trap worth knowing

Never run `ruff check --select <narrow-list> --fix` with `RUF100` in the select.
With a narrow select, RUF100 considers every `# noqa` for a *non-selected* rule
to be unused and deletes it -- silently stripping the complexity suppressions
across the repository. Run `make lint-fix`, which uses the full configured rule
set, instead of hand-rolling a `--select`.

### Complexity limits

Two rules bound how large a single function may get:

| Rule | Limit | What it measures |
| --- | --- | --- |
| `C901` | 12 | Cyclomatic complexity: independent paths through a function, which is also the number of tests needed to cover it. |
| `PLR0913` | 8 | Arguments in a function definition. |

Functions that already exceed these limits carry an explicit
`# noqa: C901` / `# noqa: PLR0913` on their `def` line. That list is a backlog,
not a precedent:

- **Do not add a new suppression.** If a function you are writing trips the
limit, split it. This matters most for generated or model-assisted code, where
branches accumulate quickly and nothing pushes back.
- **Suppressions clean themselves up.** `RUF100` fails the build on a `# noqa`
that no longer applies, so refactoring a function back under the limit forces
the marker to be removed. The backlog can only shrink.

To see what is left:

```bash
grep -rn 'noqa: C901\|noqa: PLR0913' socketsecurity/ tests/
```

## Pull request validation

The `Package Check` workflow runs automatically for pull requests. It builds
Expand Down
20 changes: 17 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: setup sync clean test lint update-lock local-dev first-time-setup dev-setup sync-all first-time-local-setup
.PHONY: setup sync clean test lint lint-fix format format-check hooks 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 ?= ../socketdev
Expand Down Expand Up @@ -57,6 +57,20 @@ clean:
test:
uv run pytest

# Installs the git pre-commit hooks (ruff + version sync).
hooks:
uv run --extra dev pre-commit install

# Exactly what the Lint workflow runs, so a green `make lint` means a green CI.
lint:
uv run ruff check .
uv run ruff format --check .
uv run --extra dev ruff check
uv run --extra dev ruff format --check

lint-fix:
uv run --extra dev ruff check --fix

format:
uv run --extra dev ruff format

format-check:
uv run --extra dev ruff format --check
9 changes: 3 additions & 6 deletions benchmarks/manifest_discovery.py
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ def legacy_discover(root: Path) -> set[str]:
insensitive = Core.to_case_insensitive_regex(pattern)
for candidate in root.rglob(insensitive):
if candidate.is_file() and not Core.is_excluded(
str(candidate),
excluded_dirs,
str(candidate),
excluded_dirs,
):
results.add(candidate.as_posix())
return results
Expand Down Expand Up @@ -85,10 +85,7 @@ def main() -> None:
)

if legacy_results != new_results:
raise SystemExit(
"Manifest result mismatch: "
f"legacy={len(legacy_results)}, single_pass={len(new_results)}"
)
raise SystemExit(f"Manifest result mismatch: legacy={len(legacy_results)}, single_pass={len(new_results)}")

speedup = legacy_seconds / new_seconds if new_seconds else float("inf")
print(f"Manifests: {len(new_results)}")
Expand Down
Loading