Coverage for src/dependenpy/_internal/dsm.py: 84.82%
201 statements
« prev ^ index » next coverage.py v7.10.5, created at 2025-08-24 18:36 +0200
« prev ^ index » next coverage.py v7.10.5, created at 2025-08-24 18:36 +0200
1# dependenpy dsm module.
2#
3# This is the core module of dependenpy. It contains the following classes:
4#
5# - [`DSM`][dependenpy.dsm.DSM]: to create a DSM-capable object for a list of packages,
6# - [`Package`][dependenpy.dsm.Package]: which represents a Python package,
7# - [`Module`][dependenpy.dsm.Module]: which represents a Python module,
8# - [`Dependency`][dependenpy.dsm.Dependency]: which represents a dependency between two modules.
10from __future__ import annotations
12import ast
13import json
14import sys
15from os import listdir
16from os.path import isdir, isfile, join, splitext
17from pathlib import Path
18from typing import TYPE_CHECKING, Any
20from dependenpy._internal.finder import Finder, PackageSpec
21from dependenpy._internal.helpers import PrintMixin
22from dependenpy._internal.node import LeafNode, NodeMixin, RootNode
24if TYPE_CHECKING:
25 from collections.abc import Sequence
28class DSM(RootNode, NodeMixin, PrintMixin):
29 """DSM-capable class.
31 Technically speaking, a DSM instance is not a real DSM but more a tree
32 representing the Python packages structure. However, it has the
33 necessary methods to build a real DSM in the form of a square matrix,
34 a dictionary or a tree-map.
35 """
37 def __init__(
38 self,
39 *packages: str,
40 build_tree: bool = True,
41 build_dependencies: bool = True,
42 enforce_init: bool = True,
43 ):
44 """Initialization method.
46 Args:
47 *packages: list of packages to search for.
48 build_tree: auto-build the tree or not.
49 build_dependencies: auto-build the dependencies or not.
50 enforce_init: if True, only treat directories if they contain an `__init__.py` file.
51 """
52 self.base_packages = packages
53 """Packages initially specified."""
54 self.finder = Finder()
55 """Finder instance for locating packages and modules."""
56 self.specs = []
57 """List of package specifications found."""
58 self.not_found = []
59 """List of packages that were not found."""
60 self.enforce_init = enforce_init
61 """Whether to enforce the presence of `__init__.py` files."""
63 specs = []
64 for package in packages:
65 spec = self.finder.find(package, enforce_init=enforce_init)
66 if spec:
67 specs.append(spec)
68 else:
69 self.not_found.append(package)
71 if not specs:
72 print("** dependenpy: DSM empty.", file=sys.stderr) # noqa: T201
74 self.specs = PackageSpec.combine(specs)
76 for module in self.not_found:
77 print(f"** dependenpy: Not found: {module}.", file=sys.stderr) # noqa: T201
79 super().__init__(build_tree)
81 if build_tree and build_dependencies:
82 self.build_dependencies()
84 def __str__(self):
85 packages_names = ", ".join([package.name for package in self.packages])
86 return f"Dependency DSM for packages: [{packages_names}]"
88 @property
89 def isdsm(self) -> bool:
90 """Inherited from NodeMixin. Always True.
92 Returns:
93 Whether this object is a DSM.
94 """
95 return True
97 def build_tree(self) -> None:
98 """Build the Python packages tree."""
99 for spec in self.specs:
100 if spec.ismodule: 100 ↛ 101line 100 didn't jump to line 101 because the condition on line 100 was never true
101 self.modules.append(Module(spec.name, spec.path, dsm=self))
102 else:
103 self.packages.append(
104 Package(
105 spec.name,
106 spec.path,
107 dsm=self,
108 limit_to=spec.limit_to,
109 build_tree=True,
110 build_dependencies=False,
111 enforce_init=self.enforce_init,
112 ),
113 )
116class Package(RootNode, LeafNode, NodeMixin, PrintMixin):
117 """Package class.
119 This class represent Python packages as nodes in a tree.
120 """
122 def __init__(
123 self,
124 name: str,
125 path: str,
126 dsm: DSM | None = None,
127 package: Package | None = None,
128 limit_to: list[str] | None = None,
129 build_tree: bool = True, # noqa: FBT001,FBT002
130 build_dependencies: bool = True, # noqa: FBT001,FBT002
131 enforce_init: bool = True, # noqa: FBT001,FBT002
132 ):
133 """Initialization method.
135 Args:
136 name: name of the package.
137 path: path to the package.
138 dsm: parent DSM.
139 package: parent package.
140 limit_to: list of string to limit the recursive tree-building to what is specified.
141 build_tree: auto-build the tree or not.
142 build_dependencies: auto-build the dependencies or not.
143 enforce_init: if True, only treat directories if they contain an `__init__.py` file.
144 """
145 self.name = name
146 """Name of the package."""
147 self.path = path
148 """Path to the package."""
149 self.package = package
150 """Parent package."""
151 self.dsm = dsm
152 """Parent DSM."""
153 self.limit_to = limit_to or []
154 """List of strings to limit the recursive tree-building."""
155 self.enforce_init = enforce_init
156 """Whether to enforce the presence of `__init__.py` files."""
158 RootNode.__init__(self, build_tree)
159 LeafNode.__init__(self)
161 if build_tree and build_dependencies: 161 ↛ 162line 161 didn't jump to line 162 because the condition on line 161 was never true
162 self.build_dependencies()
164 @property
165 def ispackage(self) -> bool:
166 """Inherited from NodeMixin. Always True.
168 Returns:
169 Whether this object is a package.
170 """
171 return True
173 @property
174 def issubpackage(self) -> bool:
175 """Property to tell if this node is a sub-package.
177 Returns:
178 This package has a parent.
179 """
180 return self.package is not None
182 @property
183 def isroot(self) -> bool:
184 """Property to tell if this node is a root node.
186 Returns:
187 This package has no parent.
188 """
189 return self.package is None
191 def split_limits_heads(self) -> tuple[list[str], list[str]]:
192 """Return first parts of dot-separated strings, and rest of strings.
194 Returns:
195 The heads and rest of the strings.
196 """
197 heads = []
198 new_limit_to = []
199 for limit in self.limit_to: 199 ↛ 200line 199 didn't jump to line 200 because the loop on line 199 never started
200 if "." in limit:
201 name, limit = limit.split(".", 1) # noqa: PLW2901
202 heads.append(name)
203 new_limit_to.append(limit)
204 else:
205 heads.append(limit)
206 return heads, new_limit_to
208 def build_tree(self) -> None:
209 """Build the tree for this package."""
210 for module in listdir(self.path):
211 abs_m = join(self.path, module)
212 if isfile(abs_m) and module.endswith(".py"):
213 name = splitext(module)[0]
214 if not self.limit_to or name in self.limit_to: 214 ↛ 210line 214 didn't jump to line 210 because the condition on line 214 was always true
215 self.modules.append(Module(name, abs_m, self.dsm, self))
216 elif isdir(abs_m) and (isfile(join(abs_m, "__init__.py")) or not self.enforce_init):
217 heads, new_limit_to = self.split_limits_heads()
218 if not heads or module in heads: 218 ↛ 210line 218 didn't jump to line 210 because the condition on line 218 was always true
219 self.packages.append(
220 Package(
221 module,
222 abs_m,
223 self.dsm,
224 self,
225 new_limit_to,
226 build_tree=True,
227 build_dependencies=False,
228 enforce_init=self.enforce_init,
229 ),
230 )
232 def cardinal(self, to: Package | Module) -> int:
233 """Return the number of dependencies of this package to the given node.
235 Args:
236 to (Package/Module): target node.
238 Returns:
239 Number of dependencies.
240 """
241 return sum(module.cardinal(to) for module in self.submodules)
244class Module(LeafNode, NodeMixin, PrintMixin):
245 """Module class.
247 This class represents a Python module (a Python file).
248 """
250 RECURSIVE_NODES = (ast.ClassDef, ast.FunctionDef, ast.If, ast.IfExp, ast.Try, ast.With, ast.ExceptHandler)
251 """Nodes that can be recursive."""
253 def __init__(self, name: str, path: str, dsm: DSM | None = None, package: Package | None = None) -> None:
254 """Initialization method.
256 Args:
257 name (str): name of the module.
258 path (str): path to the module.
259 dsm (DSM): parent DSM.
260 package (Package): parent Package.
261 """
262 super().__init__()
263 self.name = name
264 """Name of the module."""
265 self.path = path
266 """Path to the module."""
267 self.package = package
268 """Package to which the module belongs."""
269 self.dsm = dsm
270 """Parent DSM."""
271 self.dependencies: list[Dependency] = []
272 """List of dependencies."""
274 def __contains__(self, item: Package | Module) -> bool:
275 """Whether given item is contained inside this module.
277 Args:
278 item (Package/Module): a package or module.
280 Returns:
281 True if self is item or item is self's package and
282 self if an `__init__` module.
283 """
284 if self is item:
285 return True
286 return self.package is item and self.name == "__init__"
288 @property
289 def ismodule(self) -> bool:
290 """Inherited from NodeMixin. Always True.
292 Returns:
293 Whether this object is a module.
294 """
295 return True
297 def as_dict(self, absolute: bool = False) -> dict: # noqa: FBT001,FBT002
298 """Return the dependencies as a dictionary.
300 Arguments:
301 absolute: Whether to use the absolute name.
303 Returns:
304 dict: dictionary of dependencies.
305 """
306 return {
307 "name": self.absolute_name() if absolute else self.name,
308 "path": self.path,
309 "dependencies": [
310 {
311 # 'source': d.source.absolute_name(), # redundant
312 "target": dep.target if dep.external else dep.target.absolute_name(), # type: ignore[union-attr]
313 "lineno": dep.lineno,
314 "what": dep.what,
315 "external": dep.external,
316 }
317 for dep in self.dependencies
318 ],
319 }
321 def _to_text(self, **kwargs: Any) -> str:
322 indent = kwargs.pop("indent", 2)
323 base_indent = kwargs.pop("base_indent", None)
324 if base_indent is None: 324 ↛ 325line 324 didn't jump to line 325 because the condition on line 324 was never true
325 base_indent = indent
326 indent = 0
327 text = [" " * indent + self.name + "\n"]
328 new_indent = indent + base_indent
329 for dep in self.dependencies:
330 external = "! " if dep.external else ""
331 text.append(" " * new_indent + external + str(dep) + "\n")
332 return "".join(text)
334 def _to_csv(self, **kwargs: Any) -> str:
335 header = kwargs.pop("header", True)
336 text = ["module,path,target,lineno,what,external\n" if header else ""]
337 name = self.absolute_name()
338 for dep in self.dependencies:
339 target = dep.target if dep.external else dep.target.absolute_name() # type: ignore[union-attr]
340 text.append(f"{name},{self.path},{target},{dep.lineno},{dep.what or ''},{dep.external}\n")
341 return "".join(text)
343 def _to_json(self, **kwargs: Any) -> str:
344 absolute = kwargs.pop("absolute", False)
345 return json.dumps(self.as_dict(absolute=absolute), **kwargs)
347 def build_dependencies(self) -> None:
348 """Build the dependencies for this module.
350 Parse the code with ast, find all the import statements, convert
351 them into Dependency objects.
352 """
353 highest = self.dsm or self.root
354 for import_ in self.parse_code():
355 target = highest.get_target(import_["target"])
356 if target:
357 what = import_["target"].split(".")[-1]
358 if what != target.name:
359 import_["what"] = what
360 import_["target"] = target
361 self.dependencies.append(Dependency(source=self, **import_))
363 def parse_code(self) -> list[dict]:
364 """Read the source code and return all the import statements.
366 Returns:
367 list of dict: the import statements.
368 """
369 code = Path(self.path).read_text(encoding="utf-8")
370 try:
371 body = ast.parse(code).body
372 except SyntaxError:
373 code = code.encode("utf-8") # type: ignore[assignment]
374 try:
375 body = ast.parse(code).body
376 except SyntaxError:
377 return []
378 return self.get_imports(body)
380 def get_imports(self, ast_body: Sequence[ast.AST]) -> list[dict]:
381 """Return all the import statements given an AST body (AST nodes).
383 Args:
384 ast_body (compiled code's body): the body to filter.
386 Returns:
387 The import statements.
388 """
389 imports: list[dict] = []
390 for node in ast_body:
391 if isinstance(node, ast.Import):
392 imports.extend({"target": name.name, "lineno": node.lineno} for name in node.names)
393 elif isinstance(node, ast.ImportFrom):
394 for name in node.names:
395 abs_name = self.absolute_name(self.depth - node.level) + "." if node.level > 0 else ""
396 node_module = node.module + "." if node.module else ""
397 name = abs_name + node_module + name.name # type: ignore[assignment] # noqa: PLW2901
398 imports.append({"target": name, "lineno": node.lineno})
399 elif isinstance(node, Module.RECURSIVE_NODES):
400 imports.extend(self.get_imports(node.body)) # type: ignore[arg-type]
401 if isinstance(node, ast.Try):
402 imports.extend(self.get_imports(node.finalbody))
403 return imports
405 def cardinal(self, to: Package | Module) -> int:
406 """Return the number of dependencies of this module to the given node.
408 Args:
409 to (Package/Module): the target node.
411 Returns:
412 Number of dependencies.
413 """
414 return len([dep for dep in self.dependencies if not dep.external and dep.target in to]) # type: ignore[operator]
417class Dependency:
418 """Dependency class.
420 Represent a dependency from a module to another.
421 """
423 def __init__(self, source: Module, lineno: int, target: str | Module | Package, what: str | None = None) -> None:
424 """Initialization method.
426 Args:
427 source (Module): source Module.
428 lineno (int): number of line at which import statement occurs.
429 target (str/Module/Package): the target node.
430 what (str): what is imported (optional).
431 """
432 self.source = source
433 """Source module."""
434 self.lineno = lineno
435 """Line number of the import statement."""
436 self.target = target
437 """Target module or package."""
438 self.what = what
439 """What is imported (optional)."""
441 def __str__(self):
442 what = f"{self.what or ''} from "
443 target = self.target if self.external else self.target.absolute_name()
444 return f"{self.source.name} imports {what}{target} (line {self.lineno})"
446 @property
447 def external(self) -> bool:
448 """Property to tell if the dependency's target is a valid node.
450 Returns:
451 Whether the dependency's target is a valid node.
452 """
453 return isinstance(self.target, str)