Skip to content

Commit f0d4871

Browse files
committed
Move parellel import scanning into rust
1 parent 173e79e commit f0d4871

2 files changed

Lines changed: 190 additions & 67 deletions

File tree

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ authors = [
1616
]
1717
requires-python = ">=3.9"
1818
dependencies = [
19-
"joblib>=1.3.0",
2019
"typing-extensions>=3.10.0.0",
2120
]
2221
classifiers = [

src/grimp/application/usecases.py

Lines changed: 190 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,19 @@
22
Use cases handle application logic.
33
"""
44

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
97

108
from ..application.ports import caching
119
from ..application.ports.filesystem import AbstractFileSystem
1210
from ..application.ports.graph import ImportGraph
13-
from ..application.ports.importscanner import AbstractImportScanner
1411
from ..application.ports.modulefinder import AbstractModuleFinder, FoundPackage, ModuleFile
1512
from ..application.ports.packagefinder import AbstractPackageFinder
1613
from ..domain.valueobjects import DirectImport, Module
1714
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")
1918

2019

2120
class NotSupplied:
@@ -218,77 +217,202 @@ def _scan_imports(
218217
include_external_packages: bool,
219218
exclude_type_checking_imports: bool,
220219
) -> 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+
# ===========
229222

223+
modules: Set[Module] = set()
224+
for package in found_packages:
225+
modules |= {mf.module for mf in package.module_files}
230226

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+
}
236232

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+
# ==============================================
240243

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+
]
243249
)
244250

251+
# Post-processing of raw imports to obtain final result
252+
# =====================================================
245253

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]
257258

259+
is_package = _module_is_package(file_system, module_filename)
258260

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
271266

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
277298

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)
281299
return imports_by_module_file
282300

283301

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

Comments
 (0)