Skip to content

Commit

Permalink
Add independent items contract
Browse files Browse the repository at this point in the history
  • Loading branch information
Peter554 committed Jan 9, 2025
1 parent cdb3632 commit 9ab1532
Show file tree
Hide file tree
Showing 4 changed files with 308 additions and 45 deletions.
241 changes: 241 additions & 0 deletions src/contracts/independent.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
//! The `independent` module provides a [`IndependentItemsContract`], which ensures that all items are independent.
//!
//! # Example: Contract kept
//!
//! ```
//! # use anyhow::Result;
//! # use pyimports::{testpackage};
//! # use pyimports::testutils::TestPackage;
//! use pyimports::package_info::PackageInfo;
//! use pyimports::imports_info::ImportsInfo;
//! use pyimports::contracts::ImportsContract;
//! use pyimports::contracts::independent::IndependentItemsContract;
//!
//! # fn main() -> Result<()> {
//! let testpackage = testpackage! {
//! "__init__.py" => "",
//! "a.py" => "import testpackage.c",
//! "b.py" => "import testpackage.d",
//! "c.py" => "",
//! "d.py" => ""
//! };
//!
//! let package_info = PackageInfo::build(testpackage.path())?;
//! let imports_info = ImportsInfo::build(package_info)?;
//!
//! let a = imports_info.package_info().get_item_by_pypath("testpackage.a")?.unwrap().token();
//! let b = imports_info.package_info().get_item_by_pypath("testpackage.b")?.unwrap().token();
//!
//! let contract = IndependentItemsContract::new(&[a, b]);
//!
//! let result = contract.verify(&imports_info)?;
//! assert!(result.is_kept());
//! # Ok(())
//! # }
//! ```
//!
//! # Example: Contract violated
//!
//! ```
//! # use anyhow::Result;
//! # use maplit::hashset;
//! # use std::collections::HashSet;
//! # use pyimports::{testpackage};
//! # use pyimports::testutils::TestPackage;
//! use pyimports::package_info::PackageInfo;
//! use pyimports::imports_info::ImportsInfo;
//! use pyimports::contracts::{ImportsContract,ContractViolation,ForbiddenImport};
//! use pyimports::contracts::independent::IndependentItemsContract;
//!
//! # fn main() -> Result<()> {
//! let testpackage = testpackage! {
//! "__init__.py" => "",
//! "a.py" => "import testpackage.c",
//! "b.py" => "import testpackage.d",
//! "c.py" => "import testpackage.b",
//! "d.py" => "import testpackage.a"
//! };
//!
//! let package_info = PackageInfo::build(testpackage.path())?;
//! let imports_info = ImportsInfo::build(package_info)?;
//!
//! let a = imports_info.package_info().get_item_by_pypath("testpackage.a")?.unwrap().token();
//! let b = imports_info.package_info().get_item_by_pypath("testpackage.b")?.unwrap().token();
//! let c = imports_info.package_info().get_item_by_pypath("testpackage.c")?.unwrap().token();
//! let d = imports_info.package_info().get_item_by_pypath("testpackage.d")?.unwrap().token();
//!
//! let contract = IndependentItemsContract::new(&[a, b]);
//!
//! let result = contract.verify(&imports_info)?;
//! assert!(result.is_violated());
//! let expected_violations = [
//! ContractViolation::ForbiddenImport {
//! forbidden_import: ForbiddenImport::new(a, b, hashset! {}),
//! path: vec![a, c, b],
//! },
//! ContractViolation::ForbiddenImport {
//! forbidden_import: ForbiddenImport::new(b, a, hashset! {}),
//! path: vec![b, d, a],
//! },
//! ];
//! let violations = result.unwrap_violated();
//! assert_eq!(violations.len(), expected_violations.len());
//! for violation in violations.iter() {
//! assert!(expected_violations.contains(violation));
//! }
//! # Ok(())
//! # }
//! ```
use crate::contracts::utils::find_violations;
use crate::contracts::{ContractVerificationResult, ForbiddenImport, ImportsContract};
use crate::imports_info::ImportsInfo;
use crate::package_info::PackageItemToken;
use anyhow::Result;
use itertools::Itertools;
use maplit::hashset;
use std::collections::HashSet;

/// A contract which ensures that all items are independent.
/// See the [module-level documentation](./index.html) for more details.
#[derive(Debug, Clone)]
pub struct IndependentItemsContract {
items: HashSet<PackageItemToken>,
ignored_imports: Vec<(PackageItemToken, PackageItemToken)>,
ignore_typechecking_imports: bool,
}

