troml-dev-status 0.1.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.
- troml_dev_status/__about__.py +17 -0
- troml_dev_status/__init__.py +0 -0
- troml_dev_status/__main__.py +6 -0
- troml_dev_status/analysis/__init__.py +0 -0
- troml_dev_status/analysis/bureaucracy.py +375 -0
- troml_dev_status/analysis/filesystem.py +174 -0
- troml_dev_status/analysis/git.py +58 -0
- troml_dev_status/analysis/pypi.py +32 -0
- troml_dev_status/checks.py +322 -0
- troml_dev_status/cli.py +63 -0
- troml_dev_status/engine.py +176 -0
- troml_dev_status/models.py +42 -0
- troml_dev_status/py.typed +0 -0
- troml_dev_status/reporting.py +83 -0
- troml_dev_status-0.1.0.dist-info/METADATA +114 -0
- troml_dev_status-0.1.0.dist-info/RECORD +19 -0
- troml_dev_status-0.1.0.dist-info/WHEEL +4 -0
- troml_dev_status-0.1.0.dist-info/entry_points.txt +3 -0
- troml_dev_status-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Metadata for troml_dev_status."""
|
|
2
|
+
|
|
3
|
+
__all__ = [
|
|
4
|
+
"__title__",
|
|
5
|
+
"__version__",
|
|
6
|
+
"__description__",
|
|
7
|
+
"__readme__",
|
|
8
|
+
"__requires_python__",
|
|
9
|
+
"__status__",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
__title__ = "troml-dev-status"
|
|
13
|
+
__version__ = "0.1.0"
|
|
14
|
+
__description__ = "Objectively infer PyPI Development Status classifiers from code and release artifacts."
|
|
15
|
+
__readme__ = "README.md"
|
|
16
|
+
__requires_python__ = ">=3.9"
|
|
17
|
+
__status__ = "1 - Planning"
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Dict, Iterable, Iterator, List, Mapping, Pattern, Set, Tuple
|
|
7
|
+
|
|
8
|
+
import pathspec
|
|
9
|
+
|
|
10
|
+
# ---- categories we recognize -------------------------------------------------
|
|
11
|
+
|
|
12
|
+
# Keep these stable so users can filter reliably.
|
|
13
|
+
CATEGORIES: Tuple[str, ...] = (
|
|
14
|
+
"contributing", # how to contribute
|
|
15
|
+
"code_of_conduct",
|
|
16
|
+
"security",
|
|
17
|
+
"governance",
|
|
18
|
+
"support",
|
|
19
|
+
"funding",
|
|
20
|
+
"legal", # license/notice/trademark
|
|
21
|
+
"citation", # research citation formats
|
|
22
|
+
"templates", # issue/PR templates
|
|
23
|
+
"release_notes", # changelog/news/history
|
|
24
|
+
"roadmap",
|
|
25
|
+
"style", # style guides / testing guides
|
|
26
|
+
"meta", # repo meta like CODEOWNERS, MAINTAINERS, AUTHORS
|
|
27
|
+
"automation", # config for bots/tools that shape contribution process
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# Extensions to consider, including "no extension" via empty string.
|
|
31
|
+
DEFAULT_EXTS: Tuple[str, ...] = ("", ".md", ".markdown", ".rst", ".txt", ".adoc")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Utility to build filename variants like "code-of-conduct", "code_of_conduct", "code of conduct"
|
|
35
|
+
def _variants(base: str) -> List[str]:
|
|
36
|
+
parts = re.split(r"[\s_\-\.\+]+", base.strip())
|
|
37
|
+
if not parts:
|
|
38
|
+
return []
|
|
39
|
+
joins = ["-".join(parts), "_".join(parts), " ".join(parts), "".join(parts)]
|
|
40
|
+
return list(dict.fromkeys([base] + joins)) # dedupe, preserve order
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Spec for one pattern family
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class PatternSpec:
|
|
46
|
+
# Match against full POSIX path (e.g., ".github/FUNDING.yml") OR filename
|
|
47
|
+
# Provide either filename variants or explicit path regexes.
|
|
48
|
+
filename_bases: Tuple[str, ...] = ()
|
|
49
|
+
path_regexes: Tuple[str, ...] = ()
|
|
50
|
+
exts: Tuple[str, ...] = DEFAULT_EXTS
|
|
51
|
+
|
|
52
|
+
def compile(self) -> Tuple[List[Pattern[str]], List[Pattern[str]]]:
|
|
53
|
+
# Build case-insensitive regexes for filenames and full paths.
|
|
54
|
+
fname_regexes: List[Pattern[str]] = []
|
|
55
|
+
path_regexes: List[Pattern[str]] = []
|
|
56
|
+
|
|
57
|
+
# Filename patterns: cover hyphen/underscore/space/concat variants + extensions.
|
|
58
|
+
for base in self.filename_bases:
|
|
59
|
+
for name_variant in _variants(base):
|
|
60
|
+
# Allow optional extension from allowed set.
|
|
61
|
+
# Example: r"^code[-_ ]?of[-_ ]?conduct(?:\.(md|rst|txt|adoc|markdown))?$" but faster to inject list
|
|
62
|
+
if self.exts and any(self.exts):
|
|
63
|
+
exts_pattern = "|".join(
|
|
64
|
+
re.escape(e.lstrip(".")) for e in self.exts if e
|
|
65
|
+
)
|
|
66
|
+
# Either no extension or one of the listed (if "" present)
|
|
67
|
+
allow_no_ext = "" in self.exts
|
|
68
|
+
if exts_pattern:
|
|
69
|
+
if allow_no_ext:
|
|
70
|
+
ext_regex = rf"(?:\.(?:{exts_pattern}))?"
|
|
71
|
+
else:
|
|
72
|
+
ext_regex = rf"\.(?:{exts_pattern})"
|
|
73
|
+
else:
|
|
74
|
+
ext_regex = "" # only no-ext
|
|
75
|
+
else:
|
|
76
|
+
ext_regex = "" # only no-ext
|
|
77
|
+
rx = re.compile(
|
|
78
|
+
rf"^{re.escape(name_variant)}{ext_regex}$", re.IGNORECASE
|
|
79
|
+
)
|
|
80
|
+
fname_regexes.append(rx)
|
|
81
|
+
|
|
82
|
+
# Explicit path regexes (already regex, we just compile case-insensitively).
|
|
83
|
+
for pr in self.path_regexes:
|
|
84
|
+
path_regexes.append(re.compile(pr, re.IGNORECASE))
|
|
85
|
+
|
|
86
|
+
return fname_regexes, path_regexes
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# Master registry mapping categories to one or more PatternSpecs.
|
|
90
|
+
PATTERNS: Mapping[str, Tuple[PatternSpec, ...]] = {
|
|
91
|
+
"contributing": (PatternSpec(filename_bases=("contributing", "contribute")),),
|
|
92
|
+
"code_of_conduct": (
|
|
93
|
+
PatternSpec(
|
|
94
|
+
filename_bases=("code_of_conduct", "code-of-conduct", "code of conduct")
|
|
95
|
+
),
|
|
96
|
+
),
|
|
97
|
+
"security": (PatternSpec(filename_bases=("security", "security policy")),),
|
|
98
|
+
"governance": (PatternSpec(filename_bases=("governance", "governance policy")),),
|
|
99
|
+
"support": (PatternSpec(filename_bases=("support", "getting help")),),
|
|
100
|
+
"funding": (
|
|
101
|
+
# Common: .github/FUNDING.yml; also accept FUNDING.* elsewhere
|
|
102
|
+
PatternSpec(
|
|
103
|
+
filename_bases=("funding",),
|
|
104
|
+
path_regexes=(r"/\.github/(?:.*/)?funding\.ya?ml$",),
|
|
105
|
+
exts=("", ".yml", ".yaml", ".md", ".rst", ".txt"),
|
|
106
|
+
),
|
|
107
|
+
),
|
|
108
|
+
"legal": (
|
|
109
|
+
PatternSpec(filename_bases=("license", "licence")),
|
|
110
|
+
PatternSpec(filename_bases=("notice", "notices")),
|
|
111
|
+
PatternSpec(filename_bases=("patent", "patents", "trademark", "copyright")),
|
|
112
|
+
),
|
|
113
|
+
"citation": (
|
|
114
|
+
PatternSpec(filename_bases=("citation",), exts=("", ".cff", ".md", ".txt")),
|
|
115
|
+
PatternSpec(path_regexes=(r"/citation\.cff$",)),
|
|
116
|
+
),
|
|
117
|
+
"templates": (
|
|
118
|
+
# Issue/PR templates in .github or root
|
|
119
|
+
PatternSpec(filename_bases=("pull_request_template", "pr_template")),
|
|
120
|
+
PatternSpec(filename_bases=("issue_template",)),
|
|
121
|
+
PatternSpec(
|
|
122
|
+
path_regexes=(r"/\.github/(?:.*/)?pull_request_template\.(?:md|rst|txt)$",)
|
|
123
|
+
),
|
|
124
|
+
PatternSpec(
|
|
125
|
+
path_regexes=(
|
|
126
|
+
r"/\.github/(?:.*/)?issue_template(?:s)?/.*\.(?:md|rst|txt)$",
|
|
127
|
+
)
|
|
128
|
+
),
|
|
129
|
+
),
|
|
130
|
+
"release_notes": (
|
|
131
|
+
PatternSpec(
|
|
132
|
+
filename_bases=("changelog", "changes", "history", "news", "release_notes")
|
|
133
|
+
),
|
|
134
|
+
),
|
|
135
|
+
"roadmap": (PatternSpec(filename_bases=("roadmap",)),),
|
|
136
|
+
"style": (
|
|
137
|
+
PatternSpec(
|
|
138
|
+
filename_bases=(
|
|
139
|
+
"styleguide",
|
|
140
|
+
"style guide",
|
|
141
|
+
"style",
|
|
142
|
+
"testing",
|
|
143
|
+
"test guide",
|
|
144
|
+
)
|
|
145
|
+
),
|
|
146
|
+
),
|
|
147
|
+
"meta": (
|
|
148
|
+
PatternSpec(filename_bases=("authors", "maintainers", "contributors")),
|
|
149
|
+
PatternSpec(
|
|
150
|
+
filename_bases=("codeowners",), exts=("",)
|
|
151
|
+
), # CODEOWNERS usually no ext
|
|
152
|
+
PatternSpec(path_regexes=(r"/\.github/CODEOWNERS$",)),
|
|
153
|
+
),
|
|
154
|
+
"automation": (
|
|
155
|
+
# Things that strongly influence contribution/maintenance process
|
|
156
|
+
PatternSpec(filename_bases=(".editorconfig",), exts=("",)),
|
|
157
|
+
PatternSpec(filename_bases=(".gitattributes",), exts=("",)),
|
|
158
|
+
PatternSpec(filename_bases=(".pre-commit-config",), exts=("", ".yaml", ".yml")),
|
|
159
|
+
PatternSpec(filename_bases=("dependabot",), exts=(".yml", ".yaml")),
|
|
160
|
+
PatternSpec(
|
|
161
|
+
filename_bases=("renovate",), exts=(".json", ".json5", ".yaml", ".yml")
|
|
162
|
+
),
|
|
163
|
+
PatternSpec(
|
|
164
|
+
path_regexes=(r"/\.github/dependabot\.ya?ml$", r"/renovate\.json5?$")
|
|
165
|
+
),
|
|
166
|
+
# Python tooling that often encodes “policy” for contributions
|
|
167
|
+
PatternSpec(filename_bases=("pyproject",), exts=(".toml",)),
|
|
168
|
+
PatternSpec(filename_bases=("setup",), exts=(".cfg",)),
|
|
169
|
+
PatternSpec(filename_bases=("mypy",), exts=(".ini", ".cfg")),
|
|
170
|
+
PatternSpec(filename_bases=("ruff",), exts=(".toml", ".cfg")),
|
|
171
|
+
),
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
# ---- core scanning helpers ---------------------------------------------------
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _compile_registry(
|
|
178
|
+
include_categories: Iterable[str] | None,
|
|
179
|
+
exclude_categories: Iterable[str] | None,
|
|
180
|
+
) -> Dict[str, Tuple[List[Pattern[str]], List[Pattern[str]]]]:
|
|
181
|
+
include: Set[str] = set(include_categories or CATEGORIES)
|
|
182
|
+
exclude: Set[str] = set(exclude_categories or ())
|
|
183
|
+
active = sorted((include - exclude) & set(CATEGORIES))
|
|
184
|
+
|
|
185
|
+
compiled: Dict[str, Tuple[List[Pattern[str]], List[Pattern[str]]]] = {}
|
|
186
|
+
for cat in active:
|
|
187
|
+
fname_list: List[Pattern[str]] = []
|
|
188
|
+
path_list: List[Pattern[str]] = []
|
|
189
|
+
for spec in PATTERNS.get(cat, ()):
|
|
190
|
+
f, p = spec.compile()
|
|
191
|
+
fname_list.extend(f)
|
|
192
|
+
path_list.extend(p)
|
|
193
|
+
compiled[cat] = (fname_list, path_list)
|
|
194
|
+
return compiled
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _match_category(
|
|
198
|
+
cat: str,
|
|
199
|
+
compiled: Mapping[str, Tuple[List[Pattern[str]], List[Pattern[str]]]],
|
|
200
|
+
path: Path,
|
|
201
|
+
) -> bool:
|
|
202
|
+
fname = path.name
|
|
203
|
+
posix = path.as_posix()
|
|
204
|
+
fname_regexes, path_regexes = compiled[cat]
|
|
205
|
+
return any(rx.search(fname) for rx in fname_regexes) or any(
|
|
206
|
+
rx.search(posix) for rx in path_regexes
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def scan_bureaucracy(
|
|
211
|
+
repo_path: Path,
|
|
212
|
+
include_categories: Iterable[str] | None = None,
|
|
213
|
+
exclude_categories: Iterable[str] | None = None,
|
|
214
|
+
follow_symlinks: bool = False,
|
|
215
|
+
) -> Dict[str, List[Path]]:
|
|
216
|
+
"""
|
|
217
|
+
Return a mapping of category -> list of Paths found.
|
|
218
|
+
|
|
219
|
+
- Case-insensitive filename matching with multiple stylistic variants.
|
|
220
|
+
- Matches also on full POSIX path for special locations (e.g., .github/FUNDING.yml).
|
|
221
|
+
- Deduplicates paths per category and overall (no duplicates across categories for the same file).
|
|
222
|
+
(If a file matches multiple categories, it is assigned to the first category in CATEGORIES order.)
|
|
223
|
+
"""
|
|
224
|
+
# compiled = _compile_registry(include_categories, exclude_categories)
|
|
225
|
+
# found_by_cat: MutableMapping[str, List[Path]] = {cat: [] for cat in compiled.keys()}
|
|
226
|
+
# seen: Set[Path] = set()
|
|
227
|
+
#
|
|
228
|
+
# # rglob all files (not directories); filter with our compiled patterns.
|
|
229
|
+
# for p in repo_path.rglob("*"):
|
|
230
|
+
# try:
|
|
231
|
+
# is_file = p.is_file() if not follow_symlinks else p.is_file() or p.is_symlink()
|
|
232
|
+
# except OSError:
|
|
233
|
+
# continue # permissions/broken links
|
|
234
|
+
# if not is_file:
|
|
235
|
+
# continue
|
|
236
|
+
#
|
|
237
|
+
# # Assign to first matching category in our canonical category order
|
|
238
|
+
# for cat in (c for c in CATEGORIES if c in compiled):
|
|
239
|
+
# if _match_category(cat, compiled, p):
|
|
240
|
+
# if p not in seen:
|
|
241
|
+
# found_by_cat[cat].append(p)
|
|
242
|
+
# seen.add(p)
|
|
243
|
+
# break
|
|
244
|
+
# Sort paths within each category (stable, user friendly)
|
|
245
|
+
# for cat in found_by_cat:
|
|
246
|
+
# found_by_cat[cat].sort(key=lambda x: x.as_posix().lower())
|
|
247
|
+
#
|
|
248
|
+
# return dict(found_by_cat)
|
|
249
|
+
|
|
250
|
+
compiled = _compile_registry(include_categories, exclude_categories)
|
|
251
|
+
found_by_cat: dict[str, list[Path]] = {cat: [] for cat in compiled}
|
|
252
|
+
seen: set[Path] = set()
|
|
253
|
+
|
|
254
|
+
for p in iter_repo_files(repo_path, follow_symlinks=follow_symlinks):
|
|
255
|
+
for cat in (c for c in CATEGORIES if c in compiled):
|
|
256
|
+
if _match_category(cat, compiled, p):
|
|
257
|
+
if p not in seen:
|
|
258
|
+
found_by_cat[cat].append(p)
|
|
259
|
+
seen.add(p)
|
|
260
|
+
break
|
|
261
|
+
|
|
262
|
+
# sort results
|
|
263
|
+
for cat in found_by_cat:
|
|
264
|
+
found_by_cat[cat].sort(key=lambda x: x.as_posix().lower())
|
|
265
|
+
return found_by_cat
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# ---- public APIs -------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def get_bureaucracy_files(
|
|
272
|
+
repo_path: Path,
|
|
273
|
+
categories: Iterable[str] | None = None,
|
|
274
|
+
exclude_categories: Iterable[str] | None = None,
|
|
275
|
+
) -> List[Path]:
|
|
276
|
+
"""
|
|
277
|
+
Drop-in replacement for your original function but expansive:
|
|
278
|
+
returns a flat, deduped list of matching files.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
repo_path: root of the repo.
|
|
282
|
+
categories: include only these categories (default: all known).
|
|
283
|
+
exclude_categories: skip these categories.
|
|
284
|
+
"""
|
|
285
|
+
mapping = scan_bureaucracy(
|
|
286
|
+
repo_path, include_categories=categories, exclude_categories=exclude_categories
|
|
287
|
+
)
|
|
288
|
+
# Preserve deterministic order: category order then path order.
|
|
289
|
+
ordered: List[Path] = []
|
|
290
|
+
for cat in CATEGORIES:
|
|
291
|
+
if cat in mapping:
|
|
292
|
+
ordered.extend(mapping[cat])
|
|
293
|
+
print(ordered)
|
|
294
|
+
return ordered
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def summarize_bureaucracy(
|
|
298
|
+
repo_path: Path,
|
|
299
|
+
categories: Iterable[str] | None = None,
|
|
300
|
+
exclude_categories: Iterable[str] | None = None,
|
|
301
|
+
) -> Dict[str, int]:
|
|
302
|
+
"""
|
|
303
|
+
Convenience: category -> count.
|
|
304
|
+
"""
|
|
305
|
+
mapping = scan_bureaucracy(
|
|
306
|
+
repo_path, include_categories=categories, exclude_categories=exclude_categories
|
|
307
|
+
)
|
|
308
|
+
return {k: len(v) for k, v in mapping.items()}
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# --- utility: load .gitignore -----------------------------------------------
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def load_gitignore(repo_path: Path) -> pathspec.PathSpec | None:
|
|
315
|
+
gitignore = repo_path / ".gitignore"
|
|
316
|
+
if not gitignore.is_file():
|
|
317
|
+
return None
|
|
318
|
+
spec = pathspec.PathSpec.from_lines(
|
|
319
|
+
"gitwildmatch", gitignore.read_text().splitlines()
|
|
320
|
+
)
|
|
321
|
+
return spec
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
# --- utility: walk with exclusions ------------------------------------------
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def iter_repo_files(repo_path: Path, follow_symlinks: bool = False) -> Iterator[Path]:
|
|
328
|
+
"""
|
|
329
|
+
Yield all files in repo_path, excluding:
|
|
330
|
+
- .git, .venv, venv, node_modules, __pycache__, etc.
|
|
331
|
+
- Anything ignored by .gitignore (if present).
|
|
332
|
+
"""
|
|
333
|
+
skip_dirs: Set[str] = {
|
|
334
|
+
".git",
|
|
335
|
+
".hg",
|
|
336
|
+
".svn",
|
|
337
|
+
".venv",
|
|
338
|
+
"venv",
|
|
339
|
+
"__pycache__",
|
|
340
|
+
"node_modules",
|
|
341
|
+
".mypy_cache",
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
gitignore_spec = load_gitignore(repo_path)
|
|
345
|
+
|
|
346
|
+
for path in repo_path.rglob("*"):
|
|
347
|
+
# Cheap directory pruning
|
|
348
|
+
parts = set(path.parts)
|
|
349
|
+
if parts & skip_dirs:
|
|
350
|
+
continue
|
|
351
|
+
|
|
352
|
+
# Only consider files
|
|
353
|
+
try:
|
|
354
|
+
if not (path.is_file() or (follow_symlinks and path.is_symlink())):
|
|
355
|
+
continue
|
|
356
|
+
except OSError:
|
|
357
|
+
continue
|
|
358
|
+
|
|
359
|
+
# Respect .gitignore
|
|
360
|
+
if gitignore_spec:
|
|
361
|
+
rel = str(path.relative_to(repo_path))
|
|
362
|
+
if gitignore_spec.match_file(rel):
|
|
363
|
+
continue
|
|
364
|
+
|
|
365
|
+
yield path
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# ---- quick extension guide ---------------------------------------------------
|
|
369
|
+
# To add a new doc kind:
|
|
370
|
+
# 1) Pick a category (or add a new one to CATEGORIES).
|
|
371
|
+
# 2) Add a PatternSpec to PATTERNS[category] with `filename_bases` and/or `path_regexes`.
|
|
372
|
+
# - filename_bases are human names; variants (-/_/space/concat) are generated automatically.
|
|
373
|
+
# - exts controls which extensions are accepted (include "" to allow no extension).
|
|
374
|
+
# - path_regexes are matched against full POSIX path (e.g., r"/\.github/somefile\.yml$").
|
|
375
|
+
# 3) Done. The scanner will include it automatically.
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# troml_dev_status/analysis/filesystem.py
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
|
|
7
|
+
# Use tomllib for Python 3.11+, fallback to tomli for older versions
|
|
8
|
+
try:
|
|
9
|
+
import tomllib
|
|
10
|
+
except ImportError:
|
|
11
|
+
import tomli as tomllib # type: ignore[no-redef,import-not-found]
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_project_name(repo_path: Path) -> str | None:
|
|
19
|
+
"""Parses pyproject.toml to find the project name."""
|
|
20
|
+
toml_path = repo_path / "pyproject.toml"
|
|
21
|
+
if not toml_path.exists():
|
|
22
|
+
return None
|
|
23
|
+
try:
|
|
24
|
+
with toml_path.open("rb") as f:
|
|
25
|
+
data = tomllib.load(f)
|
|
26
|
+
return data.get("project", {}).get("name")
|
|
27
|
+
except tomllib.TOMLDecodeError:
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_analysis_mode(repo_path: Path) -> str:
|
|
32
|
+
"""
|
|
33
|
+
Parses pyproject.toml to find the analysis mode from [tool.troml-dev-status].
|
|
34
|
+
Defaults to 'library' if not specified or invalid.
|
|
35
|
+
"""
|
|
36
|
+
toml_path = repo_path / "pyproject.toml"
|
|
37
|
+
if not toml_path.exists():
|
|
38
|
+
return "library" # Default if no pyproject.toml
|
|
39
|
+
try:
|
|
40
|
+
with toml_path.open("rb") as f:
|
|
41
|
+
data = tomllib.load(f)
|
|
42
|
+
# Look in [tool.troml-dev-status].mode
|
|
43
|
+
tool_config = data.get("tool", {}).get("troml-dev-status", {})
|
|
44
|
+
mode = tool_config.get("mode", "library")
|
|
45
|
+
if mode not in ["library", "application"]:
|
|
46
|
+
return "library" # Fallback for invalid value
|
|
47
|
+
return mode
|
|
48
|
+
except tomllib.TOMLDecodeError:
|
|
49
|
+
return "library" # Default on parse error
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_project_dependencies(repo_path: Path) -> list[str] | None:
|
|
53
|
+
"""Parses pyproject.toml to get the list of runtime dependencies."""
|
|
54
|
+
toml_path = repo_path / "pyproject.toml"
|
|
55
|
+
if not toml_path.exists():
|
|
56
|
+
return None
|
|
57
|
+
try:
|
|
58
|
+
with toml_path.open("rb") as f:
|
|
59
|
+
data = tomllib.load(f)
|
|
60
|
+
# PEP 621 specifies dependencies are in [project].dependencies
|
|
61
|
+
return data.get("project", {}).get("dependencies")
|
|
62
|
+
except tomllib.TOMLDecodeError:
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def find_src_dir(repo_path: Path) -> Path | None:
|
|
67
|
+
"""Finds the primary source directory (e.g., 'src/', or the package dir)."""
|
|
68
|
+
if (repo_path / "src").is_dir():
|
|
69
|
+
return repo_path / "src"
|
|
70
|
+
|
|
71
|
+
name = get_project_name(repo_path)
|
|
72
|
+
if name and (repo_path / name).is_dir():
|
|
73
|
+
return repo_path / name
|
|
74
|
+
if name and (repo_path / name.replace("-", "_")).is_dir():
|
|
75
|
+
return repo_path / name.replace("-", "_")
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def count_test_files(repo_path: Path) -> int:
|
|
80
|
+
"""Counts files matching test patterns."""
|
|
81
|
+
total = 0
|
|
82
|
+
for dir_name in ["test", "tests"]:
|
|
83
|
+
tests_dir = repo_path / dir_name
|
|
84
|
+
if not tests_dir.is_dir():
|
|
85
|
+
continue
|
|
86
|
+
found = len(list(tests_dir.glob("**/test_*.py"))) + len(
|
|
87
|
+
list(tests_dir.glob("**/*_test.py"))
|
|
88
|
+
)
|
|
89
|
+
total += found
|
|
90
|
+
return total
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def count_source_modules(src_path: Path) -> int:
|
|
94
|
+
"""Counts non-__init__.py Python modules in the source directory."""
|
|
95
|
+
if not src_path or not src_path.is_dir():
|
|
96
|
+
return 0
|
|
97
|
+
return sum(
|
|
98
|
+
1 for f in src_path.rglob("*.py") if f.is_file() and f.name != "__init__.py"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def get_ci_config_files(repo_path: Path) -> list[Path]:
|
|
103
|
+
"""Finds common CI configuration files."""
|
|
104
|
+
patterns = [".github/workflows/*.yml", ".github/workflows/*.yaml", ".gitlab-ci.yml"]
|
|
105
|
+
files = []
|
|
106
|
+
for pattern in patterns:
|
|
107
|
+
files.extend(list(repo_path.glob(pattern)))
|
|
108
|
+
return files
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def has_multi_python_in_ci(ci_files: list[Path]) -> bool:
|
|
112
|
+
"""A simple check to see if CI files mention multiple Python versions."""
|
|
113
|
+
py_versions = set()
|
|
114
|
+
for file_path in ci_files:
|
|
115
|
+
try:
|
|
116
|
+
with file_path.open("r", encoding="utf-8") as f:
|
|
117
|
+
content = f.read()
|
|
118
|
+
# Simple string search, not a full parse. Good enough for an objective signal.
|
|
119
|
+
if "3.9" in content:
|
|
120
|
+
py_versions.add("3.9")
|
|
121
|
+
if "3.10" in content:
|
|
122
|
+
py_versions.add("3.10")
|
|
123
|
+
if "3.11" in content:
|
|
124
|
+
py_versions.add("3.11")
|
|
125
|
+
if "3.12" in content:
|
|
126
|
+
py_versions.add("3.12")
|
|
127
|
+
if "3.13" in content:
|
|
128
|
+
py_versions.add("3.13")
|
|
129
|
+
except (IOError, yaml.YAMLError):
|
|
130
|
+
continue
|
|
131
|
+
return len(py_versions) >= 2
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def analyze_type_hint_coverage(src_path: Path) -> tuple[float, int]:
|
|
135
|
+
"""
|
|
136
|
+
Calculates the percentage of public functions/methods with type hints.
|
|
137
|
+
Returns (coverage_percentage, total_public_symbols).
|
|
138
|
+
"""
|
|
139
|
+
if not src_path or not src_path.is_dir():
|
|
140
|
+
return 0.0, 0
|
|
141
|
+
|
|
142
|
+
total_symbols = 0
|
|
143
|
+
annotated_symbols = 0
|
|
144
|
+
|
|
145
|
+
for py_file in src_path.rglob("*.py"):
|
|
146
|
+
try:
|
|
147
|
+
with py_file.open("r", encoding="utf-8") as f:
|
|
148
|
+
tree = ast.parse(f.read(), filename=str(py_file))
|
|
149
|
+
except (SyntaxError, UnicodeDecodeError):
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
for node in ast.walk(tree):
|
|
153
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
154
|
+
# Is it public? (not starting with _)
|
|
155
|
+
if not node.name.startswith("_"):
|
|
156
|
+
total_symbols += 1
|
|
157
|
+
# Is it annotated? (return annotation is sufficient)
|
|
158
|
+
if node.returns is not None:
|
|
159
|
+
annotated_symbols += 1
|
|
160
|
+
|
|
161
|
+
if total_symbols == 0:
|
|
162
|
+
return 0.0, 0
|
|
163
|
+
|
|
164
|
+
coverage = (annotated_symbols / total_symbols) * 100
|
|
165
|
+
return coverage, total_symbols
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def get_bureaucracy_files(repo_path: Path):
|
|
169
|
+
# could be in any case, could be with or without extension, could
|
|
170
|
+
patterns = ["security.md", "contributing.md", "code_of_conduct.md"]
|
|
171
|
+
files = []
|
|
172
|
+
for pattern in patterns:
|
|
173
|
+
files.extend(list(repo_path.glob(pattern)))
|
|
174
|
+
return files
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# troml_dev_status/analysis/git.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import subprocess # nosec
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _run_git_command(cwd: Path, *args: str) -> str | None:
|
|
10
|
+
"""Helper to run a git command and return its output."""
|
|
11
|
+
try:
|
|
12
|
+
result = subprocess.run( # nosec
|
|
13
|
+
["git", "-C", str(cwd), *args],
|
|
14
|
+
capture_output=True,
|
|
15
|
+
text=True,
|
|
16
|
+
check=True,
|
|
17
|
+
encoding="utf-8",
|
|
18
|
+
)
|
|
19
|
+
return result.stdout.strip()
|
|
20
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_latest_commit_date(repo_path: Path, sub_path: str = "src") -> datetime | None:
|
|
25
|
+
"""Finds the timestamp of the last commit to touch a specific subdirectory."""
|
|
26
|
+
path_spec = (repo_path / sub_path).relative_to(repo_path)
|
|
27
|
+
output = _run_git_command(
|
|
28
|
+
repo_path, "log", "-1", "--format=%ct", "--", str(path_spec)
|
|
29
|
+
)
|
|
30
|
+
if output and output.isdigit():
|
|
31
|
+
return datetime.fromtimestamp(int(output), tz=timezone.utc)
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def is_tag_signed(repo_path: Path, tag_name: str) -> bool:
|
|
36
|
+
"""Checks if a given Git tag is GPG signed and valid."""
|
|
37
|
+
# `git tag -v` returns 0 if signed and valid, non-zero otherwise.
|
|
38
|
+
# We capture stderr to prevent it from printing to the console on failure.
|
|
39
|
+
try:
|
|
40
|
+
subprocess.run( # nosec
|
|
41
|
+
["git", "-C", str(repo_path), "tag", "-v", tag_name],
|
|
42
|
+
check=True,
|
|
43
|
+
capture_output=True,
|
|
44
|
+
)
|
|
45
|
+
return True
|
|
46
|
+
except subprocess.CalledProcessError:
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_tags_by_date(repo_path: Path) -> list[str]:
|
|
51
|
+
"""Returns a list of all tags, sorted by date (newest first)."""
|
|
52
|
+
output = _run_git_command(repo_path, "tag", "--sort=-creatordate")
|
|
53
|
+
return output.splitlines() if output else []
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get_file_content_at_tag(repo_path: Path, tag: str, file_path: str) -> str | None:
|
|
57
|
+
"""Retrieves the content of a file at a specific Git tag."""
|
|
58
|
+
return _run_git_command(repo_path, "show", f"{tag}:{file_path}")
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# troml_dev_status/analysis/pypi.py
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from packaging.version import InvalidVersion, Version
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_project_data(project_name: str) -> dict | None:
|
|
10
|
+
"""Fetches the full JSON metadata for a project from PyPI."""
|
|
11
|
+
url = f"https://pypi.org/pypi/{project_name}/json"
|
|
12
|
+
try:
|
|
13
|
+
with httpx.Client() as client:
|
|
14
|
+
response = client.get(url, follow_redirects=True)
|
|
15
|
+
if response.status_code == 404:
|
|
16
|
+
return None
|
|
17
|
+
response.raise_for_status()
|
|
18
|
+
return response.json()
|
|
19
|
+
except httpx.RequestError:
|
|
20
|
+
# Handle network-related errors
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_sorted_versions(pypi_data: dict) -> list[Version]:
|
|
25
|
+
"""Extracts, validates, and sorts all release versions from PyPI data."""
|
|
26
|
+
versions = []
|
|
27
|
+
for v_str in pypi_data.get("releases", {}):
|
|
28
|
+
try:
|
|
29
|
+
versions.append(Version(v_str))
|
|
30
|
+
except InvalidVersion:
|
|
31
|
+
continue # Ignore invalid versions
|
|
32
|
+
return sorted(versions, reverse=True)
|