Class: Archsight::Import::Handlers::PythonGrapher
- Inherits:
-
Grapher
- Object
- Archsight::Import::Handler
- Grapher
- Archsight::Import::Handlers::PythonGrapher
- Defined in:
- lib/archsight/import/handlers/python_grapher.rb
Overview
PythonGrapher handler - analyses a Python repository and generates a GraphViz DOT graph of its package/module structure, stored as architecture/modules on the TechnologyArtifact so it can be rendered in the frontend.
Uses static AST analysis (python3 stdlib only — no external Python packages required). Package paths are normalised to "/" separators internally so they are compatible with the generic Grapher layout engine.
Configuration:
import/config/path - Path to the Python repository root
import/config/ranksep - Horizontal gap between rank columns (default: 0.6)
import/config/nodesep - Vertical gap between nodes in a column (default: 0.15)
Constant Summary collapse
- PYTHON_SCANNER =
Inline Python3 script — scans a single package directory with stdlib ast. Argv: <pkg_root_dir> <pkg_name> Stdout: JSON object mapping slash-separated module paths to arrays of deps.
"from pkg import name" is resolved to "pkg/name" when that submodule exists on disk, so that intra-package submodule imports produce correct edges.
<<~PYTHON import ast, os, sys, json def collect_all_mods(pkg_root, pkg_name): mods = set() for dirpath, dirs, files in os.walk(pkg_root): dirs[:] = sorted(d for d in dirs if d != '__pycache__' and not d.startswith('.')) for f in files: if not f.endswith('.py'): continue rel = os.path.relpath(os.path.join(dirpath, f), pkg_root) parts = rel.replace(os.sep, '/').split('/') parts[-1] = parts[-1][:-3] if parts[-1] == '__init__': parts = parts[:-1] elif parts[-1] == '__main__': parts[-1] = 'main' mods.add(pkg_name if not parts else pkg_name + '/' + '/'.join(parts)) return mods def mod_from_path(path, pkg_root, pkg_name): rel = os.path.relpath(path, pkg_root) parts = rel.replace(os.sep, '/').split('/') parts[-1] = parts[-1][:-3] if parts[-1] == '__init__': parts = parts[:-1] elif parts[-1] == '__main__': parts[-1] = 'main' return pkg_name if not parts else pkg_name + '/' + '/'.join(parts) def resolve_relative_base(current_mod, level, module_name): """Resolve a relative import to an absolute slash-path. level=1 means same package (go up one component from the module name), level=2 means parent package, etc. """ parts = current_mod.split('/') # Strip `level` trailing components to get the anchor package base_parts = parts[:-level] if level <= len(parts) else [] if module_name: base_parts = base_parts + module_name.replace('.', '/').split('/') return '/'.join(base_parts) if base_parts else None def resolve_from_import(base, names, all_mods): resolved = [] for alias in names: sub = base + '/' + alias.name resolved.append(sub if sub in all_mods else base) return resolved or [base] def scan_imports(path, current_mod, pkg_name, all_mods): try: tree = ast.parse(open(path, encoding='utf-8', errors='replace').read()) except SyntaxError: return [] out = [] for node in ast.walk(tree): if isinstance(node, ast.Import): for alias in node.names: name = alias.name.replace('.', '/') if name == pkg_name or name.startswith(pkg_name + '/'): out.append(name) elif isinstance(node, ast.ImportFrom): if node.level == 0: if node.module is None: continue base = node.module.replace('.', '/') else: base = resolve_relative_base(current_mod, node.level, node.module) if base is None: continue if base == pkg_name or base.startswith(pkg_name + '/'): out.extend(resolve_from_import(base, node.names, all_mods)) return out def is_trivial_init(path): """True if __init__.py has no function/class defs and no non-dunder assignments.""" try: tree = ast.parse(open(path, encoding='utf-8', errors='replace').read()) except SyntaxError: return True for node in ast.iter_child_nodes(tree): if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): return False if isinstance(node, ast.Assign): targets = [t.id for t in node.targets if isinstance(t, ast.Name)] if any(not t.startswith('__') for t in targets): return False return True pkg_root, pkg_name = sys.argv[1], sys.argv[2] all_mods = collect_all_mods(pkg_root, pkg_name) edges = {} init_file_mods = {} for dirpath, dirs, files in os.walk(pkg_root): dirs[:] = sorted(d for d in dirs if d != '__pycache__' and not d.startswith('.')) for f in files: if not f.endswith('.py'): continue p = os.path.join(dirpath, f) mod = mod_from_path(p, pkg_root, pkg_name) if f == '__init__.py': init_file_mods[mod] = p imps = scan_imports(p, mod, pkg_name, all_mods) if mod not in edges: edges[mod] = [] edges[mod].extend(i for i in imps if i != mod) for mod, fpath in init_file_mods.items(): if not edges.get(mod) and is_trivial_init(fpath): edges.pop(mod, None) print(json.dumps(edges)) PYTHON
- ROOT_SCANNER =
Scans a list of root-level Python scripts for imports from known packages. Argv: JSON [...], "packages": [...], "all_mods": [...] Stdout: JSON ["
", ...] <<~PYTHON import ast, sys, json def extract_imports(path, pkg_names, all_mods): try: tree = ast.parse(open(path, encoding='utf-8', errors='replace').read()) except (SyntaxError, OSError): return [] out = [] for node in ast.walk(tree): if isinstance(node, ast.Import): for alias in node.names: base = alias.name.replace('.', '/') if any(base == p or base.startswith(p + '/') for p in pkg_names): out.append(base) elif isinstance(node, ast.ImportFrom): if node.level == 0 and node.module: base = node.module.replace('.', '/') if any(base == p or base.startswith(p + '/') for p in pkg_names): sub = base + '/' + node.names[0].name if node.names else base out.append(sub if sub in all_mods else base) return list(dict.fromkeys(out)) cfg = json.loads(sys.argv[1]) pkg_names = cfg['packages'] all_mods = set(cfg.get('all_mods', [])) result = {} for path in cfg['paths']: deps = extract_imports(path, pkg_names, all_mods) if not deps: continue main_pkg = next((p for dep in deps for p in pkg_names if dep == p or dep.startswith(p + '/')), None) if not main_pkg: continue result['main'] = list(dict.fromkeys(result.get('main', []) + deps)) print(json.dumps(result)) PYTHON
- SKIP_DIRS =
%w[test tests docs doc examples example vendor .git __pycache__ dist build node_modules .tox .venv venv env].freeze
Constants inherited from Grapher
Instance Attribute Summary
Attributes inherited from Archsight::Import::Handler
#database, #import_resource, #progress, #resources_dir, #shared_writer
Class Method Summary collapse
Instance Method Summary collapse
Methods inherited from Grapher
#dot_graph, #execute, #safe_glob
Methods inherited from Archsight::Import::Handler
#compute_config_hash, #config, #config_all, #execute, #import_yaml, #initialize, #resource_yaml, #resources_to_yaml, #self_marker, #write_generates_meta, #write_yaml
Constructor Details
This class inherits a constructor from Archsight::Import::Handler
Class Method Details
.applicable?(path) ⇒ Boolean
23 24 25 26 27 28 |
# File 'lib/archsight/import/handlers/python_grapher.rb', line 23 def self.applicable?(path) File.exist?(File.join(path, "__init__.py")) || File.exist?(File.join(path, "pyproject.toml")) || File.exist?(File.join(path, "setup.py")) || Dir.glob(File.join(path, "*/__init__.py")).any? end |
.language_name ⇒ Object
21 |
# File 'lib/archsight/import/handlers/python_grapher.rb', line 21 def self.language_name = "python" |
Instance Method Details
#wrap_single_module? ⇒ Boolean
190 191 192 |
# File 'lib/archsight/import/handlers/python_grapher.rb', line 190 def wrap_single_module? true end |