use std::collections::{BTreeSet, HashMap, HashSet};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::analyzers::python::scan_python_imports;
use crate::config::{StandardConfig, output_config_hash, resolve_extra_inputs};
use crate::file_index::FileIndex;
use crate::graph::{BuildGraph, Product};
use crate::processors::{Processor, ensure_output_dir};
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct RequirementsConfig {
#[serde(default = "default_requirements_output")]
pub output: String,
#[serde(default)]
pub exclude: Vec<String>,
#[serde(default = "crate::config::default_true")]
pub sorted: bool,
#[serde(default = "crate::config::default_true")]
pub header: bool,
#[serde(default)]
pub mapping: HashMap<String, String>,
#[serde(default)]
pub extra: Vec<String>,
#[serde(default)]
pub python_paths: Vec<String>,
#[serde(flatten)]
pub standard: StandardConfig,
}
fn default_requirements_output() -> String {
"requirements.txt".into()
}
impl Default for RequirementsConfig {
fn default() -> Self {
Self {
output: default_requirements_output(),
exclude: Vec::new(),
sorted: true,
header: true,
mapping: HashMap::new(),
extra: Vec::new(),
python_paths: Vec::new(),
standard: StandardConfig::default(),
}
}
}
pub struct RequirementsProcessor {
config: RequirementsConfig,
}
impl RequirementsProcessor {
pub const fn new(config: RequirementsConfig) -> Self {
Self { config }
}
fn distribution_for(&self, import_name: &str) -> String {
if let Some(mapped) = self.config.mapping.get(import_name) {
return mapped.clone();
}
resolve_distribution(import_name).to_string()
}
}
impl Processor for RequirementsProcessor {
fn scan_config(&self) -> &crate::config::StandardConfig {
&self.config.standard
}
fn config_json(&self) -> Option<String> {
crate::processors::ProcessorBase::config_json(&self.config)
}
fn clean(&self, product: &Product, verbose: bool) -> Result<usize> {
crate::processors::ProcessorBase::clean(product, &product.processor, verbose)
}
fn auto_detect(&self, file_index: &FileIndex) -> bool {
!file_index.scan(&self.config.standard, false).is_empty()
}
fn discover(
&self,
graph: &mut BuildGraph,
file_index: &FileIndex,
instance_name: &str,
) -> Result<()> {
let files = file_index.scan(&self.config.standard, true);
if files.is_empty() {
return Ok(());
}
let extra = resolve_extra_inputs(&self.config.standard.dep_inputs)?;
let mut inputs = Vec::with_capacity(files.len() + extra.len());
inputs.extend(files);
inputs.extend_from_slice(&extra);
let output = PathBuf::from(&self.config.output);
graph.add_product(
inputs,
vec![output],
instance_name,
Some(output_config_hash(
&self.config,
&crate::config::checksum_fields_of(instance_name),
)),
)?;
Ok(())
}
fn execute(&self, _ctx: &crate::build_context::BuildContext, product: &Product) -> Result<()> {
let output_path = product.primary_output();
ensure_output_dir(output_path)?;
let local_py: HashSet<&Path> = product
.inputs
.iter()
.filter(|p| p.extension().and_then(|e| e.to_str()) == Some("py"))
.map(std::path::PathBuf::as_path)
.collect();
let exclude: HashSet<&str> = self
.config
.exclude
.iter()
.map(std::string::String::as_str)
.collect();
let mut first_seen: Vec<String> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
for input in &product.inputs {
if input.extension().and_then(|e| e.to_str()) != Some("py") {
continue;
}
let modules = scan_python_imports(input)
.with_context(|| format!("Failed to scan imports in {}", input.display()))?;
for module in modules {
let top = module.split('.').next().unwrap_or(&module);
if top.is_empty() {
continue;
}
if exclude.contains(top) {
continue;
}
if is_stdlib(top) {
continue;
}
if is_local(input, top, &local_py, &self.config.python_paths) {
continue;
}
let dist = self.distribution_for(top);
if seen.insert(dist.clone()) {
first_seen.push(dist);
}
}
}
for dist in &self.config.extra {
if seen.insert(dist.clone()) {
first_seen.push(dist.clone());
}
}
let entries: Vec<String> = if self.config.sorted {
let set: BTreeSet<String> = first_seen.into_iter().collect();
set.into_iter().collect()
} else {
first_seen
};
let mut file = fs::File::create(output_path)
.with_context(|| format!("Failed to create {}", output_path.display()))?;
if self.config.header {
writeln!(file, "# Generated by rsconstruct — do not edit by hand")
.with_context(|| format!("Failed to write header to {}", output_path.display()))?;
}
for entry in &entries {
writeln!(file, "{entry}")
.with_context(|| format!("Failed to write entry to {}", output_path.display()))?;
}
Ok(())
}
}
fn is_local(
source: &Path,
module: &str,
local_py: &HashSet<&Path>,
python_paths: &[String],
) -> bool {
let module_path = module.replace('.', "/");
let source_dir = crate::processors::parent_dir(source);
let mut roots: Vec<PathBuf> = Vec::with_capacity(2 + python_paths.len());
roots.push(source_dir.to_path_buf());
roots.push(PathBuf::from("."));
for p in python_paths {
roots.push(PathBuf::from(p));
}
for root in &roots {
let candidates = [
root.join(format!("{module_path}.py")),
root.join(&module_path).join("__init__.py"),
];
for candidate in &candidates {
if local_py.contains(candidate.as_path()) {
return true;
}
if candidate.is_file() {
return true;
}
}
}
false
}
fn resolve_distribution(import_name: &str) -> &str {
MAPPINGS
.binary_search_by_key(&import_name, |&(k, _)| k)
.ok()
.map_or(import_name, |i| MAPPINGS[i].1)
}
const MAPPINGS: &[(&str, &str)] = &[
("PIL", "Pillow"),
("attr", "attrs"),
("bs4", "beautifulsoup4"),
("cv2", "opencv-python"),
("dateutil", "python-dateutil"),
("discord", "discord.py"),
("dns", "dnspython"),
("docx", "python-docx"),
("dotenv", "python-dotenv"),
("fitz", "PyMuPDF"),
("git", "GitPython"),
("google", "google-api-python-client"),
("googleapiclient", "google-api-python-client"),
("grpc", "grpcio"),
("gym", "gymnasium"),
("jwt", "PyJWT"),
("magic", "python-magic"),
("mpl_toolkits", "matplotlib"),
("mx", "egenix-mx-base"),
("nacl", "PyNaCl"),
("pptx", "python-pptx"),
("psycopg2", "psycopg2-binary"),
("pycountry", "pycountry"),
("pycryptodome", "pycryptodome"),
("serial", "pyserial"),
("skimage", "scikit-image"),
("sklearn", "scikit-learn"),
("slugify", "python-slugify"),
("socks", "PySocks"),
("tensorflow_datasets", "tensorflow-datasets"),
("tensorflow_hub", "tensorflow-hub"),
("tensorflow_probability", "tensorflow-probability"),
("uvicorn", "uvicorn"),
("win32api", "pywin32"),
("win32com", "pywin32"),
("win32con", "pywin32"),
("wx", "wxPython"),
("yaml", "PyYAML"),
("zmq", "pyzmq"),
];
fn is_stdlib(module: &str) -> bool {
STDLIB_MODULES.binary_search(&module).is_ok()
}
const STDLIB_MODULES: &[&str] = &[
"__future__",
"_abc",
"_aix_support",
"_ast",
"_asyncio",
"_bisect",
"_blake2",
"_bz2",
"_codecs",
"_codecs_cn",
"_codecs_hk",
"_codecs_iso2022",
"_codecs_jp",
"_codecs_kr",
"_codecs_tw",
"_collections",
"_collections_abc",
"_compat_pickle",
"_compression",
"_contextvars",
"_csv",
"_ctypes",
"_curses",
"_curses_panel",
"_datetime",
"_decimal",
"_elementtree",
"_frozen_importlib",
"_frozen_importlib_external",
"_functools",
"_hashlib",
"_heapq",
"_imp",
"_io",
"_json",
"_locale",
"_lsprof",
"_lzma",
"_markupbase",
"_md5",
"_multibytecodec",
"_multiprocessing",
"_opcode",
"_operator",
"_osx_support",
"_pickle",
"_posixshmem",
"_posixsubprocess",
"_py_abc",
"_pydecimal",
"_pyio",
"_queue",
"_random",
"_sha1",
"_sha2",
"_sha3",
"_signal",
"_sitebuiltins",
"_socket",
"_sqlite3",
"_sre",
"_ssl",
"_stat",
"_statistics",
"_string",
"_strptime",
"_struct",
"_symtable",
"_thread",
"_threading_local",
"_tkinter",
"_tokenize",
"_tracemalloc",
"_typing",
"_uuid",
"_warnings",
"_weakref",
"_weakrefset",
"_zoneinfo",
"abc",
"aifc",
"antigravity",
"argparse",
"array",
"ast",
"asynchat",
"asyncio",
"asyncore",
"atexit",
"audioop",
"base64",
"bdb",
"binascii",
"bisect",
"builtins",
"bz2",
"cProfile",
"calendar",
"cgi",
"cgitb",
"chunk",
"cmath",
"cmd",
"code",
"codecs",
"codeop",
"collections",
"colorsys",
"compileall",
"concurrent",
"configparser",
"contextlib",
"contextvars",
"copy",
"copyreg",
"crypt",
"csv",
"ctypes",
"curses",
"dataclasses",
"datetime",
"dbm",
"decimal",
"difflib",
"dis",
"distutils",
"doctest",
"email",
"encodings",
"ensurepip",
"enum",
"errno",
"faulthandler",
"fcntl",
"filecmp",
"fileinput",
"fnmatch",
"fractions",
"ftplib",
"functools",
"gc",
"genericpath",
"getopt",
"getpass",
"gettext",
"glob",
"graphlib",
"grp",
"gzip",
"hashlib",
"heapq",
"hmac",
"html",
"http",
"idlelib",
"imaplib",
"imghdr",
"imp",
"importlib",
"inspect",
"io",
"ipaddress",
"itertools",
"json",
"keyword",
"lib2to3",
"linecache",
"locale",
"logging",
"lzma",
"mailbox",
"mailcap",
"marshal",
"math",
"mimetypes",
"mmap",
"modulefinder",
"msilib",
"msvcrt",
"multiprocessing",
"netrc",
"nis",
"nntplib",
"ntpath",
"nturl2path",
"numbers",
"opcode",
"operator",
"optparse",
"os",
"ossaudiodev",
"pathlib",
"pdb",
"pickle",
"pickletools",
"pipes",
"pkgutil",
"platform",
"plistlib",
"poplib",
"posix",
"posixpath",
"pprint",
"profile",
"pstats",
"pty",
"pwd",
"py_compile",
"pyclbr",
"pydoc",
"pydoc_data",
"pyexpat",
"queue",
"quopri",
"random",
"re",
"readline",
"reprlib",
"resource",
"rlcompleter",
"runpy",
"sched",
"secrets",
"select",
"selectors",
"shelve",
"shlex",
"shutil",
"signal",
"site",
"smtpd",
"smtplib",
"sndhdr",
"socket",
"socketserver",
"spwd",
"sqlite3",
"sre_compile",
"sre_constants",
"sre_parse",
"ssl",
"stat",
"statistics",
"string",
"stringprep",
"struct",
"subprocess",
"sunau",
"symtable",
"sys",
"sysconfig",
"syslog",
"tabnanny",
"tarfile",
"telnetlib",
"tempfile",
"termios",
"test",
"textwrap",
"this",
"threading",
"time",
"timeit",
"tkinter",
"token",
"tokenize",
"tomllib",
"trace",
"traceback",
"tracemalloc",
"tty",
"turtle",
"turtledemo",
"types",
"typing",
"unicodedata",
"unittest",
"urllib",
"uu",
"uuid",
"venv",
"warnings",
"wave",
"weakref",
"webbrowser",
"winreg",
"winsound",
"wsgiref",
"xdrlib",
"xml",
"xmlrpc",
"zipapp",
"zipfile",
"zipimport",
"zlib",
"zoneinfo",
];
const _: () = {
const fn lt(a: &str, b: &str) -> bool {
let (a, b) = (a.as_bytes(), b.as_bytes());
let mut i = 0;
while i < a.len() && i < b.len() {
if a[i] != b[i] {
return a[i] < b[i];
}
i += 1;
}
a.len() < b.len()
}
let mut i = 1;
while i < STDLIB_MODULES.len() {
assert!(
lt(STDLIB_MODULES[i - 1], STDLIB_MODULES[i]),
"STDLIB_MODULES must stay sorted"
);
i += 1;
}
};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mappings_are_sorted() {
for pair in MAPPINGS.windows(2) {
assert!(
pair[0].0 < pair[1].0,
"MAPPINGS not sorted: {} >= {}",
pair[0].0,
pair[1].0
);
}
}
#[test]
fn known_mappings() {
assert_eq!(resolve_distribution("cv2"), "opencv-python");
assert_eq!(resolve_distribution("yaml"), "PyYAML");
assert_eq!(resolve_distribution("PIL"), "Pillow");
assert_eq!(resolve_distribution("sklearn"), "scikit-learn");
}
#[test]
fn unmapped_returns_identity() {
assert_eq!(resolve_distribution("requests"), "requests");
assert_eq!(resolve_distribution("numpy"), "numpy");
}
#[test]
fn common_stdlib_names() {
assert!(is_stdlib("os"));
assert!(is_stdlib("sys"));
assert!(is_stdlib("json"));
assert!(is_stdlib("collections"));
assert!(is_stdlib("typing"));
}
#[test]
fn not_stdlib() {
assert!(!is_stdlib("requests"));
assert!(!is_stdlib("numpy"));
assert!(!is_stdlib("flask"));
}
}
fn plugin_create(toml: &toml::Value) -> Result<Box<dyn Processor>> {
crate::registries::deserialize_and_create(toml, |cfg| Box::new(RequirementsProcessor::new(cfg)))
}
inventory::submit! {
crate::registries::ProcessorPlugin {
version: 1,
name: "requirements",
processor_type: crate::processors::ProcessorType::Generator,
create: plugin_create,
fields: &[
crate::config::FieldSpec { name: "output", ty: crate::config::FieldType::String,
affects_output: true, required: false,
doc: "Path of the generated requirements.txt file" },
crate::config::FieldSpec { name: "exclude", ty: crate::config::FieldType::StringArray,
affects_output: true, required: false,
doc: "Import names to never emit (e.g. internal vendored modules)" },
crate::config::FieldSpec { name: "sorted", ty: crate::config::FieldType::Bool,
affects_output: true, required: false,
doc: "Sort entries alphabetically (false preserves first-seen order)" },
crate::config::FieldSpec { name: "header", ty: crate::config::FieldType::Bool,
affects_output: true, required: false,
doc: "Include a comment header line in the generated file" },
crate::config::FieldSpec { name: "mapping", ty: crate::config::FieldType::Table,
affects_output: true, required: false,
doc: "Per-project import→distribution overrides (win over built-in table)" },
crate::config::FieldSpec { name: "extra", ty: crate::config::FieldType::StringArray,
affects_output: true, required: false,
doc: "Distribution names to always include, e.g. transitive deps undeclared by upstream" },
crate::config::FieldSpec { name: "python_paths", ty: crate::config::FieldType::StringArray,
affects_output: true, required: false,
doc: "Project-relative source roots resolved when classifying imports as local" },
],
omit_standard_fields: &["command", "formats", "args", "output_dir"],
scan_defaults: Some(crate::config::ScanDefaultsData { src_dirs: &[], src_extensions: &[".py"], src_exclude_dirs: &[] }),
defaults: None,
defconfig_json: crate::registries::default_config_json::<RequirementsConfig>,
keywords: &["python", "pip", "requirements", "dependencies", "generator", "py"],
description: "Generate requirements.txt from Python import statements",
is_native: true,
can_fix: false,
supports_batch: false,
max_jobs_cap: Some(1),
}
}