Skip to content

Commit

Permalink
Merge pull request #26 from Never-Over/add-regex-support
Browse files Browse the repository at this point in the history
add regex support
  • Loading branch information
emdoyle authored Feb 23, 2024
2 parents f1d2e2c + 5b6cd3d commit 873b655
Show file tree
Hide file tree
Showing 6 changed files with 70 additions and 21 deletions.
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ Modguard will now flag any incorrect dependencies between modules.
```bash
# From the root of your python project (in this example, `project/`)
> modguard check .
❌ ./utils/helpers.py: Import 'core.main.private_function' in ./utils/helpers.py is blocked by boundary 'core.main'
❌ ./utils/helpers.py: Import "core.main.private_function" in ./utils/helpers.py is blocked by boundary "core.main"
```
You can also view your entire project's set of dependencies and public interfaces. Boundaries will be marked with a `[B]`, and public members will be marked with a `[P]`. Note that a module can be both public and a boundary.
```bash
Expand All @@ -77,17 +77,17 @@ This will automatically create boundaries and define your public interface for e


### Advanced
Modguard also supports specific allow lists within `public`.
Modguard also supports specific allow lists within `public`. The `allowlist` parameter accepts a list of strings and regex expressions.
```python
@modguard.public(allowlist=['utils.helpers'])
@modguard.public(allowlist=["utils.helpers", r"core\.project\.*"])
def public_function(user_id: int) -> str:
...

PUBLIC_CONSTANT = "Hello world"
public(PUBLIC_CONSTANT, allowlist=['utils.helpers'])
public(PUBLIC_CONSTANT, allowlist=["utils.helpers", r"core\.project\.*"])

```
This will allow for `public_function` and `PUBLIC_CONSTANT` to be imported and used in `utils.helpers`, but restrict its usage elsewhere.
This will allow for `public_function` and `PUBLIC_CONSTANT` to be imported and used in `utils.helpers` and any matching regex to `core\.project\.*`, but restrict its usage elsewhere.

Alternatively, you can mark an import with the `modguard-ignore` comment:
```python
Expand All @@ -103,7 +103,7 @@ from core import main # contains public and private members
```bash
# From the root of your project
> modguard .
❌ ./utils/helpers.py: Import 'core.main' in ./utils/helpers.py is blocked by boundary 'core.main'
❌ ./utils/helpers.py: Import "core.main" in ./utils/helpers.py is blocked by boundary "core.main"
```

If you expect to be able to import the entire contents of your module, you can declare an entire module as public to avoid this:
Expand Down
18 changes: 10 additions & 8 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ When a `Boundary` appears in `__init__.py`, this marks the contents of the entir
```python
# project/core/inner.py
# This function will be considered private
# due to the boundary at 'project.core'
# due to the boundary at "project.core"
def private_function():
...
```
Expand All @@ -29,7 +29,7 @@ from modguard import Boundary
Boundary()

# This function will be considered private
# due to the boundary at 'project.core.other'
# due to the boundary at "project.core.other"
def other_private_function():
...
```
Expand All @@ -53,23 +53,24 @@ modguard.public("x")
modguard.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`.
When present, `allowlist` defines a list of module paths or regex strings which are allowed to import the object. Modules which are descendants of the modules in the `allowlist` are also allowed. Modules which additionally match the regex string are also allowed. If any other modules import the object, they will be flagged as errors by `modguard`.
```python
# In project/utils.py
import modguard

x: int = 3

modguard.public(x, allowlist=["project.core.domain"])
modguard.public(x, allowlist=["project.core.domain", r"core\.project\.*"])

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


### Module-level
When `public` is used without a `path` argument (the most common case), it signifies that the containing module and its descendants are public. This means that any descendant of the module or the module itself can be imported externally (subject to `allowlist`). Note that adding a `Boundary` in a descendant module will prevent that module from being treated as public by default.
```python
Expand All @@ -80,7 +81,7 @@ modguard.public()
...

