Skip to content

Commit

Permalink
Merge branch 'main' into speed-improvements
Browse files Browse the repository at this point in the history
  • Loading branch information
emdoyle authored Feb 13, 2024
2 parents 32da4db + 9a844ce commit 3cc0a81
Show file tree
Hide file tree
Showing 15 changed files with 492 additions and 31 deletions.
7 changes: 4 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,19 @@ jobs:
python-version: ["3.9", "3.10", "3.11", "3.12"]

steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v3
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install ruff pytest pytest-mock coverage pyright
python -m pip install ruff pytest pytest-mock coverage pyright PyYAML
- name: Check ruff
run: |
ruff check .
ruff format --check .
- name: Test with pytest and report coverage
run: |
cd tests
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v3
uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Install dependencies
Expand Down
27 changes: 27 additions & 0 deletions .github/workflows/publish_docs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: publish_docs
on:
push:
branches: [ "main" ]
permissions:
contents: write
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure Git Credentials
run: |
git config user.name github-actions[bot]
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
- uses: actions/setup-python@v5
with:
python-version: 3.x
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
- uses: actions/cache@v4
with:
key: mkdocs-material-${{ env.cache_id }}
path: .cache
restore-keys: |
mkdocs-material-
- run: pip install mkdocs-material
- run: mkdocs gh-deploy --force
30 changes: 17 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
[![Checked with pyright](https://microsoft.github.io/pyright/img/pyright_badge.svg)](https://microsoft.github.io/pyright/)
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
# modguard
A Python tool to support and enforce a modular package architecture within a monorepo.
A Python tool to support and enforce a modular, decoupled package architecture.

[Full Documentation](https://never-over.github.io/modguard/)

### What is modguard?
Modguard enables you to explicitly define the public interface for a given module. Marking a package with a `Boundary` makes all of its internals private by default, exposing only the members marked with the `@public` decorator.

This enforces an architecture of decoupled and well defined modules, and ensures the communication between domains is only done through their expected public interfaces.
This enforces an architecture of decoupled and well-defined modules, and ensures the communication between domains is only done through their expected public interfaces.

Modguard is incredibly lightweight, and has no impact on the runtime of your code. Instead, its checks are performed through a static analysis CLI tool.

Expand All @@ -25,10 +27,10 @@ Add a `Boundary` to the `__init__.py` of the module you're creating an interface
# project/core/__init__.py
import modguard

modguard.Boundary(__name__)
modguard.Boundary()
```

Add the `public` decorator to any callable in the module that should be exported.
Add the `public` decorator to any callable in the module that should be exported. You can also export individual members by passing them to `public` as function call arguments.
```python
# project/core/main.py
import modguard
Expand All @@ -41,23 +43,26 @@ def public_function(user_id: int) -> str:
# This function will be considered private
def private_function():
...

PUBLIC_CONSTANT = "Hello, world"
# This exports PUBLIC_CONSTANT from this module
public(PUBLIC_CONSTANT)
```
Modguard will now flag any incorrect dependencies between modules.
```bash
> # From the root of your python project (in this example, `project/`)
# From the root of your python project (in this example, `project/`)
> modguard .
❌ ./utils/helpers.py: Import 'core.main.private_function' in ./utils/helpers.py is blocked by boundary 'core.main'
```

### Setup
Modguard also comes bundled with a command to set up and define your initial boundaries.
```python3
```bash
modguard init .
```
By running `modguard init` from the root of your python project, modguard will inspect and declare boundaries on each python package within your project. Additionally, each accessed member of that package will be decorated with `public`.
By running `modguard init` from the root of your python project, modguard will inspect and declare boundaries on each python package within your project. Additionally, each accessed member of that package will be decorated with `public`.


This will automatically create boundaries and define your public interface for each package within your project, and instantly get your project to passing `modguard .`
This will automatically create boundaries and define your public interface for each package within your project, and instantly get your project to a passing state for `modguard .`


### Advanced Usage
Expand All @@ -67,7 +72,7 @@ Modguard also supports specific allow lists within the `public()` decorator.
def public_function(user_id: int) -> str:
...
```
This will allow for `public_function` to be imported and used in `utils.helpers`, but restrict it's usage elsewhere.
This will allow for `public_function` to be imported and used in `utils.helpers`, but restrict its usage elsewhere.

Alternatively, you can mark an import with the `modguard-ignore` comment:
```python
Expand All @@ -76,13 +81,12 @@ from core.main import private_function
```
This will stop modguard from flagging this import as a boundary violation.


Given that python allows for dynamic importing at runtime, modguard will fail if a whole module is imported without being declared public.
```python
from core import main # contains public and private members
```
```shell
> # From the root of your project
```bash
# From the root of your project
> modguard .
❌ ./utils/helpers.py: Import 'core.main' in ./utils/helpers.py is blocked by boundary 'core.main'
```
Expand Down
121 changes: 121 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# API

## `modguard.Boundary`
A `Boundary` makes all internal members private by default.

`Boundary` accepts no arguments, and has no runtime behavior. It is detected statically by `modguard`.
```python
# project/core/__init__.py
from modguard import Boundary

Boundary()
```
### In `__init__.py`
When a `Boundary` appears in `__init__.py`, this marks the contents of the entire package as private by default.
```python
# project/core/inner.py
# This function will be considered private
# due to the boundary at 'project.core'
def private_function():
...
```

### In Modules
When a `Boundary` appears in a standalone Python file, this marks the contents of the file itself as private by default.
```python
# project/core/other.py
from modguard import Boundary

Boundary()

# This function will be considered private
# due to the boundary at 'project.core.other'
def other_private_function():
...
```

## `modguard.public`
Marking a member as `public` allows it to be imported outside its boundary. This should be used to mark the intended public interface of a package or module.

`public` can be used as either a decorator or a bare function, and has no runtime behavior. It is detected statically by `modguard`.

### Parameters
`public` accepts one optional positional argument (`path`) and one optional keyword argument (`allowlist`).

When present, `path` identifies the object being marked as public.
```python
from modguard import public

x: int = 3

public("x")
public(x)
```

When present, `allowlist` defines a list of module paths which are allowed to import the object. Modules which are descendants of the modules in the `allowlist` are also allowed. If any other modules import the object, they will be flagged as errors by `modguard`.
```python
# In project/utils.py
from modguard import public

x: int = 3

public(x, allowlist=["project.core.domain"])

...
# In project/core/other_domain/logic.py
# This import is not allowed,
# because the module ('project.core.other_domain.logic')
# is not contained by any module in the allowlist
from project.utils import x
```

### As a Decorator
`public` can also be used as a decorator to mark functions and classes as public. Its behavior is the same as when used as a function, and it accepts the same keyword arguments (the decorated object is treated as `path`)

```python
from modguard import public

@public(allowlist=["project.core.domain"])
def my_pub_function():
...
```

### Entire Module
When `public` is used without a `path` argument, it signifies that the entire containing module is public. This means that any top-level member of the module or the module itself can be imported externally (subject to `allowlist`).
```python
# In project/core/logic.py
from modguard import public

public()
...
# In project/cli.py
# This import is allowed because 'project.core.logic' is public
from project.core import logic
```

### In `__init__.py`
When `public` is used without a `path` argument in the `__init__.py` of a package, the top-level module of the package is treated as public.
```python
# In project/core/__init__.py
from modguard import Boundary, public

Boundary()
public()
...
# In project/cli.py
# This import is allowed because 'project.core' is public
from project import core
```

## `modguard-ignore`
To ignore a particular import which should be allowed unconditionally, use the `modguard-ignore` comment directive.
```python
# modguard-ignore
from core.main import private_function
```
The directive can also be specific about the import to ignore, which is particularly useful when importing multiple modules.
```python
# modguard-ignore private_function
from core.main import private_function, public_function
```
Note: Names given to `modguard-ignore` should match the alias as it is used in the subsequent import line, not the full module path from the project root.
17 changes: 17 additions & 0 deletions docs/faq.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# FAQ

### How does it work?
Modguard works by analyzing the abstract syntax tree (AST) of your codebase. The `Boundary` class and `@public` decorator have no runtime impact, and are detected by modguard statically. Boundary violations are detected at import time.
### Why does `modguard` live in my application code?
Modguard is written as a Python library for a few main reasons:

- **Visibility**: When boundary information is co-located with application code, it is visible to a code reviewer or future maintainer.
- **Maintenance**: When packages or public members are moved, renamed, or removed, in-line `modguard` will automatically match the new state (since it will move along with the code, or be removed along with the code).
- **Extensibility**: Having `modguard` in-line will support future dynamic configuration or runtime violation monitoring.
### What is a boundary?
A **boundary** can be thought of as defining a logical module within your project. A project composed of decoupled logical modules with explicit public interfaces is easier to test and maintain.
### Are conditional imports checked?
At the moment, `modguard` will check all imports in your source files, including those which are called conditionally. This is an intentional design decision, but may be made configurable in the future.

### Can you catch dynamic references?
Since `modguard` uses the AST to find imports and public members, dynamic imports (e.g. using a string path) and dynamic names (e.g. using `setattr`, `locals`, `globals`) are generally not supported. If these usages cause `modguard` to report incorrect errors, the [ignore directive](api.md#modguard-ignore) should be sufficient to reach a passing state.
33 changes: 33 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Getting Started

## Installation

[PyPi package](https://pypi.org/project/modguard/)


Install modguard into a Python environment with `pip`

```bash
pip install modguard
```

Verify your installation is working correctly
```bash
modguard --help
```

## Guarding a Project

If you are adding `modguard` to an existing project, you have two main options:

1. Use [`modguard init`](usage.md#modguard-init) to automatically set up package boundaries and identify necessary public members
2. Manually mark boundaries and public members in your code ([See API docs](api.md))

## Checking Boundaries

```bash
# From the root of your Python project
modguard .
```

After guarding your project, running `modguard` from the root will check all imports to verify that packages remain correctly decoupled.
14 changes: 14 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Overview

## What is modguard?
Modguard enables you to explicitly define the public interface for a given module. Marking a package with a `Boundary` makes all of its internals private by default, exposing only the members marked with the `@public` decorator.

This enforces an architecture of decoupled and well defined modules, and ensures the communication between domains is only done through their expected public interfaces.

Modguard is incredibly lightweight, and has no impact on the runtime of your code. Instead, its checks are performed through a static analysis CLI tool.

## Commands

* [`modguard [dir-name]`](usage.md#modguard) - Check boundaries are respected throughout a directory.
* [`modguard init [dir-name]`](usage.md#modguard-init) - Initialize package boundaries in a directory.
* `modguard show [dir-name]` - Generate a YAML representation of the boundaries in a directory.
39 changes: 39 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Usage

## modguard
Modguard will flag any unwanted imports between modules. It is recommended to run `modguard` in a similar way as a linter or test runner, e.g. in pre-commit hooks, on-save hooks, and in CI pipelines.

```
usage: modguard [-h] [-e file_or_path,...] path
positional arguments:
path The path of the root of your project that contains all defined boundaries.
options:
-h, --help show this help message and exit
-e file_or_path,..., --exclude file_or_path,...
Comma separated path list to exclude. tests/,ci/,etc.
Make sure modguard is run from the root of your repo that a directory is being specified. For example: `modguard .`
```


## modguard init
Modguard comes bundled with a command to set up and define your initial boundaries.

By running `modguard init` from the root of your python project, modguard will declare boundaries on each python package within your project. Additionally, each member of that package which is imported from outside the boundary will be marked `public`.

This will automatically lock-in the public interface for each package within your project, and instantly reach a passing state when running `modguard`
```
usage: modguard init [-h] [-e file_or_path,...] path
Initialize boundaries in a repository with modguard
positional arguments:
path The path of the Python project in which boundaries should be initialized.
options:
-h, --help show this help message and exit
-e file_or_path,..., --exclude file_or_path,...
Comma separated path list to exclude. tests/,ci/,etc.
```
1 change: 1 addition & 0 deletions docs/why-modguard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Why modguard?
Loading

0 comments on commit 3cc0a81

Please sign in to comment.