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.
@@ -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
+ ]
@@ -0,0 +1,8 @@
1
+ """Allow running as python -m importguard."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
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