importguardpy 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.
- importguard/__init__.py +15 -0
- importguard/__main__.py +8 -0
- importguard/cli.py +239 -0
- importguard/config.py +199 -0
- importguard/core.py +294 -0
- importguard/models.py +168 -0
- importguard/parser.py +92 -0
- importguard/py.typed +0 -0
- importguardpy-0.1.0.dist-info/METADATA +495 -0
- importguardpy-0.1.0.dist-info/RECORD +13 -0
- importguardpy-0.1.0.dist-info/WHEEL +4 -0
- importguardpy-0.1.0.dist-info/entry_points.txt +2 -0
- importguardpy-0.1.0.dist-info/licenses/LICENSE +22 -0
importguard/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""importguard - Measure and enforce import-time behavior in Python projects."""
|
|
2
|
+
|
|
3
|
+
from .core import check_import, run_import_subprocess
|
|
4
|
+
from .models import ImportResult, ImportTiming, SubprocessResult, Violation, ViolationType
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.0"
|
|
7
|
+
__all__ = [
|
|
8
|
+
"ImportResult",
|
|
9
|
+
"ImportTiming",
|
|
10
|
+
"SubprocessResult",
|
|
11
|
+
"Violation",
|
|
12
|
+
"ViolationType",
|
|
13
|
+
"check_import",
|
|
14
|
+
"run_import_subprocess",
|
|
15
|
+
]
|
importguard/__main__.py
ADDED
importguard/cli.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Command-line interface for importguard."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .config import ImportGuardConfig, find_config, load_config
|
|
11
|
+
from .core import check_import
|
|
12
|
+
from .models import ImportResult
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _supports_unicode() -> bool:
|
|
16
|
+
"""Check if the terminal supports Unicode output."""
|
|
17
|
+
# Check if stdout encoding supports common Unicode characters
|
|
18
|
+
try:
|
|
19
|
+
encoding = getattr(sys.stdout, "encoding", None) or "ascii"
|
|
20
|
+
# Try encoding our symbols
|
|
21
|
+
"✓✗→".encode(encoding)
|
|
22
|
+
return True
|
|
23
|
+
except (UnicodeEncodeError, LookupError):
|
|
24
|
+
return False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# Use ASCII fallbacks on terminals that don't support Unicode (e.g., Windows cp1252)
|
|
28
|
+
_UNICODE_OK = _supports_unicode()
|
|
29
|
+
_CHECK = "✓" if _UNICODE_OK else "[OK]"
|
|
30
|
+
_CROSS = "✗" if _UNICODE_OK else "[FAIL]"
|
|
31
|
+
_ARROW = "→" if _UNICODE_OK else "->"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def format_time(ms: float) -> str:
|
|
35
|
+
"""Format time in milliseconds for display."""
|
|
36
|
+
if ms >= 1000:
|
|
37
|
+
return f"{ms / 1000:.1f}s"
|
|
38
|
+
return f"{ms:.0f}ms"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def print_result(
|
|
42
|
+
result: ImportResult,
|
|
43
|
+
max_ms: float | None = None,
|
|
44
|
+
top_n: int = 10,
|
|
45
|
+
quiet: bool = False,
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Print the result of an import check to stdout."""
|
|
48
|
+
if quiet and result.passed:
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
# Color codes
|
|
52
|
+
green = "\033[32m"
|
|
53
|
+
red = "\033[31m"
|
|
54
|
+
reset = "\033[0m"
|
|
55
|
+
|
|
56
|
+
# Handle import failure specially
|
|
57
|
+
if result.import_failed:
|
|
58
|
+
print(f"{red}{_CROSS} FAIL:{reset} {result.module} failed to import")
|
|
59
|
+
if result.error_message:
|
|
60
|
+
# Indent error message lines
|
|
61
|
+
for line in result.error_message.splitlines():
|
|
62
|
+
print(f" {red}{_ARROW}{reset} {line}")
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
# Status line for successful import
|
|
66
|
+
if result.passed:
|
|
67
|
+
status = _CHECK
|
|
68
|
+
color = green
|
|
69
|
+
else:
|
|
70
|
+
status = f"{_CROSS} FAIL:"
|
|
71
|
+
color = red
|
|
72
|
+
|
|
73
|
+
time_str = format_time(result.total_ms)
|
|
74
|
+
|
|
75
|
+
# Build status message
|
|
76
|
+
msg_parts = [f"{result.module} imported in {time_str}"]
|
|
77
|
+
|
|
78
|
+
if max_ms is not None:
|
|
79
|
+
msg_parts.append(f"(budget: {format_time(max_ms)})")
|
|
80
|
+
|
|
81
|
+
if result.num_runs > 1 and result.median_ms is not None:
|
|
82
|
+
msg_parts.append(f"[median of {result.num_runs} runs]")
|
|
83
|
+
|
|
84
|
+
print(f"{color}{status}{reset} {' '.join(msg_parts)}")
|
|
85
|
+
|
|
86
|
+
# Print violations
|
|
87
|
+
for violation in result.violations:
|
|
88
|
+
print(f" {color}{_ARROW}{reset} {violation}")
|
|
89
|
+
|
|
90
|
+
# Print top imports (only if showing details)
|
|
91
|
+
if result.imports and not quiet:
|
|
92
|
+
print()
|
|
93
|
+
print(f"Top {min(top_n, len(result.imports))} slowest imports:")
|
|
94
|
+
for i, timing in enumerate(result.top_imports(top_n), 1):
|
|
95
|
+
print(f" {i:2}. {timing.module:<40} {format_time(timing.self_time_ms)}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def create_parser() -> argparse.ArgumentParser:
|
|
99
|
+
"""Create the argument parser."""
|
|
100
|
+
parser = argparse.ArgumentParser(
|
|
101
|
+
prog="importguard",
|
|
102
|
+
description="Measure and enforce import-time behavior in Python projects",
|
|
103
|
+
)
|
|
104
|
+
subparsers = parser.add_subparsers(dest="command", help="Commands")
|
|
105
|
+
|
|
106
|
+
# check command
|
|
107
|
+
check_parser = subparsers.add_parser(
|
|
108
|
+
"check",
|
|
109
|
+
help="Check import time and violations for a module",
|
|
110
|
+
)
|
|
111
|
+
check_parser.add_argument(
|
|
112
|
+
"module",
|
|
113
|
+
help="Python module to check (e.g., mypkg, mypkg.cli)",
|
|
114
|
+
)
|
|
115
|
+
check_parser.add_argument(
|
|
116
|
+
"--max-ms",
|
|
117
|
+
type=float,
|
|
118
|
+
metavar="MS",
|
|
119
|
+
help="Fail if import exceeds this threshold",
|
|
120
|
+
)
|
|
121
|
+
check_parser.add_argument(
|
|
122
|
+
"--ban",
|
|
123
|
+
action="append",
|
|
124
|
+
dest="banned",
|
|
125
|
+
metavar="MODULE",
|
|
126
|
+
help="Ban a module from being imported (can repeat)",
|
|
127
|
+
)
|
|
128
|
+
check_parser.add_argument(
|
|
129
|
+
"--config",
|
|
130
|
+
type=Path,
|
|
131
|
+
metavar="FILE",
|
|
132
|
+
help="Path to .importguard.toml config file",
|
|
133
|
+
)
|
|
134
|
+
check_parser.add_argument(
|
|
135
|
+
"--top",
|
|
136
|
+
type=int,
|
|
137
|
+
default=10,
|
|
138
|
+
metavar="N",
|
|
139
|
+
help="Show top N slowest imports (default: 10)",
|
|
140
|
+
)
|
|
141
|
+
check_parser.add_argument(
|
|
142
|
+
"--json",
|
|
143
|
+
action="store_true",
|
|
144
|
+
help="Output results as JSON (for CI parsing)",
|
|
145
|
+
)
|
|
146
|
+
check_parser.add_argument(
|
|
147
|
+
"--quiet",
|
|
148
|
+
action="store_true",
|
|
149
|
+
help="Only output on failure",
|
|
150
|
+
)
|
|
151
|
+
check_parser.add_argument(
|
|
152
|
+
"--fail-on-warning",
|
|
153
|
+
action="store_true",
|
|
154
|
+
help="Exit non-zero on warnings, not just errors",
|
|
155
|
+
)
|
|
156
|
+
check_parser.add_argument(
|
|
157
|
+
"--repeat",
|
|
158
|
+
type=int,
|
|
159
|
+
default=1,
|
|
160
|
+
metavar="K",
|
|
161
|
+
help="Run K times in fresh subprocesses, report median (reduces noise)",
|
|
162
|
+
)
|
|
163
|
+
check_parser.add_argument(
|
|
164
|
+
"--python",
|
|
165
|
+
type=str,
|
|
166
|
+
metavar="PATH",
|
|
167
|
+
help="Use specific Python interpreter (e.g., /usr/bin/python3.11)",
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
return parser
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def cmd_check(args: argparse.Namespace) -> int:
|
|
174
|
+
"""Execute the check command."""
|
|
175
|
+
module: str = args.module
|
|
176
|
+
max_ms: float | None = args.max_ms
|
|
177
|
+
banned: list[str] = args.banned or []
|
|
178
|
+
config_path: Path | None = args.config
|
|
179
|
+
top_n: int = args.top
|
|
180
|
+
output_json: bool = args.json
|
|
181
|
+
quiet: bool = args.quiet
|
|
182
|
+
repeat: int = args.repeat
|
|
183
|
+
python_path: str | None = args.python
|
|
184
|
+
|
|
185
|
+
# Load config if specified or auto-discover
|
|
186
|
+
config: ImportGuardConfig | None = None
|
|
187
|
+
if config_path:
|
|
188
|
+
if not config_path.exists():
|
|
189
|
+
print(f"Error: Config file not found: {config_path}", file=sys.stderr)
|
|
190
|
+
return 1
|
|
191
|
+
config = load_config(config_path)
|
|
192
|
+
else:
|
|
193
|
+
found_config = find_config()
|
|
194
|
+
if found_config:
|
|
195
|
+
config = load_config(found_config)
|
|
196
|
+
|
|
197
|
+
# Merge config with CLI options
|
|
198
|
+
if config:
|
|
199
|
+
module_config = config.get_module_config(module)
|
|
200
|
+
if max_ms is None:
|
|
201
|
+
max_ms = module_config.max_ms
|
|
202
|
+
banned = list(set(banned) | module_config.banned)
|
|
203
|
+
|
|
204
|
+
# Run the check
|
|
205
|
+
result = check_import(
|
|
206
|
+
module=module,
|
|
207
|
+
max_ms=max_ms,
|
|
208
|
+
banned=set(banned) if banned else None,
|
|
209
|
+
python_path=python_path,
|
|
210
|
+
repeat=repeat,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# Output
|
|
214
|
+
if output_json:
|
|
215
|
+
print(json.dumps(result.to_dict(), indent=2))
|
|
216
|
+
else:
|
|
217
|
+
print_result(result, max_ms=max_ms, top_n=top_n, quiet=quiet)
|
|
218
|
+
|
|
219
|
+
# Exit code
|
|
220
|
+
return 0 if result.passed else 1
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def main(argv: list[str] | None = None) -> int:
|
|
224
|
+
"""Main entry point."""
|
|
225
|
+
parser = create_parser()
|
|
226
|
+
args = parser.parse_args(argv)
|
|
227
|
+
|
|
228
|
+
if args.command is None:
|
|
229
|
+
parser.print_help()
|
|
230
|
+
return 0
|
|
231
|
+
|
|
232
|
+
if args.command == "check":
|
|
233
|
+
return cmd_check(args)
|
|
234
|
+
|
|
235
|
+
return 0
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
if __name__ == "__main__":
|
|
239
|
+
sys.exit(main())
|
importguard/config.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Configuration loading from .importguard.toml."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
if sys.version_info >= (3, 11):
|
|
11
|
+
import tomllib
|
|
12
|
+
else:
|
|
13
|
+
# For Python 3.9 and 3.10, we need to handle TOML differently
|
|
14
|
+
# We'll use a simple fallback that works without external deps
|
|
15
|
+
tomllib = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class ModuleConfig:
|
|
20
|
+
"""Configuration for a specific module."""
|
|
21
|
+
|
|
22
|
+
max_ms: float | None = None
|
|
23
|
+
banned: set[str] = field(default_factory=set)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class ImportGuardConfig:
|
|
28
|
+
"""Full importguard configuration."""
|
|
29
|
+
|
|
30
|
+
max_total_ms: float | None = None
|
|
31
|
+
modules: dict[str, ModuleConfig] = field(default_factory=dict)
|
|
32
|
+
|
|
33
|
+
def get_module_config(self, module: str) -> ModuleConfig:
|
|
34
|
+
"""Get configuration for a specific module, with inheritance."""
|
|
35
|
+
# Try exact match first
|
|
36
|
+
if module in self.modules:
|
|
37
|
+
config = self.modules[module]
|
|
38
|
+
# Apply global max if module doesn't have one
|
|
39
|
+
if config.max_ms is None and self.max_total_ms is not None:
|
|
40
|
+
config.max_ms = self.max_total_ms
|
|
41
|
+
return config
|
|
42
|
+
|
|
43
|
+
# Try parent modules
|
|
44
|
+
parts = module.split(".")
|
|
45
|
+
for i in range(len(parts) - 1, 0, -1):
|
|
46
|
+
parent = ".".join(parts[:i])
|
|
47
|
+
if parent in self.modules:
|
|
48
|
+
config = self.modules[parent]
|
|
49
|
+
if config.max_ms is None and self.max_total_ms is not None:
|
|
50
|
+
config.max_ms = self.max_total_ms
|
|
51
|
+
return config
|
|
52
|
+
|
|
53
|
+
# Return default with global settings
|
|
54
|
+
return ModuleConfig(max_ms=self.max_total_ms)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _parse_toml_simple(content: str) -> dict[str, Any]:
|
|
58
|
+
"""
|
|
59
|
+
Simple TOML parser for basic configs (fallback for Python < 3.11).
|
|
60
|
+
|
|
61
|
+
This handles only the subset of TOML we need:
|
|
62
|
+
- [section] headers
|
|
63
|
+
- key = value pairs (strings, numbers, arrays)
|
|
64
|
+
"""
|
|
65
|
+
result: dict[str, Any] = {}
|
|
66
|
+
current_section: list[str] | None = None
|
|
67
|
+
|
|
68
|
+
for line in content.splitlines():
|
|
69
|
+
line = line.strip()
|
|
70
|
+
|
|
71
|
+
# Skip empty lines and comments
|
|
72
|
+
if not line or line.startswith("#"):
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
# Section header
|
|
76
|
+
if line.startswith("[") and line.endswith("]"):
|
|
77
|
+
section_name = line[1:-1].strip()
|
|
78
|
+
current_section = section_name.split(".")
|
|
79
|
+
# Create nested structure
|
|
80
|
+
current: dict[str, Any] = result
|
|
81
|
+
for part in current_section:
|
|
82
|
+
part = part.strip('"')
|
|
83
|
+
if part not in current:
|
|
84
|
+
current[part] = {}
|
|
85
|
+
current = current[part]
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
# Key = value
|
|
89
|
+
if "=" in line:
|
|
90
|
+
key, value = line.split("=", 1)
|
|
91
|
+
key = key.strip().strip('"')
|
|
92
|
+
value = value.strip()
|
|
93
|
+
|
|
94
|
+
# Parse value
|
|
95
|
+
parsed_value: Any
|
|
96
|
+
if value.startswith('"') and value.endswith('"'):
|
|
97
|
+
parsed_value = value[1:-1]
|
|
98
|
+
elif value.startswith("[") and value.endswith("]"):
|
|
99
|
+
# Simple array parsing
|
|
100
|
+
array_content = value[1:-1]
|
|
101
|
+
if array_content.strip():
|
|
102
|
+
items: list[str] = []
|
|
103
|
+
for item in array_content.split(","):
|
|
104
|
+
item = item.strip().strip('"')
|
|
105
|
+
if item:
|
|
106
|
+
items.append(item)
|
|
107
|
+
parsed_value = items
|
|
108
|
+
else:
|
|
109
|
+
parsed_value = []
|
|
110
|
+
elif value.isdigit():
|
|
111
|
+
parsed_value = int(value)
|
|
112
|
+
elif value.replace(".", "").isdigit():
|
|
113
|
+
parsed_value = float(value)
|
|
114
|
+
else:
|
|
115
|
+
parsed_value = value
|
|
116
|
+
|
|
117
|
+
# Set value in nested structure
|
|
118
|
+
if current_section:
|
|
119
|
+
current = result
|
|
120
|
+
for part in current_section:
|
|
121
|
+
part = part.strip('"')
|
|
122
|
+
current = current[part]
|
|
123
|
+
current[key] = parsed_value
|
|
124
|
+
else:
|
|
125
|
+
result[key] = parsed_value
|
|
126
|
+
|
|
127
|
+
return result
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def load_config(path: Path) -> ImportGuardConfig:
|
|
131
|
+
"""
|
|
132
|
+
Load configuration from a .importguard.toml file.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
path: Path to the config file
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
ImportGuardConfig object
|
|
139
|
+
"""
|
|
140
|
+
content = path.read_text()
|
|
141
|
+
|
|
142
|
+
data: dict[str, Any]
|
|
143
|
+
if tomllib is not None:
|
|
144
|
+
data = tomllib.loads(content)
|
|
145
|
+
else:
|
|
146
|
+
data = _parse_toml_simple(content)
|
|
147
|
+
|
|
148
|
+
config = ImportGuardConfig()
|
|
149
|
+
|
|
150
|
+
ig_data: dict[str, Any] = data.get("importguard", {})
|
|
151
|
+
|
|
152
|
+
# Global settings
|
|
153
|
+
if "max_total_ms" in ig_data:
|
|
154
|
+
config.max_total_ms = float(ig_data["max_total_ms"])
|
|
155
|
+
|
|
156
|
+
# Per-module budgets
|
|
157
|
+
budgets = ig_data.get("budgets", {})
|
|
158
|
+
for module, max_ms in budgets.items():
|
|
159
|
+
if module not in config.modules:
|
|
160
|
+
config.modules[module] = ModuleConfig()
|
|
161
|
+
config.modules[module].max_ms = float(max_ms)
|
|
162
|
+
|
|
163
|
+
# Per-module banned imports
|
|
164
|
+
banned = ig_data.get("banned", {})
|
|
165
|
+
for module, banned_list in banned.items():
|
|
166
|
+
if module not in config.modules:
|
|
167
|
+
config.modules[module] = ModuleConfig()
|
|
168
|
+
config.modules[module].banned = set(banned_list)
|
|
169
|
+
|
|
170
|
+
return config
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def find_config(start_path: Path | None = None) -> Path | None:
|
|
174
|
+
"""
|
|
175
|
+
Find .importguard.toml by walking up from start_path.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
start_path: Starting directory (default: current directory)
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
Path to config file if found, None otherwise
|
|
182
|
+
"""
|
|
183
|
+
if start_path is None:
|
|
184
|
+
start_path = Path.cwd()
|
|
185
|
+
|
|
186
|
+
current = start_path.resolve()
|
|
187
|
+
|
|
188
|
+
while current != current.parent:
|
|
189
|
+
config_path = current / ".importguard.toml"
|
|
190
|
+
if config_path.exists():
|
|
191
|
+
return config_path
|
|
192
|
+
current = current.parent
|
|
193
|
+
|
|
194
|
+
# Check root
|
|
195
|
+
config_path = current / ".importguard.toml"
|
|
196
|
+
if config_path.exists():
|
|
197
|
+
return config_path
|
|
198
|
+
|
|
199
|
+
return None
|