airom 0.1.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.
- airom-0.1.0/.gitignore +5 -0
- airom-0.1.0/PKG-INFO +231 -0
- airom-0.1.0/README.md +202 -0
- airom-0.1.0/hatch_build.py +86 -0
- airom-0.1.0/pyproject.toml +71 -0
- airom-0.1.0/src/airom/__init__.py +123 -0
- airom-0.1.0/src/airom/_binary.py +70 -0
- airom-0.1.0/src/airom/_run.py +178 -0
- airom-0.1.0/src/airom/api.py +212 -0
- airom-0.1.0/src/airom/errors.py +44 -0
- airom-0.1.0/src/airom/models.py +799 -0
- airom-0.1.0/src/airom/py.typed +0 -0
- airom-0.1.0/tests/conftest.py +61 -0
- airom-0.1.0/tests/test_scan.py +151 -0
airom-0.1.0/.gitignore
ADDED
airom-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: airom
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for AIROM — the open-source AI Bill of Materials (AIBOM) scanner
|
|
5
|
+
Project-URL: Homepage, https://github.com/airomhq/airom
|
|
6
|
+
Project-URL: Documentation, https://github.com/airomhq/airom/tree/main/docs-site
|
|
7
|
+
Project-URL: Source, https://github.com/airomhq/airom
|
|
8
|
+
Project-URL: Issues, https://github.com/airomhq/airom/issues
|
|
9
|
+
Author: AIROM Authors
|
|
10
|
+
License-Expression: Apache-2.0
|
|
11
|
+
Keywords: ai,aibom,cyclonedx,sarif,sbom,security,supply-chain
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Security
|
|
21
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: mypy>=1.8; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
27
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# airom — Python SDK
|
|
31
|
+
|
|
32
|
+
Python SDK for [AIROM](https://github.com/airomhq/airom), the open-source **AI Bill of
|
|
33
|
+
Materials (AIBOM) scanner**. Discover AI assets — models, prompts, datasets, embeddings,
|
|
34
|
+
vector databases, frameworks, serving infrastructure — across code, containers, and
|
|
35
|
+
Kubernetes, and get them back as typed Python objects.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install airom
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Quick start
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
import airom
|
|
45
|
+
|
|
46
|
+
inv = airom.fs("./my-app", min_confidence=0.8)
|
|
47
|
+
|
|
48
|
+
for c in inv.by_kind(airom.ComponentKind.HOSTED_LLM):
|
|
49
|
+
print(c.name, c.provider.or_default("-"), c.confidence)
|
|
50
|
+
for occ in c.evidence.occurrences:
|
|
51
|
+
print(f" {occ.location.path}:{occ.location.line} [{occ.detector_id}]")
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
gpt-4.1 openai 0.87
|
|
56
|
+
src/rag.py:88 [rules/openai/model-literal]
|
|
57
|
+
src/agent.py:12 [rules/openai/sdk-import]
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Every component carries the evidence that justifies it — that is the point of AIROM, and
|
|
61
|
+
the SDK hands you all of it.
|
|
62
|
+
|
|
63
|
+
## Scanning
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
airom.scan("./app") # auto-detect: path, git URL, or image ref
|
|
67
|
+
airom.fs("./app") # a directory tree
|
|
68
|
+
airom.repo("https://github.com/o/r") # remote (shallow clone) or a local worktree
|
|
69
|
+
airom.image(input="img.tar") # docker save -o img.tar <ref>
|
|
70
|
+
airom.k8s(manifests="./deploy") # offline: enumerate workload images
|
|
71
|
+
airom.version() # the underlying binary's ToolInfo
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Common keyword args mirror the CLI flags: `select`, `rules`, `ignore`, `min_confidence`,
|
|
75
|
+
`max_file_size`, `io_budget`, `parallel`, `no_cache`, `cache_dir`, `offline`, `stats`,
|
|
76
|
+
plus `binary`, `timeout`, and `cwd`. `None` leaves the tool's own default in place — the
|
|
77
|
+
SDK never invents defaults.
|
|
78
|
+
|
|
79
|
+
`min_confidence=0.8` is the practical high-signal filter: on general-purpose directories,
|
|
80
|
+
extension-only dataset detection and keyword-only generation-param detection emit
|
|
81
|
+
low-confidence (0.5–0.6) noise. Note the application root always survives the filter — it
|
|
82
|
+
is the scan target, not a finding.
|
|
83
|
+
|
|
84
|
+
`select` tokens are **detector IDs or tags**, not languages — `"-dataset/file"`, not
|
|
85
|
+
`"python"`. Run `airom detectors list` (or `airom.raw(["detectors", "list"])`) to see them.
|
|
86
|
+
|
|
87
|
+
> **Not wired yet:** pulling an image from a live registry/daemon, and live-cluster
|
|
88
|
+
> Kubernetes scanning. Both fail with a clear error. Use `image(input=...)` / an OCI
|
|
89
|
+
> layout and `k8s(manifests=...)` today.
|
|
90
|
+
|
|
91
|
+
## Tri-state fields
|
|
92
|
+
|
|
93
|
+
`version`, `provider`, `download_location` and `release_time` are **tri-state**, and the
|
|
94
|
+
SDK preserves the distinction rather than collapsing it into `None`:
|
|
95
|
+
|
|
96
|
+
| JSON | Meaning | `Opt` |
|
|
97
|
+
|---|---|---|
|
|
98
|
+
| key omitted | does not apply | `Presence.ABSENT` |
|
|
99
|
+
| `null` | applies, but undetermined (SPDX NOASSERTION) | `Presence.UNKNOWN` |
|
|
100
|
+
| a value | known | `Presence.KNOWN` |
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
c.version.known # bool — only True when a real value is present
|
|
104
|
+
c.version.or_none() # value, or None (collapses absent and unknown)
|
|
105
|
+
c.version.or_default("-") # value, or your fallback
|
|
106
|
+
c.version.presence # the full distinction, when you need it
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Navigating the graph
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
inv.components # sorted, deterministic
|
|
113
|
+
inv.by_kind("vector-db", "framework")
|
|
114
|
+
inv.get("airom:1f3a9b2c4d5e6f70")
|
|
115
|
+
inv.application # the scan-root component
|
|
116
|
+
inv.edges_from(c.id) # typed, evidenced relationships
|
|
117
|
+
inv.unknowns # "looked relevant, could not process" — honesty channel
|
|
118
|
+
inv.stats.files_walked # requires stats=True
|
|
119
|
+
len(inv); [c for c in inv] # Inventory is sized and iterable
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## CI gating
|
|
123
|
+
|
|
124
|
+
A `fail_on` match is a **verdict, not an error** — the scan succeeded and the AIBOM is
|
|
125
|
+
complete, so it is reported rather than raised:
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
res = airom.execute(
|
|
129
|
+
["fs", "./app"],
|
|
130
|
+
options=airom.ScanOptions(fail_on="hosted-llm&confidence>=0.9", exit_code=7),
|
|
131
|
+
)
|
|
132
|
+
if res.policy_matched:
|
|
133
|
+
raise SystemExit(res.exit_code)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Often you don't need `fail_on` at all — you have the whole graph, so gate in Python:
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
risky = [c for c in inv if c.model and c.model.pickle_risk]
|
|
140
|
+
if risky:
|
|
141
|
+
raise SystemExit(f"unsafe pickle globals in: {[c.name for c in risky]}")
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Errors
|
|
145
|
+
|
|
146
|
+
| Exception | Raised when |
|
|
147
|
+
|---|---|
|
|
148
|
+
| `BinaryNotFoundError` | the `airom` executable could not be located |
|
|
149
|
+
| `ScanError` | a fatal scan failure (exit 2): unreadable target, clone failure, bad flags |
|
|
150
|
+
| `OutputError` | no parseable AIBOM, or an unsupported `schemaVersion` |
|
|
151
|
+
|
|
152
|
+
Detector errors are **not** exceptions: they degrade to `inv.unknowns` records and the
|
|
153
|
+
scan still succeeds. That is AIROM's degrade-by-default contract, and the SDK preserves it.
|
|
154
|
+
|
|
155
|
+
## The binary
|
|
156
|
+
|
|
157
|
+
The SDK shells out to the `airom` binary and decodes its native JSON — the lossless
|
|
158
|
+
superset every other format (CycloneDX, SARIF, YAML, table) projects from. Resolution
|
|
159
|
+
order:
|
|
160
|
+
|
|
161
|
+
1. the `binary=` argument
|
|
162
|
+
2. a copy bundled in the wheel (`airom/_bin/airom`)
|
|
163
|
+
3. `$AIROM_BINARY`
|
|
164
|
+
4. `airom` on `PATH`
|
|
165
|
+
|
|
166
|
+
Platform wheels bundle the binary, so `pip install airom` is self-contained. Installing
|
|
167
|
+
from an sdist does not — put `airom` on your `PATH`
|
|
168
|
+
(`go install github.com/airomhq/airom/cmd/airom@latest`) or set `$AIROM_BINARY`.
|
|
169
|
+
|
|
170
|
+
## Development
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
cd sdk/python
|
|
174
|
+
pip install -e ".[dev]"
|
|
175
|
+
pytest # builds the binary from the checkout and tests against it
|
|
176
|
+
mypy && ruff check .
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
The suite runs against the **real binary**, not mocks: a wrapper tested only against mocks
|
|
180
|
+
proves nothing about the contract it wraps.
|
|
181
|
+
|
|
182
|
+
Building a wheel needs the Go toolchain (the build hook compiles the binary with
|
|
183
|
+
`CGO_ENABLED=0`). Set `AIROM_SKIP_BUNDLE=1` for a pure-Python wheel.
|
|
184
|
+
|
|
185
|
+
## Publishing
|
|
186
|
+
|
|
187
|
+
Releases are published by [`.github/workflows/release-pypi.yml`](../../.github/workflows/release-pypi.yml)
|
|
188
|
+
using **PyPI Trusted Publishing** (OIDC): GitHub Actions authenticates to PyPI directly,
|
|
189
|
+
so there is no API token stored as a secret, and none to leak or rotate.
|
|
190
|
+
|
|
191
|
+
### One-time setup
|
|
192
|
+
|
|
193
|
+
On [pypi.org/manage/account/publishing](https://pypi.org/manage/account/publishing/), add a
|
|
194
|
+
**pending publisher**:
|
|
195
|
+
|
|
196
|
+
| Field | Value |
|
|
197
|
+
|---|---|
|
|
198
|
+
| PyPI project name | `airom` |
|
|
199
|
+
| Owner | `airomhq` |
|
|
200
|
+
| Repository name | `airom` |
|
|
201
|
+
| Workflow name | `release-pypi.yml` |
|
|
202
|
+
| Environment | *(leave blank)* |
|
|
203
|
+
|
|
204
|
+
That is all — no secret is added to GitHub.
|
|
205
|
+
|
|
206
|
+
### Cutting a release
|
|
207
|
+
|
|
208
|
+
The workflow runs on every push to `main` that touches the SDK or the scanner, but
|
|
209
|
+
**publishes only when the version is new**: PyPI permanently refuses to re-upload a
|
|
210
|
+
version (even a deleted one), so the workflow compares `__version__` against the index and
|
|
211
|
+
skips cleanly if it is already there.
|
|
212
|
+
|
|
213
|
+
So a release is exactly one deliberate act:
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
# sdk/python/src/airom/__init__.py
|
|
217
|
+
__version__ = "0.1.0" # bump, commit, merge to main -> published
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
<!-- Version note: a PEP 440 pre-release suffix (.dev0, rc1, …) is NOT installed by
|
|
221
|
+
`pip install airom` unless the user passes --pre. Use a plain version for a
|
|
222
|
+
release people are meant to get by default. -->
|
|
223
|
+
|
|
224
|
+
Publishing is **irreversible**: a version number is burned forever once used, and yanking
|
|
225
|
+
does not free it. Test the whole path first with the manual `workflow_dispatch` run against
|
|
226
|
+
**TestPyPI** (which needs its own pending publisher at
|
|
227
|
+
[test.pypi.org](https://test.pypi.org/manage/account/publishing/)).
|
|
228
|
+
|
|
229
|
+
## License
|
|
230
|
+
|
|
231
|
+
Apache-2.0, same as AIROM.
|
airom-0.1.0/README.md
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# airom — Python SDK
|
|
2
|
+
|
|
3
|
+
Python SDK for [AIROM](https://github.com/airomhq/airom), the open-source **AI Bill of
|
|
4
|
+
Materials (AIBOM) scanner**. Discover AI assets — models, prompts, datasets, embeddings,
|
|
5
|
+
vector databases, frameworks, serving infrastructure — across code, containers, and
|
|
6
|
+
Kubernetes, and get them back as typed Python objects.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install airom
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Quick start
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
import airom
|
|
16
|
+
|
|
17
|
+
inv = airom.fs("./my-app", min_confidence=0.8)
|
|
18
|
+
|
|
19
|
+
for c in inv.by_kind(airom.ComponentKind.HOSTED_LLM):
|
|
20
|
+
print(c.name, c.provider.or_default("-"), c.confidence)
|
|
21
|
+
for occ in c.evidence.occurrences:
|
|
22
|
+
print(f" {occ.location.path}:{occ.location.line} [{occ.detector_id}]")
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
gpt-4.1 openai 0.87
|
|
27
|
+
src/rag.py:88 [rules/openai/model-literal]
|
|
28
|
+
src/agent.py:12 [rules/openai/sdk-import]
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Every component carries the evidence that justifies it — that is the point of AIROM, and
|
|
32
|
+
the SDK hands you all of it.
|
|
33
|
+
|
|
34
|
+
## Scanning
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
airom.scan("./app") # auto-detect: path, git URL, or image ref
|
|
38
|
+
airom.fs("./app") # a directory tree
|
|
39
|
+
airom.repo("https://github.com/o/r") # remote (shallow clone) or a local worktree
|
|
40
|
+
airom.image(input="img.tar") # docker save -o img.tar <ref>
|
|
41
|
+
airom.k8s(manifests="./deploy") # offline: enumerate workload images
|
|
42
|
+
airom.version() # the underlying binary's ToolInfo
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Common keyword args mirror the CLI flags: `select`, `rules`, `ignore`, `min_confidence`,
|
|
46
|
+
`max_file_size`, `io_budget`, `parallel`, `no_cache`, `cache_dir`, `offline`, `stats`,
|
|
47
|
+
plus `binary`, `timeout`, and `cwd`. `None` leaves the tool's own default in place — the
|
|
48
|
+
SDK never invents defaults.
|
|
49
|
+
|
|
50
|
+
`min_confidence=0.8` is the practical high-signal filter: on general-purpose directories,
|
|
51
|
+
extension-only dataset detection and keyword-only generation-param detection emit
|
|
52
|
+
low-confidence (0.5–0.6) noise. Note the application root always survives the filter — it
|
|
53
|
+
is the scan target, not a finding.
|
|
54
|
+
|
|
55
|
+
`select` tokens are **detector IDs or tags**, not languages — `"-dataset/file"`, not
|
|
56
|
+
`"python"`. Run `airom detectors list` (or `airom.raw(["detectors", "list"])`) to see them.
|
|
57
|
+
|
|
58
|
+
> **Not wired yet:** pulling an image from a live registry/daemon, and live-cluster
|
|
59
|
+
> Kubernetes scanning. Both fail with a clear error. Use `image(input=...)` / an OCI
|
|
60
|
+
> layout and `k8s(manifests=...)` today.
|
|
61
|
+
|
|
62
|
+
## Tri-state fields
|
|
63
|
+
|
|
64
|
+
`version`, `provider`, `download_location` and `release_time` are **tri-state**, and the
|
|
65
|
+
SDK preserves the distinction rather than collapsing it into `None`:
|
|
66
|
+
|
|
67
|
+
| JSON | Meaning | `Opt` |
|
|
68
|
+
|---|---|---|
|
|
69
|
+
| key omitted | does not apply | `Presence.ABSENT` |
|
|
70
|
+
| `null` | applies, but undetermined (SPDX NOASSERTION) | `Presence.UNKNOWN` |
|
|
71
|
+
| a value | known | `Presence.KNOWN` |
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
c.version.known # bool — only True when a real value is present
|
|
75
|
+
c.version.or_none() # value, or None (collapses absent and unknown)
|
|
76
|
+
c.version.or_default("-") # value, or your fallback
|
|
77
|
+
c.version.presence # the full distinction, when you need it
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Navigating the graph
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
inv.components # sorted, deterministic
|
|
84
|
+
inv.by_kind("vector-db", "framework")
|
|
85
|
+
inv.get("airom:1f3a9b2c4d5e6f70")
|
|
86
|
+
inv.application # the scan-root component
|
|
87
|
+
inv.edges_from(c.id) # typed, evidenced relationships
|
|
88
|
+
inv.unknowns # "looked relevant, could not process" — honesty channel
|
|
89
|
+
inv.stats.files_walked # requires stats=True
|
|
90
|
+
len(inv); [c for c in inv] # Inventory is sized and iterable
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## CI gating
|
|
94
|
+
|
|
95
|
+
A `fail_on` match is a **verdict, not an error** — the scan succeeded and the AIBOM is
|
|
96
|
+
complete, so it is reported rather than raised:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
res = airom.execute(
|
|
100
|
+
["fs", "./app"],
|
|
101
|
+
options=airom.ScanOptions(fail_on="hosted-llm&confidence>=0.9", exit_code=7),
|
|
102
|
+
)
|
|
103
|
+
if res.policy_matched:
|
|
104
|
+
raise SystemExit(res.exit_code)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Often you don't need `fail_on` at all — you have the whole graph, so gate in Python:
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
risky = [c for c in inv if c.model and c.model.pickle_risk]
|
|
111
|
+
if risky:
|
|
112
|
+
raise SystemExit(f"unsafe pickle globals in: {[c.name for c in risky]}")
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Errors
|
|
116
|
+
|
|
117
|
+
| Exception | Raised when |
|
|
118
|
+
|---|---|
|
|
119
|
+
| `BinaryNotFoundError` | the `airom` executable could not be located |
|
|
120
|
+
| `ScanError` | a fatal scan failure (exit 2): unreadable target, clone failure, bad flags |
|
|
121
|
+
| `OutputError` | no parseable AIBOM, or an unsupported `schemaVersion` |
|
|
122
|
+
|
|
123
|
+
Detector errors are **not** exceptions: they degrade to `inv.unknowns` records and the
|
|
124
|
+
scan still succeeds. That is AIROM's degrade-by-default contract, and the SDK preserves it.
|
|
125
|
+
|
|
126
|
+
## The binary
|
|
127
|
+
|
|
128
|
+
The SDK shells out to the `airom` binary and decodes its native JSON — the lossless
|
|
129
|
+
superset every other format (CycloneDX, SARIF, YAML, table) projects from. Resolution
|
|
130
|
+
order:
|
|
131
|
+
|
|
132
|
+
1. the `binary=` argument
|
|
133
|
+
2. a copy bundled in the wheel (`airom/_bin/airom`)
|
|
134
|
+
3. `$AIROM_BINARY`
|
|
135
|
+
4. `airom` on `PATH`
|
|
136
|
+
|
|
137
|
+
Platform wheels bundle the binary, so `pip install airom` is self-contained. Installing
|
|
138
|
+
from an sdist does not — put `airom` on your `PATH`
|
|
139
|
+
(`go install github.com/airomhq/airom/cmd/airom@latest`) or set `$AIROM_BINARY`.
|
|
140
|
+
|
|
141
|
+
## Development
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
cd sdk/python
|
|
145
|
+
pip install -e ".[dev]"
|
|
146
|
+
pytest # builds the binary from the checkout and tests against it
|
|
147
|
+
mypy && ruff check .
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
The suite runs against the **real binary**, not mocks: a wrapper tested only against mocks
|
|
151
|
+
proves nothing about the contract it wraps.
|
|
152
|
+
|
|
153
|
+
Building a wheel needs the Go toolchain (the build hook compiles the binary with
|
|
154
|
+
`CGO_ENABLED=0`). Set `AIROM_SKIP_BUNDLE=1` for a pure-Python wheel.
|
|
155
|
+
|
|
156
|
+
## Publishing
|
|
157
|
+
|
|
158
|
+
Releases are published by [`.github/workflows/release-pypi.yml`](../../.github/workflows/release-pypi.yml)
|
|
159
|
+
using **PyPI Trusted Publishing** (OIDC): GitHub Actions authenticates to PyPI directly,
|
|
160
|
+
so there is no API token stored as a secret, and none to leak or rotate.
|
|
161
|
+
|
|
162
|
+
### One-time setup
|
|
163
|
+
|
|
164
|
+
On [pypi.org/manage/account/publishing](https://pypi.org/manage/account/publishing/), add a
|
|
165
|
+
**pending publisher**:
|
|
166
|
+
|
|
167
|
+
| Field | Value |
|
|
168
|
+
|---|---|
|
|
169
|
+
| PyPI project name | `airom` |
|
|
170
|
+
| Owner | `airomhq` |
|
|
171
|
+
| Repository name | `airom` |
|
|
172
|
+
| Workflow name | `release-pypi.yml` |
|
|
173
|
+
| Environment | *(leave blank)* |
|
|
174
|
+
|
|
175
|
+
That is all — no secret is added to GitHub.
|
|
176
|
+
|
|
177
|
+
### Cutting a release
|
|
178
|
+
|
|
179
|
+
The workflow runs on every push to `main` that touches the SDK or the scanner, but
|
|
180
|
+
**publishes only when the version is new**: PyPI permanently refuses to re-upload a
|
|
181
|
+
version (even a deleted one), so the workflow compares `__version__` against the index and
|
|
182
|
+
skips cleanly if it is already there.
|
|
183
|
+
|
|
184
|
+
So a release is exactly one deliberate act:
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
# sdk/python/src/airom/__init__.py
|
|
188
|
+
__version__ = "0.1.0" # bump, commit, merge to main -> published
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
<!-- Version note: a PEP 440 pre-release suffix (.dev0, rc1, …) is NOT installed by
|
|
192
|
+
`pip install airom` unless the user passes --pre. Use a plain version for a
|
|
193
|
+
release people are meant to get by default. -->
|
|
194
|
+
|
|
195
|
+
Publishing is **irreversible**: a version number is burned forever once used, and yanking
|
|
196
|
+
does not free it. Test the whole path first with the manual `workflow_dispatch` run against
|
|
197
|
+
**TestPyPI** (which needs its own pending publisher at
|
|
198
|
+
[test.pypi.org](https://test.pypi.org/manage/account/publishing/)).
|
|
199
|
+
|
|
200
|
+
## License
|
|
201
|
+
|
|
202
|
+
Apache-2.0, same as AIROM.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Build hook: compile the ``airom`` binary into the wheel.
|
|
2
|
+
|
|
3
|
+
Wheels are platform-specific because they carry a compiled Go binary, so this
|
|
4
|
+
hook also stamps the wheel tag. It needs the Go toolchain and the repository
|
|
5
|
+
checkout (the module root is three levels up from this file).
|
|
6
|
+
|
|
7
|
+
Opt out with ``AIROM_SKIP_BUNDLE=1`` — the resulting wheel is pure-Python and
|
|
8
|
+
falls back to ``$AIROM_BINARY`` or ``airom`` on ``PATH`` at runtime.
|
|
9
|
+
|
|
10
|
+
Cross-compile by setting ``GOOS``/``GOARCH`` (both are forwarded to ``go
|
|
11
|
+
build``); set ``AIROM_WHEEL_TAG`` to override the platform tag when doing so.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import sysconfig
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
|
24
|
+
|
|
25
|
+
HERE = Path(__file__).parent
|
|
26
|
+
# sdk/python/hatch_build.py -> sdk/python -> sdk -> <repo root>
|
|
27
|
+
REPO_ROOT = HERE.parent.parent
|
|
28
|
+
BIN_DIR = HERE / "src" / "airom" / "_bin"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _exe_name() -> str:
|
|
32
|
+
goos = os.environ.get("GOOS") or sys.platform
|
|
33
|
+
return "airom.exe" if goos in ("win32", "windows") else "airom"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _wheel_tag() -> str:
|
|
37
|
+
if tag := os.environ.get("AIROM_WHEEL_TAG"):
|
|
38
|
+
return tag
|
|
39
|
+
# Not pure-Python, but ABI-independent: the payload is a standalone binary,
|
|
40
|
+
# so the wheel works on any CPython for this platform.
|
|
41
|
+
plat = sysconfig.get_platform().replace("-", "_").replace(".", "_")
|
|
42
|
+
return f"py3-none-{plat}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AiromBuildHook(BuildHookInterface):
|
|
46
|
+
PLUGIN_NAME = "custom"
|
|
47
|
+
|
|
48
|
+
def initialize(self, version: str, build_data: dict) -> None:
|
|
49
|
+
if self.target_name != "wheel":
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
if os.environ.get("AIROM_SKIP_BUNDLE"):
|
|
53
|
+
self.app.display_waiting("AIROM_SKIP_BUNDLE set — building a pure-Python wheel")
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
if not (REPO_ROOT / "go.mod").is_file():
|
|
57
|
+
self.app.display_warning(
|
|
58
|
+
f"no go.mod under {REPO_ROOT} — building without a bundled binary "
|
|
59
|
+
"(the SDK will fall back to $AIROM_BINARY or PATH)"
|
|
60
|
+
)
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
if shutil.which("go") is None:
|
|
64
|
+
self.app.display_warning(
|
|
65
|
+
"the Go toolchain was not found — building without a bundled binary "
|
|
66
|
+
"(set AIROM_SKIP_BUNDLE=1 to silence this)"
|
|
67
|
+
)
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
BIN_DIR.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
out = BIN_DIR / _exe_name()
|
|
72
|
+
|
|
73
|
+
env = dict(os.environ)
|
|
74
|
+
env["CGO_ENABLED"] = "0" # invariant P8: the release binary is always static
|
|
75
|
+
|
|
76
|
+
cmd = ["go", "build", "-trimpath", "-ldflags", "-s -w", "-o", str(out), "./cmd/airom"]
|
|
77
|
+
self.app.display_info(f"bundling airom: {' '.join(cmd)} (in {REPO_ROOT})")
|
|
78
|
+
try:
|
|
79
|
+
subprocess.run(cmd, cwd=REPO_ROOT, env=env, check=True)
|
|
80
|
+
except subprocess.CalledProcessError as e:
|
|
81
|
+
raise RuntimeError(f"failed to build the airom binary: {e}") from e
|
|
82
|
+
|
|
83
|
+
out.chmod(0o755)
|
|
84
|
+
build_data["pure_python"] = False
|
|
85
|
+
build_data["tag"] = _wheel_tag()
|
|
86
|
+
build_data["artifacts"].append("src/airom/_bin/*")
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.24"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "airom"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Python SDK for AIROM — the open-source AI Bill of Materials (AIBOM) scanner"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "AIROM Authors" }]
|
|
13
|
+
keywords = ["aibom", "sbom", "ai", "security", "cyclonedx", "sarif", "supply-chain"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: Apache Software License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: Security",
|
|
24
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
25
|
+
"Typing :: Typed",
|
|
26
|
+
]
|
|
27
|
+
# Deliberately dependency-free: the SDK is a typed wrapper over a static binary.
|
|
28
|
+
dependencies = []
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/airomhq/airom"
|
|
32
|
+
Documentation = "https://github.com/airomhq/airom/tree/main/docs-site"
|
|
33
|
+
Source = "https://github.com/airomhq/airom"
|
|
34
|
+
Issues = "https://github.com/airomhq/airom/issues"
|
|
35
|
+
|
|
36
|
+
[project.optional-dependencies]
|
|
37
|
+
dev = ["pytest>=7", "mypy>=1.8", "ruff>=0.5"]
|
|
38
|
+
|
|
39
|
+
[tool.hatch.version]
|
|
40
|
+
path = "src/airom/__init__.py"
|
|
41
|
+
|
|
42
|
+
[tool.hatch.build.targets.wheel]
|
|
43
|
+
packages = ["src/airom"]
|
|
44
|
+
# The bundled binary is produced by hatch_build.py; ship it if it is there.
|
|
45
|
+
artifacts = ["src/airom/_bin/*"]
|
|
46
|
+
|
|
47
|
+
[tool.hatch.build.targets.sdist]
|
|
48
|
+
# The sdist carries no binary — installing from it falls back to $AIROM_BINARY
|
|
49
|
+
# or `airom` on PATH.
|
|
50
|
+
exclude = ["src/airom/_bin"]
|
|
51
|
+
|
|
52
|
+
[tool.hatch.build.hooks.custom]
|
|
53
|
+
path = "hatch_build.py"
|
|
54
|
+
|
|
55
|
+
[tool.pytest.ini_options]
|
|
56
|
+
testpaths = ["tests"]
|
|
57
|
+
addopts = "-q"
|
|
58
|
+
|
|
59
|
+
[tool.ruff]
|
|
60
|
+
line-length = 100
|
|
61
|
+
src = ["src"]
|
|
62
|
+
|
|
63
|
+
[tool.ruff.lint]
|
|
64
|
+
select = ["E", "F", "I", "UP", "B", "SIM"]
|
|
65
|
+
|
|
66
|
+
[tool.mypy]
|
|
67
|
+
python_version = "3.10"
|
|
68
|
+
packages = ["airom"]
|
|
69
|
+
mypy_path = "src"
|
|
70
|
+
strict = true
|
|
71
|
+
warn_unused_ignores = false
|