PyTestArch

repository·main·Indexed 41 days ago

https://github.com/zyskarch/pytestarch

An open-source Python library inspired by ArchUnit for defining and testing software architecture rules. It enables automated enforcement of module boundaries and dependency constraints by analyzing imports via AST and NetworkX. Version 4.0.1 supports fluent API rules, layered architecture checks with LayeredArchitecture and LayerRule, and integration with pytest via session-scoped fixtures.

Tokens
13.5K
Snippets
22
Records
45
Agent score
51%

What's inside PyTestArch

  1. How layer architecture rules handle unassigned modules

    main

    Modules that are not explicitly assigned to a layer in your LayeredArchitecture definition are still part of the dependency graph.

    If a rule specifies that a layer should only access certain other layers, any dependency on a module that does not belong to a defined layer will cause the rule to fail.

    Example Scenario:

    • Layer A contains module M.
    • Layer B contains module N.
    • Module O is not assigned to any layer.
    • If module M imports N and O, a rule stating Layer A should only access Layer B will raise an AssertionError because M is accessing O (an unassigned module).
  2. Configure root_path and module_path correctly

    main

    When calling get_evaluable_architecture, it is critical to distinguish between root_path and module_path:

    • root_path: This should point to the highest-level module in your project hierarchy. This path serves as the base for determining fully qualified module names.
    • module_path: This is the specific directory where the dependency scan begins. It must be either identical to root_path or a submodule of it.

    Note on Module Naming: In all architecture rules, modules must be referred to by their fully qualified name relative to the root_path, not the module_path. This ensures consistent distinction between internal and external modules.

  3. How PyTestArch represents architecture

    main

    PyTestArch analyzes Python source files by creating an Abstract Syntax Tree (AST) for each file. It extracts module import information from the AST and converts it into an internal graph representation using the NetworkX library.

    When you define an architecture rule, PyTestArch converts that rule into a set of graph operations performed on this generated dependency graph. The graph includes:

    1. Parent-child relationships: A module is connected to all its submodules.
    2. Import relationships: A directed edge exists from a module to the module it imports (the arrow points at the imported module).

    By default, external dependencies (libraries not located within your project hierarchy) are filtered out of the graph.

  4. Generate architecture rules from PlantUML diagrams

    main

    Instead of manually defining rules, you can use a DiagramRule to automatically generate architectural constraints from a PlantUML component diagram.

    PyTestArch parses the diagram and converts connections into import rules:

    • Connections between nodes: Converted into should only or should import rules.
    • Absence of a connection: Converted into should not import rules.

    All generated rules are then evaluated against an Evaluable object.

    Note on relationships: Currently, parent-child relationships (submodules) are not supported. An edge between a module and its submodule is interpreted as an imports relationship rather than a structural hierarchy.

    # Example conceptual usage
    # (Note: Exact API syntax for DiagramRule depends on the core library implementation)
    rule = DiagramRule(path="path/to/diagram.puml")
    # The rule is then used to evaluate an Evaluable object
    results = rule.evaluate(my_evaluable)
  5. Use module aliases for cleaner graph labels

    main

    When module names are long or have many submodules, the graph labels can become crowded. Use the aliases dictionary in evaluable_architecture.visualize() to provide shorter labels.

    Alias Inheritance Rules:

    1. An alias for a parent module applies to all its submodules.
    2. If a submodule has its own specific alias, that alias takes priority over the parent's alias.

    Example Behavior: Given the modules: long_root_name, long_root_name.submodule, long_root_name.submodule.sub_submodule, and long_root_name.other_submodule.

    aliases inputResulting Labels
    {'long_root_name': 'r'}r, r.submodule, r.submodule.sub_submodule, r.other_submodule
    {'long_root_name': 'r', 'long_root_name.submodule': 'sub'}r, sub, sub.sub_submodule, r.other_submodule
  6. Understand the PyTestArch rule query language structure

    main

    PyTestArch uses a fluent API to define architectural rules for module dependencies. A rule follows a specific structural pattern that mimics English sentences:

    RULE_SUBJECT - VERB_MARKER_1 - IMPORT_TYPE - VERB_MARKER_2 - RULE_OBJECT

    Structural Components

    • RULE_SUBJECT: The module(s) being checked.
    • VERB_MARKER_1: Defines the expected behavior (e.g., should, should_only, should_not).
    • IMPORT_TYPE: The type of relationship being checked (e.g., import_modules_that, be_imported_by_modules_that).
    • VERB_MARKER_2: An optional exception marker (e.g., except).
    • RULE_OBJECT: The module(s) to check against or the target of the relationship.

    By combining these markers, you can express complex constraints like: "Modules named 'B' should not be imported by modules except submodules of 'A'."

  7. Structure of the PyTestArch Query Language

    main

    The PyTestArch Query Language uses a specific sentence structure to define architectural rules. A query follows this pattern:

    SUBJECT VERB_MARKER_1 IMPORT_TYPE VERB_MARKER_2 OBJECT

    Components

    VERB_MARKER_1 (The primary intent):

    • should
    • should only
    • should not

    IMPORT_TYPE (The direction of the dependency):

    • import_from (M1 imports M2)
    • be_imported_from (M2 imports M1)

    VERB_MARKER_2 (The exception clause):

    • except

    SUBJECT/OBJECT (The module identifiers):

    • sub_module_of: Targets a submodule of the specified module.
    • name: A complete match for the module name.
    • partial_name: A partial match for the module name.
    • anything: Used only as an OBJECT to represent any module (subject to specific semantic constraints).
  8. Understand Layer Rule semantics and behaviors

    main

    PyTestArch provides several semantic modes for defining how layers interact. Understanding these is critical for writing correct architectural tests:

    Import/Access Rules (Source $\rightarrow$ Target)

    • Layer 1 should import Layer 2: At least one module from Layer 1 imports at least one module from Layer 2.
    • Layer 1 should not import Layer 2: No module from Layer 1 imports any module from Layer 2.
    • Layer 1 should only import Layer 2: At least one module from Layer 1 imports something from Layer 2, AND no module from Layer 1 imports anything that is NOT in Layer 2.
    • Layer 1 should import except Layer 2: At least one module from Layer 1 imports something that is not in Layer 2.
    • Layer 1 should not import except Layer 2: No module from Layer 1 imports anything that is not in Layer 2.
    • Layer 1 should only import except Layer 2: At least one module from Layer 1 imports something not in Layer 2, AND no module from Layer 1 imports any module from Layer 2.

    Being Imported/Accessed Rules (Target $\leftarrow$ Source)

    • Layer 1 should be imported by Layer 2: At least one module from Layer 2 imports at least one module from Layer 1.
    • Layer 1 should not be imported by Layer 2: No module from Layer 2 imports any module from Layer 1.
    • Layer 1 should only be imported by Layer 2: At least one module from Layer 2 imports something from Layer 1, AND no module imports anything from Layer 1 that is not in Layer 2.
    • Layer 1 should be imported except by Layer 2: At least one module imports something from Layer 1 that is not in Layer 2.
    • Layer 1 should not be imported except by Layer 2: No module imports anything from Layer 1 that is not in Layer 2.
    • Layer 1 should only be imported except by Layer 2: At least one module imports something from Layer 1 that is not in Layer 2, AND no module from Layer 2 imports any module from Layer 1.
  9. Semantics of PyTestArch Queries

    main

    The semantics of the query language are defined by how they treat edges in a dependency graph. An edge from M1 to M2 represents M1 importing M2.

    Import Semantics (M1 imports M2)

    QuerySemantic Meaning
    M1 should import M2An edge exists from M1 to M2
    M1 should only import M2No edges exist from M1 to any non-M2 module
    M1 should not import M2No edge exists from M1 to M2
    M1 should import except M2Any edge exists from M1 to a non-M2 module
    M1 should only import except M2Any edge exists from M1 to a non-M2 module, AND no edge exists from M1 to M2
    M1 should not import except M2No edges exist from M1 to any non-M2 module

    Import-From Semantics (M2 imports M1)

    QuerySemantic Meaning
    M1 should be_imported_from M2An edge exists from M2 to M1
    M1 should only be_imported_from M2No edges exist from any non-M2 module to M1
    M1 should not be_imported_from M2No edge exists from M2 to M1
    M1 should be_imported_from except M2Any edge exists from a non-M2 module to M1
    M1 should only be_imported_from except M2Any edge exists from a non-M2 module to M1, AND no edge exists from M2 to M1
    M1 should not be_imported_from except M2No edges exist from any non-M2 module to M1

    Special Cases

    • M1 should not import anything $\rightarrow$ No edges exist from M1 to any module (effectively: M1 should not import anything except itself).
    • M1 should not be_imported by anything $\rightarrow$ No edges exist from any module to M1 (effectively: M1 should not be imported by anything except itself).
  10. How to test architectural rules with PyTestArch

    main

    Testing an architectural rule involves three distinct steps:

    1. Create an Evaluable Architecture: Use get_evaluable_architecture to scan your source code. This builds an internal representation of imports. The first argument is the project root (used to distinguish internal vs. external dependencies), and the second is the source directory.
    2. Define a Rule: Use the Rule class with a fluent API to specify requirements (e.g., which modules should or should not import others).
    3. Evaluate: Call rule.assert_applies(evaluable) to check if the code adheres to the rule.

    If using pytest, it is recommended to provide the evaluable object via a fixture to avoid re-scanning the filesystem for every test.

    from pytestarch import get_evaluable_architecture, Rule
    
    # 1. Create representation
    evaluable = get_evaluable_architecture("/home/dummy/project", "/home/dummy/project/src")
    
    # 2. Define rule
    rule = (
        Rule() 
        .modules_that() 
        .are_named("project.src.moduleB") 
        .should_not() 
        .be_imported_by_modules_that() 
        .are_sub_modules_of("project.src.moduleA") 
    )
    
    # 3. Evaluate
    rule.assert_applies(evaluable)
  11. Pull Request requirements for PyTestArch

    main

    Before submitting a Pull Request (PR) to PyTestArch, ensure the following criteria are met:

    • Tests: All existing tests must pass (run nox locally). New features or bugfixes must include new tests.
    • Formatting: Code must be properly formatted. Use the installed pre-commit hooks to verify this.
    • Documentation:
      • Update the changelog.
      • Update existing docstrings and documentation.
      • Add docstrings for all new public functions and methods.
      • Add docstrings for complex "private" functions and methods.
      • Provide high-level documentation for any new features.
  12. Install PyTestArch

    main

    Install the core library via pip:

    pip install pytestarch

    If you need to generate dependency graphs, install the optional matplotlib dependency using the [visualization] extra:

    pip install "pytestarch[visualization]"