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

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. 

9 

10from __future__ import annotations 

11 

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 

19 

20from dependenpy._internal.finder import Finder, PackageSpec 

21from dependenpy._internal.helpers import PrintMixin 

22from dependenpy._internal.node import LeafNode, NodeMixin, RootNode 

23 

24if TYPE_CHECKING: 

25 from collections.abc import Sequence 

26 

27 

28class DSM(RootNode, NodeMixin, PrintMixin): 

29 """DSM-capable class. 

30 

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 """ 

36 

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. 

45 

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.""" 

62 

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) 

70 

71 if not specs: 

72 print("** dependenpy: DSM empty.", file=sys.stderr) # noqa: T201 

73 

74 self.specs = PackageSpec.combine(specs) 

75 

76 for module in self.not_found: 

77 print(f"** dependenpy: Not found: {module}.", file=sys.stderr) # noqa: T201 

78 

79 super().__init__(build_tree) 

80 

81 if build_tree and build_dependencies: 

82 self.build_dependencies() 

83 

84 def __str__(self): 

85 packages_names = ", ".join([package.name for package in self.packages]) 

86 return f"Dependency DSM for packages: [{packages_names}]" 

87 

88 @property 

89 def isdsm(self) -> bool: 

90 """Inherited from NodeMixin. Always True. 

91 

92 Returns: 

93 Whether this object is a DSM. 

94 """ 

95 return True 

96 

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 ) 

114 

115 

116class Package(RootNode, LeafNode, NodeMixin, PrintMixin): 

117 """Package class. 

118 

119 This class represent Python packages as nodes in a tree. 

120 """ 

121 

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. 

134 

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.""" 

157 

158 RootNode.__init__(self, build_tree) 

159 LeafNode.__init__(self) 

160 

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() 

163 

164 @property 

165 def ispackage(self) -> bool: 

166 """Inherited from NodeMixin. Always True. 

167 

168 Returns: 

169 Whether this object is a package. 

170 """ 

171 return True 

172 

173 @property 

174 def issubpackage(self) -> bool: 

175 """Property to tell if this node is a sub-package. 

176 

177 Returns: 

178 This package has a parent. 

179 """ 

180 return self.package is not None 

181 

182 @property 

183 def isroot(self) -> bool: 

184 """Property to tell if this node is a root node. 

185 

186 Returns: 

187 This package has no parent. 

188 """ 

189 return self.package is None 

190 

191 def split_limits_heads(self) -> tuple[list[str], list[str]]: 

192 """Return first parts of dot-separated strings, and rest of strings. 

193 

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 

207 

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 ) 

231 

232 def cardinal(self, to: Package | Module) -> int: 

233 """Return the number of dependencies of this package to the given node. 

234 

235 Args: 

236 to (Package/Module): target node. 

237 

238 Returns: 

239 Number of dependencies. 

240 """ 

241 return sum(module.cardinal(to) for module in self.submodules) 

242 

243 

244class Module(LeafNode, NodeMixin, PrintMixin): 

245 """Module class. 

246 

247 This class represents a Python module (a Python file). 

248 """ 

249 

250 RECURSIVE_NODES = (ast.ClassDef, ast.FunctionDef, ast.If, ast.IfExp, ast.Try, ast.With, ast.ExceptHandler) 

251 """Nodes that can be recursive.""" 

252 

253 def __init__(self, name: str, path: str, dsm: DSM | None = None, package: Package | None = None) -> None: 

254 """Initialization method. 

255 

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.""" 

273 

274 def __contains__(self, item: Package | Module) -> bool: 

275 """Whether given item is contained inside this module. 

276 

277 Args: 

278 item (Package/Module): a package or module. 

279 

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__" 

287 

288 @property 

289 def ismodule(self) -> bool: 

290 """Inherited from NodeMixin. Always True. 

291 

292 Returns: 

293 Whether this object is a module. 

294 """ 

295 return True 

296 

297 def as_dict(self, absolute: bool = False) -> dict: # noqa: FBT001,FBT002 

298 """Return the dependencies as a dictionary. 

299 

300 Arguments: 

301 absolute: Whether to use the absolute name. 

302 

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 } 

320 

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) 

333 

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) 

342 

343 def _to_json(self, **kwargs: Any) -> str: 

344 absolute = kwargs.pop("absolute", False) 

345 return json.dumps(self.as_dict(absolute=absolute), **kwargs) 

346 

347 def build_dependencies(self) -> None: 

348 """Build the dependencies for this module. 

349 

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_)) 

362 

363 def parse_code(self) -> list[dict]: 

364 """Read the source code and return all the import statements. 

365 

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) 

379 

380 def get_imports(self, ast_body: Sequence[ast.AST]) -> list[dict]: 

381 """Return all the import statements given an AST body (AST nodes). 

382 

383 Args: 

384 ast_body (compiled code's body): the body to filter. 

385 

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 

404 

405 def cardinal(self, to: Package | Module) -> int: 

406 """Return the number of dependencies of this module to the given node. 

407 

408 Args: 

409 to (Package/Module): the target node. 

410 

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] 

415 

416 

417class Dependency: 

418 """Dependency class. 

419 

420 Represent a dependency from a module to another. 

421 """ 

422 

423 def __init__(self, source: Module, lineno: int, target: str | Module | Package, what: str | None = None) -> None: 

424 """Initialization method. 

425 

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).""" 

440 

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})" 

445 

446 @property 

447 def external(self) -> bool: 

448 """Property to tell if the dependency's target is a valid node. 

449 

450 Returns: 

451 Whether the dependency's target is a valid node. 

452 """ 

453 return isinstance(self.target, str)