apdev 0.1.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.
- apdev-0.1.0/PKG-INFO +141 -0
- apdev-0.1.0/README.md +113 -0
- apdev-0.1.0/pyproject.toml +61 -0
- apdev-0.1.0/setup.cfg +4 -0
- apdev-0.1.0/src/apdev/__init__.py +3 -0
- apdev-0.1.0/src/apdev/__main__.py +7 -0
- apdev-0.1.0/src/apdev/check_chars.py +79 -0
- apdev-0.1.0/src/apdev/check_imports.py +143 -0
- apdev-0.1.0/src/apdev/cli.py +116 -0
- apdev-0.1.0/src/apdev/config.py +38 -0
- apdev-0.1.0/src/apdev/py.typed +0 -0
- apdev-0.1.0/src/apdev/release.sh +815 -0
- apdev-0.1.0/src/apdev.egg-info/PKG-INFO +141 -0
- apdev-0.1.0/src/apdev.egg-info/SOURCES.txt +20 -0
- apdev-0.1.0/src/apdev.egg-info/dependency_links.txt +1 -0
- apdev-0.1.0/src/apdev.egg-info/entry_points.txt +2 -0
- apdev-0.1.0/src/apdev.egg-info/requires.txt +8 -0
- apdev-0.1.0/src/apdev.egg-info/top_level.txt +1 -0
- apdev-0.1.0/tests/test_check_chars.py +80 -0
- apdev-0.1.0/tests/test_check_imports.py +103 -0
- apdev-0.1.0/tests/test_cli.py +88 -0
- apdev-0.1.0/tests/test_config.py +55 -0
apdev-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: apdev
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared development tools for Python projects - character validation, circular import detection, and more
|
|
5
|
+
Author-email: aipartnerup <tercel.yi@gmail.com>
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://aipartnerup.com
|
|
8
|
+
Project-URL: Documentation, https://github.com/aipartnerup/apdev
|
|
9
|
+
Project-URL: Repository, https://github.com/aipartnerup/apdev
|
|
10
|
+
Project-URL: Issues, https://github.com/aipartnerup/apdev/issues
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
15
|
+
Classifier: Topic :: Software Development :: Testing
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Requires-Dist: tomli>=1.1.0; python_version < "3.11"
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
27
|
+
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
28
|
+
|
|
29
|
+
# apdev (Python)
|
|
30
|
+
|
|
31
|
+
Shared development tools for Python projects.
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install apdev
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
For development:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install apdev[dev]
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Tools
|
|
46
|
+
|
|
47
|
+
### check-chars
|
|
48
|
+
|
|
49
|
+
Validate that files contain only allowed characters (ASCII, common emoji, and technical symbols like arrows and box-drawing characters).
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
# Check specific files
|
|
53
|
+
apdev check-chars src/mypackage/*.py
|
|
54
|
+
|
|
55
|
+
# Use with pre-commit (see below)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### check-imports
|
|
59
|
+
|
|
60
|
+
Detect circular imports in a Python package.
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
# Specify package explicitly
|
|
64
|
+
apdev check-imports --package mypackage --src-dir src
|
|
65
|
+
|
|
66
|
+
# Or configure in pyproject.toml (see below)
|
|
67
|
+
apdev check-imports
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### release
|
|
71
|
+
|
|
72
|
+
Interactive release automation for publishing Python packages to PyPI and GitHub. Auto-detects project name from `pyproject.toml` and GitHub repo from git remote.
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
# Run with auto-detected version from pyproject.toml
|
|
76
|
+
apdev release
|
|
77
|
+
|
|
78
|
+
# Or specify version explicitly
|
|
79
|
+
apdev release 0.2.0
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The command provides an interactive menu with steps:
|
|
83
|
+
1. Version verification (checks `pyproject.toml` and `__init__.py` match)
|
|
84
|
+
2. Status check (tag, build files, PyPI)
|
|
85
|
+
3. Clean build files
|
|
86
|
+
4. Build package (`python -m build`)
|
|
87
|
+
5. Check package (`twine check`)
|
|
88
|
+
6. Create git tag and push
|
|
89
|
+
7. Create GitHub release (via `gh` CLI or API)
|
|
90
|
+
8. Upload to PyPI (`twine upload`)
|
|
91
|
+
|
|
92
|
+
Override auto-detection with environment variables:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
PROJECT_NAME=mypackage GITHUB_REPO=owner/repo apdev release
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Configuration
|
|
99
|
+
|
|
100
|
+
Add to your project's `pyproject.toml`:
|
|
101
|
+
|
|
102
|
+
```toml
|
|
103
|
+
[tool.apdev]
|
|
104
|
+
base_package = "mypackage"
|
|
105
|
+
src_dir = "src"
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Pre-commit Integration
|
|
109
|
+
|
|
110
|
+
```yaml
|
|
111
|
+
repos:
|
|
112
|
+
- repo: https://github.com/aipartnerup/apdev
|
|
113
|
+
rev: python/v0.1.0
|
|
114
|
+
hooks:
|
|
115
|
+
- id: check-chars
|
|
116
|
+
- id: check-imports
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Or use as a local hook with the pip-installed package:
|
|
120
|
+
|
|
121
|
+
```yaml
|
|
122
|
+
repos:
|
|
123
|
+
- repo: local
|
|
124
|
+
hooks:
|
|
125
|
+
- id: check-chars
|
|
126
|
+
name: apdev check-chars
|
|
127
|
+
entry: apdev check-chars
|
|
128
|
+
language: system
|
|
129
|
+
types_or: [text, python]
|
|
130
|
+
|
|
131
|
+
- id: check-imports
|
|
132
|
+
name: apdev check-imports
|
|
133
|
+
entry: apdev check-imports
|
|
134
|
+
language: system
|
|
135
|
+
pass_filenames: false
|
|
136
|
+
always_run: true
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## License
|
|
140
|
+
|
|
141
|
+
Apache-2.0
|
apdev-0.1.0/README.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# apdev (Python)
|
|
2
|
+
|
|
3
|
+
Shared development tools for Python projects.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install apdev
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
For development:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install apdev[dev]
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Tools
|
|
18
|
+
|
|
19
|
+
### check-chars
|
|
20
|
+
|
|
21
|
+
Validate that files contain only allowed characters (ASCII, common emoji, and technical symbols like arrows and box-drawing characters).
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# Check specific files
|
|
25
|
+
apdev check-chars src/mypackage/*.py
|
|
26
|
+
|
|
27
|
+
# Use with pre-commit (see below)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### check-imports
|
|
31
|
+
|
|
32
|
+
Detect circular imports in a Python package.
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
# Specify package explicitly
|
|
36
|
+
apdev check-imports --package mypackage --src-dir src
|
|
37
|
+
|
|
38
|
+
# Or configure in pyproject.toml (see below)
|
|
39
|
+
apdev check-imports
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### release
|
|
43
|
+
|
|
44
|
+
Interactive release automation for publishing Python packages to PyPI and GitHub. Auto-detects project name from `pyproject.toml` and GitHub repo from git remote.
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
# Run with auto-detected version from pyproject.toml
|
|
48
|
+
apdev release
|
|
49
|
+
|
|
50
|
+
# Or specify version explicitly
|
|
51
|
+
apdev release 0.2.0
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The command provides an interactive menu with steps:
|
|
55
|
+
1. Version verification (checks `pyproject.toml` and `__init__.py` match)
|
|
56
|
+
2. Status check (tag, build files, PyPI)
|
|
57
|
+
3. Clean build files
|
|
58
|
+
4. Build package (`python -m build`)
|
|
59
|
+
5. Check package (`twine check`)
|
|
60
|
+
6. Create git tag and push
|
|
61
|
+
7. Create GitHub release (via `gh` CLI or API)
|
|
62
|
+
8. Upload to PyPI (`twine upload`)
|
|
63
|
+
|
|
64
|
+
Override auto-detection with environment variables:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
PROJECT_NAME=mypackage GITHUB_REPO=owner/repo apdev release
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Configuration
|
|
71
|
+
|
|
72
|
+
Add to your project's `pyproject.toml`:
|
|
73
|
+
|
|
74
|
+
```toml
|
|
75
|
+
[tool.apdev]
|
|
76
|
+
base_package = "mypackage"
|
|
77
|
+
src_dir = "src"
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Pre-commit Integration
|
|
81
|
+
|
|
82
|
+
```yaml
|
|
83
|
+
repos:
|
|
84
|
+
- repo: https://github.com/aipartnerup/apdev
|
|
85
|
+
rev: python/v0.1.0
|
|
86
|
+
hooks:
|
|
87
|
+
- id: check-chars
|
|
88
|
+
- id: check-imports
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Or use as a local hook with the pip-installed package:
|
|
92
|
+
|
|
93
|
+
```yaml
|
|
94
|
+
repos:
|
|
95
|
+
- repo: local
|
|
96
|
+
hooks:
|
|
97
|
+
- id: check-chars
|
|
98
|
+
name: apdev check-chars
|
|
99
|
+
entry: apdev check-chars
|
|
100
|
+
language: system
|
|
101
|
+
types_or: [text, python]
|
|
102
|
+
|
|
103
|
+
- id: check-imports
|
|
104
|
+
name: apdev check-imports
|
|
105
|
+
entry: apdev check-imports
|
|
106
|
+
language: system
|
|
107
|
+
pass_filenames: false
|
|
108
|
+
always_run: true
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## License
|
|
112
|
+
|
|
113
|
+
Apache-2.0
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "apdev"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Shared development tools for Python projects - character validation, circular import detection, and more"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "Apache-2.0"}
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "aipartnerup", email = "tercel.yi@gmail.com"}
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: Apache Software License",
|
|
19
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
20
|
+
"Topic :: Software Development :: Testing",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"tomli>=1.1.0;python_version<'3.11'",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.scripts]
|
|
32
|
+
apdev = "apdev.cli:main"
|
|
33
|
+
|
|
34
|
+
[project.optional-dependencies]
|
|
35
|
+
dev = [
|
|
36
|
+
"pytest>=7.0",
|
|
37
|
+
"pytest-cov>=4.0",
|
|
38
|
+
"ruff>=0.1.0",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[tool.setuptools.packages.find]
|
|
42
|
+
where = ["src"]
|
|
43
|
+
|
|
44
|
+
[tool.setuptools.package-data]
|
|
45
|
+
apdev = ["release.sh"]
|
|
46
|
+
|
|
47
|
+
[tool.pytest.ini_options]
|
|
48
|
+
testpaths = ["tests"]
|
|
49
|
+
|
|
50
|
+
[tool.ruff]
|
|
51
|
+
target-version = "py310"
|
|
52
|
+
line-length = 100
|
|
53
|
+
|
|
54
|
+
[tool.ruff.lint]
|
|
55
|
+
select = ["E", "F", "I", "W"]
|
|
56
|
+
|
|
57
|
+
[project.urls]
|
|
58
|
+
Homepage = "https://aipartnerup.com"
|
|
59
|
+
Documentation = "https://github.com/aipartnerup/apdev"
|
|
60
|
+
Repository = "https://github.com/aipartnerup/apdev"
|
|
61
|
+
Issues = "https://github.com/aipartnerup/apdev/issues"
|
apdev-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Character validation tool.
|
|
2
|
+
|
|
3
|
+
Checks that files contain only allowed characters: ASCII, common emoji,
|
|
4
|
+
and standard technical symbols (arrows, box-drawing, math operators, etc.).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
# Allowed Unicode ranges beyond ASCII (0-127)
|
|
12
|
+
EMOJI_RANGES: list[tuple[int, int]] = [
|
|
13
|
+
(0x1F300, 0x1F5FF), # Symbols and Pictographs
|
|
14
|
+
(0x1F600, 0x1F64F), # Emoticons
|
|
15
|
+
(0x1F680, 0x1F6FF), # Transport and Map Symbols
|
|
16
|
+
(0x1F780, 0x1F7FF), # Geometric Shapes Extended
|
|
17
|
+
(0x1F900, 0x1F9FF), # Supplemental Symbols and Pictographs
|
|
18
|
+
(0x2600, 0x26FF), # Miscellaneous Symbols
|
|
19
|
+
(0x2700, 0x27BF), # Dingbats
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
EXTRA_ALLOWED_RANGES: list[tuple[int, int]] = [
|
|
23
|
+
(0x0080, 0x00FF), # Latin-1 Supplement
|
|
24
|
+
(0x2000, 0x206F), # General Punctuation
|
|
25
|
+
(0x2100, 0x214F), # Letterlike Symbols
|
|
26
|
+
(0x2190, 0x21FF), # Arrows
|
|
27
|
+
(0x2200, 0x22FF), # Mathematical Operators
|
|
28
|
+
(0x2300, 0x23FF), # Miscellaneous Technical
|
|
29
|
+
(0x2500, 0x257F), # Box Drawing
|
|
30
|
+
(0x25A0, 0x25FF), # Geometric Shapes
|
|
31
|
+
(0x2B00, 0x2BFF), # Miscellaneous Symbols and Arrows
|
|
32
|
+
(0xFE00, 0xFE0F), # Variation Selectors
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
_ALL_RANGES = EMOJI_RANGES + EXTRA_ALLOWED_RANGES
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def is_allowed_char(c: str) -> bool:
|
|
39
|
+
"""Return True if the character is in the allowed set."""
|
|
40
|
+
code = ord(c)
|
|
41
|
+
if code <= 127:
|
|
42
|
+
return True
|
|
43
|
+
for start, end in _ALL_RANGES:
|
|
44
|
+
if start <= code <= end:
|
|
45
|
+
return True
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def check_file(path: Path, *, max_problems: int = 5) -> list[str]:
|
|
50
|
+
"""Check a single file for illegal characters.
|
|
51
|
+
|
|
52
|
+
Returns a list of problem descriptions (empty if the file is clean).
|
|
53
|
+
"""
|
|
54
|
+
problems: list[str] = []
|
|
55
|
+
try:
|
|
56
|
+
content = path.read_text(encoding="utf-8")
|
|
57
|
+
for i, char in enumerate(content, 1):
|
|
58
|
+
if not is_allowed_char(char):
|
|
59
|
+
problems.append(
|
|
60
|
+
f"Illegal character at position {i}: {char!r} (U+{ord(char):04X})"
|
|
61
|
+
)
|
|
62
|
+
if len(problems) >= max_problems:
|
|
63
|
+
break
|
|
64
|
+
except Exception as e:
|
|
65
|
+
problems.append(f"Failed to read file: {e}")
|
|
66
|
+
return problems
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def check_paths(paths: list[Path]) -> int:
|
|
70
|
+
"""Check multiple files. Returns 0 if all clean, 1 if any have problems."""
|
|
71
|
+
has_error = False
|
|
72
|
+
for path in paths:
|
|
73
|
+
problems = check_file(path)
|
|
74
|
+
if problems:
|
|
75
|
+
has_error = True
|
|
76
|
+
print(f"\n{path} contains illegal characters:")
|
|
77
|
+
for p in problems:
|
|
78
|
+
print(f" {p}")
|
|
79
|
+
return 1 if has_error else 0
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Circular import detection tool.
|
|
2
|
+
|
|
3
|
+
Builds a module-level dependency graph within a Python package
|
|
4
|
+
and reports any circular import chains.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ast
|
|
10
|
+
import sys
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ImportAnalyzer(ast.NodeVisitor):
|
|
16
|
+
"""Collect fully-qualified imports from a single Python file."""
|
|
17
|
+
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
self.imports: set[str] = set()
|
|
20
|
+
|
|
21
|
+
def visit_Import(self, node: ast.Import) -> None:
|
|
22
|
+
for alias in node.names:
|
|
23
|
+
self.imports.add(alias.name)
|
|
24
|
+
|
|
25
|
+
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
26
|
+
if node.module:
|
|
27
|
+
self.imports.add(node.module)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def file_to_module(file_path: Path, src_dir: Path) -> str:
|
|
31
|
+
"""Convert a file path to a dotted module name."""
|
|
32
|
+
rel = file_path.relative_to(src_dir)
|
|
33
|
+
parts = list(rel.parts)
|
|
34
|
+
if parts[-1] == "__init__.py":
|
|
35
|
+
parts = parts[:-1]
|
|
36
|
+
else:
|
|
37
|
+
parts[-1] = parts[-1].removesuffix(".py")
|
|
38
|
+
return ".".join(parts)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _resolve_imports(raw_imports: set[str], base_package: str) -> set[str]:
|
|
42
|
+
"""Filter imports to those within the base package."""
|
|
43
|
+
resolved: set[str] = set()
|
|
44
|
+
for imp in raw_imports:
|
|
45
|
+
if imp == base_package or imp.startswith(base_package + "."):
|
|
46
|
+
resolved.add(imp)
|
|
47
|
+
return resolved
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def build_dependency_graph(
|
|
51
|
+
src_dir: Path,
|
|
52
|
+
*,
|
|
53
|
+
base_package: str,
|
|
54
|
+
) -> dict[str, set[str]]:
|
|
55
|
+
"""Build a module-to-module dependency graph for the given package."""
|
|
56
|
+
graph: dict[str, set[str]] = defaultdict(set)
|
|
57
|
+
|
|
58
|
+
for py_file in src_dir.rglob("*.py"):
|
|
59
|
+
if "__pycache__" in py_file.parts:
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
module_name = file_to_module(py_file, src_dir)
|
|
63
|
+
if not module_name:
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
source = py_file.read_text(encoding="utf-8")
|
|
68
|
+
tree = ast.parse(source, filename=str(py_file))
|
|
69
|
+
except Exception as e:
|
|
70
|
+
print(f"Warning: could not parse {py_file}: {e}", file=sys.stderr)
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
analyzer = ImportAnalyzer()
|
|
74
|
+
analyzer.visit(tree)
|
|
75
|
+
|
|
76
|
+
deps = _resolve_imports(analyzer.imports, base_package)
|
|
77
|
+
deps.discard(module_name)
|
|
78
|
+
graph[module_name] = deps
|
|
79
|
+
|
|
80
|
+
return graph
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def find_cycles(graph: dict[str, set[str]]) -> list[list[str]]:
|
|
84
|
+
"""Find all elementary cycles in the dependency graph using DFS."""
|
|
85
|
+
WHITE, GRAY, BLACK = 0, 1, 2
|
|
86
|
+
color: dict[str, int] = defaultdict(int)
|
|
87
|
+
path: list[str] = []
|
|
88
|
+
cycles: list[list[str]] = []
|
|
89
|
+
|
|
90
|
+
def dfs(node: str) -> None:
|
|
91
|
+
color[node] = GRAY
|
|
92
|
+
path.append(node)
|
|
93
|
+
for neighbor in sorted(graph.get(node, set())):
|
|
94
|
+
if color[neighbor] == GRAY and neighbor in path:
|
|
95
|
+
idx = path.index(neighbor)
|
|
96
|
+
cycle = path[idx:] + [neighbor]
|
|
97
|
+
cycles.append(cycle)
|
|
98
|
+
elif color[neighbor] == WHITE:
|
|
99
|
+
dfs(neighbor)
|
|
100
|
+
path.pop()
|
|
101
|
+
color[node] = BLACK
|
|
102
|
+
|
|
103
|
+
for node in sorted(graph):
|
|
104
|
+
if color[node] == WHITE:
|
|
105
|
+
dfs(node)
|
|
106
|
+
|
|
107
|
+
# Deduplicate by normalizing cycle rotation
|
|
108
|
+
unique: list[list[str]] = []
|
|
109
|
+
seen: set[tuple[str, ...]] = set()
|
|
110
|
+
for cycle in cycles:
|
|
111
|
+
ring = cycle[:-1]
|
|
112
|
+
min_idx = ring.index(min(ring))
|
|
113
|
+
normalized = tuple(ring[min_idx:] + ring[:min_idx])
|
|
114
|
+
if normalized not in seen:
|
|
115
|
+
seen.add(normalized)
|
|
116
|
+
unique.append(cycle)
|
|
117
|
+
|
|
118
|
+
return unique
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def check_circular_imports(
|
|
122
|
+
src_dir: Path,
|
|
123
|
+
*,
|
|
124
|
+
base_package: str,
|
|
125
|
+
) -> int:
|
|
126
|
+
"""Run circular import detection. Returns 0 if clean, 1 if cycles found."""
|
|
127
|
+
if not src_dir.exists():
|
|
128
|
+
print(f"Error: {src_dir}/ directory not found", file=sys.stderr)
|
|
129
|
+
return 1
|
|
130
|
+
|
|
131
|
+
graph = build_dependency_graph(src_dir, base_package=base_package)
|
|
132
|
+
print(f"Scanned {len(graph)} modules")
|
|
133
|
+
|
|
134
|
+
cycles = find_cycles(graph)
|
|
135
|
+
if cycles:
|
|
136
|
+
print(f"\nFound {len(cycles)} circular import(s):\n")
|
|
137
|
+
for i, cycle in enumerate(cycles, 1):
|
|
138
|
+
print(f" Cycle {i}: {' -> '.join(cycle)}")
|
|
139
|
+
print()
|
|
140
|
+
return 1
|
|
141
|
+
|
|
142
|
+
print("No circular imports detected.")
|
|
143
|
+
return 0
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Command-line interface for apdev."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import importlib.resources
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import apdev
|
|
13
|
+
from apdev.check_chars import check_paths
|
|
14
|
+
from apdev.check_imports import check_circular_imports
|
|
15
|
+
from apdev.config import load_config
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _get_release_script() -> Path:
|
|
19
|
+
"""Locate the bundled release.sh script."""
|
|
20
|
+
ref = importlib.resources.files("apdev").joinpath("release.sh")
|
|
21
|
+
return Path(str(ref))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
25
|
+
parser = argparse.ArgumentParser(
|
|
26
|
+
prog="apdev",
|
|
27
|
+
description="Shared development tools for Python projects",
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"--version",
|
|
31
|
+
action="version",
|
|
32
|
+
version=f"apdev {apdev.__version__}",
|
|
33
|
+
)
|
|
34
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
35
|
+
|
|
36
|
+
# check-chars
|
|
37
|
+
chars_parser = subparsers.add_parser(
|
|
38
|
+
"check-chars",
|
|
39
|
+
help="Validate files contain only allowed characters",
|
|
40
|
+
)
|
|
41
|
+
chars_parser.add_argument(
|
|
42
|
+
"files",
|
|
43
|
+
nargs="*",
|
|
44
|
+
type=Path,
|
|
45
|
+
help="Files to check",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# check-imports
|
|
49
|
+
imports_parser = subparsers.add_parser(
|
|
50
|
+
"check-imports",
|
|
51
|
+
help="Detect circular imports in a Python package",
|
|
52
|
+
)
|
|
53
|
+
imports_parser.add_argument(
|
|
54
|
+
"--package",
|
|
55
|
+
dest="base_package",
|
|
56
|
+
help="Base package name (e.g. apflow). Reads from [tool.apdev] if omitted.",
|
|
57
|
+
)
|
|
58
|
+
imports_parser.add_argument(
|
|
59
|
+
"--src-dir",
|
|
60
|
+
dest="src_dir",
|
|
61
|
+
help="Source directory containing the package (default: src)",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# release
|
|
65
|
+
release_parser = subparsers.add_parser(
|
|
66
|
+
"release",
|
|
67
|
+
help="Interactive release automation (build, tag, GitHub release, PyPI upload)",
|
|
68
|
+
)
|
|
69
|
+
release_parser.add_argument(
|
|
70
|
+
"version",
|
|
71
|
+
nargs="?",
|
|
72
|
+
help="Version to release (auto-detected from pyproject.toml if omitted)",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
return parser
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def main(argv: list[str] | None = None) -> int:
|
|
79
|
+
"""Entry point for the apdev CLI."""
|
|
80
|
+
parser = _build_parser()
|
|
81
|
+
args = parser.parse_args(argv)
|
|
82
|
+
|
|
83
|
+
if args.command is None:
|
|
84
|
+
parser.print_help()
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
if args.command == "check-chars":
|
|
88
|
+
return check_paths(args.files)
|
|
89
|
+
|
|
90
|
+
if args.command == "check-imports":
|
|
91
|
+
config = load_config()
|
|
92
|
+
base_package = args.base_package or config.get("base_package")
|
|
93
|
+
src_dir_str = args.src_dir or config.get("src_dir", "src")
|
|
94
|
+
src_dir = Path(src_dir_str)
|
|
95
|
+
|
|
96
|
+
if not base_package:
|
|
97
|
+
print(
|
|
98
|
+
"Error: --package is required (or set base_package in [tool.apdev])",
|
|
99
|
+
file=sys.stderr,
|
|
100
|
+
)
|
|
101
|
+
return 1
|
|
102
|
+
|
|
103
|
+
return check_circular_imports(src_dir, base_package=base_package)
|
|
104
|
+
|
|
105
|
+
if args.command == "release":
|
|
106
|
+
script = _get_release_script()
|
|
107
|
+
if not script.is_file():
|
|
108
|
+
print("Error: release.sh not found in package", file=sys.stderr)
|
|
109
|
+
return 1
|
|
110
|
+
cmd = ["bash", str(script)]
|
|
111
|
+
if args.version:
|
|
112
|
+
cmd.append(args.version)
|
|
113
|
+
result = subprocess.run(cmd, env={**os.environ})
|
|
114
|
+
return result.returncode
|
|
115
|
+
|
|
116
|
+
return 0
|