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.
- import_effects/__init__.py +15 -0
- import_effects/__main__.py +3 -0
- import_effects/_probe.py +321 -0
- import_effects/cli.py +175 -0
- import_effects/inspector.py +124 -0
- import_effects/models.py +85 -0
- import_effects/py.typed +1 -0
- import_effects-0.0.1.dist-info/METADATA +169 -0
- import_effects-0.0.1.dist-info/RECORD +12 -0
- import_effects-0.0.1.dist-info/WHEEL +4 -0
- import_effects-0.0.1.dist-info/entry_points.txt +2 -0
- import_effects-0.0.1.dist-info/licenses/LICENSE +22 -0
|
@@ -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"
|
import_effects/_probe.py
ADDED
|
@@ -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 ..."
|
import_effects/models.py
ADDED
|
@@ -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)
|
import_effects/py.typed
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/import-effects/) [](https://pypi.org/project/import-effects/) [](https://github.com/royalpinto007/import-effects/actions/workflows/ci.yml) [](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
|
+

|
|
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,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
|
+
|