diff --git a/.gitignore b/.gitignore index b8aab7f..999f368 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,7 @@ tmp/ .ninja* .fapt-lists/ .gdb_history + +src/*.egg-info +src/*.so +venv/ diff --git a/Cargo.toml b/Cargo.toml index 83ed72f..fd53b97 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ insideout = "0.2" mailparse = "0.13" md-5 = "0.10" nom = "4" +pyo3 = { version = "0.17.2", features = ["anyhow", "chrono", "extension-module"] } sha2 = "0.10" tempfile = "3" tempfile-fast = "0.3" @@ -71,3 +72,7 @@ version = "0.3" [[bin]] name = "fapt" required-features = ["binaries"] + +[lib] +name = "fapt" +crate-type = ["cdylib", "lib"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1abc008 --- /dev/null +++ b/Makefile @@ -0,0 +1,7 @@ +venv: + python3 -m venv venv + ./venv/bin/pip install -r requirements.txt + +test: venv + ./venv/bin/pip install -e . + ./venv/bin/pytest -v diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ceeed80 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +pytest +setuptools_rust diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..f1f84bb --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[metadata] +name = fapt diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..c8008ca --- /dev/null +++ b/setup.py @@ -0,0 +1,6 @@ +from setuptools import setup +from setuptools_rust import RustExtension + +setup( + rust_extensions=[RustExtension("fapt")], +) diff --git a/src/checksum.rs b/src/checksum.rs index 92f69ee..0ed7261 100644 --- a/src/checksum.rs +++ b/src/checksum.rs @@ -9,12 +9,17 @@ use hex::FromHex; use sha2::Digest; use sha2::Sha256; +use pyo3::prelude::pyclass; + pub type MD5 = [u8; 16]; pub type SHA256 = [u8; 32]; #[derive(Copy, Clone, Hash, PartialEq, Eq)] +#[pyclass] pub struct Hashes { + #[pyo3(get)] pub md5: MD5, + #[pyo3(get)] pub sha256: SHA256, } diff --git a/src/commands.rs b/src/commands.rs index 559892a..62be7ba 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -15,11 +15,14 @@ use crate::system::ListingBlocks; use crate::system::NamedBlock; use crate::system::System; +use pyo3::prelude::{pyfunction, wrap_pyfunction, PyModule, PyResult, Python}; + /// Use some set of bundled GPG keys. /// /// These may be sufficient for talking to Debian or Ubuntu mirrors. /// If you know what keys you actually want, or are using a real system, /// please use the keys from there instead. +#[pyfunction] pub fn add_builtin_keys(system: &mut System) { system .add_keys_from(io::Cursor::new(distro_keyring::supported_keys())) @@ -182,3 +185,9 @@ fn print_ninja_binary(map: &HashMap<&str, Vec<&str>>) -> Result<(), Error> { Ok(()) } + +pub fn py_commands(py: Python<'_>) -> PyResult<&PyModule> { + let mut m = PyModule::new(py, "commands")?; + m.add_function(wrap_pyfunction!(add_builtin_keys, m)?)?; + Ok(m) +} diff --git a/src/lib.rs b/src/lib.rs index 49390a8..accc05b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,8 @@ #[macro_use] extern crate nom; +use pyo3::prelude::{pymodule, PyModule, PyResult, Python}; + mod checksum; pub mod commands; mod fetch; @@ -15,3 +17,11 @@ pub mod rfc822; mod signing; pub mod sources_list; pub mod system; + +#[pymodule] +fn fapt(py: Python<'_>, m: &PyModule) -> PyResult<()> { + m.add_submodule(commands::py_commands(py)?)?; + m.add_submodule(sources_list::py_sources_list(py)?)?; + m.add_submodule(system::py_system(py)?)?; + Ok(()) +} diff --git a/src/lists.rs b/src/lists.rs index ac51dea..1bf6603 100644 --- a/src/lists.rs +++ b/src/lists.rs @@ -22,6 +22,8 @@ use crate::fetch; use crate::release::Release; use crate::release::ReleaseContent; +use pyo3::prelude::pyclass; + #[derive(Debug)] pub enum Compression { None, @@ -58,10 +60,15 @@ impl DownloadableListing { // directory: "binary", // name: "packages" #[derive(Debug, Clone, Hash, PartialEq, Eq)] +#[pyclass] pub struct Listing { + #[pyo3(get)] pub component: String, + #[pyo3(get)] pub arch: Option, + #[pyo3(get)] pub directory: String, + #[pyo3(get)] pub name: String, } diff --git a/src/release.rs b/src/release.rs index 356c395..a650769 100644 --- a/src/release.rs +++ b/src/release.rs @@ -26,47 +26,73 @@ use crate::rfc822::RfcMapExt; use crate::signing::GpgClient; use crate::sources_list::Entry; +use pyo3::prelude::pyclass; + pub struct RequestedReleases { releases: Vec<(RequestedRelease, Vec)>, } #[derive(Clone, PartialOrd, Ord, Hash, PartialEq, Eq, Debug)] +#[pyclass] pub struct RequestedRelease { mirror: Url, /// This can also be called "suite" in some places, /// e.g. "unstable" (suite) == "sid" (codename) + #[pyo3(get)] codename: String, + #[pyo3(get)] pub arches: Vec, } #[derive(Debug, Clone)] +#[pyclass] pub struct ReleaseFile { + #[pyo3(get)] origin: String, + #[pyo3(get)] label: String, + #[pyo3(get)] suite: Option, + #[pyo3(get)] codename: Option, + #[pyo3(get)] changelogs: Option, + #[pyo3(get)] date: DateTime, + #[pyo3(get)] valid_until: Option>, + #[pyo3(get)] pub acquire_by_hash: bool, + #[pyo3(get)] pub arches: Vec, + #[pyo3(get)] components: Vec, + #[pyo3(get)] description: Option, + #[pyo3(get)] pub contents: Vec, } #[derive(Clone)] +#[pyclass] pub struct ReleaseContent { + #[pyo3(get)] pub len: u64, + #[pyo3(get)] pub name: String, + #[pyo3(get)] pub hashes: Hashes, } #[derive(Debug, Clone)] +#[pyclass] pub struct Release { + #[pyo3(get)] pub req: RequestedRelease, + #[pyo3(get)] pub sources_entries: Vec, + #[pyo3(get)] pub file: ReleaseFile, } diff --git a/src/sources_list.rs b/src/sources_list.rs index 91de552..1bea910 100644 --- a/src/sources_list.rs +++ b/src/sources_list.rs @@ -7,8 +7,12 @@ use anyhow::bail; use anyhow::Context; use anyhow::Error; +use pyo3::basic::CompareOp; +use pyo3::prelude::{pyclass, pymethods, IntoPy, Py, PyAny, PyModule, PyResult, Python}; + /// Our representation of a classic sources list entry. #[derive(Debug, PartialEq, Eq, Clone)] +#[pyclass] pub struct Entry { pub src: bool, pub url: String, @@ -17,6 +21,34 @@ pub struct Entry { pub arch: Option, } +#[pymethods] +impl Entry { + #[new] + fn py_new( + src: bool, + url: String, + suite_codename: String, + components: Vec, + arch: Option, + ) -> Self { + Entry { + src, + url, + suite_codename, + components, + arch, + } + } + + fn __richcmp__(&self, py: Python<'_>, other: &Self, op: CompareOp) -> Py { + match op { + CompareOp::Eq => (self == other).into_py(py), + CompareOp::Ne => (self != other).into_py(py), + _ => py.NotImplemented(), + } + } +} + fn read_single_line(line: &str) -> Result, Error> { let line = match line.find('#') { Some(comment) => &line[..comment], @@ -132,3 +164,9 @@ deb-src http://foo bar baz quux ); } } + +pub fn py_sources_list(py: Python<'_>) -> PyResult<&PyModule> { + let mut m = PyModule::new(py, "sources_list")?; + m.add_class::()?; + Ok(m) +} diff --git a/src/system.rs b/src/system.rs index 64a46c1..30eca3e 100644 --- a/src/system.rs +++ b/src/system.rs @@ -28,7 +28,10 @@ use crate::release; use crate::rfc822; use crate::sources_list::Entry; +use pyo3::prelude::{pyclass, pymethods, PyModule, PyResult, Python}; + /// The core object, tying together configuration, caching, and listing. +#[pyclass] pub struct System { pub(crate) lists_dir: PathBuf, dpkg_database: Option, @@ -40,13 +43,19 @@ pub struct System { /// A _Listing_ that has been downloaded, and the _Release_ it came from. #[derive(Debug, Clone)] +#[pyclass] pub struct DownloadedList { + #[pyo3(get)] pub release: release::Release, + #[pyo3(get)] pub listing: lists::Listing, } +#[pymethods] impl System { + // Methods exposed to Python /// Produce a `System` with no configuration, using the user's cache directory. + #[staticmethod] pub fn cache_only() -> Result { let mut cache_dir = directories::ProjectDirs::from("xxx", "fau", "fapt") .ok_or(anyhow!("couldn't find HOME's data directories"))? @@ -56,59 +65,6 @@ impl System { Self::cache_only_in(cache_dir) } - /// Produce a `System` with no configuration, using a specified cache directory. - pub fn cache_only_in>(lists_dir: P) -> Result { - fs::create_dir_all(lists_dir.as_ref())?; - - let client = if let Ok(proxy) = env::var("http_proxy") { - reqwest::blocking::Client::builder() - .proxy(reqwest::Proxy::http(&proxy)?) - .build()? - } else { - reqwest::blocking::Client::new() - }; - - Ok(System { - lists_dir: lists_dir.as_ref().to_path_buf(), - dpkg_database: None, - sources_entries: Vec::new(), - arches: Vec::new(), - keyring: Keyring::new(), - client, - }) - } - - /// Add prepared sources entries. - /// - /// These can be acquired from [crate::sources_list]. It is not recommended that you - /// build them by hand. - pub fn add_sources_entries>(&mut self, entries: I) { - self.sources_entries.extend(entries); - } - - /// Configure the architectures this system is using. - /// - /// The first architecture is the "primary" architecture. - pub fn set_arches>(&mut self, arches: I) { - self.arches = arches.into_iter().map(|x| x.to_string()).collect(); - } - - /// Configure the location of the `dpkg` database. - /// - /// This can be used to view `status` information, i.e. information on - /// currently installed packages. - pub fn set_dpkg_database>(&mut self, dpkg: P) { - self.dpkg_database = Some(dpkg.as_ref().to_path_buf()); - } - - /// Load GPG keys from an old-style keyring (i.e. not a keybox file). - /// - /// Note that this will reject invalid keyring files, unlike other `*apt` implementations. - pub fn add_keys_from(&mut self, source: R) -> Result<(), Error> { - self.keyring.append_keys_from(source)?; - Ok(()) - } - /// Download any necessary _Listings_ for the configured _Sources Entries_. pub fn update(&self) -> Result<(), Error> { let requested = @@ -171,9 +127,79 @@ impl System { inner: rfc822::Blocks::new(fs::File::open(status)?, "status".to_string()), }) } + + // XXX: pyo3 doesn't support generic parameters + // (https://pyo3.rs/main/class.html#no-generic-parameters), the below are Python-specific + // concrete versions of the otherwise generic methods + + #[pyo3(name = "add_sources_entries")] + pub fn add_sources_entries_py(&mut self, entries: Vec) { + self.add_sources_entries(entries); + } + + #[pyo3(name = "set_arches")] + pub fn set_arches_py(&mut self, arches: Vec) { + self.set_arches(arches); + } +} + +impl System { + /// Produce a `System` with no configuration, using a specified cache directory. + pub fn cache_only_in>(lists_dir: P) -> Result { + fs::create_dir_all(lists_dir.as_ref())?; + + let client = if let Ok(proxy) = env::var("http_proxy") { + reqwest::blocking::Client::builder() + .proxy(reqwest::Proxy::http(&proxy)?) + .build()? + } else { + reqwest::blocking::Client::new() + }; + + Ok(System { + lists_dir: lists_dir.as_ref().to_path_buf(), + dpkg_database: None, + sources_entries: Vec::new(), + arches: Vec::new(), + keyring: Keyring::new(), + client, + }) + } + + /// Add prepared sources entries. + /// + /// These can be acquired from [crate::sources_list]. It is not recommended that you + /// build them by hand. + pub fn add_sources_entries>(&mut self, entries: I) { + self.sources_entries.extend(entries); + } + + /// Configure the architectures this system is using. + /// + /// The first architecture is the "primary" architecture. + pub fn set_arches>(&mut self, arches: I) { + self.arches = arches.into_iter().map(|x| x.to_string()).collect(); + } + + /// Configure the location of the `dpkg` database. + /// + /// This can be used to view `status` information, i.e. information on + /// currently installed packages. + pub fn set_dpkg_database>(&mut self, dpkg: P) { + self.dpkg_database = Some(dpkg.as_ref().to_path_buf()); + } + + /// Load GPG keys from an old-style keyring (i.e. not a keybox file). + /// + /// Note that this will reject invalid keyring files, unlike other `*apt` implementations. + pub fn add_keys_from(&mut self, source: R) -> Result<(), Error> { + self.keyring.append_keys_from(source)?; + Ok(()) + } } /// The _Blocks_ of a _Listing_. +#[pyclass] pub struct ListingBlocks { pub(crate) inner: rfc822::Blocks, } @@ -211,3 +237,9 @@ impl NamedBlock { self.inner } } + +pub fn py_system(py: Python<'_>) -> PyResult<&PyModule> { + let mut m = PyModule::new(py, "system")?; + m.add_class::()?; + Ok(m) +} diff --git a/tests/test_fapt.py b/tests/test_fapt.py new file mode 100644 index 0000000..ddfa5ec --- /dev/null +++ b/tests/test_fapt.py @@ -0,0 +1,46 @@ +import pytest + +from fapt import commands, sources_list, system + + +def test_instantiation(): + system.System.cache_only() + + +@pytest.fixture +def system_instance(): + return system.System.cache_only() + + +@pytest.fixture +def entry_instance(): + root_url = "http://ca.archive.ubuntu.com/ubuntu/" + dist = "focal" + components = ["main", "restricted"] + + return sources_list.Entry(False, root_url, dist, components, None) + + +def test_add_sources_entries(entry_instance, system_instance): + system_instance.add_sources_entries([]) + system_instance.add_sources_entries([entry_instance]) + + +class TestUpdate: + def test_noop(self, system_instance): + system_instance.update() + + def test_with_entry(self, entry_instance, system_instance): + commands.add_builtin_keys(system_instance) + system_instance.add_sources_entries([entry_instance]) + system_instance.update() + + +def test_listings(entry_instance, system_instance): + commands.add_builtin_keys(system_instance) + system_instance.add_sources_entries([entry_instance]) + system_instance.set_arches(["amd64"]) + system_instance.update() + assert len(system_instance.listings()) > 0 + for dl in system_instance.listings(): + assert [entry_instance] == dl.release.sources_entries