# In project/cli.py
# This import is allowed because 'project.core.logic' is public
# This import is allowed because "project.core.logic" is public
from project.core import logic
```
```python
Expand All @@ -96,9 +97,10 @@ modguard.Boundary()
...

# In project/cli.py
# This import is allowed because 'project.core' is public

# This import is allowed because "project.core" is public
from project.core import logic
# This import is NOT allowed because 'project.core.utils' has
# This import is NOT allowed because "project.core.utils" has
# a Boundary and is not marked public
from project.core import utils
```
Expand Down
20 changes: 15 additions & 5 deletions modguard/check.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
import re
from dataclasses import dataclass
from typing import Optional

Expand All @@ -24,6 +25,18 @@ def message(self) -> str:
return f"Import '{self.import_mod_path}' in {self.location} is blocked by boundary '{self.boundary_path}'"


def check_allowlist(allowlist: list[str], file_mod_path: str) -> bool:
for allowed_path in allowlist:
if file_mod_path.startswith(allowed_path):
return True
try:
if re.match(allowed_path, file_mod_path):
return True
except re.error:
pass
return False


def check_import(
boundary_trie: BoundaryTrie,
import_mod_path: str,
Expand Down Expand Up @@ -58,11 +71,8 @@ def check_import(
import_mod_public_member_definition is not None
and (
import_mod_public_member_definition.allowlist is None
or any(
(
file_mod_path.startswith(allowed_path)
for allowed_path in import_mod_public_member_definition.allowlist
)
or check_allowlist(
import_mod_public_member_definition.allowlist, file_mod_path
)
)
)
Expand Down
5 changes: 5 additions & 0 deletions tests/example/domain_one/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ def domain_one_interface():
...


@public(allowlist=[r".*domain_three.*"])
def domain_one_regex_interface():
...


domain_one_var = "hello domain two"

public(domain_one_var, allowlist=["example.domain_two"])
7 changes: 6 additions & 1 deletion tests/example/domain_three/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import modguard
from ..domain_one.interface import domain_one_interface, domain_one_var
from ..domain_one.interface import (
domain_one_interface,
domain_one_var,
domain_one_regex_interface,
)

modguard.Boundary()


# Usages
domain_one_interface()
domain_one_regex_interface()
local_var = domain_one_var
29 changes: 28 additions & 1 deletion tests/test_check.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import pytest
from modguard.check import check, ErrorInfo, check_import
from modguard.check import check, ErrorInfo, check_allowlist, check_import
from modguard.core import BoundaryTrie, PublicMember


Expand Down Expand Up @@ -119,3 +119,30 @@ def test_check_example_dir_end_to_end():
assert len(check_results) == 0, "\n".join(
(result.message for result in check_results)
)


@pytest.mark.parametrize(
"allowlist, file_mod_path, expected",
[
# Test cases where the allowlist matches the file_mod_path
(["/usr", "/var"], "/usr/bin/python", True),
(["/usr", "/var"], "/var/log/syslog", True),
(["/usr", "/var"], "/bin/bash", False), # Not in allowlist
# Test cases where regex patterns are used in allowlist
(["/usr.*", "/var"], "/usr/local/bin/python", True),
(["/usr.*", "/var"], "/var/www/index.html", True),
(["/usr.*", "/var"], "/home/user/file.txt", False), # Not in allowlist
# Test cases with invalid regex patterns
(["[a-z", "/var"], "/usr/bin/python", False), # Invalid regex pattern
([r"/usr\d+", "/var"], "/usr123/file.txt", True),
(["/usr", "/var"], "/var/log/syslog", True),
],
)
def test_check_allowlist(allowlist, file_mod_path, expected):
assert check_allowlist(allowlist, file_mod_path) == expected


def test_empty_allowlist():
assert not check_allowlist(
[], "/usr/bin/python"
) # Empty allowlist should always return False

0 comments on commit 873b655

Please sign in to comment.