ipscout 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ipscout-1.0.0/.claude-plugin/marketplace.json +16 -0
- ipscout-1.0.0/.claude-plugin/plugin.json +11 -0
- ipscout-1.0.0/.devcontainer/devcontainer.json +22 -0
- ipscout-1.0.0/.devcontainer/settings.json +6 -0
- ipscout-1.0.0/.env.example +11 -0
- ipscout-1.0.0/.github/actions/extract-metadata/action.yml +108 -0
- ipscout-1.0.0/.github/dependabot.yml +22 -0
- ipscout-1.0.0/.github/workflows/codeql.yml +39 -0
- ipscout-1.0.0/.github/workflows/default_cicd_public.yml +436 -0
- ipscout-1.0.0/.github/workflows/default_release_public.yml +95 -0
- ipscout-1.0.0/.gitignore +196 -0
- ipscout-1.0.0/.qlty/qlty.toml +2 -0
- ipscout-1.0.0/.snyk +5 -0
- ipscout-1.0.0/CHANGELOG.md +117 -0
- ipscout-1.0.0/CONTRIBUTING.md +55 -0
- ipscout-1.0.0/LICENSE +22 -0
- ipscout-1.0.0/Makefile +453 -0
- ipscout-1.0.0/PKG-INFO +338 -0
- ipscout-1.0.0/README.md +295 -0
- ipscout-1.0.0/ai-stance.md +90 -0
- ipscout-1.0.0/ai-transparency.md +88 -0
- ipscout-1.0.0/codecov.yml +28 -0
- ipscout-1.0.0/conftest.py +124 -0
- ipscout-1.0.0/docs/development.md +98 -0
- ipscout-1.0.0/docs/installation.md +152 -0
- ipscout-1.0.0/docs/systemdesign/module_reference.md +377 -0
- ipscout-1.0.0/docs/usage.md +592 -0
- ipscout-1.0.0/notebooks/Quickstart.ipynb +165 -0
- ipscout-1.0.0/pyproject.toml +275 -0
- ipscout-1.0.0/skills/python-network-probe/.skillwriter/checklist-20260729-full-surface.md +34 -0
- ipscout-1.0.0/skills/python-network-probe/SKILL.md +214 -0
- ipscout-1.0.0/src/ipscout/__init__.py +137 -0
- ipscout-1.0.0/src/ipscout/__init__conf__.py +79 -0
- ipscout-1.0.0/src/ipscout/__main__.py +18 -0
- ipscout-1.0.0/src/ipscout/api.py +425 -0
- ipscout-1.0.0/src/ipscout/arp.py +250 -0
- ipscout-1.0.0/src/ipscout/bsdroute.py +260 -0
- ipscout-1.0.0/src/ipscout/cli.py +765 -0
- ipscout-1.0.0/src/ipscout/errors.py +95 -0
- ipscout-1.0.0/src/ipscout/factory.py +129 -0
- ipscout-1.0.0/src/ipscout/interfaces.py +47 -0
- ipscout-1.0.0/src/ipscout/interfaces_posix.py +358 -0
- ipscout-1.0.0/src/ipscout/interfaces_windows.py +223 -0
- ipscout-1.0.0/src/ipscout/leases_linux.py +219 -0
- ipscout-1.0.0/src/ipscout/models.py +564 -0
- ipscout-1.0.0/src/ipscout/mtu.py +242 -0
- ipscout-1.0.0/src/ipscout/neighbours.py +303 -0
- ipscout-1.0.0/src/ipscout/neighbours_linux.py +352 -0
- ipscout-1.0.0/src/ipscout/neighbours_macos.py +336 -0
- ipscout-1.0.0/src/ipscout/neighbours_windows.py +243 -0
- ipscout-1.0.0/src/ipscout/netlink.py +153 -0
- ipscout-1.0.0/src/ipscout/packet.py +416 -0
- ipscout-1.0.0/src/ipscout/pool.py +82 -0
- ipscout-1.0.0/src/ipscout/ports.py +150 -0
- ipscout-1.0.0/src/ipscout/portscan.py +387 -0
- ipscout-1.0.0/src/ipscout/py.typed +0 -0
- ipscout-1.0.0/src/ipscout/resolve.py +201 -0
- ipscout-1.0.0/src/ipscout/routes.py +98 -0
- ipscout-1.0.0/src/ipscout/routes_linux.py +217 -0
- ipscout-1.0.0/src/ipscout/routes_macos.py +206 -0
- ipscout-1.0.0/src/ipscout/routes_windows.py +175 -0
- ipscout-1.0.0/src/ipscout/scan.py +168 -0
- ipscout-1.0.0/src/ipscout/serialize.py +89 -0
- ipscout-1.0.0/src/ipscout/service.py +165 -0
- ipscout-1.0.0/src/ipscout/subnet.py +111 -0
- ipscout-1.0.0/src/ipscout/tcpsyn.py +250 -0
- ipscout-1.0.0/src/ipscout/traceroute.py +237 -0
- ipscout-1.0.0/src/ipscout/transport_posix.py +621 -0
- ipscout-1.0.0/src/ipscout/transport_tcp.py +211 -0
- ipscout-1.0.0/src/ipscout/transport_windows.py +350 -0
- ipscout-1.0.0/src/ipscout/typed_click.py +57 -0
- ipscout-1.0.0/src/ipscout/unixio.py +50 -0
- ipscout-1.0.0/src/ipscout/winapi.py +577 -0
- ipscout-1.0.0/src/ipscout/wol.py +106 -0
- ipscout-1.0.0/tests/conftest.py +42 -0
- ipscout-1.0.0/tests/test_arp.py +212 -0
- ipscout-1.0.0/tests/test_cli.py +239 -0
- ipscout-1.0.0/tests/test_integration_internet.py +180 -0
- ipscout-1.0.0/tests/test_integration_loopback.py +206 -0
- ipscout-1.0.0/tests/test_interfaces.py +92 -0
- ipscout-1.0.0/tests/test_metadata.py +228 -0
- ipscout-1.0.0/tests/test_models.py +180 -0
- ipscout-1.0.0/tests/test_module_entry.py +56 -0
- ipscout-1.0.0/tests/test_neighbours.py +411 -0
- ipscout-1.0.0/tests/test_packet.py +205 -0
- ipscout-1.0.0/tests/test_resolve.py +105 -0
- ipscout-1.0.0/tests/test_routes.py +320 -0
- ipscout-1.0.0/tests/test_scan_and_subnet.py +564 -0
- ipscout-1.0.0/tests/test_service.py +234 -0
- ipscout-1.0.0/tests/test_tcp_fallback.py +134 -0
- ipscout-1.0.0/tests/test_traceroute.py +246 -0
- ipscout-1.0.0/tests/test_transport_posix_logic.py +273 -0
- ipscout-1.0.0/tests/test_ttl_capability_probe.py +126 -0
- ipscout-1.0.0/tests/test_winapi_layout.py +185 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ipscout",
|
|
3
|
+
"owner": {
|
|
4
|
+
"name": "bitranox"
|
|
5
|
+
},
|
|
6
|
+
"metadata": {
|
|
7
|
+
"description": "ipscout delivered as a single Claude Code plugin: a skill for probing network reachability and inspecting the local network from Python or the shell, in-process and without administrator rights."
|
|
8
|
+
},
|
|
9
|
+
"plugins": [
|
|
10
|
+
{
|
|
11
|
+
"name": "ipscout",
|
|
12
|
+
"source": ".",
|
|
13
|
+
"description": "Ping, sweep, traceroute and interface enumeration from Python or the CLI, unprivileged and without a subprocess. Covers the library API, the JSON output for machine consumers, and the platform limits measured on Linux, macOS and Windows."
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ipscout",
|
|
3
|
+
"description": "Ping hosts, sweep a subnet, measure round-trip time and packet loss, run a traceroute, scan ports, look up MAC addresses and the neighbour cache, query routes and the default gateway, list interfaces and subnets, send wake-on-LAN and find the path MTU - from Python or the shell, as an ordinary user with no root or Administrator rights and without shelling out. Ships the python-network-probe skill: the library API, the CLI with its JSON envelope, the raise-on-setup-problems error contract, and the measured platform limits.",
|
|
4
|
+
"version": "1.1.0",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "bitranox"
|
|
7
|
+
},
|
|
8
|
+
"homepage": "https://github.com/bitranox/ipscout",
|
|
9
|
+
"repository": "https://github.com/bitranox/ipscout",
|
|
10
|
+
"license": "MIT"
|
|
11
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"image": "mcr.microsoft.com/devcontainers/python:3.13",
|
|
3
|
+
|
|
4
|
+
"postCreateCommand": "bash -lc '\nPY=/usr/local/bin/python;\n$PY -m pip install -U pip && \\\n$PY -m pip install ipykernel && \\\n$PY -m pip install -e . && \\\n# register kernel using this exact interpreter\n$PY -m ipykernel install --name=python3 --display-name=\"Python 3 (3.13)\" --user && \\\n# patch the notebook to point to the python3 kernelspec (VS Code will bind to /usr/local/bin/python)\n$PY - <<\"PY\"\nimport json, pathlib\np = pathlib.Path(\"notebooks/Quickstart.ipynb\")\nif p.exists():\n nb = json.loads(p.read_text(encoding=\"utf-8\"))\n md = nb.setdefault(\"metadata\", {})\n md[\"kernelspec\"] = {\n \"name\": \"python3\",\n \"display_name\": \"Python 3 (3.13)\",\n \"language\": \"python\"\n }\n md[\"language_info\"] = {\"name\": \"python\"}\n p.write_text(json.dumps(nb, ensure_ascii=False, indent=1), encoding=\"utf-8\")\n print(\"Pinned kernelspec to python3 in\", p)\nelse:\n print(\"Notebook not found:\", p)\nPY'\n",
|
|
5
|
+
|
|
6
|
+
"customizations": {
|
|
7
|
+
"vscode": {
|
|
8
|
+
"extensions": ["ms-toolsai.jupyter", "ms-python.python"],
|
|
9
|
+
"settings": {
|
|
10
|
+
"workbench.startupEditor": "none",
|
|
11
|
+
"jupyter.alwaysTrustNotebooks": true,
|
|
12
|
+
"jupyter.kernelPickerType": "OnlyRecommended",
|
|
13
|
+
"python.defaultInterpreterPath": "/usr/local/bin/python"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"codespaces": {
|
|
17
|
+
"openFiles": ["README.md"]
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
"postAttachCommand": "bash -lc 'if [ -f notebooks/Quickstart.ipynb ]; then code -r notebooks/Quickstart.ipynb; fi'"
|
|
22
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Copy to .env and fill values as needed.
|
|
2
|
+
|
|
3
|
+
# Codecov upload token (required for private repos; optional for public repos)
|
|
4
|
+
CODECOV_TOKEN=
|
|
5
|
+
|
|
6
|
+
# PyPI API token for release workflow (.github/workflows/release.yml)
|
|
7
|
+
# Format: pypi-AgENdGVzdC5weXBpLm9yZwIk...
|
|
8
|
+
PYPI_API_TOKEN=
|
|
9
|
+
|
|
10
|
+
# Optional: GitHub token for API-limited tasks
|
|
11
|
+
GITHUB_TOKEN=
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
name: Extract project metadata
|
|
2
|
+
description: >-
|
|
3
|
+
Parse pyproject.toml and expose project metadata as step outputs.
|
|
4
|
+
Requires Python to be available in the runner environment.
|
|
5
|
+
Auto-installs rtoml if not already present.
|
|
6
|
+
|
|
7
|
+
outputs:
|
|
8
|
+
PACKAGE_MODULE:
|
|
9
|
+
description: Python-importable module name (underscored)
|
|
10
|
+
value: ${{ steps.meta.outputs.PACKAGE_MODULE }}
|
|
11
|
+
PROJECT_NAME:
|
|
12
|
+
description: Project name from pyproject.toml
|
|
13
|
+
value: ${{ steps.meta.outputs.PROJECT_NAME }}
|
|
14
|
+
CLI_BIN:
|
|
15
|
+
description: CLI entry-point binary name
|
|
16
|
+
value: ${{ steps.meta.outputs.CLI_BIN }}
|
|
17
|
+
SRC_PATH:
|
|
18
|
+
description: Source directory path (e.g. "src")
|
|
19
|
+
value: ${{ steps.meta.outputs.SRC_PATH }}
|
|
20
|
+
COV_FAIL_UNDER:
|
|
21
|
+
description: Minimum coverage percentage from tool.coverage.report.fail_under
|
|
22
|
+
value: ${{ steps.meta.outputs.COV_FAIL_UNDER }}
|
|
23
|
+
COV_REPORT_FILE:
|
|
24
|
+
description: Coverage report filename (e.g. "coverage.xml")
|
|
25
|
+
value: ${{ steps.meta.outputs.COV_REPORT_FILE }}
|
|
26
|
+
PYTEST_VERBOSITY:
|
|
27
|
+
description: Pytest verbosity flag (e.g. "-vv")
|
|
28
|
+
value: ${{ steps.meta.outputs.PYTEST_VERBOSITY }}
|
|
29
|
+
BANDIT_SKIP_FLAG:
|
|
30
|
+
description: Bandit skip flag string (e.g. "-s B101,B601") or empty
|
|
31
|
+
value: ${{ steps.meta.outputs.BANDIT_SKIP_FLAG }}
|
|
32
|
+
PYTEST_MARKER_EXPR:
|
|
33
|
+
description: >-
|
|
34
|
+
Marker expression for the CI test run (e.g. "not local_only and not integration"),
|
|
35
|
+
built from tool.ci.pytest-exclude-markers
|
|
36
|
+
value: ${{ steps.meta.outputs.PYTEST_MARKER_EXPR }}
|
|
37
|
+
|
|
38
|
+
runs:
|
|
39
|
+
using: composite
|
|
40
|
+
steps:
|
|
41
|
+
- name: Extract project metadata from pyproject.toml
|
|
42
|
+
id: meta
|
|
43
|
+
shell: python
|
|
44
|
+
run: |
|
|
45
|
+
import os, subprocess, sys
|
|
46
|
+
try:
|
|
47
|
+
import rtoml
|
|
48
|
+
except ModuleNotFoundError:
|
|
49
|
+
subprocess.check_call([sys.executable, "-m", "pip", "install", "rtoml", "-q"])
|
|
50
|
+
import rtoml
|
|
51
|
+
|
|
52
|
+
from pathlib import Path
|
|
53
|
+
data = rtoml.load(Path('pyproject.toml'))
|
|
54
|
+
|
|
55
|
+
# --- [project] section ---
|
|
56
|
+
project = data['project']['name']
|
|
57
|
+
module = project.replace('-', '_')
|
|
58
|
+
dash = project.replace('_', '-')
|
|
59
|
+
scripts = list(data['project'].get('scripts', {}).keys())
|
|
60
|
+
cli_bin = scripts[0] if scripts else dash
|
|
61
|
+
|
|
62
|
+
# --- [tool.scripts.test] section ---
|
|
63
|
+
scripts_test = data.get('tool', {}).get('scripts', {}).get('test', {})
|
|
64
|
+
src_path = scripts_test.get('src-path', 'src')
|
|
65
|
+
pytest_verbosity = scripts_test.get('pytest-verbosity', '-vv')
|
|
66
|
+
cov_report_file = scripts_test.get('coverage-report-file', 'coverage.xml')
|
|
67
|
+
|
|
68
|
+
# --- [tool.coverage.report] section ---
|
|
69
|
+
cov_fail_under = (
|
|
70
|
+
data.get('tool', {}).get('coverage', {}).get('report', {}).get('fail_under', 90)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# --- [tool.bandit] section ---
|
|
74
|
+
bandit_skips = data.get('tool', {}).get('bandit', {}).get('skips', [])
|
|
75
|
+
bandit_skip_flag = f"-s {','.join(bandit_skips)}" if bandit_skips else ""
|
|
76
|
+
|
|
77
|
+
# --- [tool.ci] section ---
|
|
78
|
+
# CI always skips local_only (those tests need a dev box). A project whose
|
|
79
|
+
# suite also carries markers CI cannot satisfy - integration tests needing a
|
|
80
|
+
# container that a separate workflow starts - names them here, rather than
|
|
81
|
+
# every such repo forking this workflow to edit one -m expression.
|
|
82
|
+
ci_config = data.get('tool', {}).get('ci', {})
|
|
83
|
+
exclude_markers = ['local_only', *ci_config.get('pytest-exclude-markers', [])]
|
|
84
|
+
pytest_marker_expr = ' and '.join(f'not {m}' for m in exclude_markers)
|
|
85
|
+
|
|
86
|
+
# Write to GITHUB_OUTPUT for composite action outputs
|
|
87
|
+
with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as out:
|
|
88
|
+
out.write(f"PROJECT_NAME={project}\n")
|
|
89
|
+
out.write(f"PACKAGE_MODULE={module}\n")
|
|
90
|
+
out.write(f"CLI_BIN={cli_bin}\n")
|
|
91
|
+
out.write(f"SRC_PATH={src_path}\n")
|
|
92
|
+
out.write(f"PYTEST_VERBOSITY={pytest_verbosity}\n")
|
|
93
|
+
out.write(f"COV_REPORT_FILE={cov_report_file}\n")
|
|
94
|
+
out.write(f"COV_FAIL_UNDER={cov_fail_under}\n")
|
|
95
|
+
out.write(f"BANDIT_SKIP_FLAG={bandit_skip_flag}\n")
|
|
96
|
+
out.write(f"PYTEST_MARKER_EXPR={pytest_marker_expr}\n")
|
|
97
|
+
|
|
98
|
+
# Also write to GITHUB_ENV so subsequent steps can use ${{ env.VAR }}
|
|
99
|
+
with open(os.environ['GITHUB_ENV'], 'a', encoding='utf-8') as env:
|
|
100
|
+
env.write(f"PROJECT_NAME={project}\n")
|
|
101
|
+
env.write(f"PACKAGE_MODULE={module}\n")
|
|
102
|
+
env.write(f"CLI_BIN={cli_bin}\n")
|
|
103
|
+
env.write(f"SRC_PATH={src_path}\n")
|
|
104
|
+
env.write(f"PYTEST_VERBOSITY={pytest_verbosity}\n")
|
|
105
|
+
env.write(f"COV_REPORT_FILE={cov_report_file}\n")
|
|
106
|
+
env.write(f"COV_FAIL_UNDER={cov_fail_under}\n")
|
|
107
|
+
env.write(f"BANDIT_SKIP_FLAG={bandit_skip_flag}\n")
|
|
108
|
+
env.write(f"PYTEST_MARKER_EXPR={pytest_marker_expr}\n")
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
version: 2
|
|
2
|
+
updates:
|
|
3
|
+
- package-ecosystem: "pip"
|
|
4
|
+
directory: "/"
|
|
5
|
+
schedule:
|
|
6
|
+
interval: "weekly"
|
|
7
|
+
allow:
|
|
8
|
+
- dependency-type: "direct"
|
|
9
|
+
labels:
|
|
10
|
+
- "dependencies"
|
|
11
|
+
commit-message:
|
|
12
|
+
prefix: "deps"
|
|
13
|
+
include: "scope"
|
|
14
|
+
- package-ecosystem: "github-actions"
|
|
15
|
+
directory: "/"
|
|
16
|
+
schedule:
|
|
17
|
+
interval: "weekly"
|
|
18
|
+
labels:
|
|
19
|
+
- "dependencies"
|
|
20
|
+
commit-message:
|
|
21
|
+
prefix: "deps"
|
|
22
|
+
include: "scope"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
name: CodeQL
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [ main, master ]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [ main, master ]
|
|
8
|
+
schedule:
|
|
9
|
+
- cron: '0 8 * * 1'
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
analyze:
|
|
13
|
+
name: CodeQL Analyze
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
permissions:
|
|
16
|
+
actions: read
|
|
17
|
+
contents: read
|
|
18
|
+
security-events: write
|
|
19
|
+
strategy:
|
|
20
|
+
fail-fast: false
|
|
21
|
+
matrix:
|
|
22
|
+
language: [ 'python' ]
|
|
23
|
+
|
|
24
|
+
steps:
|
|
25
|
+
- name: Checkout repository
|
|
26
|
+
uses: actions/checkout@v7
|
|
27
|
+
|
|
28
|
+
- name: Initialize CodeQL
|
|
29
|
+
uses: github/codeql-action/init@v4
|
|
30
|
+
with:
|
|
31
|
+
languages: ${{ matrix.language }}
|
|
32
|
+
|
|
33
|
+
- name: Autobuild
|
|
34
|
+
uses: github/codeql-action/autobuild@v4
|
|
35
|
+
|
|
36
|
+
- name: Perform CodeQL Analysis
|
|
37
|
+
uses: github/codeql-action/analyze@v4
|
|
38
|
+
with:
|
|
39
|
+
category: "/language:${{ matrix.language }}"
|
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [ main, master ]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [ main, master ]
|
|
8
|
+
schedule:
|
|
9
|
+
# Run daily at 3:17 AM UTC (off-peak hours, odd minute to avoid congestion)
|
|
10
|
+
- cron: '17 3 * * *'
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
setup:
|
|
14
|
+
name: Extract Python versions and OS matrix from pyproject.toml
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
outputs:
|
|
17
|
+
python-versions: ${{ steps.metadata.outputs.python-versions }}
|
|
18
|
+
python-latest: ${{ steps.metadata.outputs.python-latest }}
|
|
19
|
+
os-matrix: ${{ steps.metadata.outputs.os-matrix }}
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@v7
|
|
22
|
+
- uses: actions/setup-python@v7
|
|
23
|
+
with:
|
|
24
|
+
python-version: "3.x"
|
|
25
|
+
- name: Parse pyproject.toml for Python versions and OS matrix
|
|
26
|
+
id: metadata
|
|
27
|
+
shell: python
|
|
28
|
+
run: |
|
|
29
|
+
import json, os, tomllib
|
|
30
|
+
|
|
31
|
+
with open("pyproject.toml", "rb") as f:
|
|
32
|
+
data = tomllib.load(f)
|
|
33
|
+
|
|
34
|
+
# Python versions from classifiers
|
|
35
|
+
classifiers = data.get("project", {}).get("classifiers", [])
|
|
36
|
+
prefix = "Programming Language :: Python :: "
|
|
37
|
+
versions = [
|
|
38
|
+
c[len(prefix):]
|
|
39
|
+
for c in classifiers
|
|
40
|
+
if c.startswith(prefix) and "." in c[len(prefix):]
|
|
41
|
+
]
|
|
42
|
+
if not versions:
|
|
43
|
+
raise SystemExit("No Python X.Y versions found in classifiers")
|
|
44
|
+
|
|
45
|
+
# OS matrix from [tool.ci]
|
|
46
|
+
ci_config = data.get("tool", {}).get("ci", {})
|
|
47
|
+
os_list = ci_config.get("os", ["ubuntu-latest"])
|
|
48
|
+
|
|
49
|
+
with open(os.environ["GITHUB_OUTPUT"], "a") as fh:
|
|
50
|
+
fh.write(f"python-versions={json.dumps(versions)}\n")
|
|
51
|
+
fh.write(f"python-latest={versions[-1]}\n")
|
|
52
|
+
fh.write(f"os-matrix={json.dumps(os_list)}\n")
|
|
53
|
+
|
|
54
|
+
test:
|
|
55
|
+
name: Tests (Python ${{ matrix.python }}, ${{ matrix.os }})
|
|
56
|
+
needs: setup
|
|
57
|
+
runs-on: ${{ matrix.os }}
|
|
58
|
+
strategy:
|
|
59
|
+
fail-fast: false
|
|
60
|
+
matrix:
|
|
61
|
+
os: ${{ fromJSON(needs.setup.outputs.os-matrix) }}
|
|
62
|
+
python: ${{ fromJSON(needs.setup.outputs.python-versions) }}
|
|
63
|
+
env:
|
|
64
|
+
PYO3_USE_ABI3_FORWARD_COMPATIBILITY: "1"
|
|
65
|
+
PYTHONIOENCODING: "utf-8"
|
|
66
|
+
UV_LINK_MODE: "copy"
|
|
67
|
+
UV_BREAK_SYSTEM_PACKAGES: "1"
|
|
68
|
+
steps:
|
|
69
|
+
- uses: actions/checkout@v7
|
|
70
|
+
- uses: actions/setup-python@v7
|
|
71
|
+
with:
|
|
72
|
+
python-version: ${{ matrix.python }}
|
|
73
|
+
- name: Install uv
|
|
74
|
+
uses: astral-sh/setup-uv@v7
|
|
75
|
+
with:
|
|
76
|
+
enable-cache: true
|
|
77
|
+
prune-cache: false
|
|
78
|
+
|
|
79
|
+
- name: Install bash 4+ on macOS
|
|
80
|
+
if: runner.os == 'macOS'
|
|
81
|
+
run: |
|
|
82
|
+
brew install bash
|
|
83
|
+
echo "$(brew --prefix)/bin" >> "$GITHUB_PATH"
|
|
84
|
+
|
|
85
|
+
- name: Extract project metadata
|
|
86
|
+
id: metadata
|
|
87
|
+
uses: ./.github/actions/extract-metadata
|
|
88
|
+
|
|
89
|
+
- name: Install CI system dependencies (Ubuntu)
|
|
90
|
+
if: runner.os == 'Linux'
|
|
91
|
+
shell: python
|
|
92
|
+
run: |
|
|
93
|
+
import subprocess
|
|
94
|
+
try:
|
|
95
|
+
import tomllib
|
|
96
|
+
except ModuleNotFoundError:
|
|
97
|
+
import pip._vendor.tomli as tomllib
|
|
98
|
+
with open("pyproject.toml", "rb") as f:
|
|
99
|
+
data = tomllib.load(f)
|
|
100
|
+
deps = data.get("tool", {}).get("ci", {}).get("system-dependencies-ubuntu", [])
|
|
101
|
+
if deps:
|
|
102
|
+
print(f"Installing system packages: {' '.join(deps)}")
|
|
103
|
+
subprocess.check_call(["sudo", "apt-get", "update"])
|
|
104
|
+
subprocess.check_call(["sudo", "apt-get", "install", "-y", *deps])
|
|
105
|
+
else:
|
|
106
|
+
print("No CI system dependencies configured for Ubuntu.")
|
|
107
|
+
|
|
108
|
+
- name: Install CI system dependencies (macOS)
|
|
109
|
+
if: runner.os == 'macOS'
|
|
110
|
+
shell: python
|
|
111
|
+
run: |
|
|
112
|
+
import subprocess
|
|
113
|
+
try:
|
|
114
|
+
import tomllib
|
|
115
|
+
except ModuleNotFoundError:
|
|
116
|
+
import pip._vendor.tomli as tomllib
|
|
117
|
+
with open("pyproject.toml", "rb") as f:
|
|
118
|
+
data = tomllib.load(f)
|
|
119
|
+
deps = data.get("tool", {}).get("ci", {}).get("system-dependencies-macos", [])
|
|
120
|
+
if deps:
|
|
121
|
+
print(f"Installing system packages: {' '.join(deps)}")
|
|
122
|
+
subprocess.check_call(["brew", "install", *deps])
|
|
123
|
+
else:
|
|
124
|
+
print("No CI system dependencies configured for macOS.")
|
|
125
|
+
|
|
126
|
+
- name: Install dev deps
|
|
127
|
+
shell: bash
|
|
128
|
+
run: |
|
|
129
|
+
uv pip install -e .[dev] --system
|
|
130
|
+
|
|
131
|
+
- name: Install CI system dependencies (Windows/choco)
|
|
132
|
+
if: runner.os == 'Windows'
|
|
133
|
+
shell: python
|
|
134
|
+
run: |
|
|
135
|
+
import subprocess
|
|
136
|
+
try:
|
|
137
|
+
import tomllib
|
|
138
|
+
except ModuleNotFoundError:
|
|
139
|
+
import pip._vendor.tomli as tomllib
|
|
140
|
+
with open("pyproject.toml", "rb") as f:
|
|
141
|
+
data = tomllib.load(f)
|
|
142
|
+
deps = data.get("tool", {}).get("ci", {}).get("system-dependencies-windows-choco", [])
|
|
143
|
+
if deps:
|
|
144
|
+
print(f"Installing choco packages: {' '.join(deps)}")
|
|
145
|
+
subprocess.check_call(["choco", "install", "-y", *deps])
|
|
146
|
+
else:
|
|
147
|
+
print("No CI choco dependencies configured for Windows.")
|
|
148
|
+
|
|
149
|
+
- name: Install CI pip dependencies (Windows)
|
|
150
|
+
if: runner.os == 'Windows'
|
|
151
|
+
shell: python
|
|
152
|
+
run: |
|
|
153
|
+
import subprocess
|
|
154
|
+
try:
|
|
155
|
+
import tomllib
|
|
156
|
+
except ModuleNotFoundError:
|
|
157
|
+
import pip._vendor.tomli as tomllib
|
|
158
|
+
with open("pyproject.toml", "rb") as f:
|
|
159
|
+
data = tomllib.load(f)
|
|
160
|
+
deps = data.get("tool", {}).get("ci", {}).get("system-dependencies-windows-pip", [])
|
|
161
|
+
if deps:
|
|
162
|
+
print(f"Installing pip packages: {' '.join(deps)}")
|
|
163
|
+
subprocess.check_call(["uv", "pip", "install", *deps, "--system"])
|
|
164
|
+
else:
|
|
165
|
+
print("No CI pip dependencies configured for Windows.")
|
|
166
|
+
|
|
167
|
+
# Self-hosted runners persist ruff cache via RUFF_CACHE_DIR volume mount,
|
|
168
|
+
# so actions/cache is only needed on GitHub-hosted runners.
|
|
169
|
+
- name: Restore ruff cache
|
|
170
|
+
if: runner.environment == 'github-hosted'
|
|
171
|
+
id: ruff-cache
|
|
172
|
+
uses: actions/cache/restore@v6
|
|
173
|
+
with:
|
|
174
|
+
path: .ruff_cache
|
|
175
|
+
key: ruff-${{ runner.os }}-py${{ matrix.python }}-${{ github.sha }}
|
|
176
|
+
restore-keys: |
|
|
177
|
+
ruff-${{ runner.os }}-py${{ matrix.python }}-
|
|
178
|
+
ruff-${{ runner.os }}-
|
|
179
|
+
|
|
180
|
+
- name: Run ruff (lint + format)
|
|
181
|
+
id: ruff
|
|
182
|
+
continue-on-error: true
|
|
183
|
+
shell: bash
|
|
184
|
+
run: |
|
|
185
|
+
ruff check .
|
|
186
|
+
ruff format --check .
|
|
187
|
+
|
|
188
|
+
- name: Run pyright (type check)
|
|
189
|
+
id: pyright
|
|
190
|
+
continue-on-error: true
|
|
191
|
+
shell: bash
|
|
192
|
+
run: pyright
|
|
193
|
+
|
|
194
|
+
- name: Run pytest (tests + coverage)
|
|
195
|
+
id: pytest
|
|
196
|
+
continue-on-error: true
|
|
197
|
+
shell: bash
|
|
198
|
+
env:
|
|
199
|
+
TEST_VERBOSE: "1"
|
|
200
|
+
# Write coverage SQLite database to local disk — network mounts
|
|
201
|
+
# and stale journal files cause "database is locked" errors.
|
|
202
|
+
COVERAGE_FILE: "${{ runner.temp }}/.coverage"
|
|
203
|
+
run: >-
|
|
204
|
+
pytest -m "${{ env.PYTEST_MARKER_EXPR }}"
|
|
205
|
+
--cov=${{ env.SRC_PATH }}/${{ env.PACKAGE_MODULE }}
|
|
206
|
+
--cov-report=term-missing
|
|
207
|
+
--cov-report=xml:${{ env.COV_REPORT_FILE }}
|
|
208
|
+
--cov-fail-under=${{ env.COV_FAIL_UNDER }}
|
|
209
|
+
${{ env.PYTEST_VERBOSITY }}
|
|
210
|
+
|
|
211
|
+
- name: Upload coverage to Codecov
|
|
212
|
+
if: >-
|
|
213
|
+
always()
|
|
214
|
+
&& steps.pytest.outcome == 'success'
|
|
215
|
+
&& matrix.os == 'ubuntu-latest'
|
|
216
|
+
&& matrix.python == needs.setup.outputs.python-latest
|
|
217
|
+
uses: codecov/codecov-action@v7
|
|
218
|
+
with:
|
|
219
|
+
files: ${{ env.COV_REPORT_FILE }}
|
|
220
|
+
fail_ci_if_error: false
|
|
221
|
+
|
|
222
|
+
- name: Run import-linter (architecture)
|
|
223
|
+
id: importlinter
|
|
224
|
+
continue-on-error: true
|
|
225
|
+
shell: bash
|
|
226
|
+
run: lint-imports
|
|
227
|
+
|
|
228
|
+
- name: Run bandit (security scan)
|
|
229
|
+
id: bandit
|
|
230
|
+
continue-on-error: true
|
|
231
|
+
shell: bash
|
|
232
|
+
run: bandit -q -c pyproject.toml -r ${{ env.SRC_PATH }}/${{ env.PACKAGE_MODULE }} ${{ env.BANDIT_SKIP_FLAG }}
|
|
233
|
+
|
|
234
|
+
- name: Run pip-audit (vulnerability scan)
|
|
235
|
+
id: pipaudit
|
|
236
|
+
continue-on-error: true
|
|
237
|
+
shell: python
|
|
238
|
+
run: |
|
|
239
|
+
import subprocess, sys, tempfile
|
|
240
|
+
import rtoml
|
|
241
|
+
from pathlib import Path
|
|
242
|
+
|
|
243
|
+
# Audit the project's OWN resolved dependency tree, not the ambient
|
|
244
|
+
# environment. "Install dev deps" uses --system, so the interpreter
|
|
245
|
+
# being audited is the runner image's, and that image ships packages
|
|
246
|
+
# this project neither declares nor distributes (setuptools, pip,
|
|
247
|
+
# wheel). Auditing those reported CVEs no consumer of this project is
|
|
248
|
+
# exposed to, and a scan that is permanently yellow is a scan everyone
|
|
249
|
+
# learns to ignore. Resolving pyproject.toml here keeps the result
|
|
250
|
+
# identical on every OS/python and dependent only on what we declare.
|
|
251
|
+
data = rtoml.load(Path("pyproject.toml"))
|
|
252
|
+
|
|
253
|
+
# Not every project declares dev dependencies, and those that do may use
|
|
254
|
+
# either an extra or a PEP 735 group. Asking uv for one that does not
|
|
255
|
+
# exist is a hard error ("Requested extra not found"), which would leave
|
|
256
|
+
# the audit permanently broken here rather than merely noisy, so select
|
|
257
|
+
# the flag from what this project actually declares.
|
|
258
|
+
selector = []
|
|
259
|
+
if "dev" in data.get("project", {}).get("optional-dependencies", {}):
|
|
260
|
+
selector = ["--extra", "dev"]
|
|
261
|
+
elif "dev" in data.get("dependency-groups", {}):
|
|
262
|
+
selector = ["--group", "dev"]
|
|
263
|
+
|
|
264
|
+
tmp = Path(tempfile.mkdtemp())
|
|
265
|
+
req = tmp / "compiled-requirements.txt"
|
|
266
|
+
subprocess.check_call(
|
|
267
|
+
["uv", "pip", "compile", *selector, "pyproject.toml", "-o", str(req), "--quiet"]
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
# Drop direct URL references ("pkg @ git+https://...") before auditing.
|
|
271
|
+
# pip-audit cannot audit those (they are not on PyPI) but, given a
|
|
272
|
+
# requirements file, first tries to `git clone` each one to resolve a
|
|
273
|
+
# version - a slow, credential-dependent fetch. No public project here
|
|
274
|
+
# uses one today, yet "allow-direct-references" is enabled, so this
|
|
275
|
+
# guards the day one appears and keeps this step identical to the
|
|
276
|
+
# default_cicd_private template's.
|
|
277
|
+
audit_req = tmp / "audit-requirements.txt"
|
|
278
|
+
kept, dropped = [], []
|
|
279
|
+
for line in req.read_text(encoding="utf-8").splitlines():
|
|
280
|
+
if " @ " in line and not line.lstrip().startswith("#"):
|
|
281
|
+
dropped.append(line.split(" @ ", 1)[0].strip())
|
|
282
|
+
else:
|
|
283
|
+
kept.append(line)
|
|
284
|
+
audit_req.write_text("\n".join(kept) + "\n", encoding="utf-8")
|
|
285
|
+
if dropped:
|
|
286
|
+
# Never drop coverage silently: name what is not being audited here.
|
|
287
|
+
print(f"::notice::pip-audit: not auditing {len(dropped)} direct-URL dependency/ies "
|
|
288
|
+
f"(audited in their own repo): {', '.join(sorted(dropped))}")
|
|
289
|
+
|
|
290
|
+
req = audit_req
|
|
291
|
+
vulns = data.get("tool", {}).get("pip-audit", {}).get("ignore-vulns", [])
|
|
292
|
+
# --no-deps: the compiled file is already a complete pinned resolution,
|
|
293
|
+
# so pip-audit must audit it verbatim rather than re-resolving it.
|
|
294
|
+
cmd = ["pip-audit", "--no-deps", "-r", str(req)]
|
|
295
|
+
for v in vulns:
|
|
296
|
+
cmd.extend(["--ignore-vuln", v])
|
|
297
|
+
sys.exit(subprocess.call(cmd))
|
|
298
|
+
|
|
299
|
+
- name: Check all steps passed
|
|
300
|
+
shell: bash
|
|
301
|
+
run: |
|
|
302
|
+
if [[ "${{ steps.ruff.outcome }}" != "success" ]]; then
|
|
303
|
+
echo "::error::Ruff check failed"
|
|
304
|
+
exit 1
|
|
305
|
+
fi
|
|
306
|
+
if [[ "${{ steps.pyright.outcome }}" != "success" ]]; then
|
|
307
|
+
echo "::error::Pyright check failed"
|
|
308
|
+
exit 1
|
|
309
|
+
fi
|
|
310
|
+
if [[ "${{ steps.pytest.outcome }}" != "success" ]]; then
|
|
311
|
+
echo "::error::Pytest failed"
|
|
312
|
+
exit 1
|
|
313
|
+
fi
|
|
314
|
+
if [[ "${{ steps.importlinter.outcome }}" != "success" ]]; then
|
|
315
|
+
echo "::error::Import-linter check failed"
|
|
316
|
+
exit 1
|
|
317
|
+
fi
|
|
318
|
+
if [[ "${{ steps.bandit.outcome }}" != "success" ]]; then
|
|
319
|
+
echo "::error::Bandit security scan failed"
|
|
320
|
+
exit 1
|
|
321
|
+
fi
|
|
322
|
+
if [[ "${{ steps.pipaudit.outcome }}" != "success" ]]; then
|
|
323
|
+
echo "::warning::pip-audit vulnerability scan failed"
|
|
324
|
+
fi
|
|
325
|
+
echo "All checks passed!"
|
|
326
|
+
|
|
327
|
+
- name: Build wheel/sdist
|
|
328
|
+
shell: bash
|
|
329
|
+
run: uv build
|
|
330
|
+
|
|
331
|
+
- name: Verify wheel install in clean env
|
|
332
|
+
shell: bash
|
|
333
|
+
run: |
|
|
334
|
+
uv venv .venv_wheel
|
|
335
|
+
. .venv_wheel/bin/activate 2>/dev/null || . .venv_wheel/Scripts/activate 2>/dev/null
|
|
336
|
+
uv pip install dist/*.whl
|
|
337
|
+
"$CLI_BIN" --version 2>/dev/null || python -m "$PACKAGE_MODULE" --version
|
|
338
|
+
|
|
339
|
+
- name: Prune uv cache
|
|
340
|
+
continue-on-error: true
|
|
341
|
+
shell: bash
|
|
342
|
+
run: uv cache prune --ci
|
|
343
|
+
|
|
344
|
+
- name: Save ruff cache
|
|
345
|
+
if: always() && runner.environment == 'github-hosted' && steps.ruff-cache.outputs.cache-hit != 'true'
|
|
346
|
+
uses: actions/cache/save@v6
|
|
347
|
+
with:
|
|
348
|
+
path: .ruff_cache
|
|
349
|
+
key: ruff-${{ runner.os }}-py${{ matrix.python }}-${{ github.sha }}
|
|
350
|
+
|
|
351
|
+
pipx-uv:
|
|
352
|
+
name: pipx/uv verification (ubuntu)
|
|
353
|
+
runs-on: ubuntu-latest
|
|
354
|
+
steps:
|
|
355
|
+
- uses: actions/checkout@v7
|
|
356
|
+
- uses: actions/setup-python@v7
|
|
357
|
+
with:
|
|
358
|
+
python-version: "3.14"
|
|
359
|
+
|
|
360
|
+
- name: Install uv
|
|
361
|
+
uses: astral-sh/setup-uv@v7
|
|
362
|
+
with:
|
|
363
|
+
enable-cache: true
|
|
364
|
+
prune-cache: false
|
|
365
|
+
|
|
366
|
+
- name: Extract project metadata
|
|
367
|
+
id: metadata
|
|
368
|
+
uses: ./.github/actions/extract-metadata
|
|
369
|
+
|
|
370
|
+
- name: Build wheel
|
|
371
|
+
run: uv build
|
|
372
|
+
- name: pipx install from wheel
|
|
373
|
+
run: |
|
|
374
|
+
uv pip install --system pipx
|
|
375
|
+
pipx ensurepath
|
|
376
|
+
export PATH="$HOME/.local/bin:$PATH"
|
|
377
|
+
pipx install dist/*.whl
|
|
378
|
+
"$CLI_BIN" --version
|
|
379
|
+
|
|
380
|
+
- name: uv tool install
|
|
381
|
+
run: |
|
|
382
|
+
export PATH="$HOME/.local/bin:$PATH"
|
|
383
|
+
uv tool install --force .
|
|
384
|
+
"$CLI_BIN" --version
|
|
385
|
+
|
|
386
|
+
- name: Prune uv cache
|
|
387
|
+
continue-on-error: true
|
|
388
|
+
run: uv cache prune --ci
|
|
389
|
+
|
|
390
|
+
notebooks:
|
|
391
|
+
name: Execute notebooks (ubuntu, Python 3.14)
|
|
392
|
+
runs-on: ubuntu-latest
|
|
393
|
+
steps:
|
|
394
|
+
- uses: actions/checkout@v7
|
|
395
|
+
- uses: actions/setup-python@v7
|
|
396
|
+
with:
|
|
397
|
+
python-version: "3.14"
|
|
398
|
+
|
|
399
|
+
- name: Install uv
|
|
400
|
+
uses: astral-sh/setup-uv@v7
|
|
401
|
+
with:
|
|
402
|
+
enable-cache: true
|
|
403
|
+
prune-cache: false
|
|
404
|
+
|
|
405
|
+
- name: Install notebook runner deps
|
|
406
|
+
run: |
|
|
407
|
+
uv pip install --system nbclient nbformat ipykernel jupyter_client
|
|
408
|
+
python -m ipykernel install --user --name python3 --display-name "Python 3"
|
|
409
|
+
- name: Execute Quickstart notebook
|
|
410
|
+
env:
|
|
411
|
+
PIP_DISABLE_PIP_VERSION_CHECK: "1"
|
|
412
|
+
PIP_NO_INPUT: "1"
|
|
413
|
+
run: |
|
|
414
|
+
python - << 'PY'
|
|
415
|
+
from pathlib import Path
|
|
416
|
+
|
|
417
|
+
import nbformat
|
|
418
|
+
from nbclient import NotebookClient
|
|
419
|
+
|
|
420
|
+
nb_path = Path('notebooks/Quickstart.ipynb')
|
|
421
|
+
if not nb_path.exists():
|
|
422
|
+
raise SystemExit(f"Notebook not found: {nb_path}")
|
|
423
|
+
|
|
424
|
+
notebook = nbformat.read(nb_path, as_version=4)
|
|
425
|
+
client = NotebookClient(
|
|
426
|
+
notebook,
|
|
427
|
+
timeout=900,
|
|
428
|
+
kernel_name='python3',
|
|
429
|
+
allow_errors=False,
|
|
430
|
+
)
|
|
431
|
+
client.execute()
|
|
432
|
+
|
|
433
|
+
out_path = Path('notebooks/Quickstart-executed.ipynb')
|
|
434
|
+
nbformat.write(notebook, out_path)
|
|
435
|
+
print(f"Executed notebook written to: {out_path}")
|
|
436
|
+
PY
|