pytest-fsd 0.2.0__tar.gz
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-0.2.0/PKG-INFO +171 -0
- pytest_fsd-0.2.0/README.md +160 -0
- pytest_fsd-0.2.0/pyproject.toml +25 -0
- pytest_fsd-0.2.0/src/pytest_fsd/__init__.py +84 -0
- pytest_fsd-0.2.0/src/pytest_fsd/_lib/__init__.py +1 -0
- pytest_fsd-0.2.0/src/pytest_fsd/_lib/ast_utils.py +54 -0
- pytest_fsd-0.2.0/src/pytest_fsd/_lib/fs_utils.py +31 -0
- pytest_fsd-0.2.0/src/pytest_fsd/_lib/violations.py +22 -0
- pytest_fsd-0.2.0/src/pytest_fsd/config.py +62 -0
- pytest_fsd-0.2.0/src/pytest_fsd/py.typed +0 -0
- pytest_fsd-0.2.0/src/pytest_fsd/rules/__init__.py +1 -0
- pytest_fsd-0.2.0/src/pytest_fsd/rules/ambiguous_slice_names/README.md +29 -0
- pytest_fsd-0.2.0/src/pytest_fsd/rules/ambiguous_slice_names/__init__.py +70 -0
- pytest_fsd-0.2.0/src/pytest_fsd/rules/excessive_slicing/README.md +41 -0
- pytest_fsd-0.2.0/src/pytest_fsd/rules/excessive_slicing/__init__.py +47 -0
- pytest_fsd-0.2.0/src/pytest_fsd/rules/forbidden_imports/README.md +37 -0
- pytest_fsd-0.2.0/src/pytest_fsd/rules/forbidden_imports/__init__.py +61 -0
- pytest_fsd-0.2.0/src/pytest_fsd/rules/import_locality/README.md +44 -0
- pytest_fsd-0.2.0/src/pytest_fsd/rules/inconsistent_naming/README.md +34 -0
- pytest_fsd-0.2.0/src/pytest_fsd/rules/insignificant_slice/README.md +26 -0
- pytest_fsd-0.2.0/src/pytest_fsd/rules/no_cross_imports/README.md +30 -0
- pytest_fsd-0.2.0/src/pytest_fsd/rules/no_cross_imports/__init__.py +57 -0
- pytest_fsd-0.2.0/src/pytest_fsd/rules/no_file_segments/README.md +39 -0
- pytest_fsd-0.2.0/src/pytest_fsd/rules/no_file_segments/__init__.py +72 -0
- pytest_fsd-0.2.0/src/pytest_fsd/rules/no_layer_public_api/README.md +30 -0
- pytest_fsd-0.2.0/src/pytest_fsd/rules/no_layer_public_api/__init__.py +41 -0
- pytest_fsd-0.2.0/src/pytest_fsd/rules/no_processes/README.md +24 -0
- pytest_fsd-0.2.0/src/pytest_fsd/rules/no_public_api_sidestep/README.md +31 -0
- pytest_fsd-0.2.0/src/pytest_fsd/rules/no_public_api_sidestep/__init__.py +154 -0
- pytest_fsd-0.2.0/src/pytest_fsd/rules/no_reserved_folder_names/README.md +37 -0
- pytest_fsd-0.2.0/src/pytest_fsd/rules/no_reserved_folder_names/__init__.py +71 -0
- pytest_fsd-0.2.0/src/pytest_fsd/rules/no_segmentless_slices/README.md +32 -0
- pytest_fsd-0.2.0/src/pytest_fsd/rules/no_segmentless_slices/__init__.py +62 -0
- pytest_fsd-0.2.0/src/pytest_fsd/rules/no_segments_on_sliced_layers/README.md +29 -0
- pytest_fsd-0.2.0/src/pytest_fsd/rules/no_segments_on_sliced_layers/__init__.py +46 -0
- pytest_fsd-0.2.0/src/pytest_fsd/rules/no_ui_in_app/README.md +38 -0
- pytest_fsd-0.2.0/src/pytest_fsd/rules/no_ui_in_app/__init__.py +87 -0
- pytest_fsd-0.2.0/src/pytest_fsd/rules/public_api/README.md +34 -0
- pytest_fsd-0.2.0/src/pytest_fsd/rules/public_api/__init__.py +65 -0
- pytest_fsd-0.2.0/src/pytest_fsd/rules/repetitive_naming/README.md +31 -0
- pytest_fsd-0.2.0/src/pytest_fsd/rules/repetitive_naming/__init__.py +57 -0
- pytest_fsd-0.2.0/src/pytest_fsd/rules/segments_by_purpose/README.md +38 -0
- pytest_fsd-0.2.0/src/pytest_fsd/rules/segments_by_purpose/__init__.py +112 -0
- pytest_fsd-0.2.0/src/pytest_fsd/rules/shared_lib_grouping/README.md +46 -0
- pytest_fsd-0.2.0/src/pytest_fsd/rules/shared_lib_grouping/__init__.py +47 -0
- pytest_fsd-0.2.0/src/pytest_fsd/rules/typo_in_layer_name/README.md +29 -0
- pytest_fsd-0.2.0/src/pytest_fsd/rules/unknown_folders_in_root/README.md +35 -0
- pytest_fsd-0.2.0/src/pytest_fsd/rules/unknown_folders_in_root/__init__.py +50 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: pytest-fsd
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Author: Lebedev Nikita
|
|
6
|
+
Author-email: Lebedev Nikita <rachet337@gmail.com>
|
|
7
|
+
Requires-Dist: pytest-archon>=0.0.7
|
|
8
|
+
Requires-Dist: tomli>=2.0.1 ; python_full_version < '3.11'
|
|
9
|
+
Requires-Python: >=3.8
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# pytest-fsd
|
|
13
|
+
|
|
14
|
+
**FSD Architecture Validation for Python Projects.**
|
|
15
|
+
|
|
16
|
+
`pytest-fsd` автоматически проверяет архитектуру вашего Python-проекта на соответствие методологии [Feature-Sliced Design](https://feature-sliced.design/).
|
|
17
|
+
|
|
18
|
+
Использует гибридный подход: динамические проверки через [pytest-archon](https://pypi.org/project/pytest-archon/) + статический AST-анализ + проверка файловой структуры.
|
|
19
|
+
|
|
20
|
+
## Установка
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install pytest-fsd
|
|
24
|
+
# или
|
|
25
|
+
uv add --dev pytest-fsd
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Использование
|
|
29
|
+
|
|
30
|
+
### 1. Настройка `pyproject.toml`
|
|
31
|
+
|
|
32
|
+
```toml
|
|
33
|
+
[tool.pytest_fsd]
|
|
34
|
+
base_path = "src"
|
|
35
|
+
layers = ["app", "windows", "widgets", "features", "entities", "shared"]
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### 2. Создание теста
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
# tests/test_architecture.py
|
|
42
|
+
from pytest_fsd import validate_fsd_architecture
|
|
43
|
+
|
|
44
|
+
def test_project_architecture():
|
|
45
|
+
validate_fsd_architecture()
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### 3. Запуск
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pytest tests/test_architecture.py -vv
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Архитектура библиотеки
|
|
55
|
+
|
|
56
|
+
Каждое правило — это папка внутри `src/pytest_fsd/rules/<rule_name>/` с:
|
|
57
|
+
|
|
58
|
+
- `__init__.py` — логика проверки (функция `check(config, project_root) -> List[Violation]`)
|
|
59
|
+
- `README.md` — описание правила, примеры, rationale
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
src/pytest_fsd/
|
|
63
|
+
__init__.py # Фасад: validate_fsd_architecture()
|
|
64
|
+
config.py # Чтение [tool.pytest_fsd] из pyproject.toml
|
|
65
|
+
_lib/ # Общие утилиты
|
|
66
|
+
violations.py # Единый Violation dataclass
|
|
67
|
+
ast_utils.py # AST-парсинг импортов
|
|
68
|
+
fs_utils.py # Файловые утилиты, константы сегментов
|
|
69
|
+
rules/
|
|
70
|
+
forbidden_imports/ # pytest-archon: слои импортируют только нижележащие
|
|
71
|
+
no_cross_imports/ # pytest-archon: слайсы в одном слое независимы
|
|
72
|
+
no_public_api_sidestep/ # AST: импорт только через __init__.py слайса
|
|
73
|
+
no_layer_public_api/ # FS: слоевые папки без __init__.py
|
|
74
|
+
no_ui_in_app/ # AST: запрет UI-фреймворков в app
|
|
75
|
+
repetitive_naming/ # FS: файлы не дублируют имя слайса
|
|
76
|
+
no_segmentless_slices/ # FS: слайс содержит хотя бы один сегмент
|
|
77
|
+
segments_by_purpose/ # FS: запрет utils/helpers/components/hooks
|
|
78
|
+
ambiguous_slice_names/ # FS: имена слайсов ≠ имена сегментов shared
|
|
79
|
+
no_segments_on_sliced_layers/ # FS: в слайсовых слоях не должно быть сегментов напрямую
|
|
80
|
+
public_api/ # FS: каждый слайс должен иметь __init__.py
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## Матрица покрытия правил Steiger
|
|
86
|
+
|
|
87
|
+
Полный список правил из [Steiger FSD Plugin](https://github.com/feature-sliced/steiger) и их статус в `pytest-fsd`:
|
|
88
|
+
|
|
89
|
+
| # | Steiger Rule | pytest-fsd Status | Описание |
|
|
90
|
+
| --- | ----------------------------------------------- | ---------------------- | -------------------------------------------------------------------------------------- |
|
|
91
|
+
| 1 | `forbidden-imports` / `no-higher-level-imports` | ✅ **Полностью** | Слои импортируют только нижележащие слои |
|
|
92
|
+
| 2 | `no-cross-imports` | ✅ **Полностью** | Слайсы в одном слое независимы друг от друга |
|
|
93
|
+
| 3 | `no-public-api-sidestep` | ✅ **Полностью** | Импорт из чужого слайса только через `__init__.py` |
|
|
94
|
+
| 4 | `public-api` | ✅ **Полностью** | Каждый слайс и сегмент shared обязан иметь `__init__.py` |
|
|
95
|
+
| 5 | `no-layer-public-api` | ✅ **Полностью** | Папки слоев (`features/`, `entities/`) не должны содержать `__init__.py` |
|
|
96
|
+
| 6 | `segments-by-purpose` | ✅ **Полностью** | Запрет `utils`, `helpers`, `hooks`, `components`, `modals`, `types`, `constants` и др. |
|
|
97
|
+
| 7 | `no-segmentless-slices` | ✅ **Полностью** | Слайс обязан содержать хотя бы один стандартный сегмент |
|
|
98
|
+
| 8 | `repetitive-naming` | ✅ **Полностью** | Файлы не дублируют имя слайса (`user/user_model.py` → `user/model.py`) |
|
|
99
|
+
| 9 | `no-ui-in-app` | ✅ **Полностью** | Слой `app` не должен импортировать UI-фреймворки |
|
|
100
|
+
| 10 | `ambiguous-slice-names` | ✅ **Полностью** | Имена слайсов не совпадают с сегментами `shared/` |
|
|
101
|
+
| 11 | `no-segments-on-sliced-layers` | ✅ **Полностью** | В слайсовых слоях нет прямых папок-сегментов |
|
|
102
|
+
| 12 | `inconsistent-naming` | 🔶 **Ruff** | Обеспечивается плагином `N` (pep8-naming) в `Ruff` |
|
|
103
|
+
| 13 | `import-locality` | 🔶 **Ruff** | Обеспечивается плагином `TID` (flake8-tidy-imports) в `Ruff` |
|
|
104
|
+
| 14 | `typo-in-layer-name` | 🔶 **Конфигурация** | Покрывается блоком `[tool.pytest_fsd].layers` в `pyproject.toml` |
|
|
105
|
+
| 15 | `no-processes` | 🔶 **Конфигурация** | Слой `processes` deprecated; просто не включайте его в `layers` |
|
|
106
|
+
| 16 | `excessive-slicing` | ⚡ **Опционально** | Более 20 слайсов в одном слое (порог: 20) |
|
|
107
|
+
| 17 | `insignificant-slice` | 🟡 **Ручная проверка** | Требует анализа графа импортов для определения "незначительных" слайсов |
|
|
108
|
+
| 18 | `no-file-segments` | ⚡ **Опционально** | Сегмент как файл (`model.py`) вместо папки (`model/`) |
|
|
109
|
+
| 19 | `shared-lib-grouping` | ⚡ **Опционально** | Более 15 файлов в `shared/lib` без группировки |
|
|
110
|
+
| 20 | `no-reserved-folder-names` | ⚡ **Опционально** | Подпапки в сегментах не должны совпадать с именами сегментов |
|
|
111
|
+
|
|
112
|
+
### Легенда
|
|
113
|
+
|
|
114
|
+
| Статус | Значение |
|
|
115
|
+
| -------------------------- | --------------------------------------------------------------------------------------------------- |
|
|
116
|
+
| ✅ **Полностью** | Правило полностью автоматизировано и выполняется при каждом запуске `pytest` |
|
|
117
|
+
| ⚡ **Опционально** | Правило автоматизировано, но включается через `extra_rules` в `pyproject.toml` |
|
|
118
|
+
| 🔶 **Ruff / Конфигурация** | Покрывается внешними инструментами (`Ruff`) или конфигурацией `pyproject.toml` |
|
|
119
|
+
| 🟡 **Ручная проверка** | Требует субъективной оценки или сложного анализа, который лучше производить вручную при code review |
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## Включение дополнительных правил
|
|
124
|
+
|
|
125
|
+
Добавьте в `pyproject.toml`:
|
|
126
|
+
|
|
127
|
+
```toml
|
|
128
|
+
[tool.pytest_fsd]
|
|
129
|
+
base_path = "src"
|
|
130
|
+
layers = ["app", "windows", "widgets", "features", "entities", "shared"]
|
|
131
|
+
extra_rules = [
|
|
132
|
+
"excessive-slicing", # ≤ 20 слайсов на слой
|
|
133
|
+
"shared-lib-grouping", # ≤ 15 файлов в shared/lib
|
|
134
|
+
"no-file-segments", # Сегменты должны быть папками, не файлами
|
|
135
|
+
"no-reserved-folder-names" # Подпапки сегментов не могут называться ui/model/api/lib/config
|
|
136
|
+
]
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Каждое правило подробно описано в `src/pytest_fsd/rules/<rule_name>/README.md`.
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## Настройка Ruff для смежных правил
|
|
144
|
+
|
|
145
|
+
Для полного покрытия FSD-правил, которые Steiger проверяет на уровне линтинга (и которые `pytest-fsd` не дублирует), добавьте в `pyproject.toml`:
|
|
146
|
+
|
|
147
|
+
```toml
|
|
148
|
+
[tool.ruff.lint]
|
|
149
|
+
select = ["N", "TID"]
|
|
150
|
+
|
|
151
|
+
[tool.ruff.lint.flake8-tidy-imports]
|
|
152
|
+
ban-relative-imports = "parents"
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
| Ruff Plugin | Steiger Rule | Что проверяет |
|
|
156
|
+
| --------------------------- | --------------------- | ----------------------------------------------- |
|
|
157
|
+
| `N` (pep8-naming) | `inconsistent-naming` | `snake_case` для модулей, переменных, функций |
|
|
158
|
+
| `TID` (flake8-tidy-imports) | `import-locality` | Запрет relative imports из родительских пакетов |
|
|
159
|
+
|
|
160
|
+
Подробные описания и примеры конфигурации: `src/pytest_fsd/rules/inconsistent_naming/README.md` и `src/pytest_fsd/rules/import_locality/README.md`.
|
|
161
|
+
|
|
162
|
+
## Известные ограничения (Known Limitations)
|
|
163
|
+
|
|
164
|
+
- **`TYPE_CHECKING` импорты**: Правила, использующие `pytest-archon` (например, `forbidden-imports` и `no-cross-imports`), работают на базе динамического анализа графа импортов в рантайме. Импорты, находящиеся внутри блоков `if TYPE_CHECKING:`, не выполняются при загрузке модуля и, следовательно, **не видны для этих правил**.
|
|
165
|
+
- **Относительные импорты**: В модуле `ast_utils.py` добавлена поддержка относительных путей для проверки `no-public-api-sidestep`, однако `Ruff` (плагин `TID`) всё равно лучше справляется с контролем относительных импортов за пределами слайсов.
|
|
166
|
+
- **Динамический `__all__`**: Правило `no-public-api-sidestep` использует статический AST-анализ для извлечения `__all__` из файла `__init__.py`. Если список экспортов формируется динамически (например, `__all__ = a + b`), статический анализатор не сможет его прочитать, и инструмент может выдать ложноположительные нарушения. Экспорты в `__all__` должны быть заданы как явный список или кортеж.
|
|
167
|
+
- **Минимальная версия Python**: Библиотека поддерживает Python **3.8+**. Для Python `<3.11` используется обратная совместимость через пакет `tomli`, а на Python `3.11+` — встроенный `tomllib`.
|
|
168
|
+
|
|
169
|
+
## Лицензия
|
|
170
|
+
|
|
171
|
+
MIT
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# pytest-fsd
|
|
2
|
+
|
|
3
|
+
**FSD Architecture Validation for Python Projects.**
|
|
4
|
+
|
|
5
|
+
`pytest-fsd` автоматически проверяет архитектуру вашего Python-проекта на соответствие методологии [Feature-Sliced Design](https://feature-sliced.design/).
|
|
6
|
+
|
|
7
|
+
Использует гибридный подход: динамические проверки через [pytest-archon](https://pypi.org/project/pytest-archon/) + статический AST-анализ + проверка файловой структуры.
|
|
8
|
+
|
|
9
|
+
## Установка
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install pytest-fsd
|
|
13
|
+
# или
|
|
14
|
+
uv add --dev pytest-fsd
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Использование
|
|
18
|
+
|
|
19
|
+
### 1. Настройка `pyproject.toml`
|
|
20
|
+
|
|
21
|
+
```toml
|
|
22
|
+
[tool.pytest_fsd]
|
|
23
|
+
base_path = "src"
|
|
24
|
+
layers = ["app", "windows", "widgets", "features", "entities", "shared"]
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### 2. Создание теста
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
# tests/test_architecture.py
|
|
31
|
+
from pytest_fsd import validate_fsd_architecture
|
|
32
|
+
|
|
33
|
+
def test_project_architecture():
|
|
34
|
+
validate_fsd_architecture()
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### 3. Запуск
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pytest tests/test_architecture.py -vv
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Архитектура библиотеки
|
|
44
|
+
|
|
45
|
+
Каждое правило — это папка внутри `src/pytest_fsd/rules/<rule_name>/` с:
|
|
46
|
+
|
|
47
|
+
- `__init__.py` — логика проверки (функция `check(config, project_root) -> List[Violation]`)
|
|
48
|
+
- `README.md` — описание правила, примеры, rationale
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
src/pytest_fsd/
|
|
52
|
+
__init__.py # Фасад: validate_fsd_architecture()
|
|
53
|
+
config.py # Чтение [tool.pytest_fsd] из pyproject.toml
|
|
54
|
+
_lib/ # Общие утилиты
|
|
55
|
+
violations.py # Единый Violation dataclass
|
|
56
|
+
ast_utils.py # AST-парсинг импортов
|
|
57
|
+
fs_utils.py # Файловые утилиты, константы сегментов
|
|
58
|
+
rules/
|
|
59
|
+
forbidden_imports/ # pytest-archon: слои импортируют только нижележащие
|
|
60
|
+
no_cross_imports/ # pytest-archon: слайсы в одном слое независимы
|
|
61
|
+
no_public_api_sidestep/ # AST: импорт только через __init__.py слайса
|
|
62
|
+
no_layer_public_api/ # FS: слоевые папки без __init__.py
|
|
63
|
+
no_ui_in_app/ # AST: запрет UI-фреймворков в app
|
|
64
|
+
repetitive_naming/ # FS: файлы не дублируют имя слайса
|
|
65
|
+
no_segmentless_slices/ # FS: слайс содержит хотя бы один сегмент
|
|
66
|
+
segments_by_purpose/ # FS: запрет utils/helpers/components/hooks
|
|
67
|
+
ambiguous_slice_names/ # FS: имена слайсов ≠ имена сегментов shared
|
|
68
|
+
no_segments_on_sliced_layers/ # FS: в слайсовых слоях не должно быть сегментов напрямую
|
|
69
|
+
public_api/ # FS: каждый слайс должен иметь __init__.py
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Матрица покрытия правил Steiger
|
|
75
|
+
|
|
76
|
+
Полный список правил из [Steiger FSD Plugin](https://github.com/feature-sliced/steiger) и их статус в `pytest-fsd`:
|
|
77
|
+
|
|
78
|
+
| # | Steiger Rule | pytest-fsd Status | Описание |
|
|
79
|
+
| --- | ----------------------------------------------- | ---------------------- | -------------------------------------------------------------------------------------- |
|
|
80
|
+
| 1 | `forbidden-imports` / `no-higher-level-imports` | ✅ **Полностью** | Слои импортируют только нижележащие слои |
|
|
81
|
+
| 2 | `no-cross-imports` | ✅ **Полностью** | Слайсы в одном слое независимы друг от друга |
|
|
82
|
+
| 3 | `no-public-api-sidestep` | ✅ **Полностью** | Импорт из чужого слайса только через `__init__.py` |
|
|
83
|
+
| 4 | `public-api` | ✅ **Полностью** | Каждый слайс и сегмент shared обязан иметь `__init__.py` |
|
|
84
|
+
| 5 | `no-layer-public-api` | ✅ **Полностью** | Папки слоев (`features/`, `entities/`) не должны содержать `__init__.py` |
|
|
85
|
+
| 6 | `segments-by-purpose` | ✅ **Полностью** | Запрет `utils`, `helpers`, `hooks`, `components`, `modals`, `types`, `constants` и др. |
|
|
86
|
+
| 7 | `no-segmentless-slices` | ✅ **Полностью** | Слайс обязан содержать хотя бы один стандартный сегмент |
|
|
87
|
+
| 8 | `repetitive-naming` | ✅ **Полностью** | Файлы не дублируют имя слайса (`user/user_model.py` → `user/model.py`) |
|
|
88
|
+
| 9 | `no-ui-in-app` | ✅ **Полностью** | Слой `app` не должен импортировать UI-фреймворки |
|
|
89
|
+
| 10 | `ambiguous-slice-names` | ✅ **Полностью** | Имена слайсов не совпадают с сегментами `shared/` |
|
|
90
|
+
| 11 | `no-segments-on-sliced-layers` | ✅ **Полностью** | В слайсовых слоях нет прямых папок-сегментов |
|
|
91
|
+
| 12 | `inconsistent-naming` | 🔶 **Ruff** | Обеспечивается плагином `N` (pep8-naming) в `Ruff` |
|
|
92
|
+
| 13 | `import-locality` | 🔶 **Ruff** | Обеспечивается плагином `TID` (flake8-tidy-imports) в `Ruff` |
|
|
93
|
+
| 14 | `typo-in-layer-name` | 🔶 **Конфигурация** | Покрывается блоком `[tool.pytest_fsd].layers` в `pyproject.toml` |
|
|
94
|
+
| 15 | `no-processes` | 🔶 **Конфигурация** | Слой `processes` deprecated; просто не включайте его в `layers` |
|
|
95
|
+
| 16 | `excessive-slicing` | ⚡ **Опционально** | Более 20 слайсов в одном слое (порог: 20) |
|
|
96
|
+
| 17 | `insignificant-slice` | 🟡 **Ручная проверка** | Требует анализа графа импортов для определения "незначительных" слайсов |
|
|
97
|
+
| 18 | `no-file-segments` | ⚡ **Опционально** | Сегмент как файл (`model.py`) вместо папки (`model/`) |
|
|
98
|
+
| 19 | `shared-lib-grouping` | ⚡ **Опционально** | Более 15 файлов в `shared/lib` без группировки |
|
|
99
|
+
| 20 | `no-reserved-folder-names` | ⚡ **Опционально** | Подпапки в сегментах не должны совпадать с именами сегментов |
|
|
100
|
+
|
|
101
|
+
### Легенда
|
|
102
|
+
|
|
103
|
+
| Статус | Значение |
|
|
104
|
+
| -------------------------- | --------------------------------------------------------------------------------------------------- |
|
|
105
|
+
| ✅ **Полностью** | Правило полностью автоматизировано и выполняется при каждом запуске `pytest` |
|
|
106
|
+
| ⚡ **Опционально** | Правило автоматизировано, но включается через `extra_rules` в `pyproject.toml` |
|
|
107
|
+
| 🔶 **Ruff / Конфигурация** | Покрывается внешними инструментами (`Ruff`) или конфигурацией `pyproject.toml` |
|
|
108
|
+
| 🟡 **Ручная проверка** | Требует субъективной оценки или сложного анализа, который лучше производить вручную при code review |
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## Включение дополнительных правил
|
|
113
|
+
|
|
114
|
+
Добавьте в `pyproject.toml`:
|
|
115
|
+
|
|
116
|
+
```toml
|
|
117
|
+
[tool.pytest_fsd]
|
|
118
|
+
base_path = "src"
|
|
119
|
+
layers = ["app", "windows", "widgets", "features", "entities", "shared"]
|
|
120
|
+
extra_rules = [
|
|
121
|
+
"excessive-slicing", # ≤ 20 слайсов на слой
|
|
122
|
+
"shared-lib-grouping", # ≤ 15 файлов в shared/lib
|
|
123
|
+
"no-file-segments", # Сегменты должны быть папками, не файлами
|
|
124
|
+
"no-reserved-folder-names" # Подпапки сегментов не могут называться ui/model/api/lib/config
|
|
125
|
+
]
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Каждое правило подробно описано в `src/pytest_fsd/rules/<rule_name>/README.md`.
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## Настройка Ruff для смежных правил
|
|
133
|
+
|
|
134
|
+
Для полного покрытия FSD-правил, которые Steiger проверяет на уровне линтинга (и которые `pytest-fsd` не дублирует), добавьте в `pyproject.toml`:
|
|
135
|
+
|
|
136
|
+
```toml
|
|
137
|
+
[tool.ruff.lint]
|
|
138
|
+
select = ["N", "TID"]
|
|
139
|
+
|
|
140
|
+
[tool.ruff.lint.flake8-tidy-imports]
|
|
141
|
+
ban-relative-imports = "parents"
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
| Ruff Plugin | Steiger Rule | Что проверяет |
|
|
145
|
+
| --------------------------- | --------------------- | ----------------------------------------------- |
|
|
146
|
+
| `N` (pep8-naming) | `inconsistent-naming` | `snake_case` для модулей, переменных, функций |
|
|
147
|
+
| `TID` (flake8-tidy-imports) | `import-locality` | Запрет relative imports из родительских пакетов |
|
|
148
|
+
|
|
149
|
+
Подробные описания и примеры конфигурации: `src/pytest_fsd/rules/inconsistent_naming/README.md` и `src/pytest_fsd/rules/import_locality/README.md`.
|
|
150
|
+
|
|
151
|
+
## Известные ограничения (Known Limitations)
|
|
152
|
+
|
|
153
|
+
- **`TYPE_CHECKING` импорты**: Правила, использующие `pytest-archon` (например, `forbidden-imports` и `no-cross-imports`), работают на базе динамического анализа графа импортов в рантайме. Импорты, находящиеся внутри блоков `if TYPE_CHECKING:`, не выполняются при загрузке модуля и, следовательно, **не видны для этих правил**.
|
|
154
|
+
- **Относительные импорты**: В модуле `ast_utils.py` добавлена поддержка относительных путей для проверки `no-public-api-sidestep`, однако `Ruff` (плагин `TID`) всё равно лучше справляется с контролем относительных импортов за пределами слайсов.
|
|
155
|
+
- **Динамический `__all__`**: Правило `no-public-api-sidestep` использует статический AST-анализ для извлечения `__all__` из файла `__init__.py`. Если список экспортов формируется динамически (например, `__all__ = a + b`), статический анализатор не сможет его прочитать, и инструмент может выдать ложноположительные нарушения. Экспорты в `__all__` должны быть заданы как явный список или кортеж.
|
|
156
|
+
- **Минимальная версия Python**: Библиотека поддерживает Python **3.8+**. Для Python `<3.11` используется обратная совместимость через пакет `tomli`, а на Python `3.11+` — встроенный `tomllib`.
|
|
157
|
+
|
|
158
|
+
## Лицензия
|
|
159
|
+
|
|
160
|
+
MIT
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pytest-fsd"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Add your description here"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Lebedev Nikita", email = "rachet337@gmail.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.8"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"pytest-archon>=0.0.7",
|
|
12
|
+
"tomli>=2.0.1; python_version < '3.11'",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[dependency-groups]
|
|
16
|
+
dev = [
|
|
17
|
+
"pytest>=8.3.5,<9",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[tool.pytest.ini_options]
|
|
21
|
+
pythonpath = ["src"]
|
|
22
|
+
|
|
23
|
+
[build-system]
|
|
24
|
+
requires = ["uv_build>=0.9.27,<0.10.0"]
|
|
25
|
+
build-backend = "uv_build"
|
|
@@ -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}"
|
|
@@ -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
|
|
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.
|