pytest-fsd 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pytest_fsd/__init__.py +84 -0
- pytest_fsd/_lib/__init__.py +1 -0
- pytest_fsd/_lib/ast_utils.py +54 -0
- pytest_fsd/_lib/fs_utils.py +31 -0
- pytest_fsd/_lib/violations.py +22 -0
- pytest_fsd/config.py +62 -0
- pytest_fsd/py.typed +0 -0
- pytest_fsd/rules/__init__.py +1 -0
- pytest_fsd/rules/ambiguous_slice_names/README.md +29 -0
- pytest_fsd/rules/ambiguous_slice_names/__init__.py +70 -0
- pytest_fsd/rules/excessive_slicing/README.md +41 -0
- pytest_fsd/rules/excessive_slicing/__init__.py +47 -0
- pytest_fsd/rules/forbidden_imports/README.md +37 -0
- pytest_fsd/rules/forbidden_imports/__init__.py +61 -0
- pytest_fsd/rules/import_locality/README.md +44 -0
- pytest_fsd/rules/inconsistent_naming/README.md +34 -0
- pytest_fsd/rules/insignificant_slice/README.md +26 -0
- pytest_fsd/rules/no_cross_imports/README.md +30 -0
- pytest_fsd/rules/no_cross_imports/__init__.py +57 -0
- pytest_fsd/rules/no_file_segments/README.md +39 -0
- pytest_fsd/rules/no_file_segments/__init__.py +72 -0
- pytest_fsd/rules/no_layer_public_api/README.md +30 -0
- pytest_fsd/rules/no_layer_public_api/__init__.py +41 -0
- pytest_fsd/rules/no_processes/README.md +24 -0
- pytest_fsd/rules/no_public_api_sidestep/README.md +31 -0
- pytest_fsd/rules/no_public_api_sidestep/__init__.py +154 -0
- pytest_fsd/rules/no_reserved_folder_names/README.md +37 -0
- pytest_fsd/rules/no_reserved_folder_names/__init__.py +71 -0
- pytest_fsd/rules/no_segmentless_slices/README.md +32 -0
- pytest_fsd/rules/no_segmentless_slices/__init__.py +62 -0
- pytest_fsd/rules/no_segments_on_sliced_layers/README.md +29 -0
- pytest_fsd/rules/no_segments_on_sliced_layers/__init__.py +46 -0
- pytest_fsd/rules/no_ui_in_app/README.md +38 -0
- pytest_fsd/rules/no_ui_in_app/__init__.py +87 -0
- pytest_fsd/rules/public_api/README.md +34 -0
- pytest_fsd/rules/public_api/__init__.py +65 -0
- pytest_fsd/rules/repetitive_naming/README.md +31 -0
- pytest_fsd/rules/repetitive_naming/__init__.py +57 -0
- pytest_fsd/rules/segments_by_purpose/README.md +38 -0
- pytest_fsd/rules/segments_by_purpose/__init__.py +112 -0
- pytest_fsd/rules/shared_lib_grouping/README.md +46 -0
- pytest_fsd/rules/shared_lib_grouping/__init__.py +47 -0
- pytest_fsd/rules/typo_in_layer_name/README.md +29 -0
- pytest_fsd/rules/unknown_folders_in_root/README.md +35 -0
- pytest_fsd/rules/unknown_folders_in_root/__init__.py +50 -0
- pytest_fsd-0.2.0.dist-info/METADATA +171 -0
- pytest_fsd-0.2.0.dist-info/RECORD +48 -0
- pytest_fsd-0.2.0.dist-info/WHEEL +4 -0
pytest_fsd/__init__.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# src/pytest_fsd/__init__.py
|
|
2
|
+
"""
|
|
3
|
+
pytest-fsd: FSD Architecture Validation for Python Projects.
|
|
4
|
+
|
|
5
|
+
Provides validate_fsd_architecture() — a single facade function
|
|
6
|
+
that runs all FSD rules configured in pyproject.toml.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import List
|
|
10
|
+
|
|
11
|
+
from ._lib.violations import Violation
|
|
12
|
+
from .config import load_config
|
|
13
|
+
from .rules import (
|
|
14
|
+
ambiguous_slice_names,
|
|
15
|
+
forbidden_imports,
|
|
16
|
+
no_cross_imports,
|
|
17
|
+
no_layer_public_api,
|
|
18
|
+
no_public_api_sidestep,
|
|
19
|
+
no_segmentless_slices,
|
|
20
|
+
no_segments_on_sliced_layers,
|
|
21
|
+
no_ui_in_app,
|
|
22
|
+
public_api,
|
|
23
|
+
repetitive_naming,
|
|
24
|
+
segments_by_purpose,
|
|
25
|
+
unknown_folders_in_root,
|
|
26
|
+
)
|
|
27
|
+
from .rules import (
|
|
28
|
+
excessive_slicing,
|
|
29
|
+
no_file_segments,
|
|
30
|
+
no_reserved_folder_names,
|
|
31
|
+
shared_lib_grouping,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# Базовые правила — всегда активны
|
|
35
|
+
CORE_RULES = [
|
|
36
|
+
forbidden_imports,
|
|
37
|
+
no_cross_imports,
|
|
38
|
+
no_public_api_sidestep,
|
|
39
|
+
no_layer_public_api,
|
|
40
|
+
no_ui_in_app,
|
|
41
|
+
repetitive_naming,
|
|
42
|
+
no_segmentless_slices,
|
|
43
|
+
segments_by_purpose,
|
|
44
|
+
ambiguous_slice_names,
|
|
45
|
+
no_segments_on_sliced_layers,
|
|
46
|
+
public_api,
|
|
47
|
+
unknown_folders_in_root,
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
# Дополнительные правила — включаются через extra_rules в pyproject.toml
|
|
51
|
+
EXTRA_RULES = {
|
|
52
|
+
"excessive-slicing": excessive_slicing,
|
|
53
|
+
"shared-lib-grouping": shared_lib_grouping,
|
|
54
|
+
"no-file-segments": no_file_segments,
|
|
55
|
+
"no-reserved-folder-names": no_reserved_folder_names,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def validate_fsd_architecture(project_root: str = ".") -> None:
|
|
60
|
+
"""
|
|
61
|
+
Validate the project architecture against FSD rules.
|
|
62
|
+
|
|
63
|
+
Reads configuration from pyproject.toml -> [tool.pytest_fsd].
|
|
64
|
+
Runs all core rules + any extra_rules specified in config.
|
|
65
|
+
Raises AssertionError if any violations are found.
|
|
66
|
+
"""
|
|
67
|
+
config = load_config(project_root)
|
|
68
|
+
all_violations: List[Violation] = []
|
|
69
|
+
|
|
70
|
+
# Запускаем базовые правила
|
|
71
|
+
for rule_module in CORE_RULES:
|
|
72
|
+
all_violations.extend(rule_module.check(config, project_root))
|
|
73
|
+
|
|
74
|
+
# Запускаем дополнительные правила, если они включены в конфиге
|
|
75
|
+
for extra_rule_name in config.extra_rules:
|
|
76
|
+
rule_module = EXTRA_RULES.get(extra_rule_name)
|
|
77
|
+
if rule_module is not None:
|
|
78
|
+
all_violations.extend(rule_module.check(config, project_root))
|
|
79
|
+
|
|
80
|
+
if all_violations:
|
|
81
|
+
error_msg = "FSD Architecture Validation failed:\n"
|
|
82
|
+
for v in all_violations:
|
|
83
|
+
error_msg += v.format() + "\n"
|
|
84
|
+
raise AssertionError(error_msg)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Shared utilities for pytest-fsd rules."""
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# src/pytest_fsd/_lib/ast_utils.py
|
|
2
|
+
"""Shared AST parsing utilities used by multiple rules."""
|
|
3
|
+
|
|
4
|
+
import ast
|
|
5
|
+
from typing import List, Tuple, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_imports_from_file(
|
|
9
|
+
filepath: str,
|
|
10
|
+
) -> List[Tuple[int, Optional[str], int, List[str]]]:
|
|
11
|
+
"""Return list of (line_number, full_module_path, level, imported_names).
|
|
12
|
+
|
|
13
|
+
Level represents relative imports:
|
|
14
|
+
0 = absolute import (e.g. `import foo` or `from foo import bar`)
|
|
15
|
+
1 = current directory (`from . import bar` -> module=None, level=1)
|
|
16
|
+
2 = parent directory (`from ..foo import bar` -> module='foo', level=2)
|
|
17
|
+
"""
|
|
18
|
+
imports = []
|
|
19
|
+
try:
|
|
20
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
21
|
+
tree = ast.parse(f.read(), filename=filepath)
|
|
22
|
+
except Exception:
|
|
23
|
+
return []
|
|
24
|
+
|
|
25
|
+
for node in ast.walk(tree):
|
|
26
|
+
if isinstance(node, ast.Import):
|
|
27
|
+
for alias in node.names:
|
|
28
|
+
imports.append((node.lineno, alias.name, 0, [alias.name]))
|
|
29
|
+
elif isinstance(node, ast.ImportFrom):
|
|
30
|
+
names = [alias.name for alias in node.names]
|
|
31
|
+
imports.append((node.lineno, node.module, node.level, names))
|
|
32
|
+
|
|
33
|
+
return imports
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_exported_names(filepath: str) -> Optional[List[str]]:
|
|
37
|
+
"""Return the list of names exported in __all__ if defined, or None if not defined."""
|
|
38
|
+
try:
|
|
39
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
40
|
+
tree = ast.parse(f.read(), filename=filepath)
|
|
41
|
+
except Exception:
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
for node in tree.body:
|
|
45
|
+
if isinstance(node, ast.Assign):
|
|
46
|
+
for target in node.targets:
|
|
47
|
+
if isinstance(target, ast.Name) and target.id == "__all__":
|
|
48
|
+
if isinstance(node.value, (ast.List, ast.Tuple)):
|
|
49
|
+
return [
|
|
50
|
+
elt.value
|
|
51
|
+
for elt in node.value.elts
|
|
52
|
+
if getattr(elt, "value", None) is not None
|
|
53
|
+
]
|
|
54
|
+
return None
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# src/pytest_fsd/_lib/fs_utils.py
|
|
2
|
+
"""Shared filesystem utilities used by multiple rules."""
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from typing import List
|
|
6
|
+
|
|
7
|
+
from ..config import FsdConfig
|
|
8
|
+
|
|
9
|
+
# Стандартные сегменты FSD для Python-проектов
|
|
10
|
+
STANDARD_SEGMENTS = ("ui", "model", "api", "lib", "config")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_sliced_layers(config: FsdConfig) -> List[str]:
|
|
14
|
+
"""Return layers that contain slices (excluding app and shared)."""
|
|
15
|
+
return [
|
|
16
|
+
layer_name
|
|
17
|
+
for layer_name in config.layers
|
|
18
|
+
if layer_name not in ("app", "shared")
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_slices(layer: str, base_path: str) -> List[str]:
|
|
23
|
+
"""Return list of slice directory names inside a given layer."""
|
|
24
|
+
path = os.path.join(base_path, layer)
|
|
25
|
+
if not os.path.isdir(path):
|
|
26
|
+
return []
|
|
27
|
+
return [
|
|
28
|
+
d
|
|
29
|
+
for d in os.listdir(path)
|
|
30
|
+
if os.path.isdir(os.path.join(path, d)) and not d.startswith("_")
|
|
31
|
+
]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# src/pytest_fsd/_lib/violations.py
|
|
2
|
+
"""Unified violation dataclass for all FSD rules."""
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class Violation:
|
|
10
|
+
"""Represents a single FSD architecture violation."""
|
|
11
|
+
|
|
12
|
+
rule: str
|
|
13
|
+
file_path: str
|
|
14
|
+
message: str
|
|
15
|
+
line_number: Optional[int] = None
|
|
16
|
+
|
|
17
|
+
def format(self) -> str:
|
|
18
|
+
"""Format the violation as a human-readable string."""
|
|
19
|
+
location = self.file_path
|
|
20
|
+
if self.line_number is not None:
|
|
21
|
+
location += f":{self.line_number}"
|
|
22
|
+
return f" - [{self.rule}] {location} -> {self.message}"
|
pytest_fsd/config.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import List
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
import tomllib
|
|
8
|
+
except ModuleNotFoundError:
|
|
9
|
+
import tomli as tomllib
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class FsdConfig:
|
|
14
|
+
"""Configuration for pytest-fsd, read from [tool.pytest_fsd] in pyproject.toml."""
|
|
15
|
+
|
|
16
|
+
base_path: str = "src"
|
|
17
|
+
layers: List[str] = field(
|
|
18
|
+
default_factory=lambda: [
|
|
19
|
+
"app",
|
|
20
|
+
"pages",
|
|
21
|
+
"widgets",
|
|
22
|
+
"features",
|
|
23
|
+
"entities",
|
|
24
|
+
"shared",
|
|
25
|
+
]
|
|
26
|
+
)
|
|
27
|
+
ignore_paths: List[str] = field(default_factory=list)
|
|
28
|
+
extra_rules: List[str] = field(default_factory=list)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Доступные дополнительные правила
|
|
32
|
+
AVAILABLE_EXTRA_RULES = {
|
|
33
|
+
"excessive-slicing",
|
|
34
|
+
"shared-lib-grouping",
|
|
35
|
+
"no-file-segments",
|
|
36
|
+
"no-reserved-folder-names",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load_config(root_dir: str) -> FsdConfig:
|
|
41
|
+
"""Загружает настройки [tool.pytest_fsd] из pyproject.toml."""
|
|
42
|
+
pyproject_path = os.path.join(root_dir, "pyproject.toml")
|
|
43
|
+
|
|
44
|
+
if not os.path.exists(pyproject_path):
|
|
45
|
+
return FsdConfig()
|
|
46
|
+
|
|
47
|
+
with open(pyproject_path, "rb") as f:
|
|
48
|
+
data = tomllib.load(f)
|
|
49
|
+
|
|
50
|
+
tool_config = data.get("tool", {}).get("pytest_fsd", {})
|
|
51
|
+
|
|
52
|
+
config = FsdConfig()
|
|
53
|
+
if "base_path" in tool_config:
|
|
54
|
+
config.base_path = tool_config["base_path"]
|
|
55
|
+
if "layers" in tool_config:
|
|
56
|
+
config.layers = tool_config["layers"]
|
|
57
|
+
if "ignore_paths" in tool_config:
|
|
58
|
+
config.ignore_paths = tool_config["ignore_paths"]
|
|
59
|
+
if "extra_rules" in tool_config:
|
|
60
|
+
config.extra_rules = tool_config["extra_rules"]
|
|
61
|
+
|
|
62
|
+
return config
|
pytest_fsd/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Rules registry for pytest-fsd."""
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# `ambiguous-slice-names`
|
|
2
|
+
|
|
3
|
+
Forbid slice names that match segment names in the `shared` layer. For example, if `shared/i18n` exists, having `features/i18n` is confusing.
|
|
4
|
+
|
|
5
|
+
## Examples
|
|
6
|
+
|
|
7
|
+
✅ Pass:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
📂 shared/
|
|
11
|
+
📂 ui/
|
|
12
|
+
📂 i18n/
|
|
13
|
+
📂 features/
|
|
14
|
+
📂 auth/ # OK: "auth" is not a shared segment
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
❌ Fail:
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
📂 shared/
|
|
21
|
+
📂 ui/
|
|
22
|
+
📂 i18n/
|
|
23
|
+
📂 features/
|
|
24
|
+
📂 i18n/ # ❌ same name as shared/i18n
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Rationale
|
|
28
|
+
|
|
29
|
+
When there is a segment in `shared` with the same name as a slice in another layer, it becomes ambiguous where new code should be written and obscures the search for existing code.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# src/pytest_fsd/rules/ambiguous_slice_names/__init__.py
|
|
2
|
+
"""
|
|
3
|
+
[Rule: ambiguous-slice-names]
|
|
4
|
+
Slice names should not match segment names in the shared layer.
|
|
5
|
+
For example, if shared/i18n exists, having features/i18n is confusing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from typing import Dict, List
|
|
10
|
+
|
|
11
|
+
from ..._lib.fs_utils import get_sliced_layers
|
|
12
|
+
from ..._lib.violations import Violation
|
|
13
|
+
from ...config import FsdConfig
|
|
14
|
+
|
|
15
|
+
RULE_NAME = "ambiguous-slice-names"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def check(config: FsdConfig, project_root: str) -> List[Violation]:
|
|
19
|
+
"""Verify that slice names don't collide with shared segment names."""
|
|
20
|
+
violations = []
|
|
21
|
+
base_dir = os.path.join(project_root, config.base_path)
|
|
22
|
+
|
|
23
|
+
# Собираем имена сегментов из shared
|
|
24
|
+
shared_path = os.path.join(base_dir, "shared")
|
|
25
|
+
if not os.path.isdir(shared_path):
|
|
26
|
+
return violations
|
|
27
|
+
|
|
28
|
+
# Собираем имена компонентов (слайсов) внутри сегментов shared
|
|
29
|
+
# Например: shared/ui/button -> регистрируем 'button' (внутри сегмента 'ui')
|
|
30
|
+
shared_slices: Dict[str, str] = {}
|
|
31
|
+
for segment in os.listdir(shared_path):
|
|
32
|
+
segment_path = os.path.join(shared_path, segment)
|
|
33
|
+
if not os.path.isdir(segment_path) or segment.startswith("_"):
|
|
34
|
+
continue
|
|
35
|
+
|
|
36
|
+
for shared_slice in os.listdir(segment_path):
|
|
37
|
+
if os.path.isdir(
|
|
38
|
+
os.path.join(segment_path, shared_slice)
|
|
39
|
+
) and not shared_slice.startswith("_"):
|
|
40
|
+
shared_slices[shared_slice] = segment
|
|
41
|
+
|
|
42
|
+
if not shared_slices:
|
|
43
|
+
return violations
|
|
44
|
+
|
|
45
|
+
# Проверяем слайсы во всех слоях
|
|
46
|
+
sliced_layers = get_sliced_layers(config)
|
|
47
|
+
|
|
48
|
+
for layer in sliced_layers:
|
|
49
|
+
layer_path = os.path.join(base_dir, layer)
|
|
50
|
+
if not os.path.isdir(layer_path):
|
|
51
|
+
continue
|
|
52
|
+
|
|
53
|
+
for slice_name in os.listdir(layer_path):
|
|
54
|
+
slice_path = os.path.join(layer_path, slice_name)
|
|
55
|
+
if not os.path.isdir(slice_path) or slice_name.startswith("_"):
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
if slice_name in shared_slices:
|
|
59
|
+
conflict_segment = shared_slices[slice_name]
|
|
60
|
+
violations.append(
|
|
61
|
+
Violation(
|
|
62
|
+
rule=RULE_NAME,
|
|
63
|
+
file_path=slice_path,
|
|
64
|
+
message=f"Slice '{slice_name}' in layer '{layer}' has the same name as "
|
|
65
|
+
f"component 'shared/{conflict_segment}/{slice_name}'. This creates ambiguity about "
|
|
66
|
+
f"where new code should be placed.",
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
return violations
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# `excessive-slicing`
|
|
2
|
+
|
|
3
|
+
> ⚡ **Optional rule.** Enable via `extra_rules = ["excessive-slicing"]` in `pyproject.toml`.
|
|
4
|
+
|
|
5
|
+
Forbid having too many ungrouped slices in a single layer.
|
|
6
|
+
|
|
7
|
+
**Threshold: 20 slices per layer** (as per Steiger defaults).
|
|
8
|
+
|
|
9
|
+
## Configuration
|
|
10
|
+
|
|
11
|
+
```toml
|
|
12
|
+
[tool.pytest_fsd]
|
|
13
|
+
extra_rules = ["excessive-slicing"]
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Examples
|
|
17
|
+
|
|
18
|
+
✅ Pass:
|
|
19
|
+
|
|
20
|
+
```text
|
|
21
|
+
📂 features/
|
|
22
|
+
📂 auth/
|
|
23
|
+
📂 profile/
|
|
24
|
+
📂 settings/
|
|
25
|
+
# ... up to 20 slices is fine
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
❌ Fail:
|
|
29
|
+
|
|
30
|
+
```text
|
|
31
|
+
📂 features/ # ❌ 21+ slices
|
|
32
|
+
📂 comments/
|
|
33
|
+
📂 posts/
|
|
34
|
+
📂 users/
|
|
35
|
+
📂 cars/
|
|
36
|
+
📂 ... (21+ directories)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Rationale
|
|
40
|
+
|
|
41
|
+
Having too many slices in a layer makes it harder to discover features in a project and promotes excessive decomposition. Consider grouping related slices into nested folders.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# src/pytest_fsd/rules/excessive_slicing/__init__.py
|
|
2
|
+
"""
|
|
3
|
+
[Rule: excessive-slicing] (optional)
|
|
4
|
+
Forbid having too many slices in a single layer.
|
|
5
|
+
Default threshold: 20 slices per layer.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from typing import List
|
|
10
|
+
|
|
11
|
+
from ..._lib.fs_utils import get_sliced_layers
|
|
12
|
+
from ..._lib.violations import Violation
|
|
13
|
+
from ...config import FsdConfig
|
|
14
|
+
|
|
15
|
+
RULE_NAME = "excessive-slicing"
|
|
16
|
+
DEFAULT_THRESHOLD = 20
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def check(config: FsdConfig, project_root: str) -> List[Violation]:
|
|
20
|
+
"""Verify that no layer contains too many slices."""
|
|
21
|
+
violations = []
|
|
22
|
+
base_dir = os.path.join(project_root, config.base_path)
|
|
23
|
+
sliced_layers = get_sliced_layers(config)
|
|
24
|
+
|
|
25
|
+
for layer in sliced_layers:
|
|
26
|
+
layer_path = os.path.join(base_dir, layer)
|
|
27
|
+
if not os.path.isdir(layer_path):
|
|
28
|
+
continue
|
|
29
|
+
|
|
30
|
+
slices = [
|
|
31
|
+
d
|
|
32
|
+
for d in os.listdir(layer_path)
|
|
33
|
+
if os.path.isdir(os.path.join(layer_path, d)) and not d.startswith("_")
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
if len(slices) > DEFAULT_THRESHOLD:
|
|
37
|
+
violations.append(
|
|
38
|
+
Violation(
|
|
39
|
+
rule=RULE_NAME,
|
|
40
|
+
file_path=layer_path,
|
|
41
|
+
message=f"Layer '{layer}' contains {len(slices)} slices "
|
|
42
|
+
f"(threshold: {DEFAULT_THRESHOLD}). Too many slices make navigation "
|
|
43
|
+
f"difficult. Consider grouping related slices.",
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
return violations
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# `forbidden-imports`
|
|
2
|
+
|
|
3
|
+
Forbids imports from higher layers. This is in accordance with the FSD import rule on layers:
|
|
4
|
+
|
|
5
|
+
> A module in a slice can only import other slices when they are located on layers strictly below.
|
|
6
|
+
|
|
7
|
+
This rule maps to Steiger's `no-higher-level-imports`.
|
|
8
|
+
|
|
9
|
+
## How it works
|
|
10
|
+
|
|
11
|
+
Uses `pytest-archon` to dynamically verify that modules in each layer do not import from any layer above them in the configured hierarchy.
|
|
12
|
+
|
|
13
|
+
For example, with default layers `["app", "windows", "widgets", "features", "entities", "shared"]`:
|
|
14
|
+
|
|
15
|
+
- `features` can import from `entities` and `shared`
|
|
16
|
+
- `features` **cannot** import from `widgets`, `windows`, or `app`
|
|
17
|
+
|
|
18
|
+
## Examples
|
|
19
|
+
|
|
20
|
+
✅ Pass:
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
# src/features/auth/model/handler.py
|
|
24
|
+
from src.entities.user import User # OK: entities is below features
|
|
25
|
+
from src.shared.lib.hash import hash_pw # OK: shared is below features
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
❌ Fail:
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
# src/features/auth/model/handler.py
|
|
32
|
+
from src.pages.home import HomePage # VIOLATION: pages is above features
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Rationale
|
|
36
|
+
|
|
37
|
+
This is one of the main rules of Feature-Sliced Design. It ensures low coupling and predictability during refactoring.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# src/pytest_fsd/rules/forbidden_imports/__init__.py
|
|
2
|
+
"""
|
|
3
|
+
[Rule: forbidden-imports / no-higher-level-imports]
|
|
4
|
+
Layers can only import from layers below them in the FSD hierarchy.
|
|
5
|
+
Uses pytest-archon for dynamic runtime checking.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from typing import List
|
|
10
|
+
|
|
11
|
+
from pytest_archon import archrule
|
|
12
|
+
|
|
13
|
+
from ..._lib.violations import Violation
|
|
14
|
+
from ...config import FsdConfig
|
|
15
|
+
|
|
16
|
+
RULE_NAME = "forbidden-imports"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def check(config: FsdConfig, project_root: str) -> List[Violation]:
|
|
20
|
+
"""Verify that each layer only imports from layers below it."""
|
|
21
|
+
violations = []
|
|
22
|
+
base_path = config.base_path
|
|
23
|
+
layers = config.layers
|
|
24
|
+
|
|
25
|
+
for i, current_layer in enumerate(layers):
|
|
26
|
+
layer_path = os.path.join(project_root, base_path, current_layer)
|
|
27
|
+
if not os.path.isdir(layer_path):
|
|
28
|
+
continue
|
|
29
|
+
|
|
30
|
+
# Проверяем наличие .py файлов в слое
|
|
31
|
+
has_python_files = False
|
|
32
|
+
for _, _, files in os.walk(layer_path):
|
|
33
|
+
if any(f.endswith(".py") and f != "__init__.py" for f in files):
|
|
34
|
+
has_python_files = True
|
|
35
|
+
break
|
|
36
|
+
|
|
37
|
+
if not has_python_files:
|
|
38
|
+
continue
|
|
39
|
+
|
|
40
|
+
forbidden_layers = layers[:i]
|
|
41
|
+
if forbidden_layers:
|
|
42
|
+
forbidden_modules = [f"{base_path}.{layer}.*" for layer in forbidden_layers]
|
|
43
|
+
try:
|
|
44
|
+
(
|
|
45
|
+
archrule(
|
|
46
|
+
f"[{current_layer.upper()}] must not import layers above it"
|
|
47
|
+
)
|
|
48
|
+
.match(f"{base_path}.{current_layer}.*")
|
|
49
|
+
.should_not_import(*forbidden_modules)
|
|
50
|
+
.check(base_path)
|
|
51
|
+
)
|
|
52
|
+
except BaseException as e:
|
|
53
|
+
violations.append(
|
|
54
|
+
Violation(
|
|
55
|
+
rule=RULE_NAME,
|
|
56
|
+
file_path=current_layer,
|
|
57
|
+
message=str(e).strip(),
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
return violations
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# `import-locality`
|
|
2
|
+
|
|
3
|
+
> 🔶 **Covered by Ruff.** This rule is NOT part of `pytest-fsd` core checks, but is fully enforceable via Ruff's `TID` (flake8-tidy-imports) plugin.
|
|
4
|
+
|
|
5
|
+
Require that imports **within the same slice** be relative, and imports **from another slice** be absolute.
|
|
6
|
+
|
|
7
|
+
## Ruff Configuration
|
|
8
|
+
|
|
9
|
+
```toml
|
|
10
|
+
[tool.ruff.lint]
|
|
11
|
+
select = ["TID"] # flake8-tidy-imports
|
|
12
|
+
|
|
13
|
+
[tool.ruff.lint.flake8-tidy-imports]
|
|
14
|
+
ban-relative-imports = "parents" # Запрет relative imports из родительских пакетов
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
### What `TID` covers
|
|
18
|
+
|
|
19
|
+
| Rule | Description |
|
|
20
|
+
| -------- | ------------------------------------------------ |
|
|
21
|
+
| `TID251` | Banned imports (configurable) |
|
|
22
|
+
| `TID252` | Relative imports from parent packages are banned |
|
|
23
|
+
|
|
24
|
+
## How it maps to FSD
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
# src/entities/user/ui/avatar.py
|
|
28
|
+
|
|
29
|
+
# ✅ Relative import within the same slice — OK
|
|
30
|
+
from .styles import avatar_styles
|
|
31
|
+
|
|
32
|
+
# ✅ Absolute import from another slice — OK
|
|
33
|
+
from src.shared.ui import Button
|
|
34
|
+
|
|
35
|
+
# ❌ Absolute self-import — wasteful
|
|
36
|
+
from src.entities.user.ui.styles import avatar_styles
|
|
37
|
+
|
|
38
|
+
# ❌ Relative import from another slice — fragile
|
|
39
|
+
from ...shared.ui import Button
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Rationale
|
|
43
|
+
|
|
44
|
+
Imports between slices should be absolute to stay stable during refactors. Imports within a slice should be relative to keep them short and because the public API should not need to expose internal modules.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# `inconsistent-naming`
|
|
2
|
+
|
|
3
|
+
> 🔶 **Covered by Ruff.** This rule is NOT part of `pytest-fsd` core checks, but is fully enforceable via Ruff's `N` (pep8-naming) plugin.
|
|
4
|
+
|
|
5
|
+
Ensure that all modules and directories follow consistent naming conventions (`snake_case` for Python).
|
|
6
|
+
|
|
7
|
+
In Steiger, this rule specifically checks consistency of **pluralization** in the `entities` layer (e.g., all entities should be either singular or plural, not mixed). In Python, `Ruff` handles the broader naming consistency problem via PEP 8 conventions.
|
|
8
|
+
|
|
9
|
+
## Ruff Configuration
|
|
10
|
+
|
|
11
|
+
```toml
|
|
12
|
+
[tool.ruff.lint]
|
|
13
|
+
select = ["N"] # pep8-naming — enforces snake_case for modules, variables, functions
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
### What `N` covers
|
|
17
|
+
|
|
18
|
+
| Rule | Description |
|
|
19
|
+
| ------ | ----------------------------------------------- |
|
|
20
|
+
| `N801` | Class names should use CapitalCase |
|
|
21
|
+
| `N802` | Function names should be lowercase |
|
|
22
|
+
| `N806` | Variable names should be lowercase |
|
|
23
|
+
| `N815` | Variable in class scope should not be camelCase |
|
|
24
|
+
| `N999` | Invalid module name |
|
|
25
|
+
|
|
26
|
+
## Steiger Equivalent
|
|
27
|
+
|
|
28
|
+
```text
|
|
29
|
+
📂 entities/
|
|
30
|
+
📂 users/ # Plural
|
|
31
|
+
📂 post/ # ❌ Singular — inconsistent with "users"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
In Python FSD, use `snake_case` everywhere and pick one pluralization style for your entities layer. Ruff enforces the `snake_case` part; pluralization consistency is a team convention enforced via code review.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# `insignificant-slice`
|
|
2
|
+
|
|
3
|
+
> 🟡 **Manual review only.** This rule requires full import graph analysis which is beyond the scope of `pytest-fsd`.
|
|
4
|
+
|
|
5
|
+
Detects slices that have no references (unused) or only one reference (can be merged into the layer above).
|
|
6
|
+
|
|
7
|
+
## Why not automated?
|
|
8
|
+
|
|
9
|
+
This rule requires building a complete import graph of the project and counting how many other modules reference each slice. This is a complex analysis that:
|
|
10
|
+
|
|
11
|
+
1. Requires resolving all import paths (including dynamic imports, `__init__.py` re-exports)
|
|
12
|
+
2. Cannot be done with simple AST parsing — needs full module resolution
|
|
13
|
+
3. Is potentially slow for large projects
|
|
14
|
+
|
|
15
|
+
## How to check manually
|
|
16
|
+
|
|
17
|
+
1. Use your IDE's "Find Usages" feature on a slice's `__init__.py`
|
|
18
|
+
2. If a slice has 0 imports from other modules — it's dead code, remove it
|
|
19
|
+
3. If a slice has exactly 1 import — consider merging it into the importing layer
|
|
20
|
+
|
|
21
|
+
## Steiger Equivalent
|
|
22
|
+
|
|
23
|
+
- Slices with **no references** → remove or archive
|
|
24
|
+
- Slices with **one reference** → merge into the importing module
|
|
25
|
+
- **Exception**: pages are allowed to have just 1 reference (from app/routing)
|
|
26
|
+
- **Exception**: slices only used in `app` layer don't count (app shouldn't have UI)
|