slurmray 6.0.4__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.

Potentially problematic release.


This version of slurmray might be problematic. Click here for more details.

slurmray/scanner.py ADDED
@@ -0,0 +1,441 @@
1
+ import os
2
+ import ast
3
+ import sys
4
+ import pkgutil
5
+ import importlib.util
6
+ import site
7
+ from typing import List, Set, Dict, Tuple
8
+ import logging
9
+
10
+
11
+ class ProjectScanner:
12
+ """
13
+ Scans the project for local imports and potential dynamic loading issues.
14
+ """
15
+
16
+ def __init__(self, project_root: str, logger: logging.Logger = None):
17
+ self.project_root = os.path.abspath(project_root)
18
+ self.logger = logger or logging.getLogger(__name__)
19
+ self.local_modules = set()
20
+ self.dynamic_imports_warnings = []
21
+
22
+ # Standard library modules to ignore
23
+ self.stdlib_modules = self._get_stdlib_modules()
24
+
25
+ def _get_stdlib_modules(self) -> Set[str]:
26
+ """Get a set of standard library module names."""
27
+ stdlib = set(sys.builtin_module_names)
28
+
29
+ # Add standard library modules from dist-packages/lib-dynload
30
+ for module in pkgutil.iter_modules():
31
+ # This is a heuristic, might include some site-packages if not careful
32
+ # But we primarily filter by checking if file exists locally
33
+ pass
34
+
35
+ return stdlib
36
+
37
+ def _is_system_or_venv_file(self, file_path: str) -> bool:
38
+ """
39
+ Check if a file belongs to system or venv (installed package or stdlib).
40
+ Uses site.getsitepackages() and sys.prefix for robust detection.
41
+ """
42
+ file_abs = os.path.abspath(file_path)
43
+
44
+ # 1. Check against site-packages directories (primary method for installed packages)
45
+ site_packages_dirs = site.getsitepackages()
46
+ if hasattr(site, "getusersitepackages"):
47
+ user_site = site.getusersitepackages()
48
+ if user_site:
49
+ site_packages_dirs = list(site_packages_dirs) + [user_site]
50
+
51
+ for site_pkg_dir in site_packages_dirs:
52
+ if file_abs.startswith(os.path.abspath(site_pkg_dir)):
53
+ return True
54
+
55
+ # 2. Check against sys.prefix/base_prefix (for standard library and venv files)
56
+ # We verify subdirectories like 'lib', 'include' to avoid matching the project root
57
+ # if the project is located directly in the prefix (unlikely but possible)
58
+ prefixes = [sys.prefix]
59
+ if hasattr(sys, "base_prefix") and sys.base_prefix != sys.prefix:
60
+ prefixes.append(sys.base_prefix)
61
+
62
+ # Common system directories to exclude
63
+ system_dirs = ["lib", "include", "bin", "Scripts", "Library", "DLLs", "Lib"]
64
+
65
+ for prefix in prefixes:
66
+ prefix_abs = os.path.abspath(prefix)
67
+ if file_abs.startswith(prefix_abs):
68
+ # Check if it's in a system subdirectory
69
+ for sys_dir in system_dirs:
70
+ sys_dir_abs = os.path.join(prefix_abs, sys_dir)
71
+ if file_abs.startswith(sys_dir_abs):
72
+ return True
73
+
74
+ return False
75
+
76
+ def is_local_file(self, module_name: str) -> Tuple[bool, str]:
77
+ """
78
+ Check if a module corresponds to a local file in the project.
79
+ Uses Python's import system to resolve the module location robustly.
80
+ Returns (is_local, file_path).
81
+ """
82
+ try:
83
+ # Use Python's import system to find where the module actually is
84
+ spec = importlib.util.find_spec(module_name)
85
+
86
+ if spec is None:
87
+ # Module not found
88
+ return False, None
89
+
90
+ # Check if module has an origin (file location)
91
+ if spec.origin is None:
92
+ # Built-in module or namespace package without origin
93
+ return False, None
94
+
95
+ # Check if origin is a file (not a directory for namespace packages)
96
+ if not spec.origin.endswith(".py"):
97
+ # Could be a namespace package or compiled module
98
+ # For namespace packages, check if it's in our project
99
+ if (
100
+ hasattr(spec, "submodule_search_locations")
101
+ and spec.submodule_search_locations
102
+ ):
103
+ for location in spec.submodule_search_locations:
104
+ location_abs = os.path.abspath(location)
105
+ if location_abs.startswith(self.project_root):
106
+ # It's a namespace package in our project
107
+ rel_path = os.path.relpath(location_abs, self.project_root)
108
+ return True, rel_path
109
+ return False, None
110
+
111
+ # Get absolute path of the module file
112
+ module_file = os.path.abspath(spec.origin)
113
+
114
+ # Check if it's a system or venv file
115
+ if self._is_system_or_venv_file(module_file):
116
+ return False, None
117
+
118
+ # Check if it's within project root
119
+ if not module_file.startswith(self.project_root):
120
+ return False, None
121
+
122
+ # Get relative path from project root
123
+ rel_path = os.path.relpath(module_file, self.project_root)
124
+
125
+ # For packages, we might want to return the directory instead of __init__.py
126
+ if rel_path.endswith("__init__.py"):
127
+ # Return the package directory
128
+ rel_path = os.path.dirname(rel_path)
129
+ # If it's empty after removing __init__.py, it's the root package
130
+ if not rel_path:
131
+ return False, None
132
+
133
+ return True, rel_path
134
+
135
+ except (ImportError, ValueError, AttributeError) as e:
136
+ # Module resolution failed
137
+ if self.logger:
138
+ self.logger.debug(f"Could not resolve module {module_name}: {e}")
139
+ return False, None
140
+
141
+ def scan_file(self, file_path: str) -> Set[str]:
142
+ """
143
+ Scan a python file for imports and dynamic loading patterns.
144
+ Returns a set of detected local dependencies (relative paths from project root).
145
+ """
146
+ dependencies = set()
147
+
148
+ try:
149
+ with open(file_path, "r", encoding="utf-8") as f:
150
+ content = f.read()
151
+
152
+ tree = ast.parse(content, filename=file_path)
153
+
154
+ # Get directory of current file for relative imports
155
+ file_dir = os.path.dirname(os.path.abspath(file_path))
156
+
157
+ for node in ast.walk(tree):
158
+ # 1. Static Imports
159
+ if isinstance(node, ast.Import):
160
+ for alias in node.names:
161
+ is_local, path = self.is_local_file(alias.name)
162
+ if is_local:
163
+ dependencies.add(path)
164
+
165
+ elif isinstance(node, ast.ImportFrom):
166
+ if node.level > 0:
167
+ # Relative import (from . import ... or from .. import ...)
168
+ # Resolve relative import based on current file location
169
+ # Calculate relative path from current file
170
+ current_rel = os.path.relpath(file_dir, self.project_root)
171
+ parts = (
172
+ current_rel.split(os.sep) if current_rel != "." else []
173
+ )
174
+
175
+ # Go up 'level' directories
176
+ if node.level <= len(parts):
177
+ parent_parts = parts[: len(parts) - (node.level - 1)]
178
+ module_parts = node.module.split(".") if node.module else []
179
+
180
+ # We want to check:
181
+ # 1. The module itself (if present)
182
+ # 2. Each name imported from it
183
+
184
+ to_check = []
185
+ if node.module:
186
+ to_check.append(parent_parts + module_parts)
187
+
188
+ for alias in node.names:
189
+ to_check.append(parent_parts + module_parts + [alias.name])
190
+
191
+ for parts_list in to_check:
192
+ rel_path = os.path.join(*parts_list) if parts_list else ""
193
+ if rel_path:
194
+ # Try as file
195
+ abs_path = os.path.join(
196
+ self.project_root, rel_path + ".py"
197
+ )
198
+ if os.path.exists(abs_path):
199
+ dependencies.add(rel_path + ".py")
200
+ else:
201
+ # Try as package
202
+ abs_path = os.path.join(
203
+ self.project_root, rel_path, "__init__.py"
204
+ )
205
+ if os.path.exists(abs_path):
206
+ dependencies.add(rel_path)
207
+ elif node.module:
208
+ # from module import ... (absolute import)
209
+ # Check the module itself
210
+ is_local, path = self.is_local_file(node.module)
211
+ if is_local:
212
+ dependencies.add(path)
213
+
214
+ # Also check each name in case they are submodules
215
+ for alias in node.names:
216
+ full_name = f"{node.module}.{alias.name}"
217
+ is_local_sub, sub_path = self.is_local_file(full_name)
218
+ if is_local_sub:
219
+ dependencies.add(sub_path)
220
+
221
+ # 2. Dynamic Imports & File Operations Warnings
222
+ elif isinstance(node, ast.Call):
223
+ if isinstance(node.func, ast.Attribute):
224
+ # importlib.import_module(...)
225
+ if (
226
+ node.func.attr == "import_module"
227
+ and isinstance(node.func.value, ast.Name)
228
+ and node.func.value.id == "importlib"
229
+ ):
230
+ self._add_warning(
231
+ file_path,
232
+ node.lineno,
233
+ "importlib.import_module usage detected",
234
+ )
235
+
236
+ elif isinstance(node.func, ast.Name):
237
+ # __import__(...)
238
+ if node.func.id == "__import__":
239
+ self._add_warning(
240
+ file_path, node.lineno, "__import__ usage detected"
241
+ )
242
+ # open(...)
243
+ elif node.func.id == "open":
244
+ # Check if argument is a string literal
245
+ if (
246
+ node.args
247
+ and isinstance(node.args[0], ast.Constant)
248
+ and isinstance(node.args[0].value, str)
249
+ ):
250
+ path_arg = node.args[0].value
251
+ # Warn if it looks like a path to a file that might be missing
252
+ if not os.path.isabs(
253
+ path_arg
254
+ ) and not path_arg.startswith("/"):
255
+ self._add_warning(
256
+ file_path,
257
+ node.lineno,
258
+ f"open('{path_arg}') detected. Ensure this file is in 'files' list if needed.",
259
+ )
260
+ else:
261
+ self._add_warning(
262
+ file_path,
263
+ node.lineno,
264
+ "open() with dynamic path detected",
265
+ )
266
+
267
+ except Exception as e:
268
+ self.logger.debug(f"Failed to scan {file_path}: {e}")
269
+
270
+ return dependencies
271
+
272
+ def _add_warning(self, file_path: str, lineno: int, message: str):
273
+ file_abs = os.path.abspath(file_path)
274
+
275
+ # Filter out warnings from installed packages or system files
276
+ if self._is_system_or_venv_file(file_abs):
277
+ return
278
+
279
+ # Check if file is within project root
280
+ # If file is outside project root and not in site-packages (already checked), ignore it
281
+ try:
282
+ # If file is outside project root, relpath will start with '../'
283
+ rel_path = os.path.relpath(file_abs, self.project_root)
284
+ if rel_path.startswith("../"):
285
+ # File is outside project root → ignore
286
+ return
287
+ except ValueError:
288
+ # If files are on different drives (Windows), relpath raises ValueError
289
+ # In this case, check if file_abs starts with project_root
290
+ if not file_abs.startswith(self.project_root):
291
+ # File is outside project root → ignore
292
+ return
293
+ rel_path = os.path.relpath(file_abs, self.project_root)
294
+
295
+ # Filter out warnings from slurmray's own code and test files
296
+ # Use __file__ to get the absolute path of slurmray package
297
+ try:
298
+ import slurmray
299
+
300
+ slurmray_dir = os.path.dirname(os.path.abspath(slurmray.__file__))
301
+
302
+ # Check if file is in slurmray directory or its subdirectories
303
+ if file_abs.startswith(slurmray_dir):
304
+ return # Skip warnings from slurmray framework code
305
+ except (AttributeError, ImportError):
306
+ # Fallback: if we can't determine slurmray location, use path prefix check
307
+ if rel_path.startswith("slurmray/"):
308
+ return
309
+
310
+ # Also filter out common non-user directories
311
+ if (
312
+ rel_path.startswith("tests/")
313
+ or rel_path.startswith(".slogs/")
314
+ or rel_path.startswith("old_")
315
+ or rel_path.startswith("debug_")
316
+ ):
317
+ return # Skip warnings from test files and temporary directories
318
+
319
+ self.dynamic_imports_warnings.append(f"{rel_path}:{lineno}: {message}")
320
+
321
+ def _follow_imports_recursive(
322
+ self, file_path: str, visited: Set[str] = None, path_modified: bool = False
323
+ ) -> Set[str]:
324
+ """
325
+ Recursively follow imports starting from a file.
326
+ Returns set of relative paths to local dependencies.
327
+ """
328
+ if visited is None:
329
+ visited = set()
330
+ # Ensure project_root is in sys.path for importlib to work
331
+ # (needed for is_local_file to resolve modules correctly)
332
+ project_in_path = self.project_root in sys.path
333
+ src_path = os.path.join(self.project_root, "src")
334
+ src_in_path = src_path in sys.path
335
+
336
+ if not project_in_path:
337
+ sys.path.insert(0, self.project_root)
338
+ if not src_in_path and os.path.exists(src_path):
339
+ sys.path.insert(0, src_path)
340
+ path_modified = not project_in_path or (
341
+ not src_in_path and os.path.exists(src_path)
342
+ )
343
+
344
+ # Normalize path
345
+ file_path = os.path.abspath(file_path)
346
+ if file_path in visited:
347
+ return set()
348
+ visited.add(file_path)
349
+
350
+ # Check if file is within project
351
+ if not file_path.startswith(self.project_root):
352
+ return set()
353
+
354
+ # Check if it's a system or venv file (double check to avoid scanning venv inside project)
355
+ if self._is_system_or_venv_file(file_path):
356
+ return set()
357
+
358
+ # Get relative path
359
+ try:
360
+ rel_path = os.path.relpath(file_path, self.project_root)
361
+ except ValueError:
362
+ return set()
363
+
364
+ dependencies = set()
365
+
366
+ try:
367
+ # Scan this file for imports
368
+ deps = self.scan_file(file_path)
369
+ dependencies.update(deps)
370
+
371
+ # Recursively follow each dependency
372
+ for dep in deps:
373
+ # Convert relative path to absolute
374
+ dep_abs = os.path.abspath(os.path.join(self.project_root, dep))
375
+
376
+ # Check if it's a file or directory
377
+ if os.path.isfile(dep_abs):
378
+ # It's a file, follow it
379
+ sub_deps = self._follow_imports_recursive(
380
+ dep_abs, visited, path_modified
381
+ )
382
+ dependencies.update(sub_deps)
383
+ elif os.path.isdir(dep_abs):
384
+ # It's a directory (package), check for __init__.py
385
+ init_file = os.path.join(dep_abs, "__init__.py")
386
+ if os.path.exists(init_file):
387
+ sub_deps = self._follow_imports_recursive(
388
+ init_file, visited, path_modified
389
+ )
390
+ dependencies.update(sub_deps)
391
+
392
+ return dependencies
393
+ finally:
394
+ # Restore sys.path only if we're the top-level call
395
+ if path_modified and len(visited) == 1: # Only restore on first call
396
+ if self.project_root in sys.path:
397
+ sys.path.remove(self.project_root)
398
+ src_path = os.path.join(self.project_root, "src")
399
+ if src_path in sys.path:
400
+ sys.path.remove(src_path)
401
+
402
+ def detect_dependencies_from_function(self, func) -> List[str]:
403
+ """
404
+ Detect dependencies starting from a function's source file.
405
+ Follows imports recursively to find all local dependencies.
406
+ """
407
+ import inspect
408
+
409
+ try:
410
+ # Get the file where the function is defined
411
+ func_file = inspect.getfile(func)
412
+ func_file = os.path.abspath(func_file)
413
+
414
+ # Check if function is in project
415
+ if not func_file.startswith(self.project_root):
416
+ self.logger.debug(
417
+ f"Function {func.__name__} is not in project root, skipping dependency detection"
418
+ )
419
+ return []
420
+
421
+ self.logger.info(
422
+ f"Analyzing dependencies starting from {os.path.relpath(func_file, self.project_root)}"
423
+ )
424
+
425
+ # Follow imports recursively (sys.path is managed inside _follow_imports_recursive)
426
+ dependencies = self._follow_imports_recursive(func_file)
427
+
428
+ # Convert to relative paths and normalize
429
+ result = set()
430
+ for dep in dependencies:
431
+ if dep:
432
+ # dep is already a relative path from scan_file
433
+ result.add(dep)
434
+
435
+ return list(result)
436
+
437
+ except (OSError, TypeError) as e:
438
+ self.logger.warning(
439
+ f"Could not determine source file for function {func.__name__}: {e}"
440
+ )
441
+ return []