|
2 | 2 | Use cases handle application logic. |
3 | 3 | """ |
4 | 4 |
|
5 | | -from typing import Dict, Sequence, Set, Type, Union, cast, Iterable, Collection |
6 | | -import math |
7 | | - |
8 | | -import joblib # type: ignore |
| 5 | +from typing import Dict, Sequence, Set, Type, Union, cast, Iterable, Collection, Optional |
| 6 | +import re |
9 | 7 |
|
10 | 8 | from ..application.ports import caching |
11 | 9 | from ..application.ports.filesystem import AbstractFileSystem |
12 | 10 | from ..application.ports.graph import ImportGraph |
13 | | -from ..application.ports.importscanner import AbstractImportScanner |
14 | 11 | from ..application.ports.modulefinder import AbstractModuleFinder, FoundPackage, ModuleFile |
15 | 12 | from ..application.ports.packagefinder import AbstractPackageFinder |
16 | 13 | from ..domain.valueobjects import DirectImport, Module |
17 | 14 | from .config import settings |
18 | | -import os |
| 15 | +from grimp import _rustgrimp as rust # type: ignore[attr-defined] |
| 16 | + |
| 17 | +_LEADING_DOT_REGEX = re.compile(r"^(\.+)\w") |
19 | 18 |
|
20 | 19 |
|
21 | 20 | class NotSupplied: |
@@ -218,77 +217,202 @@ def _scan_imports( |
218 | 217 | include_external_packages: bool, |
219 | 218 | exclude_type_checking_imports: bool, |
220 | 219 | ) -> Dict[ModuleFile, Set[DirectImport]]: |
221 | | - chunks = _create_chunks(module_files) |
222 | | - return _scan_chunks( |
223 | | - chunks, |
224 | | - file_system, |
225 | | - found_packages, |
226 | | - include_external_packages, |
227 | | - exclude_type_checking_imports, |
228 | | - ) |
| 220 | + # Preparation |
| 221 | + # =========== |
229 | 222 |
|
| 223 | + modules: Set[Module] = set() |
| 224 | + for package in found_packages: |
| 225 | + modules |= {mf.module for mf in package.module_files} |
230 | 226 |
|
231 | | -def _create_chunks(module_files: Collection[ModuleFile]) -> tuple[tuple[ModuleFile, ...], ...]: |
232 | | - """ |
233 | | - Split the module files into chunks, each to be worked on by a separate OS process. |
234 | | - """ |
235 | | - module_files_tuple = tuple(module_files) |
| 227 | + found_packages_by_module: Dict[Module, FoundPackage] = { |
| 228 | + module_file.module: package |
| 229 | + for package in found_packages |
| 230 | + for module_file in package.module_files |
| 231 | + } |
236 | 232 |
|
237 | | - number_of_module_files = len(module_files_tuple) |
238 | | - n_chunks = _decide_number_of_processes(number_of_module_files) |
239 | | - chunk_size = math.ceil(number_of_module_files / n_chunks) |
| 233 | + module_filenames_by_module = {} |
| 234 | + for module_file in module_files: |
| 235 | + found_package = found_packages_by_module[module_file.module] |
| 236 | + module_filename = _determine_module_filename( |
| 237 | + file_system, module_file.module, found_package |
| 238 | + ) |
| 239 | + module_filenames_by_module[module_file.module] = module_filename |
| 240 | + |
| 241 | + # Scan raw imported objects in parallel via rust |
| 242 | + # ============================================== |
240 | 243 |
|
241 | | - return tuple( |
242 | | - module_files_tuple[i * chunk_size : (i + 1) * chunk_size] for i in range(n_chunks) |
| 244 | + imported_objects_by_module_name = rust.parse_imported_objects( |
| 245 | + [ |
| 246 | + (module_file.module.name, module_filenames_by_module[module_file.module]) |
| 247 | + for module_file in module_files |
| 248 | + ] |
243 | 249 | ) |
244 | 250 |
|
| 251 | + # Post-processing of raw imports to obtain final result |
| 252 | + # ===================================================== |
245 | 253 |
|
246 | | -def _decide_number_of_processes(number_of_module_files: int) -> int: |
247 | | - min_number_of_modules = int( |
248 | | - os.environ.get( |
249 | | - MIN_NUMBER_OF_MODULES_TO_SCAN_USING_MULTIPROCESSING_ENV_NAME, |
250 | | - DEFAULT_MIN_NUMBER_OF_MODULES_TO_SCAN_USING_MULTIPROCESSING, |
251 | | - ) |
252 | | - ) |
253 | | - if number_of_module_files < min_number_of_modules: |
254 | | - # Don't incur the overhead of multiple processes. |
255 | | - return 1 |
256 | | - return min(joblib.cpu_count(), number_of_module_files) |
| 254 | + imports_by_module_file = {} |
| 255 | + for module_file in module_files: |
| 256 | + module = module_file.module |
| 257 | + module_filename = module_filenames_by_module[module] |
257 | 258 |
|
| 259 | + is_package = _module_is_package(file_system, module_filename) |
258 | 260 |
|
259 | | -def _scan_chunks( |
260 | | - chunks: Collection[Collection[ModuleFile]], |
261 | | - file_system: AbstractFileSystem, |
262 | | - found_packages: Set[FoundPackage], |
263 | | - include_external_packages: bool, |
264 | | - exclude_type_checking_imports: bool, |
265 | | -) -> Dict[ModuleFile, Set[DirectImport]]: |
266 | | - import_scanner: AbstractImportScanner = settings.IMPORT_SCANNER_CLASS( |
267 | | - file_system=file_system, |
268 | | - found_packages=found_packages, |
269 | | - include_external_packages=include_external_packages, |
270 | | - ) |
| 261 | + imports = set() |
| 262 | + for imported_object in imported_objects_by_module_name[module_file.module.name]: |
| 263 | + # Filter on `exclude_type_checking_imports`. |
| 264 | + if exclude_type_checking_imports and imported_object["typechecking_only"]: |
| 265 | + continue |
271 | 266 |
|
272 | | - number_of_processes = len(chunks) |
273 | | - import_scanning_jobs = joblib.Parallel(n_jobs=number_of_processes)( |
274 | | - joblib.delayed(_scan_chunk)(import_scanner, exclude_type_checking_imports, chunk) |
275 | | - for chunk in chunks |
276 | | - ) |
| 267 | + # Resolve relative imports. |
| 268 | + imported_object_name = _get_absolute_imported_object_name( |
| 269 | + module=module, is_package=is_package, imported_object_name=imported_object["name"] |
| 270 | + ) |
| 271 | + |
| 272 | + # Resolve imported module. |
| 273 | + imported_module = _get_internal_module(imported_object_name, modules=modules) |
| 274 | + if imported_module is None: |
| 275 | + # => External import. |
| 276 | + |
| 277 | + # Filter on `include_external_packages`. |
| 278 | + if not include_external_packages: |
| 279 | + continue |
| 280 | + |
| 281 | + # Distill module. |
| 282 | + imported_module = _distill_external_module( |
| 283 | + Module(imported_object_name), found_packages=found_packages |
| 284 | + ) |
| 285 | + if imported_module is None: |
| 286 | + continue |
| 287 | + |
| 288 | + imports.add( |
| 289 | + DirectImport( |
| 290 | + importer=module, |
| 291 | + imported=imported_module, |
| 292 | + line_number=imported_object["line_number"], |
| 293 | + line_contents=imported_object["line_contents"], |
| 294 | + ) |
| 295 | + ) |
| 296 | + |
| 297 | + imports_by_module_file[module_file] = imports |
277 | 298 |
|
278 | | - imports_by_module_file = {} |
279 | | - for chunk_imports_by_module_file in import_scanning_jobs: |
280 | | - imports_by_module_file.update(chunk_imports_by_module_file) |
281 | 299 | return imports_by_module_file |
282 | 300 |
|
283 | 301 |
|
284 | | -def _scan_chunk( |
285 | | - import_scanner: AbstractImportScanner, |
286 | | - exclude_type_checking_imports: bool, |
287 | | - chunk: Iterable[ModuleFile], |
288 | | -) -> Dict[ModuleFile, Set[DirectImport]]: |
289 | | - return { |
290 | | - module_file: import_scanner.scan_for_imports( |
291 | | - module_file.module, exclude_type_checking_imports=exclude_type_checking_imports |
292 | | - ) |
293 | | - for module_file in chunk |
294 | | - } |
| 302 | +def _determine_module_filename( |
| 303 | + file_system: AbstractFileSystem, module: Module, found_package: FoundPackage |
| 304 | +) -> str: |
| 305 | + """ |
| 306 | + Work out the full filename of the given module. |
| 307 | +
|
| 308 | + Any given module can either be a straight Python file (foo.py) or else a package |
| 309 | + (in which case the file is an __init__.py within a directory). |
| 310 | + """ |
| 311 | + top_level_components = found_package.name.split(".") |
| 312 | + module_components = module.name.split(".") |
| 313 | + leaf_components = module_components[len(top_level_components) :] |
| 314 | + package_directory = found_package.directory |
| 315 | + |
| 316 | + filename_root = file_system.join(package_directory, *leaf_components) |
| 317 | + candidate_filenames = ( |
| 318 | + f"{filename_root}.py", |
| 319 | + file_system.join(filename_root, "__init__.py"), |
| 320 | + ) |
| 321 | + for candidate_filename in candidate_filenames: |
| 322 | + if file_system.exists(candidate_filename): |
| 323 | + return candidate_filename |
| 324 | + raise FileNotFoundError(f"Could not find module {module}.") |
| 325 | + |
| 326 | + |
| 327 | +def _module_is_package(file_system: AbstractFileSystem, module_filename: str) -> bool: |
| 328 | + """ |
| 329 | + Whether or not the supplied module filename is a package. |
| 330 | + """ |
| 331 | + return file_system.split(module_filename)[-1] == "__init__.py" |
| 332 | + |
| 333 | + |
| 334 | +def _get_absolute_imported_object_name( |
| 335 | + *, module: Module, is_package: bool, imported_object_name: str |
| 336 | +) -> str: |
| 337 | + leading_dot_match = _LEADING_DOT_REGEX.match(imported_object_name) |
| 338 | + if leading_dot_match is None: |
| 339 | + return imported_object_name |
| 340 | + |
| 341 | + n_leading_dots = len(leading_dot_match.group(1)) |
| 342 | + if is_package: |
| 343 | + if n_leading_dots == 1: |
| 344 | + imported_object_name_base = module.name |
| 345 | + else: |
| 346 | + imported_object_name_base = ".".join(module.name.split(".")[: -(n_leading_dots - 1)]) |
| 347 | + else: |
| 348 | + imported_object_name_base = ".".join(module.name.split(".")[:-n_leading_dots]) |
| 349 | + return imported_object_name_base + "." + imported_object_name[n_leading_dots:] |
| 350 | + |
| 351 | + |
| 352 | +def _get_internal_module(object_name: str, *, modules: Set[Module]) -> Optional[Module]: |
| 353 | + candidate_module = Module(object_name) |
| 354 | + if candidate_module in modules: |
| 355 | + return candidate_module |
| 356 | + |
| 357 | + try: |
| 358 | + candidate_module = candidate_module.parent |
| 359 | + except ValueError: |
| 360 | + return None |
| 361 | + else: |
| 362 | + if candidate_module in modules: |
| 363 | + return candidate_module |
| 364 | + else: |
| 365 | + return None |
| 366 | + |
| 367 | + |
| 368 | +def _distill_external_module( |
| 369 | + module: Module, *, found_packages: Set[FoundPackage] |
| 370 | +) -> Optional[Module]: |
| 371 | + """ |
| 372 | + Given a module that we already know is external, turn it into a module to add to the graph. |
| 373 | +
|
| 374 | + The 'distillation' process involves removing any unwanted subpackages. For example, |
| 375 | + Module("django.models.db") should be turned into simply Module("django"). |
| 376 | +
|
| 377 | + The process is more complex for potential namespace packages, as it's not possible to |
| 378 | + determine the portion package simply from name. Rather than adding the overhead of a |
| 379 | + filesystem read, we just get the shallowest component that does not clash with an internal |
| 380 | + module namespace. Take, for example, a Module("foo.blue.alpha.one"). If one of the found |
| 381 | + packages is foo.blue.beta, the module will be distilled to Module("foo.blue.alpha"). |
| 382 | + Alternatively, if the found package is foo.green, the distilled module will |
| 383 | + be Module("foo.blue"). |
| 384 | + """ |
| 385 | + # If it's a module that is a parent of one of the internal packages, return None |
| 386 | + # as it doesn't make sense and is probably an import of a namespace package. |
| 387 | + if any(Module(package.name).is_descendant_of(module) for package in found_packages): |
| 388 | + return None |
| 389 | + |
| 390 | + # If it shares a namespace with an internal module, get the shallowest component that does |
| 391 | + # not clash with an internal module namespace. |
| 392 | + candidate_portions: Set[Module] = set() |
| 393 | + for found_package in sorted(found_packages, key=lambda p: p.name, reverse=True): |
| 394 | + root_module = Module(found_package.name) |
| 395 | + if root_module.is_descendant_of(module.root): |
| 396 | + ( |
| 397 | + internal_path_components, |
| 398 | + external_path_components, |
| 399 | + ) = root_module.name.split( |
| 400 | + "." |
| 401 | + ), module.name.split(".") |
| 402 | + external_namespace_components = [] |
| 403 | + while external_path_components[0] == internal_path_components[0]: |
| 404 | + external_namespace_components.append(external_path_components[0]) |
| 405 | + external_path_components = external_path_components[1:] |
| 406 | + internal_path_components = internal_path_components[1:] |
| 407 | + external_namespace_components.append(external_path_components[0]) |
| 408 | + candidate_portions.add(Module(".".join(external_namespace_components))) |
| 409 | + |
| 410 | + if candidate_portions: |
| 411 | + # If multiple found packages share a namespace with this module, use the deepest one |
| 412 | + # as we know that that will be a namespace too. |
| 413 | + deepest_candidate_portion = sorted( |
| 414 | + candidate_portions, key=lambda p: len(p.name.split(".")) |
| 415 | + )[-1] |
| 416 | + return deepest_candidate_portion |
| 417 | + else: |
| 418 | + return module.root |
0 commit comments