Skip to main content

ossctl_core/release/adapters/
python.rs

1//! Python ecosystem adapter: `gh-action-pypi-publish` and `twine`.
2//!
3//! Publishes wheels/sdists to `PyPI`. `twine` uploads directly from this host;
4//! `gh-action-pypi-publish` is the CI trusted-publisher flow, so its publish
5//! body is a clearly-marked skeleton (the real upload happens in the workflow).
6//! `verify` reconciles against `PyPI` via the default [`RegistryQuery`] path.
7
8use std::time::Duration;
9
10use crate::contract::schema::Adapter;
11use crate::protocol::release::{BuildArtifacts, DryRunReport, PlannedCommand, PublishReceipt};
12
13use super::{make_receipt, run_all, AdapterError, AdapterTarget, EffectCtx, ReleaseAdapter};
14
15/// The python release adapter, operating as `gh-action-pypi-publish` or `twine`.
16pub struct PythonAdapter {
17    adapter: Adapter,
18}
19
20impl PythonAdapter {
21    /// Construct for a resolved python adapter identity.
22    #[must_use]
23    pub fn new(adapter: Adapter) -> Self {
24        debug_assert!(matches!(
25            adapter,
26            Adapter::GhActionPypiPublish | Adapter::Twine
27        ));
28        Self { adapter }
29    }
30}
31
32impl ReleaseAdapter for PythonAdapter {
33    fn adapter(&self) -> Adapter {
34        self.adapter
35    }
36
37    fn dry_run(
38        &self,
39        _ctx: &EffectCtx<'_>,
40        _t: &AdapterTarget,
41    ) -> Result<DryRunReport, AdapterError> {
42        let notes = match self.adapter {
43            Adapter::GhActionPypiPublish => {
44                vec![
45                    "upload runs in CI via the PyPI trusted publisher, not from this host"
46                        .to_string(),
47                ]
48            }
49            _ => vec![],
50        };
51        // `twine check` validates the built distributions; it is side-effect-free.
52        Ok(DryRunReport {
53            adapter: self.adapter,
54            planned_commands: vec![PlannedCommand::new("twine", &["check", "dist/*"])],
55            notes,
56        })
57    }
58
59    fn build(
60        &self,
61        ctx: &EffectCtx<'_>,
62        t: &AdapterTarget,
63    ) -> Result<BuildArtifacts, AdapterError> {
64        run_all(ctx, &[PlannedCommand::new("python", &["-m", "build"])])?;
65        // SKELETON: a production build enumerates the wheels/sdists under dist/.
66        Ok(BuildArtifacts {
67            adapter: self.adapter,
68            artifacts: vec![
69                format!("{}-{}-py3-none-any.whl", t.package, t.version),
70                format!("{}-{}.tar.gz", t.package, t.version),
71            ],
72            notes: vec![],
73        })
74    }
75
76    fn publish(
77        &self,
78        ctx: &EffectCtx<'_>,
79        t: &AdapterTarget,
80    ) -> Result<PublishReceipt, AdapterError> {
81        // PER-TARGET IRREVERSIBLE.
82        if matches!(self.adapter, Adapter::GhActionPypiPublish) {
83            // SKELETON: the real upload is the CI trusted-publisher job; there is
84            // no honest host publish to perform, so we say so rather than
85            // fabricate a receipt.
86            return Err(AdapterError::Unsupported {
87                adapter: self.adapter,
88                operation: "publish",
89            });
90        }
91        run_all(ctx, &[PlannedCommand::new("twine", &["upload", "dist/*"])])?;
92        let remote_url = Some(format!(
93            "https://pypi.org/project/{}/{}/",
94            t.package, t.version
95        ));
96        Ok(make_receipt(ctx, t, None, remote_url))
97    }
98
99    fn timeout(&self) -> Duration {
100        Duration::from_secs(600)
101    }
102}