mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-24 20:28:06 +02:00
[pitboss] phase 02: M2 — Python end-to-end excellence with all hardening baked in
This commit is contained in:
parent
894f587b60
commit
0bf39047b9
50 changed files with 4167 additions and 170 deletions
223
src/dynamic/toolchain.rs
Normal file
223
src/dynamic/toolchain.rs
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
//! Toolchain resolver (§22.2).
|
||||
//!
|
||||
//! Reads project metadata files to determine the pinned Python version, then
|
||||
//! maps it to the closest Nyx reference image. Records `pin_origin` (where the
|
||||
//! version was found) and a `toolchain_drift` flag when the resolved image is
|
||||
//! not an exact match for the requested version.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Resolved toolchain information for a target directory.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToolchainResolution {
|
||||
/// Nyx reference toolchain identifier (e.g. `"python-3.11"`).
|
||||
pub toolchain_id: String,
|
||||
/// Where the version pin was read from.
|
||||
pub pin_origin: PinOrigin,
|
||||
/// Whether the resolved toolchain differs from the exact pinned version.
|
||||
pub toolchain_drift: bool,
|
||||
/// Resolved semver string (e.g. `"3.11.5"`).
|
||||
pub version_string: String,
|
||||
}
|
||||
|
||||
/// Where the toolchain version pin was discovered.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PinOrigin {
|
||||
/// `.python-version` file (pyenv).
|
||||
PythonVersion,
|
||||
/// `pyproject.toml` `[tool.python]` or `[project] requires-python`.
|
||||
PyprojectToml,
|
||||
/// `Pipfile` `[requires] python_version`.
|
||||
Pipfile,
|
||||
/// `runtime.txt` (Heroku-style).
|
||||
RuntimeTxt,
|
||||
/// No pin found; used the system default.
|
||||
SystemDefault,
|
||||
}
|
||||
|
||||
/// Resolve the Python toolchain for `project_root`.
|
||||
///
|
||||
/// Reads project pin files in priority order:
|
||||
/// `.python-version` > `pyproject.toml` > `Pipfile` > `runtime.txt` > default.
|
||||
pub fn resolve_python(project_root: &Path) -> ToolchainResolution {
|
||||
if let Some(r) = try_python_version_file(project_root) {
|
||||
return r;
|
||||
}
|
||||
if let Some(r) = try_pyproject_toml(project_root) {
|
||||
return r;
|
||||
}
|
||||
if let Some(r) = try_pipfile(project_root) {
|
||||
return r;
|
||||
}
|
||||
if let Some(r) = try_runtime_txt(project_root) {
|
||||
return r;
|
||||
}
|
||||
default_python()
|
||||
}
|
||||
|
||||
fn try_python_version_file(root: &Path) -> Option<ToolchainResolution> {
|
||||
let path = root.join(".python-version");
|
||||
let content = std::fs::read_to_string(&path).ok()?;
|
||||
let version = content.trim().to_owned();
|
||||
if version.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(map_version(&version, PinOrigin::PythonVersion))
|
||||
}
|
||||
|
||||
fn try_pyproject_toml(root: &Path) -> Option<ToolchainResolution> {
|
||||
let content = std::fs::read_to_string(root.join("pyproject.toml")).ok()?;
|
||||
// Look for `requires-python = ">=3.11"` or `python = "3.11"`.
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
if line.starts_with("requires-python") || (line.starts_with("python") && line.contains('=') && !line.starts_with("python_requires")) {
|
||||
if let Some(ver) = extract_version_from_toml_value(line) {
|
||||
return Some(map_version(&ver, PinOrigin::PyprojectToml));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn try_pipfile(root: &Path) -> Option<ToolchainResolution> {
|
||||
let content = std::fs::read_to_string(root.join("Pipfile")).ok()?;
|
||||
let mut in_requires = false;
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
if line == "[requires]" {
|
||||
in_requires = true;
|
||||
continue;
|
||||
}
|
||||
if line.starts_with('[') {
|
||||
in_requires = false;
|
||||
}
|
||||
if in_requires && line.starts_with("python_version") {
|
||||
if let Some(ver) = extract_version_from_toml_value(line) {
|
||||
return Some(map_version(&ver, PinOrigin::Pipfile));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn try_runtime_txt(root: &Path) -> Option<ToolchainResolution> {
|
||||
let content = std::fs::read_to_string(root.join("runtime.txt")).ok()?;
|
||||
let line = content.lines().next()?.trim();
|
||||
// e.g. "python-3.11.5"
|
||||
let version = line.strip_prefix("python-").unwrap_or(line);
|
||||
if version.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(map_version(version, PinOrigin::RuntimeTxt))
|
||||
}
|
||||
|
||||
fn default_python() -> ToolchainResolution {
|
||||
ToolchainResolution {
|
||||
toolchain_id: "python-3".to_owned(),
|
||||
pin_origin: PinOrigin::SystemDefault,
|
||||
toolchain_drift: false,
|
||||
version_string: "3".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the bare version string from a TOML assignment like:
|
||||
/// `requires-python = ">=3.11"` → `"3.11"`
|
||||
/// `python_version = "3.11"` → `"3.11"`
|
||||
fn extract_version_from_toml_value(line: &str) -> Option<String> {
|
||||
let after_eq = line.splitn(2, '=').nth(1)?;
|
||||
let raw = after_eq.trim().trim_matches('"').trim_matches('\'');
|
||||
// Strip leading comparators: >=, <=, ==, ~=, ^, >
|
||||
let ver = raw.trim_start_matches(|c: char| !c.is_ascii_digit());
|
||||
if ver.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(ver.to_owned())
|
||||
}
|
||||
|
||||
/// Map a raw version string to a Nyx reference toolchain ID.
|
||||
///
|
||||
/// Reference images: `python-3.8`, `python-3.9`, `python-3.10`,
|
||||
/// `python-3.11`, `python-3.12`, `python-3.13`.
|
||||
fn map_version(version: &str, origin: PinOrigin) -> ToolchainResolution {
|
||||
// Normalise: take major.minor from "3.11.5" → "3.11"
|
||||
let parts: Vec<&str> = version.splitn(3, '.').collect();
|
||||
let major = parts.first().copied().unwrap_or("3");
|
||||
let minor = parts.get(1).copied();
|
||||
|
||||
let (toolchain_id, drift) = match (major, minor) {
|
||||
("3", Some("8")) => ("python-3.8".to_owned(), false),
|
||||
("3", Some("9")) => ("python-3.9".to_owned(), false),
|
||||
("3", Some("10")) => ("python-3.10".to_owned(), false),
|
||||
("3", Some("11")) => ("python-3.11".to_owned(), false),
|
||||
("3", Some("12")) => ("python-3.12".to_owned(), false),
|
||||
("3", Some("13")) => ("python-3.13".to_owned(), false),
|
||||
// Older 3.x → nearest supported is 3.8
|
||||
("3", Some(m)) if m.parse::<u32>().map_or(false, |v| v < 8) => {
|
||||
("python-3.8".to_owned(), true)
|
||||
}
|
||||
// Newer 3.x beyond catalog → use 3.13 as closest
|
||||
("3", Some(_)) => ("python-3.13".to_owned(), true),
|
||||
("3", None) => ("python-3".to_owned(), false),
|
||||
// Python 2 → unsupported, use system default as closest
|
||||
("2", _) => ("python-3".to_owned(), true),
|
||||
_ => ("python-3".to_owned(), true),
|
||||
};
|
||||
|
||||
ToolchainResolution {
|
||||
version_string: version.to_owned(),
|
||||
toolchain_id,
|
||||
pin_origin: origin,
|
||||
toolchain_drift: drift,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn python_version_file_exact() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::write(dir.path().join(".python-version"), "3.11.5\n").unwrap();
|
||||
let r = resolve_python(dir.path());
|
||||
assert_eq!(r.toolchain_id, "python-3.11");
|
||||
assert!(!r.toolchain_drift);
|
||||
assert_eq!(r.pin_origin, PinOrigin::PythonVersion);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn python_version_file_drift() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::write(dir.path().join(".python-version"), "3.7\n").unwrap();
|
||||
let r = resolve_python(dir.path());
|
||||
assert!(r.toolchain_drift);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pyproject_requires_python() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::write(dir.path().join("pyproject.toml"), "[project]\nrequires-python = \">=3.11\"\n").unwrap();
|
||||
let r = resolve_python(dir.path());
|
||||
assert_eq!(r.toolchain_id, "python-3.11");
|
||||
assert_eq!(r.pin_origin, PinOrigin::PyprojectToml);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipfile_python_version() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::write(dir.path().join("Pipfile"), "[requires]\npython_version = \"3.10\"\n").unwrap();
|
||||
let r = resolve_python(dir.path());
|
||||
assert_eq!(r.toolchain_id, "python-3.10");
|
||||
assert_eq!(r.pin_origin, PinOrigin::Pipfile);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_to_system_default() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let r = resolve_python(dir.path());
|
||||
assert_eq!(r.pin_origin, PinOrigin::SystemDefault);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue