import-effects 0.0.1__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.
@@ -0,0 +1,15 @@
1
+ from .inspector import InspectionError, assert_no_effects, inspect_import
2
+ from .models import Attribution, Confidence, Effect, EffectKind, ImportReport
3
+
4
+ __all__ = [
5
+ "Attribution",
6
+ "Confidence",
7
+ "Effect",
8
+ "EffectKind",
9
+ "ImportReport",
10
+ "InspectionError",
11
+ "assert_no_effects",
12
+ "inspect_import",
13
+ ]
14
+
15
+ __version__ = "0.0.1"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,321 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib
4
+ import inspect
5
+ import json
6
+ import logging
7
+ import multiprocessing
8
+ import os
9
+ import platform
10
+ import re
11
+ import signal
12
+ import sys
13
+ import threading
14
+ import time
15
+ import warnings
16
+ from collections.abc import Mapping
17
+ from dataclasses import asdict, dataclass
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from .models import Attribution, Confidence, Effect, EffectKind, ImportReport
22
+
23
+ _SECRET_RE = re.compile(
24
+ r"(?i)(token|secret|password|passwd|api[-_]?key|authorization)(=|:)([^\s]+)"
25
+ )
26
+ _WRITE_FLAGS = os.O_WRONLY | os.O_RDWR | os.O_CREAT | os.O_TRUNC | os.O_APPEND
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class _Snapshot:
31
+ cwd: str
32
+ environment: dict[str, str]
33
+ sys_path: tuple[str, ...]
34
+ warning_filters: tuple[str, ...]
35
+ logging_handlers: tuple[tuple[str, str], ...]
36
+ signal_handlers: dict[int, str]
37
+ modules: frozenset[str]
38
+
39
+
40
+ class _Observer:
41
+ def __init__(self, target: str, report_path: Path) -> None:
42
+ self.target = target
43
+ self.report_path = report_path.resolve()
44
+ self.effects: list[Effect] = []
45
+ self.active = False
46
+ self._keys: set[tuple[str, str]] = set()
47
+
48
+ def add(
49
+ self,
50
+ kind: EffectKind,
51
+ detail: str,
52
+ *,
53
+ confidence: Confidence = "high",
54
+ source: str | None = None,
55
+ attribution: Attribution | None = None,
56
+ metadata: Mapping[str, str | int | float | bool | None] | None = None,
57
+ ) -> None:
58
+ if not self.active:
59
+ return
60
+ clean_detail = _redact(detail)
61
+ key = (kind, clean_detail)
62
+ if key in self._keys:
63
+ return
64
+ self._keys.add(key)
65
+ inferred_source, inferred_attribution = self._infer_source()
66
+ self.effects.append(
67
+ Effect(
68
+ kind=kind,
69
+ detail=clean_detail,
70
+ source=source or inferred_source,
71
+ confidence=confidence,
72
+ attribution=attribution or inferred_attribution,
73
+ metadata=metadata or {},
74
+ )
75
+ )
76
+
77
+ def _infer_source(self) -> tuple[str | None, Attribution]:
78
+ frame = inspect.currentframe()
79
+ try:
80
+ while frame:
81
+ name = str(frame.f_globals.get("__name__", ""))
82
+ if name == self.target or name.startswith(self.target + "."):
83
+ return name, "target"
84
+ if name and not name.startswith(("import_effects", "importlib", "threading")):
85
+ path = frame.f_globals.get("__file__")
86
+ if path and "site-packages" in os.fspath(path):
87
+ return name, "dependency"
88
+ frame = frame.f_back
89
+ finally:
90
+ del frame
91
+ return None, "observed-during-import"
92
+
93
+ def audit(self, event: str, args: tuple[Any, ...]) -> None:
94
+ if not self.active:
95
+ return
96
+ try:
97
+ if event == "open":
98
+ self._audit_open(args)
99
+ elif event in {"os.remove", "os.unlink"}:
100
+ path = _path(args[0])
101
+ if not _is_runtime_path(path):
102
+ self.add("file-delete", path)
103
+ elif event in {"os.rename", "os.replace"}:
104
+ source, destination = _path(args[0]), _path(args[1])
105
+ if not (_is_runtime_path(source) or _is_runtime_path(destination)):
106
+ self.add("file-rename", f"{source} -> {destination}")
107
+ elif event in {"os.mkdir", "os.rmdir"}:
108
+ path = _path(args[0])
109
+ if not _is_runtime_path(path):
110
+ action = "mkdir" if event == "os.mkdir" else "rmdir"
111
+ self.add("directory", f"{action} {path}")
112
+ elif event == "socket.connect":
113
+ self.add("network", _address(args[-1]), metadata={"operation": "connect"})
114
+ elif event == "socket.bind":
115
+ self.add("network", f"bind {_address(args[-1])}", metadata={"operation": "bind"})
116
+ elif event == "socket.getaddrinfo":
117
+ host = args[0] if args else "unknown"
118
+ port = args[1] if len(args) > 1 else None
119
+ self.add(
120
+ "network",
121
+ _address((host, port)),
122
+ confidence="medium",
123
+ metadata={"operation": "dns"},
124
+ )
125
+ elif event == "subprocess.Popen":
126
+ executable = args[0] if args else "unknown"
127
+ arguments = args[1] if len(args) > 1 else ()
128
+ self.add("subprocess", _command(executable, arguments))
129
+ elif event in {"os.system", "os.posix_spawn", "os.posix_spawnp"}:
130
+ self.add("subprocess", _command(args[0] if args else "unknown", ()))
131
+ elif event in {"os.fork", "os.forkpty"}:
132
+ self.add("multiprocessing", "forked child process")
133
+ except Exception:
134
+ # Audit hooks must never break the target import.
135
+ return
136
+
137
+ def _audit_open(self, args: tuple[Any, ...]) -> None:
138
+ if not args:
139
+ return
140
+ if isinstance(args[0], int):
141
+ return
142
+ path = _path(args[0])
143
+ if _is_internal_write(path, self.report_path):
144
+ return
145
+ mode = args[1] if len(args) > 1 else None
146
+ flags = args[2] if len(args) > 2 else 0
147
+ writes = isinstance(mode, str) and any(character in mode for character in "wax+")
148
+ writes = writes or (isinstance(flags, int) and bool(flags & _WRITE_FLAGS))
149
+ if writes:
150
+ self.add("file-write", path)
151
+
152
+
153
+ def _snapshot() -> _Snapshot:
154
+ return _Snapshot(
155
+ cwd=os.getcwd(),
156
+ environment=dict(os.environ),
157
+ sys_path=tuple(sys.path),
158
+ warning_filters=tuple(repr(item) for item in warnings.filters),
159
+ logging_handlers=tuple(
160
+ sorted(
161
+ (logger_name or "root", type(handler).__name__)
162
+ for logger_name, logger in [
163
+ ("", logging.getLogger()),
164
+ *logging.Logger.manager.loggerDict.items(),
165
+ ]
166
+ if isinstance(logger, logging.Logger)
167
+ for handler in logger.handlers
168
+ )
169
+ ),
170
+ signal_handlers={
171
+ number: _handler_name(signal.getsignal(number)) for number in _available_signals()
172
+ },
173
+ modules=frozenset(sys.modules),
174
+ )
175
+
176
+
177
+ def _compare(observer: _Observer, before: _Snapshot, after: _Snapshot) -> None:
178
+ if before.cwd != after.cwd:
179
+ observer.add("cwd", f"{before.cwd} -> {after.cwd}", confidence="high")
180
+ before_keys, after_keys = set(before.environment), set(after.environment)
181
+ for key in sorted(after_keys - before_keys):
182
+ observer.add("environment", f"added {key}", confidence="high")
183
+ for key in sorted(before_keys - after_keys):
184
+ observer.add("environment", f"removed {key}", confidence="high")
185
+ for key in sorted(before_keys & after_keys):
186
+ if before.environment[key] != after.environment[key]:
187
+ observer.add("environment", f"changed {key}", confidence="high")
188
+ if before.sys_path != after.sys_path:
189
+ observer.add("sys-path", "sys.path changed", confidence="medium")
190
+ if before.warning_filters != after.warning_filters:
191
+ observer.add("warnings", "warnings filters changed", confidence="medium")
192
+ added_handlers = list(after.logging_handlers)
193
+ for original_handler in before.logging_handlers:
194
+ if original_handler in added_handlers:
195
+ added_handlers.remove(original_handler)
196
+ for logger_name, handler_name in added_handlers:
197
+ observer.add("logging", f"added {handler_name} handler to {logger_name}")
198
+ for number, signal_handler in after.signal_handlers.items():
199
+ if before.signal_handlers.get(number) != signal_handler:
200
+ observer.add("signal", f"{_signal_name(number)} handler changed", confidence="medium")
201
+
202
+
203
+ def run_probe(module: str, report_path: Path) -> int:
204
+ observer = _Observer(module, report_path)
205
+ sys.addaudithook(observer.audit)
206
+ original_thread_start = threading.Thread.start
207
+ original_process_start = multiprocessing.process.BaseProcess.start
208
+
209
+ def thread_start(thread: threading.Thread, *args: Any, **kwargs: Any) -> Any:
210
+ observer.add("thread", thread.name or type(thread).__name__)
211
+ return original_thread_start(thread, *args, **kwargs)
212
+
213
+ def process_start(
214
+ process: multiprocessing.process.BaseProcess, *args: Any, **kwargs: Any
215
+ ) -> Any:
216
+ observer.add("multiprocessing", process.name or type(process).__name__)
217
+ return original_process_start(process, *args, **kwargs)
218
+
219
+ threading.Thread.start = thread_start # type: ignore[assignment]
220
+ multiprocessing.process.BaseProcess.start = process_start # type: ignore[assignment]
221
+ before = _snapshot()
222
+ observer.active = True
223
+ started = time.perf_counter()
224
+ success = True
225
+ exception_type: str | None = None
226
+ exception_message: str | None = None
227
+ try:
228
+ importlib.import_module(module)
229
+ except BaseException as error:
230
+ success = False
231
+ exception_type = type(error).__name__
232
+ exception_message = _redact(str(error))[:1000]
233
+ duration_ms = (time.perf_counter() - started) * 1000
234
+ after = _snapshot()
235
+ _compare(observer, before, after)
236
+ observer.active = False
237
+ threading.Thread.start = original_thread_start # type: ignore[method-assign]
238
+ multiprocessing.process.BaseProcess.start = original_process_start # type: ignore[method-assign]
239
+ imported_modules = tuple(sorted(after.modules - before.modules))
240
+ report = ImportReport(
241
+ module=module,
242
+ duration_ms=duration_ms,
243
+ effects=tuple(observer.effects),
244
+ imported_modules=imported_modules,
245
+ success=success,
246
+ exception_type=exception_type,
247
+ exception_message=exception_message,
248
+ platform=platform.platform(),
249
+ python_version=platform.python_version(),
250
+ )
251
+ report_path.write_text(json.dumps(asdict(report), sort_keys=True), encoding="utf-8")
252
+ return 0
253
+
254
+
255
+ def _path(value: Any) -> str:
256
+ try:
257
+ return os.path.abspath(os.fsdecode(value))
258
+ except (TypeError, ValueError):
259
+ return "<unprintable path>"
260
+
261
+
262
+ def _address(value: Any) -> str:
263
+ if isinstance(value, tuple) and len(value) >= 2:
264
+ return f"{value[0]}:{value[1]}"
265
+ return str(value)
266
+
267
+
268
+ def _command(executable: Any, arguments: Any) -> str:
269
+ if isinstance(arguments, (list, tuple)):
270
+ rendered = " ".join(str(item) for item in arguments[:12])
271
+ return rendered or str(executable)
272
+ if isinstance(arguments, (str, bytes)):
273
+ return os.fsdecode(arguments)
274
+ return str(executable)
275
+
276
+
277
+ def _redact(value: str) -> str:
278
+ return _SECRET_RE.sub(lambda match: f"{match.group(1)}{match.group(2)}<redacted>", value)
279
+
280
+
281
+ def _is_internal_write(path: str, report_path: Path) -> bool:
282
+ return Path(path) == report_path or _is_runtime_path(path)
283
+
284
+
285
+ def _is_runtime_path(path: str) -> bool:
286
+ return path.endswith((".pyc", ".pyo")) or "__pycache__" in path
287
+
288
+
289
+ def _available_signals() -> list[int]:
290
+ values: list[int] = []
291
+ for member in signal.Signals:
292
+ try:
293
+ signal.getsignal(member.value)
294
+ except (OSError, RuntimeError, ValueError):
295
+ continue
296
+ values.append(member.value)
297
+ return values
298
+
299
+
300
+ def _handler_name(handler: Any) -> str:
301
+ if handler in {signal.SIG_DFL, signal.SIG_IGN, None}:
302
+ return str(handler)
303
+ return getattr(handler, "__qualname__", type(handler).__name__)
304
+
305
+
306
+ def _signal_name(number: int) -> str:
307
+ try:
308
+ return signal.Signals(number).name
309
+ except ValueError:
310
+ return str(number)
311
+
312
+
313
+ def main(argv: list[str] | None = None) -> int:
314
+ arguments = sys.argv[1:] if argv is None else argv
315
+ if len(arguments) != 2:
316
+ return 2
317
+ return run_probe(arguments[0], Path(arguments[1]))
318
+
319
+
320
+ if __name__ == "__main__":
321
+ raise SystemExit(main())
import_effects/cli.py ADDED
@@ -0,0 +1,175 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import fnmatch
5
+ import json
6
+ import os
7
+ import sys
8
+ from collections import Counter
9
+ from collections.abc import Sequence
10
+ from dataclasses import replace
11
+
12
+ from . import __version__
13
+ from .inspector import InspectionError, inspect_import
14
+ from .models import EffectKind, ImportReport
15
+
16
+ _KINDS: tuple[EffectKind, ...] = (
17
+ "file-write",
18
+ "file-delete",
19
+ "file-rename",
20
+ "directory",
21
+ "network",
22
+ "subprocess",
23
+ "thread",
24
+ "multiprocessing",
25
+ "environment",
26
+ "cwd",
27
+ "logging",
28
+ "sys-path",
29
+ "warnings",
30
+ "signal",
31
+ )
32
+
33
+
34
+ def _parser() -> argparse.ArgumentParser:
35
+ parser = argparse.ArgumentParser(
36
+ prog="import-effects",
37
+ description="See what Python does when you import.",
38
+ epilog=(
39
+ "Warning: the target module executes with your normal OS permissions. "
40
+ "This is not a sandbox."
41
+ ),
42
+ )
43
+ parser.add_argument("module", nargs="?", help="already-importable module or package")
44
+ parser.add_argument("--json", action="store_true", help="emit a machine-readable report")
45
+ parser.add_argument("--quiet", action="store_true", help="show only the summary or error")
46
+ parser.add_argument(
47
+ "--verbose", action="store_true", help="show attribution and imported modules"
48
+ )
49
+ parser.add_argument("--timeout", type=float, default=10.0, metavar="SECONDS")
50
+ parser.add_argument(
51
+ "--fail-on",
52
+ default="",
53
+ metavar="KINDS",
54
+ help="comma-separated effect kinds that should exit 1",
55
+ )
56
+ parser.add_argument(
57
+ "--ignore",
58
+ action="append",
59
+ default=[],
60
+ metavar="GLOB",
61
+ help="ignore matching effect details; may be repeated",
62
+ )
63
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
64
+ return parser
65
+
66
+
67
+ def main(argv: Sequence[str] | None = None) -> int:
68
+ parser = _parser()
69
+ arguments = parser.parse_args(argv)
70
+ if not arguments.module:
71
+ parser.error("the following arguments are required: module")
72
+ if arguments.timeout <= 0:
73
+ parser.error("--timeout must be greater than zero")
74
+ try:
75
+ fail_on = _parse_fail_on(arguments.fail_on)
76
+ except ValueError as error:
77
+ parser.error(str(error))
78
+ try:
79
+ report = inspect_import(arguments.module, timeout=arguments.timeout)
80
+ except ValueError as error:
81
+ parser.error(str(error))
82
+ except InspectionError as error:
83
+ print(f"import-effects: internal inspection failure: {error}", file=sys.stderr)
84
+ return 4
85
+ report = _apply_ignores(report, arguments.ignore)
86
+ if arguments.json:
87
+ _print_safe(json.dumps(report.to_dict(), indent=2, sort_keys=True))
88
+ else:
89
+ _print_safe(_format_text(report, quiet=arguments.quiet, verbose=arguments.verbose))
90
+ if not report.success:
91
+ return 3
92
+ if any(effect.kind in fail_on for effect in report.effects):
93
+ return 1
94
+ return 0
95
+
96
+
97
+ def _parse_fail_on(value: str) -> set[EffectKind]:
98
+ if not value:
99
+ return set()
100
+ result: set[EffectKind] = set()
101
+ for raw in value.split(","):
102
+ kind = raw.strip()
103
+ if kind not in _KINDS:
104
+ choices = ", ".join(_KINDS)
105
+ raise ValueError(f"unknown --fail-on kind {kind!r}; choose from: {choices}")
106
+ result.add(kind)
107
+ return result
108
+
109
+
110
+ def _apply_ignores(report: ImportReport, patterns: Sequence[str]) -> ImportReport:
111
+ expanded = [os.path.expanduser(pattern) for pattern in patterns]
112
+ if not expanded:
113
+ return report
114
+ effects = tuple(
115
+ effect
116
+ for effect in report.effects
117
+ if not any(fnmatch.fnmatch(effect.detail, pattern) for pattern in expanded)
118
+ )
119
+ return replace(report, effects=effects)
120
+
121
+
122
+ def _format_text(report: ImportReport, *, quiet: bool, verbose: bool) -> str:
123
+ lines = [f"import {report.module}", ""]
124
+ if report.timed_out:
125
+ lines.append(f"✗ timed out after {report.duration_ms / 1000:g} s")
126
+ elif report.success:
127
+ lines.append(f"✓ imported in {report.duration_ms:.0f} ms")
128
+ else:
129
+ detail = report.exception_message or "unknown error"
130
+ lines.append(f"✗ {report.exception_type or 'ImportError'}: {detail}")
131
+ if quiet:
132
+ lines.extend(["", _summary(report)])
133
+ return "\n".join(lines)
134
+ if report.effects:
135
+ lines.extend(["", "SIDE EFFECTS"])
136
+ for effect in report.effects:
137
+ lines.extend(["", f"⚠ {effect.kind.upper().replace('-', ' ')}", f" {effect.detail}"])
138
+ if verbose:
139
+ source = effect.source or "unknown source"
140
+ lines.append(
141
+ f" {effect.attribution}; {effect.confidence} confidence; source: {source}"
142
+ )
143
+ if verbose and report.imported_modules:
144
+ lines.extend(["", f"IMPORTED MODULES ({len(report.imported_modules)})"])
145
+ lines.append(" " + ", ".join(report.imported_modules))
146
+ if report.child_stdout:
147
+ lines.extend(["", "TARGET STDOUT", _indent(report.child_stdout.rstrip())])
148
+ if report.child_stderr:
149
+ lines.extend(["", "TARGET STDERR", _indent(report.child_stderr.rstrip())])
150
+ lines.extend(["", _summary(report)])
151
+ return "\n".join(lines)
152
+
153
+
154
+ def _summary(report: ImportReport) -> str:
155
+ count = len(report.effects)
156
+ if count == 0:
157
+ return "No side effects detected"
158
+ counts = Counter(effect.kind for effect in report.effects)
159
+ kinds = " · ".join(f"{amount} {kind}" for kind, amount in sorted(counts.items()))
160
+ noun = "effect" if count == 1 else "effects"
161
+ return f"{count} side {noun} detected · {kinds}"
162
+
163
+
164
+ def _indent(value: str) -> str:
165
+ return "\n".join(f" {line}" for line in value.splitlines())
166
+
167
+
168
+ def _print_safe(value: str) -> None:
169
+ encoding = sys.stdout.encoding or "utf-8"
170
+ printable = value.encode(encoding, errors="replace").decode(encoding)
171
+ print(printable)
172
+
173
+
174
+ if __name__ == "__main__": # pragma: no cover
175
+ raise SystemExit(main())
@@ -0,0 +1,124 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import signal
7
+ import subprocess
8
+ import sys
9
+ import tempfile
10
+ from collections.abc import Iterable
11
+ from pathlib import Path
12
+
13
+ from .models import EffectKind, ImportReport
14
+
15
+ _MODULE_RE = re.compile(r"^[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*$")
16
+
17
+
18
+ class InspectionError(RuntimeError):
19
+ """Raised when the isolated inspector cannot produce a valid report."""
20
+
21
+
22
+ def _validate_module(module: str) -> None:
23
+ if not _MODULE_RE.fullmatch(module):
24
+ raise ValueError(f"Invalid module name: {module!r}")
25
+
26
+
27
+ def inspect_import(
28
+ module: str,
29
+ *,
30
+ timeout: float = 10.0,
31
+ python_executable: str | os.PathLike[str] | None = None,
32
+ ) -> ImportReport:
33
+ """Inspect one import in a fresh child interpreter.
34
+
35
+ The target module is never imported into the caller. This is observation, not a
36
+ security sandbox: target code executes with the child's normal OS permissions.
37
+ """
38
+
39
+ _validate_module(module)
40
+ if timeout <= 0:
41
+ raise ValueError("timeout must be greater than zero")
42
+ executable = os.fspath(python_executable or sys.executable)
43
+ with tempfile.TemporaryDirectory(prefix="import-effects-") as directory:
44
+ report_path = Path(directory) / "report.json"
45
+ command = [executable, "-m", "import_effects._probe", module, os.fspath(report_path)]
46
+ try:
47
+ process = subprocess.Popen(
48
+ command,
49
+ stdout=subprocess.PIPE,
50
+ stderr=subprocess.PIPE,
51
+ text=True,
52
+ encoding="utf-8",
53
+ errors="replace",
54
+ start_new_session=os.name == "posix",
55
+ creationflags=(
56
+ getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) if os.name == "nt" else 0
57
+ ),
58
+ )
59
+ except OSError as error:
60
+ raise InspectionError(f"Could not start child interpreter: {error}") from error
61
+ try:
62
+ stdout, stderr = process.communicate(timeout=timeout)
63
+ except subprocess.TimeoutExpired:
64
+ _terminate_process_tree(process)
65
+ stdout, stderr = process.communicate()
66
+ return ImportReport(
67
+ module=module,
68
+ duration_ms=timeout * 1000,
69
+ success=False,
70
+ timed_out=True,
71
+ exception_type="TimeoutExpired",
72
+ exception_message=f"Import exceeded {timeout:g} seconds.",
73
+ child_stdout=_limit_output(stdout),
74
+ child_stderr=_limit_output(stderr),
75
+ platform=sys.platform,
76
+ python_version=sys.version.split()[0],
77
+ )
78
+ if not report_path.exists():
79
+ raise InspectionError(
80
+ f"Inspector exited with code {process.returncode} without producing a report."
81
+ )
82
+ try:
83
+ payload = json.loads(report_path.read_text(encoding="utf-8"))
84
+ except (OSError, json.JSONDecodeError) as error:
85
+ raise InspectionError("Inspector produced an unreadable report.") from error
86
+ payload["child_stdout"] = _limit_output(stdout)
87
+ payload["child_stderr"] = _limit_output(stderr)
88
+ return ImportReport.from_dict(payload)
89
+
90
+
91
+ def assert_no_effects(
92
+ module: str,
93
+ *,
94
+ forbidden: Iterable[EffectKind] = ("network", "subprocess", "file-write"),
95
+ timeout: float = 10.0,
96
+ ) -> ImportReport:
97
+ """Inspect *module* and raise AssertionError for forbidden effects or import failure."""
98
+
99
+ report = inspect_import(module, timeout=timeout)
100
+ if not report.success:
101
+ message = report.exception_message or "unknown import failure"
102
+ raise AssertionError(f"Import of {module!r} failed: {message}")
103
+ forbidden_set = set(forbidden)
104
+ found = [effect for effect in report.effects if effect.kind in forbidden_set]
105
+ if found:
106
+ summary = ", ".join(sorted({effect.kind for effect in found}))
107
+ raise AssertionError(f"Import of {module!r} produced forbidden effects: {summary}")
108
+ return report
109
+
110
+
111
+ def _terminate_process_tree(process: subprocess.Popen[str]) -> None:
112
+ try:
113
+ if os.name == "posix":
114
+ os.killpg(process.pid, signal.SIGKILL)
115
+ else: # pragma: no cover - exercised by Windows CI
116
+ process.kill()
117
+ except ProcessLookupError:
118
+ pass
119
+
120
+
121
+ def _limit_output(value: str, limit: int = 16_384) -> str:
122
+ if len(value) <= limit:
123
+ return value
124
+ return value[:limit] + "\n... output truncated by import-effects ..."
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import asdict, dataclass, field
5
+ from typing import Any, Literal
6
+
7
+ EffectKind = Literal[
8
+ "file-write",
9
+ "file-delete",
10
+ "file-rename",
11
+ "directory",
12
+ "network",
13
+ "subprocess",
14
+ "thread",
15
+ "multiprocessing",
16
+ "environment",
17
+ "cwd",
18
+ "logging",
19
+ "sys-path",
20
+ "warnings",
21
+ "signal",
22
+ ]
23
+ Confidence = Literal["high", "medium", "low"]
24
+ Attribution = Literal["target", "dependency", "observed-during-import", "runtime"]
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class Effect:
29
+ kind: EffectKind
30
+ detail: str
31
+ source: str | None = None
32
+ confidence: Confidence = "high"
33
+ attribution: Attribution = "observed-during-import"
34
+ metadata: Mapping[str, str | int | float | bool | None] = field(default_factory=dict)
35
+
36
+ @classmethod
37
+ def from_dict(cls, value: Mapping[str, Any]) -> Effect:
38
+ return cls(
39
+ kind=value["kind"],
40
+ detail=str(value["detail"]),
41
+ source=value.get("source"),
42
+ confidence=value.get("confidence", "high"),
43
+ attribution=value.get("attribution", "observed-during-import"),
44
+ metadata=value.get("metadata", {}),
45
+ )
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class ImportReport:
50
+ module: str
51
+ duration_ms: float
52
+ effects: tuple[Effect, ...] = ()
53
+ imported_modules: tuple[str, ...] = ()
54
+ success: bool = True
55
+ exception_type: str | None = None
56
+ exception_message: str | None = None
57
+ timed_out: bool = False
58
+ child_stdout: str = ""
59
+ child_stderr: str = ""
60
+ platform: str = ""
61
+ python_version: str = ""
62
+
63
+ def to_dict(self) -> dict[str, Any]:
64
+ return asdict(self)
65
+
66
+ @classmethod
67
+ def from_dict(cls, value: Mapping[str, Any]) -> ImportReport:
68
+ return cls(
69
+ module=str(value["module"]),
70
+ duration_ms=float(value.get("duration_ms", 0)),
71
+ effects=tuple(Effect.from_dict(item) for item in value.get("effects", [])),
72
+ imported_modules=tuple(value.get("imported_modules", [])),
73
+ success=bool(value.get("success", False)),
74
+ exception_type=value.get("exception_type"),
75
+ exception_message=value.get("exception_message"),
76
+ timed_out=bool(value.get("timed_out", False)),
77
+ child_stdout=str(value.get("child_stdout", "")),
78
+ child_stderr=str(value.get("child_stderr", "")),
79
+ platform=str(value.get("platform", "")),
80
+ python_version=str(value.get("python_version", "")),
81
+ )
82
+
83
+ def effects_of(self, *kinds: EffectKind) -> tuple[Effect, ...]:
84
+ wanted = set(kinds)
85
+ return tuple(effect for effect in self.effects if effect.kind in wanted)
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,169 @@
1
+ Metadata-Version: 2.5
2
+ Name: import-effects
3
+ Version: 0.0.1
4
+ Summary: See what Python does when you import.
5
+ Project-URL: Homepage, https://github.com/royalpinto007/import-effects
6
+ Project-URL: Documentation, https://github.com/royalpinto007/import-effects#readme
7
+ Project-URL: Issues, https://github.com/royalpinto007/import-effects/issues
8
+ Project-URL: Source, https://github.com/royalpinto007/import-effects
9
+ Author-email: Royal Pinto <royalpinto007@gmail.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: audit-hooks,cli,debugging,imports,observability,python,security,side-effects,testing
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Programming Language :: Python :: Implementation :: CPython
25
+ Classifier: Topic :: Software Development :: Debuggers
26
+ Classifier: Topic :: Software Development :: Testing
27
+ Requires-Python: >=3.10
28
+ Provides-Extra: dev
29
+ Requires-Dist: build>=1.3; extra == 'dev'
30
+ Requires-Dist: mypy>=1.17; extra == 'dev'
31
+ Requires-Dist: pytest-cov>=6.2; extra == 'dev'
32
+ Requires-Dist: pytest>=8.4; extra == 'dev'
33
+ Requires-Dist: ruff>=0.12; extra == 'dev'
34
+ Requires-Dist: twine>=6.2; extra == 'dev'
35
+ Description-Content-Type: text/markdown
36
+
37
+ # import-effects
38
+
39
+ > See what Python does when you `import`.
40
+
41
+ [![PyPI](https://img.shields.io/pypi/v/import-effects)](https://pypi.org/project/import-effects/) [![Python](https://img.shields.io/pypi/pyversions/import-effects)](https://pypi.org/project/import-effects/) [![CI](https://github.com/royalpinto007/import-effects/actions/workflows/ci.yml/badge.svg)](https://github.com/royalpinto007/import-effects/actions/workflows/ci.yml) [![license](https://img.shields.io/pypi/l/import-effects)](LICENSE)
42
+
43
+ `import-effects` runs one import in a fresh child interpreter and reports file writes, sockets, subprocesses, threads, environment changes, and other observable import-time behavior.
44
+
45
+ ```bash
46
+ pip install import-effects
47
+ import-effects requests
48
+ ```
49
+
50
+ ![A Python import starts a thread, opens a socket, writes a file, and launches a subprocess](docs/assets/demo.gif)
51
+
52
+ ## 30-second quickstart
53
+
54
+ ```console
55
+ $ import-effects mypackage
56
+ import mypackage
57
+
58
+ ✓ imported in 143 ms
59
+
60
+ SIDE EFFECTS
61
+
62
+ ⚠ FILE WRITE
63
+ /home/me/.cache/mypackage/config.json
64
+
65
+ ⚠ NETWORK
66
+ api.example.com:443
67
+
68
+ ⚠ THREAD
69
+ background-worker
70
+
71
+ 3 side effects detected · 1 file-write · 1 network · 1 thread
72
+ ```
73
+
74
+ The target must already be importable. `import-effects` never installs it and normal inspection needs no server, database, Docker, network access, or elevated privileges.
75
+
76
+ ## CLI
77
+
78
+ ```bash
79
+ import-effects package.submodule
80
+ import-effects requests --json
81
+ import-effects requests --quiet
82
+ import-effects requests --verbose
83
+ import-effects requests --timeout 10
84
+ import-effects requests --fail-on network,subprocess,file-write
85
+ import-effects requests --ignore '~/.cache/**'
86
+ ```
87
+
88
+ `--fail-on` turns selected observations into policy failures without pretending every observation is inherently unsafe. `--ignore` matches the human-readable effect detail and may be repeated.
89
+
90
+ Exit codes:
91
+
92
+ | Code | Meaning |
93
+ | ---: | ------------------------------------------------- |
94
+ | 0 | Import succeeded with no configured violation |
95
+ | 1 | A configured `--fail-on` effect was detected |
96
+ | 2 | Invalid CLI input |
97
+ | 3 | Target import failed or timed out |
98
+ | 4 | Internal inspector failure |
99
+
100
+ ## Python API
101
+
102
+ ```python
103
+ from import_effects import inspect_import
104
+
105
+ report = inspect_import("mypackage", timeout=10)
106
+
107
+ print(report.duration_ms)
108
+ for effect in report.effects:
109
+ print(effect.kind, effect.detail, effect.confidence)
110
+ ```
111
+
112
+ For tests:
113
+
114
+ ```python
115
+ from import_effects import assert_no_effects
116
+
117
+
118
+ def test_import_stays_quiet() -> None:
119
+ assert_no_effects("mypackage", forbidden=("network", "subprocess", "file-write"))
120
+ ```
121
+
122
+ The models are frozen, typed dataclasses. Reports can be converted with `report.to_dict()` and filtered with `report.effects_of("network")`.
123
+
124
+ ## What it observes
125
+
126
+ - files opened for writing, removal, rename, and directory operations
127
+ - socket connect, bind, and DNS activity
128
+ - subprocess spawning, `os.system`, and process forks
129
+ - Python threads and `multiprocessing` children started
130
+ - environment variable names added, removed, or changed, never their values
131
+ - current directory, logging handlers, `sys.path`, warning filters, and signal handlers
132
+ - newly imported module names, import duration, stdout/stderr, failures, crashes, and timeouts
133
+
134
+ CPython audit events provide high-confidence observations for many OS operations. Before/after snapshots and lifecycle wrappers cover state changes and thread/process starts. Each effect includes confidence and attribution metadata because an effect may come from the target, one of its dependencies, or code merely observed during the import window.
135
+
136
+ See [Architecture and limitations](docs/architecture.md) and the [API reference](docs/api.md).
137
+
138
+ ## Security warning
139
+
140
+ **Importing untrusted Python code executes that code. `import-effects` is an observer, not a sandbox.**
141
+
142
+ The target runs in a separate child interpreter, so it cannot directly mutate the parent Python process. It still runs as your user with normal filesystem, network, and process permissions. Use an OS sandbox or disposable virtual machine when inspecting code you do not trust.
143
+
144
+ Target stdout and stderr are capped. Suspected credentials in captured messages are redacted, and environment variable values are never included. Audit hooks are visibility mechanisms, not a security boundary, and sufficiently hostile native code can evade or disable Python-level observation.
145
+
146
+ ## Platform support
147
+
148
+ `import-effects` targets CPython 3.10 through 3.14 on Linux, macOS, and Windows. Audit event availability and process termination behavior differ by Python and OS. Linux currently provides the broadest coverage. Windows does not offer the same process-group cleanup guarantees as POSIX, and some native extensions perform operations below CPython's audit surface.
149
+
150
+ ## Development
151
+
152
+ ```bash
153
+ python -m venv .venv
154
+ . .venv/bin/activate
155
+ pip install -e '.[dev]'
156
+ ruff format --check .
157
+ ruff check .
158
+ mypy
159
+ pytest --cov
160
+ python -m build
161
+ twine check dist/*
162
+ ```
163
+
164
+ See [Contributing](CONTRIBUTING.md), [Security](SECURITY.md), and the [Changelog](CHANGELOG.md).
165
+
166
+ ## License
167
+
168
+ MIT
169
+
@@ -0,0 +1,12 @@
1
+ import_effects/__init__.py,sha256=b9QPB7Y_v7r4TXYrbfBG4iuGmI_KHL2WDe6IctEBZBU,349
2
+ import_effects/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
3
+ import_effects/_probe.py,sha256=XLzzo_dvozo7I9_4SOFH24n1zIYwI0_bGLOrwbiwiE4,11976
4
+ import_effects/cli.py,sha256=tgZuxbdRP5TqXdJio4ZulklfxQg7NkY1WO2q8pygx-4,5997
5
+ import_effects/inspector.py,sha256=_ftQ20NfX8NjbTnzjOKVMYHFR5E4UvHcqK_HisqrBXg,4518
6
+ import_effects/models.py,sha256=yUDrdTb0OjbuBViEmJd_W1auWvH5zXPftsdT8djtm-U,2752
7
+ import_effects/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
8
+ import_effects-0.0.1.dist-info/METADATA,sha256=7Xsbo-FJ4eVv2weEEDKcRcG36Q4-VY1GyjPKEo833xA,6834
9
+ import_effects-0.0.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
10
+ import_effects-0.0.1.dist-info/entry_points.txt,sha256=Vsz30VUGVhVjYPek7PrmseTggoA5MYg19B6bzLGjXzw,59
11
+ import_effects-0.0.1.dist-info/licenses/LICENSE,sha256=BEKTFnGcu1Vx4zP9S43oph8H13Mlz0gJr9u_lAKnrTk,1069
12
+ import_effects-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ import-effects = import_effects.cli:main
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Royal Pinto
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+