impl IndependentItemsContract {
/// Create a new [`IndependentItemsContract`].
pub fn new(items: &[PackageItemToken]) -> Self {
IndependentItemsContract {
items: items.iter().cloned().collect(),
ignored_imports: vec![],
ignore_typechecking_imports: false,
}
}

/// Ignore the passed imports when verifying the contract.
pub fn with_ignored_imports(
mut self,
imports: &[(PackageItemToken, PackageItemToken)],
) -> Self {
self.ignored_imports.extend(imports.to_vec());
self
}

/// Ignore typechecking imports when verifying the contract.
pub fn with_typechecking_imports_ignored(mut self) -> Self {
self.ignore_typechecking_imports = true;
self
}
}

impl ImportsContract for IndependentItemsContract {
fn verify(&self, imports_info: &ImportsInfo) -> Result<ContractVerificationResult> {
// Assumption: It's best/reasonable to clone here and remove the ignored imports from the graph.
// An alternative could be to ignore the imports dynamically via a new field on `InternalImportsPathQuery`.
let imports_info = {
let mut imports_info = imports_info.clone();
if !self.ignored_imports.is_empty() {
imports_info.remove_imports(self.ignored_imports.clone(), [])?;
}
if self.ignore_typechecking_imports {
imports_info.remove_typechecking_imports()?;
}
imports_info
};

let forbidden_imports = self
.items
.iter()
.permutations(2)
.map(|permutation| ForbiddenImport::new(*permutation[0], *permutation[1], hashset! {}))
.collect::<Vec<_>>();

let violations = find_violations(forbidden_imports, &imports_info)?;

if violations.is_empty() {
Ok(ContractVerificationResult::Kept)
} else {
Ok(ContractVerificationResult::Violated(violations))
}
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::contracts::ContractViolation;
use crate::package_info::PackageInfo;
use crate::testpackage;
use crate::testutils::TestPackage;
use anyhow::Result;
use maplit::hashset;
use pretty_assertions::assert_eq;

#[test]
fn test_independent_items_ok() -> Result<()> {
let testpackage = testpackage! {
"__init__.py" => "",
"a.py" => "import testpackage.c",
"b.py" => "import testpackage.d",
"c.py" => "",
"d.py" => ""
};

let package_info = PackageInfo::build(testpackage.path())?;
let imports_info = ImportsInfo::build(package_info)?;

let a = imports_info.package_info()._item("testpackage.a");
let b = imports_info.package_info()._item("testpackage.b");

let contract = IndependentItemsContract::new(&[a, b]);

let result = contract.verify(&imports_info)?;
assert!(result.is_kept());

Ok(())
}

#[test]
fn test_independent_items_violated() -> Result<()> {
let testpackage = testpackage! {
"__init__.py" => "",
"a.py" => "import testpackage.c",
"b.py" => "import testpackage.d",
"c.py" => "import testpackage.b",
"d.py" => "import testpackage.a"
};

let package_info = PackageInfo::build(testpackage.path())?;
let imports_info = ImportsInfo::build(package_info)?;

let a = imports_info.package_info()._item("testpackage.a");
let b = imports_info.package_info()._item("testpackage.b");
let c = imports_info.package_info()._item("testpackage.c");
let d = imports_info.package_info()._item("testpackage.d");

let contract = IndependentItemsContract::new(&[a, b]);

let result = contract.verify(&imports_info)?;
assert!(result.is_violated());
let expected_violations = [
ContractViolation::ForbiddenImport {
forbidden_import: ForbiddenImport::new(a, b, hashset! {}),
path: vec![a, c, b],
},
ContractViolation::ForbiddenImport {
forbidden_import: ForbiddenImport::new(b, a, hashset! {}),
path: vec![b, d, a],
},
];
let violations = result.unwrap_violated();
assert_eq!(violations.len(), expected_violations.len());
for violation in violations.iter() {
assert!(expected_violations.contains(violation));
}

Ok(())
}
}
51 changes: 6 additions & 45 deletions src/contracts/layers.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//! The layers module provides a [`LayeredArchitectureContract`], which enforces a layered architecture.
//! The `layers` module provides a [`LayeredArchitectureContract`], which enforces a layered architecture.
//!
//! A layered architecture involves a set of layers, with rules for imports between layers:
//!
Expand Down Expand Up @@ -104,18 +104,14 @@
//! # }
//! ```
use crate::contracts::{
ContractVerificationResult, ContractViolation, ForbiddenImport, ImportsContract,
};
use crate::imports_info::{ImportsInfo, InternalImportsPathQueryBuilder};
use crate::contracts::utils::find_violations;
use crate::contracts::{ContractVerificationResult, ForbiddenImport, ImportsContract};
use crate::imports_info::ImportsInfo;
use crate::package_info::PackageItemToken;
use crate::prelude::*;
use anyhow::Result;
use itertools::Itertools;
use maplit::hashset;
use rayon::prelude::*;
use std::collections::HashSet;
use tap::prelude::*;

/// A contract used to enforce a layered architecture.
/// See the [module-level documentation](./index.html) for more details.
Expand Down Expand Up @@ -181,43 +177,7 @@ impl ImportsContract for LayeredArchitectureContract {

let forbidden_imports = get_forbidden_imports(&self.layers, self.allow_deep_imports);

let violations = forbidden_imports
.into_par_iter()
.try_fold(Vec::new, |mut violations, forbidden_import| -> Result<_> {
// A layers contract operates in "as packages" mode, meaning
// items are expanded to include their descendants.
let from = forbidden_import
.from
.conv::<HashSet<PackageItemToken>>()
.with_descendants(imports_info.package_info());
let to = forbidden_import
.to
.conv::<HashSet<PackageItemToken>>()
.with_descendants(imports_info.package_info());
let except_via = forbidden_import
.except_via()
.clone()
.with_descendants(imports_info.package_info());

let path = imports_info.internal_imports().find_path(
&InternalImportsPathQueryBuilder::default()
.from(from)
.to(to)
.excluding_paths_via(except_via)
.build()?,
)?;
if let Some(path) = path {
violations.push(ContractViolation::ForbiddenImport {
forbidden_import,
path,
})
};
Ok(violations)
})
.try_reduce(Vec::new, |mut all_violations, violations| -> Result<_> {
all_violations.extend(violations);
Ok(all_violations)
})?;
let violations = find_violations(forbidden_imports, &imports_info)?;

if violations.is_empty() {
Ok(ContractVerificationResult::Kept)
Expand Down Expand Up @@ -301,6 +261,7 @@ fn get_forbidden_imports(layers: &[Layer], allow_deep_imports: bool) -> Vec<Forb
#[cfg(test)]
mod tests {
use super::*;
use crate::contracts::ContractViolation;
use crate::package_info::{PackageInfo, PackageToken};
use crate::testpackage;
use crate::testutils::TestPackage;
Expand Down
2 changes: 2 additions & 0 deletions src/contracts/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ use derive_new::new;
use getset::{CopyGetters, Getters};
use std::collections::HashSet;

pub mod independent;
pub mod layers;
mod utils;

/// An [`ImportsContract`] defines a set of verifiable conditions
/// related to imports that we wish to enforce.
Expand Down
59 changes: 59 additions & 0 deletions src/contracts/utils.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
use crate::contracts::{ContractViolation, ForbiddenImport};
use crate::imports_info::{ImportsInfo, InternalImportsPathQueryBuilder};
use crate::package_info::PackageItemToken;
use crate::prelude::*;
use anyhow::Result;
use rayon::prelude::*;
use std::collections::HashSet;
use tap::prelude::*;

pub(super) fn find_violations(
forbidden_imports: Vec<ForbiddenImport>,
imports_info: &ImportsInfo,
) -> Result<Vec<ContractViolation>> {
let violations = forbidden_imports
.into_par_iter()
.try_fold(
Vec::new,
|mut violations, forbidden_import| -> anyhow::Result<_> {
// A contract operates in "as packages" mode, meaning
// items are expanded to include their descendants.
let from = forbidden_import
.from
.conv::<HashSet<PackageItemToken>>()
.with_descendants(imports_info.package_info());
let to = forbidden_import
.to
.conv::<HashSet<PackageItemToken>>()
.with_descendants(imports_info.package_info());
let except_via = forbidden_import
.except_via()
.clone()
.with_descendants(imports_info.package_info());

let path = imports_info.internal_imports().find_path(
&InternalImportsPathQueryBuilder::default()
.from(from)
.to(to)
.excluding_paths_via(except_via)
.build()?,
)?;
if let Some(path) = path {
violations.push(ContractViolation::ForbiddenImport {
forbidden_import,
path,
})
};
Ok(violations)
},
)
.try_reduce(
Vec::new,
|mut all_violations, violations| -> anyhow::Result<_> {
all_violations.extend(violations);
Ok(all_violations)
},
)?;

Ok(violations)
}

0 comments on commit 9ab1532

Please sign in to comment.