Skip to main content

anodizer_core/config/
pypi.rs

1use std::collections::BTreeMap;
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6use super::{Amd64Variant, StringOrBool, deserialize_string_or_bool_opt};
7
8/// PyPI publisher configuration.
9///
10/// Publishes the project's prebuilt binaries as native Python wheels — one
11/// `py3-none-<platform>` wheel per built target, with the platform tag
12/// derived by inspecting each binary (glibc floor for `manylinux`, Mach-O
13/// deployment target for `macosx`) — and uploads them via PyPI's legacy
14/// (twine-protocol) upload API. Optionally also builds and uploads a source
15/// distribution via `maturin sdist`. Each `pypis[]` entry produces one
16/// publish.
17///
18/// ```yaml
19/// pypis:
20///   - name: my-tool
21///     requires_python: ">=3.7"
22///     sdist: true
23///     sdist_manifest: "pypi/"
24/// ```
25#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
26#[serde(default, deny_unknown_fields)]
27pub struct PypiConfig {
28    /// Unique identifier for selecting this entry from the CLI (`--id=...`).
29    pub id: Option<String>,
30
31    /// Build IDs filter: only include binaries whose crate is in this list.
32    pub ids: Option<Vec<String>>,
33
34    /// Target-triple allowlist: restrict the wheels to a subset of the built
35    /// targets. When unset (the default), every built target becomes a wheel.
36    /// When set, only binaries whose target triple appears in this list are
37    /// built into wheels; the rest are silently skipped. Orthogonal to `ids:`:
38    /// both filters apply (a binary must pass the `ids` filter AND, when this
39    /// is set, be listed here). A listed triple that no selected build
40    /// produces is a config error, and an explicit empty list (`targets: []`)
41    /// is rejected — omit the field to publish every built target. A common use
42    /// is excluding `x86_64-pc-windows-gnu` so it does not collide with the
43    /// `x86_64-pc-windows-msvc` wheel on the shared `win_amd64` platform tag.
44    /// Example: `targets: [x86_64-unknown-linux-gnu, x86_64-pc-windows-msvc]`.
45    pub targets: Option<Vec<String>>,
46
47    /// PyPI project name. May use any PEP 508 name form (`My.Tool`,
48    /// `my_tool`); PyPI normalizes it per PEP 503 for index lookups and the
49    /// wheel filename escapes it per PEP 427. Falls back to the crate name
50    /// when unset.
51    pub name: Option<String>,
52
53    /// Also build and upload a source distribution via `maturin sdist`.
54    /// Default `false`. Requires `sdist_manifest` to point at the directory
55    /// containing the project's `pyproject.toml`, and `maturin` on `PATH`.
56    ///
57    /// ```yaml
58    /// pypis:
59    ///   - sdist: true
60    ///     sdist_manifest: "pypi/"
61    /// ```
62    pub sdist: bool,
63
64    /// Templated directory containing the `pyproject.toml` that `maturin
65    /// sdist` builds from, relative to the project root (e.g. `"pypi/"`).
66    /// Required when `sdist: true`; unused otherwise.
67    pub sdist_manifest: Option<String>,
68
69    /// Templated twine upload endpoint URL. Default
70    /// `https://upload.pypi.org/legacy/` (the production PyPI upload API).
71    /// Point it at TestPyPI to rehearse a release:
72    ///
73    /// ```yaml
74    /// pypis:
75    ///   - index_url: "https://test.pypi.org/legacy/"
76    /// ```
77    ///
78    /// This is the twine *upload* target, not a `{owner, name}` source
79    /// repository — the name `index_url` keeps it distinct from the reserved
80    /// `repository` meaning every git-based publisher uses. The legacy
81    /// `repository:` spelling is still accepted via serde alias.
82    #[serde(alias = "repository")]
83    pub index_url: Option<String>,
84
85    /// Tolerate the index rejecting a file that already exists (the
86    /// twine `--skip-existing` semantics). Default `true` so a re-run of an
87    /// already-published tag skips previously-uploaded files instead of
88    /// failing the release. Set to `false` to make a duplicate upload a hard
89    /// error.
90    pub skip_existing: bool,
91
92    /// `Requires-Python` version specifier written into each wheel's
93    /// METADATA (e.g. `">=3.7"`). Purely declarative for a binary wheel —
94    /// the shipped executable does not import Python — but pip honors it
95    /// during resolution. Omitted when unset.
96    pub requires_python: Option<String>,
97
98    /// Templated one-line `Summary` for the package METADATA. Falls back to
99    /// the project-level `metadata.description` (and then the crate's
100    /// `Cargo.toml [package].description`) when unset.
101    pub summary: Option<String>,
102
103    /// Templated long description written as the METADATA body (rendered on
104    /// the PyPI project page). Falls back to the summary when unset.
105    pub description: Option<String>,
106
107    /// `Description-Content-Type` for the long-description body — how PyPI
108    /// renders it (`text/markdown`, `text/x-rst`, `text/plain`). When a
109    /// `description` is present and this is unset, defaults to
110    /// `text/markdown` (the modern norm); without the header PyPI renders the
111    /// body as raw plaintext. Omitted entirely when there is no description.
112    pub description_content_type: Option<String>,
113
114    /// Package author, emitted as the METADATA `Author` header.
115    pub author: Option<String>,
116
117    /// Package author email, emitted as the METADATA `Author-email` header.
118    pub author_email: Option<String>,
119
120    /// Arbitrary `Project-URL` label → URL map, one
121    /// `Project-URL: <label>, <url>` METADATA header each (the PyPI sidebar
122    /// links). Emitted in addition to the `Homepage` link derived from
123    /// `homepage`; use this for `Repository`, `Documentation`, `Changelog`,
124    /// `Funding`, etc. Rendered in sorted label order for a byte-stable wheel.
125    ///
126    /// ```yaml
127    /// pypis:
128    ///   - project_urls:
129    ///       Repository: "https://github.com/me/my-tool"
130    ///       Documentation: "https://docs.example.com"
131    /// ```
132    pub project_urls: Option<BTreeMap<String, String>>,
133
134    /// Templated homepage URL, emitted as `Project-URL: Homepage`. Falls
135    /// back to `metadata.homepage` (then `Cargo.toml [package].homepage`)
136    /// when unset.
137    pub homepage: Option<String>,
138
139    /// Templated license expression (e.g. `MIT`, `Apache-2.0`), emitted as
140    /// the METADATA `License` field. Falls back to `metadata.license` (then
141    /// `Cargo.toml [package].license`) when unset.
142    pub license: Option<String>,
143
144    /// Keywords list, emitted comma-separated in METADATA.
145    pub keywords: Option<Vec<String>>,
146
147    /// Trove classifier lines (e.g.
148    /// `"Programming Language :: Rust"`), one `Classifier:` METADATA header
149    /// each.
150    pub classifiers: Option<Vec<String>>,
151
152    /// Per-target-triple wheel platform-tag overrides: `<target triple>` →
153    /// explicit wheel platform tag. When a built target has an entry, its tag
154    /// is used *verbatim* — binary inspection (the glibc floor for
155    /// `manylinux`, the Mach-O deployment target for `macosx`) is skipped for
156    /// that target. Every target without an entry keeps the auto-detected tag.
157    ///
158    /// The escape hatch for toolchains whose emitted glibc floor is stricter
159    /// than the compatibility a project wants to advertise — e.g. pinning
160    /// `aarch64-unknown-linux-gnu` to `manylinux_2_28` to match a
161    /// `maturin`/PyO3 build environment rather than shipping the higher floor
162    /// the binary's symbols imply.
163    ///
164    /// ```yaml
165    /// pypis:
166    ///   - platform_tag_overrides:
167    ///       aarch64-unknown-linux-gnu: manylinux_2_28_aarch64
168    /// ```
169    pub platform_tag_overrides: Option<BTreeMap<String, String>>,
170
171    /// `x86_64` micro-architecture variant selector — `v1` (baseline), `v2`,
172    /// `v3` (AVX2), or `v4`. When set, an amd64 binary carrying
173    /// `amd64_variant` metadata becomes the `win_amd64`/`manylinux…x86_64`
174    /// wheel only when its variant matches; a binary with no variant metadata
175    /// still matches (the baseline build). Default: `v1`. Typed as
176    /// [`Amd64Variant`], so any value outside `v1`..`v4` is rejected at parse
177    /// time.
178    pub amd64_variant: Option<Amd64Variant>,
179
180    /// ARM version selector (e.g. `"6"`, `"7"`). When set, a 32-bit ARM binary
181    /// carrying `arm_variant` metadata becomes the wheel only when its variant
182    /// matches; a binary with no variant metadata still matches. Does not
183    /// affect `aarch64`/`arm64` (64-bit ARM has no sub-variant).
184    pub arm_variant: Option<String>,
185
186    /// Whether the upload authenticates with a long-lived API token or with
187    /// GitHub Actions OIDC (PyPI Trusted Publishing). Default [`Auto`]:
188    /// a token when one is available, otherwise a Trusted-Publishing exchange
189    /// when an OIDC context is present.
190    ///
191    /// [`Auto`]: PypiAuthMode::Auto
192    pub auth: PypiAuthMode,
193
194    /// API token for the upload (templated). Falls back to the `PYPI_TOKEN`
195    /// env var, then `MATURIN_PYPI_TOKEN`, when unset. Sent as HTTP Basic
196    /// auth with the literal username `__token__` and NEVER logged. Unused
197    /// when `auth: oidc` (Trusted Publishing mints its own short-lived token).
198    pub token: Option<String>,
199
200    /// Skip this publisher. Accepts bool or template string.
201    /// Accepts the legacy `disable:` spelling via serde alias for back-compat.
202    #[serde(
203        default,
204        alias = "disable",
205        deserialize_with = "deserialize_string_or_bool_opt"
206    )]
207    pub skip: Option<StringOrBool>,
208
209    /// Override whether this publisher failing should fail the overall release.
210    ///
211    /// Default: `true` — PyPI is a Manager-group publisher whose uploads are
212    /// one-way (a published filename can never be re-uploaded, even after
213    /// deletion), so a failed publish aborts by default to avoid surprising
214    /// the operator with a half-released version. Set to `false` to log
215    /// failures but continue.
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub required: Option<bool>,
218
219    /// Template-conditional gate: when the rendered result is falsy
220    /// (`"false"` / `"0"` / `"no"` / empty), the PyPI publisher entry is
221    /// skipped. Render failure hard-errors.
222    #[serde(rename = "if")]
223    pub if_condition: Option<String>,
224
225    /// When `true`, a triggered rollback leaves this publisher's work in
226    /// place rather than attempting to undo it. Default `false`. (PyPI has
227    /// no programmatic delete path anyway — rollback is warn-only — but the
228    /// flag suppresses even that warning.)
229    pub retain_on_rollback: Option<bool>,
230}
231
232impl Default for PypiConfig {
233    fn default() -> Self {
234        Self {
235            id: None,
236            ids: None,
237            targets: None,
238            name: None,
239            sdist: false,
240            sdist_manifest: None,
241            index_url: None,
242            skip_existing: true,
243            requires_python: None,
244            summary: None,
245            description: None,
246            description_content_type: None,
247            author: None,
248            author_email: None,
249            project_urls: None,
250            homepage: None,
251            license: None,
252            keywords: None,
253            classifiers: None,
254            platform_tag_overrides: None,
255            amd64_variant: None,
256            arm_variant: None,
257            auth: PypiAuthMode::default(),
258            token: None,
259            skip: None,
260            required: None,
261            if_condition: None,
262            retain_on_rollback: None,
263        }
264    }
265}
266
267/// How a `pypis[]` entry authenticates its upload: a long-lived API token, or
268/// GitHub Actions OIDC (PyPI Trusted Publishing, which mints a short-lived
269/// upload token per run — no stored secret).
270///
271/// Unlike npm, PyPI is uploaded directly over HTTP rather than through a CLI,
272/// so the Trusted-Publishing exchange (Actions id-token → PyPI mint-token) is
273/// performed by anodizer itself. Trusted Publishing also creates brand-new
274/// projects when a *pending* publisher is configured on PyPI, so there is no
275/// per-package "must already exist" caveat as there is for npm.
276#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
277#[serde(rename_all = "kebab-case")]
278pub enum PypiAuthMode {
279    /// Use a token when one is available (`cfg.token` / `PYPI_TOKEN` /
280    /// `MATURIN_PYPI_TOKEN`); otherwise, when an OIDC context is present, mint
281    /// a Trusted-Publishing token. Errors only when neither is available.
282    #[default]
283    Auto,
284    /// Always authenticate with the token; never attempt OIDC. Errors if no
285    /// token is available. This is anodizer's historical behaviour.
286    Token,
287    /// Always authenticate with OIDC (Trusted Publishing); never fall back to
288    /// the token. Errors if the GitHub Actions OIDC request env
289    /// (`ACTIONS_ID_TOKEN_REQUEST_URL` / `_TOKEN`) is absent, so a misconfigured
290    /// Trusted Publisher fails the release loudly instead of silently falling
291    /// back to a token.
292    Oidc,
293}