python-infrakit-dev 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.
- infrakit/__init__.py +0 -0
- infrakit/cli/__init__.py +1 -0
- infrakit/cli/commands/__init__.py +1 -0
- infrakit/cli/commands/deps.py +530 -0
- infrakit/cli/commands/init.py +129 -0
- infrakit/cli/commands/llm.py +295 -0
- infrakit/cli/commands/logger.py +160 -0
- infrakit/cli/commands/module.py +342 -0
- infrakit/cli/commands/time.py +81 -0
- infrakit/cli/main.py +65 -0
- infrakit/core/__init__.py +0 -0
- infrakit/core/config/__init__.py +0 -0
- infrakit/core/config/converter.py +480 -0
- infrakit/core/config/exporter.py +304 -0
- infrakit/core/config/loader.py +713 -0
- infrakit/core/config/validator.py +389 -0
- infrakit/core/logger/__init__.py +21 -0
- infrakit/core/logger/formatters.py +143 -0
- infrakit/core/logger/handlers.py +322 -0
- infrakit/core/logger/retention.py +176 -0
- infrakit/core/logger/setup.py +314 -0
- infrakit/deps/__init__.py +239 -0
- infrakit/deps/clean.py +141 -0
- infrakit/deps/depfile.py +405 -0
- infrakit/deps/health.py +357 -0
- infrakit/deps/optimizer.py +642 -0
- infrakit/deps/scanner.py +550 -0
- infrakit/llm/__init__.py +35 -0
- infrakit/llm/batch.py +165 -0
- infrakit/llm/client.py +575 -0
- infrakit/llm/key_manager.py +728 -0
- infrakit/llm/llm_readme.md +306 -0
- infrakit/llm/models.py +148 -0
- infrakit/llm/providers/__init__.py +5 -0
- infrakit/llm/providers/base.py +112 -0
- infrakit/llm/providers/gemini.py +164 -0
- infrakit/llm/providers/openai.py +168 -0
- infrakit/llm/rate_limiter.py +54 -0
- infrakit/scaffolder/__init__.py +31 -0
- infrakit/scaffolder/ai.py +508 -0
- infrakit/scaffolder/backend.py +555 -0
- infrakit/scaffolder/cli_tool.py +386 -0
- infrakit/scaffolder/generator.py +338 -0
- infrakit/scaffolder/pipeline.py +562 -0
- infrakit/scaffolder/registry.py +121 -0
- infrakit/time/__init__.py +60 -0
- infrakit/time/profiler.py +511 -0
- python_infrakit_dev-0.1.0.dist-info/METADATA +124 -0
- python_infrakit_dev-0.1.0.dist-info/RECORD +51 -0
- python_infrakit_dev-0.1.0.dist-info/WHEEL +4 -0
- python_infrakit_dev-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""
|
|
2
|
+
infrakit.core.logger.setup
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
Single entry point for configuring infrakit logging.
|
|
5
|
+
|
|
6
|
+
Call setup() once at application startup. Every module then calls
|
|
7
|
+
get_logger(__name__) — no configuration there.
|
|
8
|
+
|
|
9
|
+
from infrakit.core.logger import setup, get_logger
|
|
10
|
+
|
|
11
|
+
# Files only
|
|
12
|
+
setup(strategy="date_level", stream=None)
|
|
13
|
+
|
|
14
|
+
# Stream only
|
|
15
|
+
setup(strategy=None, stream="stdout")
|
|
16
|
+
|
|
17
|
+
# Files + stream (most common in prod)
|
|
18
|
+
setup(strategy="date_level", stream="stdout")
|
|
19
|
+
|
|
20
|
+
# Isolated session — new subfolder per run
|
|
21
|
+
setup(strategy="date_level", stream="stdout", session=True)
|
|
22
|
+
setup(strategy="date_level", stream="stdout", session="deploy-v1.2.0")
|
|
23
|
+
|
|
24
|
+
log = get_logger(__name__)
|
|
25
|
+
log.info("App started")
|
|
26
|
+
|
|
27
|
+
Env var overrides (take priority over kwargs):
|
|
28
|
+
INFRAKIT_LOG_LEVEL DEBUG | INFO | WARNING | ERROR | CRITICAL
|
|
29
|
+
INFRAKIT_LOG_FORMAT human | json
|
|
30
|
+
INFRAKIT_LOG_FILE_FMT human | json
|
|
31
|
+
INFRAKIT_LOG_STRATEGY file | date | level | date_level | date_size | None
|
|
32
|
+
INFRAKIT_LOG_STREAM stdout | stderr | none
|
|
33
|
+
INFRAKIT_LOG_RETENTION <int days>
|
|
34
|
+
INFRAKIT_LOG_SESSION <name> | true | false
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import logging
|
|
40
|
+
import os
|
|
41
|
+
import sys
|
|
42
|
+
from datetime import datetime, timezone
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
|
|
45
|
+
from infrakit.core.logger.handlers import FILE_STRATEGIES, build_handlers
|
|
46
|
+
from infrakit.core.logger.retention import sweep
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# Module-level state
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
_configured: bool = False
|
|
54
|
+
_ROOT_LOGGER = "infrakit"
|
|
55
|
+
|
|
56
|
+
_VALID_LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
|
|
57
|
+
_VALID_FORMATS = {"human", "json"}
|
|
58
|
+
|
|
59
|
+
_DEFAULT_LEVEL = "INFO"
|
|
60
|
+
_DEFAULT_FMT = "human"
|
|
61
|
+
_DEFAULT_FILE_FMT = "json"
|
|
62
|
+
_DEFAULT_STRATEGY = "date_level"
|
|
63
|
+
_DEFAULT_STREAM = "stdout"
|
|
64
|
+
_DEFAULT_LOG_DIR = "logs"
|
|
65
|
+
_DEFAULT_RETENTION = 30
|
|
66
|
+
_DEFAULT_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# Public API
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
def setup(
|
|
74
|
+
*,
|
|
75
|
+
level: str = _DEFAULT_LEVEL,
|
|
76
|
+
fmt: str = _DEFAULT_FMT,
|
|
77
|
+
file_fmt: str = _DEFAULT_FILE_FMT,
|
|
78
|
+
strategy: str | None = _DEFAULT_STRATEGY,
|
|
79
|
+
stream: str | None = _DEFAULT_STREAM,
|
|
80
|
+
log_dir: str | Path = _DEFAULT_LOG_DIR,
|
|
81
|
+
session: bool | str | None = None,
|
|
82
|
+
retention: int = _DEFAULT_RETENTION,
|
|
83
|
+
max_bytes: int = _DEFAULT_MAX_BYTES,
|
|
84
|
+
force: bool = False,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Configure infrakit logging. Call once at application startup.
|
|
87
|
+
|
|
88
|
+
Parameters
|
|
89
|
+
----------
|
|
90
|
+
level:
|
|
91
|
+
Minimum log level: ``DEBUG``, ``INFO``, ``WARNING``, ``ERROR``,
|
|
92
|
+
``CRITICAL``. Overridden by ``INFRAKIT_LOG_LEVEL``.
|
|
93
|
+
fmt:
|
|
94
|
+
Format for stream output: ``"human"`` (default) or ``"json"``.
|
|
95
|
+
Overridden by ``INFRAKIT_LOG_FORMAT``.
|
|
96
|
+
file_fmt:
|
|
97
|
+
Format for file output: ``"json"`` (default) or ``"human"``.
|
|
98
|
+
JSON is recommended — easier to parse in log aggregators.
|
|
99
|
+
Overridden by ``INFRAKIT_LOG_FILE_FMT``.
|
|
100
|
+
strategy:
|
|
101
|
+
File storage strategy — controls folder + filename layout:
|
|
102
|
+
|
|
103
|
+
``"file"`` logs/app.log (size-rotating)
|
|
104
|
+
``"date"`` logs/app.YYYY-MM-DD.log
|
|
105
|
+
``"level"`` logs/<level>/<level>.log
|
|
106
|
+
``"date_level"`` logs/<level>/<level>.YYYY-MM-DD.log
|
|
107
|
+
``"date_size"`` logs/app.YYYY-MM-DD.log + size cap
|
|
108
|
+
``None`` no file output
|
|
109
|
+
|
|
110
|
+
Overridden by ``INFRAKIT_LOG_STRATEGY``.
|
|
111
|
+
stream:
|
|
112
|
+
Stream to mirror all logs to, independent of strategy:
|
|
113
|
+
|
|
114
|
+
``"stdout"`` write to stdout
|
|
115
|
+
``"stderr"`` write to stderr
|
|
116
|
+
``None`` no stream output
|
|
117
|
+
|
|
118
|
+
Overridden by ``INFRAKIT_LOG_STREAM``.
|
|
119
|
+
log_dir:
|
|
120
|
+
Base directory for file strategies. Created automatically.
|
|
121
|
+
session:
|
|
122
|
+
Isolate this run in its own subfolder inside *log_dir*:
|
|
123
|
+
|
|
124
|
+
``True`` auto-generate timestamp folder:
|
|
125
|
+
logs/2025-03-22_14-32-01/
|
|
126
|
+
``"my-label"`` use named folder:
|
|
127
|
+
logs/my-label/
|
|
128
|
+
``None`` no isolation, write directly into log_dir (default)
|
|
129
|
+
|
|
130
|
+
Overridden by ``INFRAKIT_LOG_SESSION``.
|
|
131
|
+
retention:
|
|
132
|
+
Days to keep log files. Files older than this are deleted on startup.
|
|
133
|
+
Pass ``0`` to keep all files forever.
|
|
134
|
+
Overridden by ``INFRAKIT_LOG_RETENTION``.
|
|
135
|
+
max_bytes:
|
|
136
|
+
Max file size before rotation (for ``file`` and ``date_size``).
|
|
137
|
+
Default: 10 MB.
|
|
138
|
+
force:
|
|
139
|
+
Tear down existing handlers and reconfigure from scratch.
|
|
140
|
+
Required when calling setup() more than once (e.g. in tests).
|
|
141
|
+
"""
|
|
142
|
+
global _configured
|
|
143
|
+
|
|
144
|
+
if _configured and not force:
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
# --- Resolve env var overrides ---
|
|
148
|
+
level = _env_str("INFRAKIT_LOG_LEVEL", level).upper()
|
|
149
|
+
fmt = _env_str("INFRAKIT_LOG_FORMAT", fmt)
|
|
150
|
+
file_fmt = _env_str("INFRAKIT_LOG_FILE_FMT", file_fmt)
|
|
151
|
+
retention = _env_int("INFRAKIT_LOG_RETENTION", retention)
|
|
152
|
+
|
|
153
|
+
raw_strategy = _env_str("INFRAKIT_LOG_STRATEGY", "" if strategy is None else strategy)
|
|
154
|
+
strategy = None if raw_strategy.lower() in ("none", "") else raw_strategy
|
|
155
|
+
|
|
156
|
+
raw_stream = _env_str("INFRAKIT_LOG_STREAM", "" if stream is None else stream)
|
|
157
|
+
stream = None if raw_stream.lower() in ("none", "") else raw_stream
|
|
158
|
+
|
|
159
|
+
raw_session = os.environ.get("INFRAKIT_LOG_SESSION", "").strip()
|
|
160
|
+
if raw_session:
|
|
161
|
+
if raw_session.lower() == "true":
|
|
162
|
+
session = True
|
|
163
|
+
elif raw_session.lower() in ("false", "none", ""):
|
|
164
|
+
session = None
|
|
165
|
+
else:
|
|
166
|
+
session = raw_session
|
|
167
|
+
|
|
168
|
+
# --- Validate ---
|
|
169
|
+
if level not in _VALID_LEVELS:
|
|
170
|
+
raise ValueError(
|
|
171
|
+
f"Invalid log level '{level}'. "
|
|
172
|
+
f"Choose one of: {', '.join(sorted(_VALID_LEVELS))}"
|
|
173
|
+
)
|
|
174
|
+
if fmt not in _VALID_FORMATS:
|
|
175
|
+
raise ValueError(f"Invalid log format '{fmt}'. Choose 'human' or 'json'.")
|
|
176
|
+
if file_fmt not in _VALID_FORMATS:
|
|
177
|
+
raise ValueError(f"Invalid file_fmt '{file_fmt}'. Choose 'human' or 'json'.")
|
|
178
|
+
if strategy is not None and strategy not in FILE_STRATEGIES:
|
|
179
|
+
raise ValueError(
|
|
180
|
+
f"Invalid strategy '{strategy}'. "
|
|
181
|
+
f"Valid: {', '.join(sorted(FILE_STRATEGIES))} or None."
|
|
182
|
+
)
|
|
183
|
+
if stream not in {None, "stdout", "stderr"}:
|
|
184
|
+
raise ValueError(
|
|
185
|
+
f"Invalid stream '{stream}'. Choose 'stdout', 'stderr', or None."
|
|
186
|
+
)
|
|
187
|
+
if strategy is None and stream is None:
|
|
188
|
+
raise ValueError(
|
|
189
|
+
"At least one of strategy or stream must be set — "
|
|
190
|
+
"otherwise nothing will be logged anywhere."
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
numeric_level = getattr(logging, level)
|
|
194
|
+
log_dir = Path(log_dir)
|
|
195
|
+
|
|
196
|
+
# --- Resolve session subfolder ---
|
|
197
|
+
resolved_log_dir = _resolve_session_dir(log_dir, session)
|
|
198
|
+
|
|
199
|
+
# --- Retention sweep (runs before handlers attach, on root log_dir) ---
|
|
200
|
+
if strategy is not None and retention > 0:
|
|
201
|
+
try:
|
|
202
|
+
sweep(log_dir, retention_days=retention)
|
|
203
|
+
except Exception as exc:
|
|
204
|
+
print(
|
|
205
|
+
f"[infrakit.logger] Retention sweep failed: {exc}",
|
|
206
|
+
file=sys.stderr,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
# --- Configure root logger ---
|
|
210
|
+
root = logging.getLogger(_ROOT_LOGGER)
|
|
211
|
+
|
|
212
|
+
if force:
|
|
213
|
+
for h in root.handlers[:]:
|
|
214
|
+
h.close()
|
|
215
|
+
root.removeHandler(h)
|
|
216
|
+
|
|
217
|
+
root.setLevel(numeric_level)
|
|
218
|
+
root.propagate = False
|
|
219
|
+
|
|
220
|
+
# --- Build and attach handlers ---
|
|
221
|
+
handlers = build_handlers(
|
|
222
|
+
strategy=strategy,
|
|
223
|
+
stream=stream,
|
|
224
|
+
log_dir=resolved_log_dir,
|
|
225
|
+
fmt=fmt,
|
|
226
|
+
file_fmt=file_fmt,
|
|
227
|
+
max_bytes=max_bytes,
|
|
228
|
+
level=numeric_level,
|
|
229
|
+
)
|
|
230
|
+
for h in handlers:
|
|
231
|
+
root.addHandler(h)
|
|
232
|
+
|
|
233
|
+
_configured = True
|
|
234
|
+
|
|
235
|
+
root.debug(
|
|
236
|
+
"Logger configured: level=%s, fmt=%s, file_fmt=%s, "
|
|
237
|
+
"strategy=%s, stream=%s, log_dir='%s'",
|
|
238
|
+
level, fmt, file_fmt, strategy, stream, resolved_log_dir,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def get_logger(name: str) -> logging.Logger:
|
|
243
|
+
"""Return a stdlib Logger for *name*.
|
|
244
|
+
|
|
245
|
+
Always call as ``get_logger(__name__)``.
|
|
246
|
+
|
|
247
|
+
If setup() has not been called, a minimal stderr handler is added
|
|
248
|
+
automatically so logs are never silently swallowed.
|
|
249
|
+
"""
|
|
250
|
+
if not _configured:
|
|
251
|
+
_bootstrap()
|
|
252
|
+
return logging.getLogger(name)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def reset() -> None:
|
|
256
|
+
"""Tear down all handlers and reset configured state.
|
|
257
|
+
|
|
258
|
+
For use in tests only — lets each test start with a clean slate.
|
|
259
|
+
"""
|
|
260
|
+
global _configured
|
|
261
|
+
root = logging.getLogger(_ROOT_LOGGER)
|
|
262
|
+
for h in root.handlers[:]:
|
|
263
|
+
h.close()
|
|
264
|
+
root.removeHandler(h)
|
|
265
|
+
_configured = False
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# ---------------------------------------------------------------------------
|
|
269
|
+
# Internal helpers
|
|
270
|
+
# ---------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
def _resolve_session_dir(log_dir: Path, session: bool | str | None) -> Path:
|
|
273
|
+
"""Return the effective log directory, incorporating the session subfolder.
|
|
274
|
+
|
|
275
|
+
session=None → log_dir/
|
|
276
|
+
session=True → log_dir/2025-03-22_14-32-01/
|
|
277
|
+
session="my-run" → log_dir/my-run/
|
|
278
|
+
"""
|
|
279
|
+
if session is None:
|
|
280
|
+
return log_dir
|
|
281
|
+
if session is True:
|
|
282
|
+
ts = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d_%H-%M-%S")
|
|
283
|
+
return log_dir / ts
|
|
284
|
+
# String label — sanitise to avoid path traversal.
|
|
285
|
+
# Split on both separators, drop any ".." or "." components, rejoin
|
|
286
|
+
# with "-" so "../../evil" becomes "evil" and "../run" becomes "run".
|
|
287
|
+
raw = str(session).replace("\\", "/")
|
|
288
|
+
parts = [p for p in raw.split("/") if p and p not in ("..", ".")]
|
|
289
|
+
safe = "-".join(parts) if parts else "session"
|
|
290
|
+
return log_dir / safe
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _bootstrap() -> None:
|
|
294
|
+
"""Minimal fallback — add a WARNING stderr handler if setup() not called."""
|
|
295
|
+
root = logging.getLogger(_ROOT_LOGGER)
|
|
296
|
+
if not root.handlers:
|
|
297
|
+
h = logging.StreamHandler(sys.stderr)
|
|
298
|
+
h.setLevel(logging.WARNING)
|
|
299
|
+
root.addHandler(h)
|
|
300
|
+
root.setLevel(logging.WARNING)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _env_str(key: str, default: str) -> str:
|
|
304
|
+
return os.environ.get(key, "").strip() or default
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _env_int(key: str, default: int) -> int:
|
|
308
|
+
raw = os.environ.get(key, "").strip()
|
|
309
|
+
if not raw:
|
|
310
|
+
return default
|
|
311
|
+
try:
|
|
312
|
+
return int(raw)
|
|
313
|
+
except ValueError:
|
|
314
|
+
return default
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""
|
|
2
|
+
infrakit.deps
|
|
3
|
+
~~~~~~~~~~~~~
|
|
4
|
+
Dependency management module for infrakit.
|
|
5
|
+
|
|
6
|
+
Public API
|
|
7
|
+
----------
|
|
8
|
+
scan(root, ...) → ScanResult
|
|
9
|
+
export(root, ...) → exports used deps to file
|
|
10
|
+
check(packages, ...) → HealthReport
|
|
11
|
+
clean(root, ...) → CleanResult
|
|
12
|
+
optimise(root, ...) → list[OptimizeResult]
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import fnmatch
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
from infrakit.deps.scanner import ScanResult, scan_project
|
|
22
|
+
from infrakit.deps.depfile import (
|
|
23
|
+
DepFile,
|
|
24
|
+
find_dep_files,
|
|
25
|
+
all_declared_packages,
|
|
26
|
+
write_requirements,
|
|
27
|
+
update_requirements_inplace,
|
|
28
|
+
update_pyproject_inplace,
|
|
29
|
+
)
|
|
30
|
+
from .health import HealthReport, run_health_check
|
|
31
|
+
from infrakit.deps.clean import CleanResult, clean_environment
|
|
32
|
+
from infrakit.deps.optimizer import OptimizeResult, optimise_project
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Gitignore integration
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
def _build_gitignore_filter(root: Path):
|
|
40
|
+
"""
|
|
41
|
+
Returns a callable(rel_posix: str, parts: tuple) → bool that returns
|
|
42
|
+
True when a path should be excluded.
|
|
43
|
+
|
|
44
|
+
Tries to reuse infrakit's existing module-tree gitignore logic first;
|
|
45
|
+
falls back to a simple pattern matcher.
|
|
46
|
+
|
|
47
|
+
The filter receives:
|
|
48
|
+
rel_posix — forward-slash relative path string, e.g. "ignored/secret.py"
|
|
49
|
+
parts — tuple of path components, e.g. ("ignored", "secret.py")
|
|
50
|
+
"""
|
|
51
|
+
try:
|
|
52
|
+
from infrakit.module.tree import build_gitignore_filter as _ext # type: ignore
|
|
53
|
+
|
|
54
|
+
_inner = _ext(root)
|
|
55
|
+
|
|
56
|
+
def _wrapped(rel_posix: str, parts: tuple) -> bool:
|
|
57
|
+
# The existing infrakit filter expects a Path object
|
|
58
|
+
return _inner(root / Path(rel_posix))
|
|
59
|
+
|
|
60
|
+
return _wrapped
|
|
61
|
+
except ImportError:
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
# ── Fallback: simple .gitignore parser ────────────────────────────────
|
|
65
|
+
gitignore = root / ".gitignore"
|
|
66
|
+
if not gitignore.exists():
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
patterns: list[str] = []
|
|
70
|
+
for line in gitignore.read_text(encoding="utf-8").splitlines():
|
|
71
|
+
line = line.strip()
|
|
72
|
+
if line and not line.startswith("#"):
|
|
73
|
+
# Strip trailing slash — we'll check directory components directly
|
|
74
|
+
patterns.append(line.rstrip("/"))
|
|
75
|
+
|
|
76
|
+
if not patterns:
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
def _filter(rel_posix: str, parts: tuple) -> bool:
|
|
80
|
+
# Check each component of the path against all patterns.
|
|
81
|
+
# This correctly handles "ignored/" matching any directory named "ignored"
|
|
82
|
+
# regardless of OS path separator.
|
|
83
|
+
for pat in patterns:
|
|
84
|
+
# Match against full relative path (forward slashes)
|
|
85
|
+
if fnmatch.fnmatch(rel_posix, pat):
|
|
86
|
+
return True
|
|
87
|
+
# Match against filename
|
|
88
|
+
if parts and fnmatch.fnmatch(parts[-1], pat):
|
|
89
|
+
return True
|
|
90
|
+
# Match against each directory component (handles "ignored/" patterns)
|
|
91
|
+
for part in parts[:-1]:
|
|
92
|
+
if fnmatch.fnmatch(part, pat):
|
|
93
|
+
return True
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
return _filter
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
# High-level API
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
def scan(
|
|
104
|
+
root: Path,
|
|
105
|
+
include_notebooks: bool = False,
|
|
106
|
+
use_gitignore: bool = True,
|
|
107
|
+
) -> ScanResult:
|
|
108
|
+
"""Scan *root* for Python dependencies."""
|
|
109
|
+
gi_filter = _build_gitignore_filter(root) if use_gitignore else None
|
|
110
|
+
return scan_project(root, include_notebooks=include_notebooks, gitignore_filter=gi_filter)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def export(
|
|
114
|
+
root: Path,
|
|
115
|
+
output: Optional[Path] = None,
|
|
116
|
+
inplace: bool = False,
|
|
117
|
+
keep_versions: bool = True,
|
|
118
|
+
include_notebooks: bool = False,
|
|
119
|
+
use_gitignore: bool = True,
|
|
120
|
+
) -> tuple[ScanResult, list[DepFile]]:
|
|
121
|
+
"""
|
|
122
|
+
Scan project and export only used dependencies.
|
|
123
|
+
|
|
124
|
+
Parameters
|
|
125
|
+
----------
|
|
126
|
+
root:
|
|
127
|
+
Project root directory.
|
|
128
|
+
output:
|
|
129
|
+
Path for new file to write (when inplace=False).
|
|
130
|
+
inplace:
|
|
131
|
+
When True, updates existing dep files in-place.
|
|
132
|
+
keep_versions:
|
|
133
|
+
Preserve version specifiers from existing dep files.
|
|
134
|
+
include_notebooks:
|
|
135
|
+
Also scan .ipynb files.
|
|
136
|
+
use_gitignore:
|
|
137
|
+
Skip files matched by .gitignore.
|
|
138
|
+
"""
|
|
139
|
+
result = scan(root, include_notebooks=include_notebooks, use_gitignore=use_gitignore)
|
|
140
|
+
dep_files = find_dep_files(root)
|
|
141
|
+
declared = all_declared_packages(dep_files)
|
|
142
|
+
|
|
143
|
+
used_normalised = {
|
|
144
|
+
pkg.lower().replace("_", "-")
|
|
145
|
+
for pkg in result.used_packages
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if inplace:
|
|
149
|
+
for df in dep_files:
|
|
150
|
+
if df.format == "requirements":
|
|
151
|
+
update_requirements_inplace(df, used_normalised)
|
|
152
|
+
elif df.format == "pyproject":
|
|
153
|
+
update_pyproject_inplace(df, used_normalised)
|
|
154
|
+
elif output:
|
|
155
|
+
write_requirements(
|
|
156
|
+
packages=list(result.used_packages.keys()),
|
|
157
|
+
declared=declared,
|
|
158
|
+
output_path=output,
|
|
159
|
+
keep_versions=keep_versions,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
return result, dep_files
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def check(
|
|
166
|
+
root: Optional[Path] = None,
|
|
167
|
+
packages: Optional[list[str]] = None,
|
|
168
|
+
outdated: bool = True,
|
|
169
|
+
security: bool = True,
|
|
170
|
+
licenses: bool = True,
|
|
171
|
+
) -> HealthReport:
|
|
172
|
+
"""
|
|
173
|
+
Run health checks on packages.
|
|
174
|
+
If *packages* is None and *root* is given, scans root first.
|
|
175
|
+
"""
|
|
176
|
+
if packages is None:
|
|
177
|
+
if root is None:
|
|
178
|
+
raise ValueError("Provide either root or packages")
|
|
179
|
+
result = scan(root)
|
|
180
|
+
packages = list(result.used_packages.keys())
|
|
181
|
+
|
|
182
|
+
return run_health_check(
|
|
183
|
+
packages=packages,
|
|
184
|
+
check_outdated_flag=outdated,
|
|
185
|
+
check_vulns_flag=security,
|
|
186
|
+
check_licenses_flag=licenses,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def clean(
|
|
191
|
+
root: Path,
|
|
192
|
+
protected: Optional[set[str]] = None,
|
|
193
|
+
dry_run: bool = True,
|
|
194
|
+
) -> CleanResult:
|
|
195
|
+
"""
|
|
196
|
+
Find and optionally remove unused packages from the venv.
|
|
197
|
+
Always dry-run by default — pass dry_run=False to actually uninstall.
|
|
198
|
+
"""
|
|
199
|
+
result = scan(root)
|
|
200
|
+
dep_files = find_dep_files(root)
|
|
201
|
+
declared = all_declared_packages(dep_files)
|
|
202
|
+
|
|
203
|
+
return clean_environment(
|
|
204
|
+
used_packages=set(result.used_packages.keys()),
|
|
205
|
+
declared_packages={d.name for d in declared.values()},
|
|
206
|
+
protected=protected,
|
|
207
|
+
dry_run=dry_run,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def optimise(
|
|
212
|
+
root: Path,
|
|
213
|
+
files: Optional[list[Path]] = None,
|
|
214
|
+
convert_to: Optional[str] = None,
|
|
215
|
+
use_isort: bool = True,
|
|
216
|
+
dry_run: bool = False,
|
|
217
|
+
) -> list[OptimizeResult]:
|
|
218
|
+
"""Optimise imports across the project."""
|
|
219
|
+
local_pkgs: set[str] = set()
|
|
220
|
+
for p in root.iterdir():
|
|
221
|
+
if p.is_dir() and (p / "__init__.py").exists():
|
|
222
|
+
local_pkgs.add(p.name)
|
|
223
|
+
if p.is_file() and p.suffix == ".py":
|
|
224
|
+
local_pkgs.add(p.stem)
|
|
225
|
+
|
|
226
|
+
return optimise_project(
|
|
227
|
+
root=root,
|
|
228
|
+
files=files,
|
|
229
|
+
local_packages=local_pkgs,
|
|
230
|
+
convert_to=convert_to,
|
|
231
|
+
use_isort=use_isort,
|
|
232
|
+
dry_run=dry_run,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
__all__ = [
|
|
237
|
+
"scan", "export", "check", "clean", "optimise",
|
|
238
|
+
"ScanResult", "HealthReport", "CleanResult", "OptimizeResult",
|
|
239
|
+
]
|
infrakit/deps/clean.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""
|
|
2
|
+
infrakit.deps.clean
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
Remove unused packages from the active virtual environment.
|
|
5
|
+
Never touches dependency files — that is the user's job via `ik deps export`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from infrakit.deps.health import get_all_installed
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# Data structures
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class CleanResult:
|
|
24
|
+
to_remove: list[str] = field(default_factory=list)
|
|
25
|
+
removed: list[str] = field(default_factory=list)
|
|
26
|
+
skipped: list[str] = field(default_factory=list)
|
|
27
|
+
errors: list[str] = field(default_factory=list)
|
|
28
|
+
dry_run: bool = True
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Protected / never-uninstall packages
|
|
33
|
+
# These are pip / setuptools / wheel and infrakit itself.
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
_ALWAYS_KEEP: frozenset[str] = frozenset({
|
|
37
|
+
"pip", "setuptools", "wheel", "pkg-resources", "pkg_resources",
|
|
38
|
+
"distribute", "infrakit",
|
|
39
|
+
# Common tools users always want
|
|
40
|
+
"build", "twine", "flit", "hatch", "hatchling", "poetry",
|
|
41
|
+
"pip-tools", "pip_tools",
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _normalise(name: str) -> str:
|
|
46
|
+
return name.lower().replace("_", "-")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# Core logic
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
def compute_removable(
|
|
54
|
+
used_packages: set[str], # pip names actually used in code
|
|
55
|
+
declared_packages: set[str], # pip names in dep file
|
|
56
|
+
protected: Optional[set[str]] = None,
|
|
57
|
+
) -> list[str]:
|
|
58
|
+
"""
|
|
59
|
+
Return a list of installed packages that are:
|
|
60
|
+
- NOT in used_packages
|
|
61
|
+
- NOT in declared_packages (user explicitly declared = keep)
|
|
62
|
+
- NOT in _ALWAYS_KEEP
|
|
63
|
+
- NOT protected by the caller
|
|
64
|
+
"""
|
|
65
|
+
installed = get_all_installed()
|
|
66
|
+
extra_protect = {_normalise(p) for p in (protected or set())}
|
|
67
|
+
used_norm = {_normalise(p) for p in used_packages}
|
|
68
|
+
declared_norm = {_normalise(p) for p in declared_packages}
|
|
69
|
+
keep = {_normalise(p) for p in _ALWAYS_KEEP} | extra_protect
|
|
70
|
+
|
|
71
|
+
removable: list[str] = []
|
|
72
|
+
for pkg_name in installed:
|
|
73
|
+
norm = _normalise(pkg_name)
|
|
74
|
+
if norm in keep:
|
|
75
|
+
continue
|
|
76
|
+
if norm in used_norm or norm in declared_norm:
|
|
77
|
+
continue
|
|
78
|
+
removable.append(pkg_name)
|
|
79
|
+
|
|
80
|
+
return sorted(removable, key=str.lower)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def uninstall_packages(
|
|
84
|
+
packages: list[str],
|
|
85
|
+
dry_run: bool = True,
|
|
86
|
+
) -> CleanResult:
|
|
87
|
+
"""
|
|
88
|
+
Uninstall packages from the current Python environment.
|
|
89
|
+
When dry_run=True, only reports what would be removed.
|
|
90
|
+
"""
|
|
91
|
+
result = CleanResult(to_remove=packages, dry_run=dry_run)
|
|
92
|
+
|
|
93
|
+
if dry_run or not packages:
|
|
94
|
+
return result
|
|
95
|
+
|
|
96
|
+
for pkg in packages:
|
|
97
|
+
try:
|
|
98
|
+
proc = subprocess.run(
|
|
99
|
+
[sys.executable, "-m", "pip", "uninstall", "-y", pkg],
|
|
100
|
+
capture_output=True,
|
|
101
|
+
text=True,
|
|
102
|
+
timeout=60,
|
|
103
|
+
)
|
|
104
|
+
if proc.returncode == 0:
|
|
105
|
+
result.removed.append(pkg)
|
|
106
|
+
else:
|
|
107
|
+
err = proc.stderr.strip() or proc.stdout.strip()
|
|
108
|
+
result.errors.append(f"{pkg}: {err}")
|
|
109
|
+
result.skipped.append(pkg)
|
|
110
|
+
except subprocess.TimeoutExpired:
|
|
111
|
+
result.errors.append(f"{pkg}: uninstall timed out")
|
|
112
|
+
result.skipped.append(pkg)
|
|
113
|
+
except Exception as exc:
|
|
114
|
+
result.errors.append(f"{pkg}: {exc}")
|
|
115
|
+
result.skipped.append(pkg)
|
|
116
|
+
|
|
117
|
+
return result
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def clean_environment(
|
|
121
|
+
used_packages: set[str],
|
|
122
|
+
declared_packages: set[str],
|
|
123
|
+
protected: Optional[set[str]] = None,
|
|
124
|
+
dry_run: bool = True,
|
|
125
|
+
) -> CleanResult:
|
|
126
|
+
"""
|
|
127
|
+
High-level entry: compute what to remove and optionally remove it.
|
|
128
|
+
|
|
129
|
+
Parameters
|
|
130
|
+
----------
|
|
131
|
+
used_packages:
|
|
132
|
+
Packages confirmed used in source code (from scanner).
|
|
133
|
+
declared_packages:
|
|
134
|
+
Packages explicitly declared in requirements.txt / pyproject.toml.
|
|
135
|
+
protected:
|
|
136
|
+
Additional package names the caller wants to keep regardless.
|
|
137
|
+
dry_run:
|
|
138
|
+
When True (default), only compute + report — don't uninstall.
|
|
139
|
+
"""
|
|
140
|
+
removable = compute_removable(used_packages, declared_packages, protected)
|
|
141
|
+
return uninstall_packages(removable, dry_run=dry_run)
|