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.
Files changed (51) hide show
  1. infrakit/__init__.py +0 -0
  2. infrakit/cli/__init__.py +1 -0
  3. infrakit/cli/commands/__init__.py +1 -0
  4. infrakit/cli/commands/deps.py +530 -0
  5. infrakit/cli/commands/init.py +129 -0
  6. infrakit/cli/commands/llm.py +295 -0
  7. infrakit/cli/commands/logger.py +160 -0
  8. infrakit/cli/commands/module.py +342 -0
  9. infrakit/cli/commands/time.py +81 -0
  10. infrakit/cli/main.py +65 -0
  11. infrakit/core/__init__.py +0 -0
  12. infrakit/core/config/__init__.py +0 -0
  13. infrakit/core/config/converter.py +480 -0
  14. infrakit/core/config/exporter.py +304 -0
  15. infrakit/core/config/loader.py +713 -0
  16. infrakit/core/config/validator.py +389 -0
  17. infrakit/core/logger/__init__.py +21 -0
  18. infrakit/core/logger/formatters.py +143 -0
  19. infrakit/core/logger/handlers.py +322 -0
  20. infrakit/core/logger/retention.py +176 -0
  21. infrakit/core/logger/setup.py +314 -0
  22. infrakit/deps/__init__.py +239 -0
  23. infrakit/deps/clean.py +141 -0
  24. infrakit/deps/depfile.py +405 -0
  25. infrakit/deps/health.py +357 -0
  26. infrakit/deps/optimizer.py +642 -0
  27. infrakit/deps/scanner.py +550 -0
  28. infrakit/llm/__init__.py +35 -0
  29. infrakit/llm/batch.py +165 -0
  30. infrakit/llm/client.py +575 -0
  31. infrakit/llm/key_manager.py +728 -0
  32. infrakit/llm/llm_readme.md +306 -0
  33. infrakit/llm/models.py +148 -0
  34. infrakit/llm/providers/__init__.py +5 -0
  35. infrakit/llm/providers/base.py +112 -0
  36. infrakit/llm/providers/gemini.py +164 -0
  37. infrakit/llm/providers/openai.py +168 -0
  38. infrakit/llm/rate_limiter.py +54 -0
  39. infrakit/scaffolder/__init__.py +31 -0
  40. infrakit/scaffolder/ai.py +508 -0
  41. infrakit/scaffolder/backend.py +555 -0
  42. infrakit/scaffolder/cli_tool.py +386 -0
  43. infrakit/scaffolder/generator.py +338 -0
  44. infrakit/scaffolder/pipeline.py +562 -0
  45. infrakit/scaffolder/registry.py +121 -0
  46. infrakit/time/__init__.py +60 -0
  47. infrakit/time/profiler.py +511 -0
  48. python_infrakit_dev-0.1.0.dist-info/METADATA +124 -0
  49. python_infrakit_dev-0.1.0.dist-info/RECORD +51 -0
  50. python_infrakit_dev-0.1.0.dist-info/WHEEL +4 -0
  51. 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)