grimp 3.2__cp312-none-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
grimp/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ __version__ = "3.2"
2
+
3
+ from .application.ports.graph import DetailedImport, ImportGraph
4
+ from .domain.analysis import PackageDependency, Route
5
+ from .domain.valueobjects import DirectImport, Module, Layer
6
+ from .main import build_graph
7
+
8
+ __all__ = [
9
+ "Module",
10
+ "DetailedImport",
11
+ "DirectImport",
12
+ "ImportGraph",
13
+ "PackageDependency",
14
+ "Route",
15
+ "build_graph",
16
+ "Layer",
17
+ ]
Binary file
File without changes
@@ -0,0 +1,174 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING, Any, Iterator, Sequence, TypedDict
5
+
6
+ from grimp import Route
7
+ from grimp import _rustgrimp as rust # type: ignore[attr-defined]
8
+
9
+ if TYPE_CHECKING:
10
+ from grimp.adaptors.graph import ImportGraph
11
+
12
+ from grimp.domain.analysis import PackageDependency
13
+ from grimp.exceptions import NoSuchContainer
14
+ from grimp.domain.valueobjects import Layer
15
+
16
+
17
+ def parse_layers(layers: Sequence[Layer | str | set[str]]) -> tuple[Layer, ...]:
18
+ """
19
+ Convert the passed raw `layers` into `Layer`s.
20
+ """
21
+ out_layers = []
22
+ for layer in layers:
23
+ if isinstance(layer, Layer):
24
+ out_layers.append(layer)
25
+ elif isinstance(layer, str):
26
+ out_layers.append(Layer(layer, independent=True))
27
+ else:
28
+ out_layers.append(Layer(*tuple(layer), independent=True))
29
+ return tuple(out_layers)
30
+
31
+
32
+ def find_illegal_dependencies(
33
+ graph: ImportGraph,
34
+ layers: Sequence[Layer],
35
+ containers: set[str],
36
+ ) -> set[PackageDependency]:
37
+ """
38
+ Find dependencies that don't conform to the supplied layered architecture.
39
+
40
+ See ImportGraph.find_illegal_dependencies_for_layers.
41
+
42
+ The only difference between this and the method is that the containers passed in
43
+ is already a (potentially empty) set.
44
+ """
45
+ try:
46
+ rust_package_dependency_tuple = rust.find_illegal_dependencies(
47
+ levels=tuple(
48
+ {"layers": layer.module_tails, "independent": layer.independent}
49
+ for layer in layers
50
+ ),
51
+ containers=set(containers),
52
+ importeds_by_importer=graph._importeds_by_importer,
53
+ )
54
+ except rust.NoSuchContainer as e:
55
+ raise NoSuchContainer(str(e))
56
+
57
+ rust_package_dependencies = _dependencies_from_tuple(rust_package_dependency_tuple)
58
+ return rust_package_dependencies
59
+
60
+
61
+ class _RustRoute(TypedDict):
62
+ heads: frozenset[str]
63
+ middle: tuple[str, ...]
64
+ tails: frozenset[str]
65
+
66
+
67
+ class _RustPackageDependency(TypedDict):
68
+ importer: str
69
+ imported: str
70
+ routes: tuple[_RustRoute, ...]
71
+
72
+
73
+ def _dependencies_from_tuple(
74
+ rust_package_dependency_tuple: tuple[_RustPackageDependency, ...]
75
+ ) -> set[PackageDependency]:
76
+ return {
77
+ PackageDependency(
78
+ imported=dep_dict["imported"],
79
+ importer=dep_dict["importer"],
80
+ routes=frozenset(
81
+ {
82
+ Route(
83
+ heads=route_dict["heads"],
84
+ middle=route_dict["middle"],
85
+ tails=route_dict["tails"],
86
+ )
87
+ for route_dict in dep_dict["routes"]
88
+ }
89
+ ),
90
+ )
91
+ for dep_dict in rust_package_dependency_tuple
92
+ }
93
+
94
+
95
+ class _Module:
96
+ """
97
+ A Python module.
98
+ """
99
+
100
+ def __init__(self, name: str) -> None:
101
+ """
102
+ Args:
103
+ name: The fully qualified name of a Python module, e.g. 'package.foo.bar'.
104
+ """
105
+ self.name = name
106
+
107
+ def __str__(self) -> str:
108
+ return self.name
109
+
110
+ def __eq__(self, other: Any) -> bool:
111
+ if isinstance(other, self.__class__):
112
+ return hash(self) == hash(other)
113
+ else:
114
+ return False
115
+
116
+ def __hash__(self) -> int:
117
+ return hash(str(self))
118
+
119
+ def is_descendant_of(self, module: "_Module") -> bool:
120
+ return self.name.startswith(f"{module.name}.")
121
+
122
+
123
+ @dataclass(frozen=True)
124
+ class _Link:
125
+ importer: str
126
+ imported: str
127
+
128
+
129
+ # A chain of modules, each of which imports the next.
130
+ if TYPE_CHECKING:
131
+ # TODO: remove TYPE_CHECKING conditional once on Python 3.9.
132
+ _Chain = tuple[str, ...]
133
+
134
+
135
+ def _generate_module_permutations(
136
+ graph: ImportGraph,
137
+ layers: Sequence[str],
138
+ containers: set[str],
139
+ ) -> Iterator[tuple[_Module, _Module, str | None]]:
140
+ """
141
+ Return all possible combinations of higher level and lower level modules, in pairs.
142
+
143
+ Each pair of modules consists of immediate children of two different layers. The first
144
+ module is in a layer higher than the layer of the second module. This means the first
145
+ module is allowed to import the second, but not the other way around.
146
+
147
+ Returns:
148
+ module_in_higher_layer, module_in_lower_layer, container
149
+ """
150
+ # If there are no containers, we still want to run the loop once.
151
+ quasi_containers = containers or [None]
152
+
153
+ for container in quasi_containers:
154
+ for index, higher_layer in enumerate(layers):
155
+ higher_layer_module = _module_from_layer(higher_layer, container)
156
+
157
+ if higher_layer_module.name not in graph.modules:
158
+ continue
159
+
160
+ for lower_layer in layers[index + 1 :]:
161
+ lower_layer_module = _module_from_layer(lower_layer, container)
162
+
163
+ if lower_layer_module.name not in graph.modules:
164
+ continue
165
+
166
+ yield higher_layer_module, lower_layer_module, container
167
+
168
+
169
+ def _module_from_layer(layer: str, container: str | None = None) -> _Module:
170
+ if container:
171
+ name = ".".join([container, layer])
172
+ else:
173
+ name = layer
174
+ return _Module(name)
@@ -0,0 +1,286 @@
1
+ import hashlib
2
+ import json
3
+ import logging
4
+ from typing import Dict, List, Optional, Set, Tuple, Type
5
+
6
+ from grimp.application.ports.filesystem import AbstractFileSystem
7
+ from grimp.application.ports.modulefinder import FoundPackage, ModuleFile
8
+ from grimp.domain.valueobjects import DirectImport, Module
9
+
10
+ from ..application.ports.caching import Cache as AbstractCache
11
+ from ..application.ports.caching import CacheMiss
12
+
13
+ logger = logging.getLogger(__name__)
14
+ PrimitiveFormat = Dict[str, List[Tuple[str, Optional[int], str]]]
15
+
16
+
17
+ class CacheFileNamer:
18
+ @classmethod
19
+ def make_meta_file_name(cls, found_package: FoundPackage) -> str:
20
+ return f"{found_package.name}.meta.json"
21
+
22
+ @classmethod
23
+ def make_data_file_name(
24
+ cls,
25
+ found_packages: Set[FoundPackage],
26
+ include_external_packages: bool,
27
+ exclude_type_checking_imports: bool,
28
+ ) -> str:
29
+ identifier = cls.make_data_file_unique_string(
30
+ found_packages, include_external_packages, exclude_type_checking_imports
31
+ )
32
+
33
+ bytes_identifier = identifier.encode()
34
+ # Use a hash algorithm with a limited size to avoid cache filenames that are too long
35
+ # the filesystem, which can happen if there are more than a few root packages
36
+ # being analyzed.
37
+ safe_unicode_identifier = hashlib.blake2b(bytes_identifier, digest_size=20).hexdigest()
38
+ return f"{safe_unicode_identifier}.data.json"
39
+
40
+ @classmethod
41
+ def make_data_file_unique_string(
42
+ cls,
43
+ found_packages: Set[FoundPackage],
44
+ include_external_packages: bool,
45
+ exclude_type_checking_imports: bool,
46
+ ) -> str:
47
+ """
48
+ Construct a unique string that identifies the analysis parameters.
49
+
50
+ Doesn't need to be safe to use for a filename.
51
+ """
52
+ package_names = (p.name for p in found_packages)
53
+ csv_packages = ",".join(sorted(package_names))
54
+ include_external_packages_option = ":external" if include_external_packages else ""
55
+ exclude_type_checking_imports_option = (
56
+ ":no_type_checking" if exclude_type_checking_imports else ""
57
+ )
58
+ return (
59
+ csv_packages + include_external_packages_option + exclude_type_checking_imports_option
60
+ )
61
+
62
+
63
+ class Cache(AbstractCache):
64
+ DEFAULT_CACHE_DIR = ".grimp_cache"
65
+
66
+ def __init__(self, *args, namer: Type[CacheFileNamer], **kwargs) -> None:
67
+ """
68
+ Don't instantiate Cache directly; use Cache.setup().
69
+ """
70
+ super().__init__(*args, **kwargs)
71
+ self._mtime_map: Dict[str, float] = {}
72
+ self._data_map: Dict[Module, Set[DirectImport]] = {}
73
+ self._namer = namer
74
+
75
+ @classmethod
76
+ def setup(
77
+ cls,
78
+ file_system: AbstractFileSystem,
79
+ found_packages: Set[FoundPackage],
80
+ include_external_packages: bool,
81
+ exclude_type_checking_imports: bool = False,
82
+ cache_dir: Optional[str] = None,
83
+ namer: Type[CacheFileNamer] = CacheFileNamer,
84
+ ) -> "Cache":
85
+ cache = cls(
86
+ file_system=file_system,
87
+ found_packages=found_packages,
88
+ include_external_packages=include_external_packages,
89
+ exclude_type_checking_imports=exclude_type_checking_imports,
90
+ cache_dir=cls.cache_dir_or_default(cache_dir),
91
+ namer=namer,
92
+ )
93
+ cache._build_mtime_map()
94
+ cache._build_data_map()
95
+ assert cache.cache_dir
96
+ return cache
97
+
98
+ @classmethod
99
+ def cache_dir_or_default(cls, cache_dir: Optional[str]) -> str:
100
+ return cache_dir or cls.DEFAULT_CACHE_DIR
101
+
102
+ def read_imports(self, module_file: ModuleFile) -> Set[DirectImport]:
103
+ try:
104
+ cached_mtime = self._mtime_map[module_file.module.name]
105
+ except KeyError:
106
+ raise CacheMiss
107
+ if cached_mtime != module_file.mtime:
108
+ raise CacheMiss
109
+
110
+ try:
111
+ return self._data_map[module_file.module]
112
+ except KeyError:
113
+ # While we would expect the module to be in here,
114
+ # there's no point in crashing if, for some reason, it's not.
115
+ raise CacheMiss
116
+
117
+ def write(
118
+ self,
119
+ imports_by_module: Dict[Module, Set[DirectImport]],
120
+ ) -> None:
121
+ self._write_marker_files_if_not_already_there()
122
+ # Write data file.
123
+ primitives_map: PrimitiveFormat = {}
124
+ for found_package in self.found_packages:
125
+ primitives_map_for_found_package: PrimitiveFormat = {
126
+ module_file.module.name: [
127
+ (
128
+ direct_import.imported.name,
129
+ direct_import.line_number,
130
+ direct_import.line_contents,
131
+ )
132
+ for direct_import in imports_by_module[module_file.module]
133
+ ]
134
+ for module_file in found_package.module_files
135
+ }
136
+ primitives_map.update(primitives_map_for_found_package)
137
+
138
+ serialized = json.dumps(primitives_map)
139
+ data_cache_filename = self.file_system.join(
140
+ self.cache_dir,
141
+ self._namer.make_data_file_name(
142
+ found_packages=self.found_packages,
143
+ include_external_packages=self.include_external_packages,
144
+ exclude_type_checking_imports=self.exclude_type_checking_imports,
145
+ ),
146
+ )
147
+ self.file_system.write(data_cache_filename, serialized)
148
+ logger.info(f"Wrote data cache file {data_cache_filename}.")
149
+
150
+ # Write meta files.
151
+ for found_package in self.found_packages:
152
+ meta_filename = self.file_system.join(
153
+ self.cache_dir, self._namer.make_meta_file_name(found_package)
154
+ )
155
+ mtime_map = {
156
+ module_file.module.name: module_file.mtime
157
+ for module_file in found_package.module_files
158
+ }
159
+ serialized_meta = json.dumps(mtime_map)
160
+ self.file_system.write(meta_filename, serialized_meta)
161
+ logger.info(f"Wrote meta cache file {meta_filename}.")
162
+
163
+ def _build_mtime_map(self) -> None:
164
+ self._mtime_map = self._read_mtime_map_files()
165
+
166
+ def _read_mtime_map_files(self) -> Dict[str, float]:
167
+ all_mtimes: Dict[str, float] = {}
168
+ for found_package in self.found_packages:
169
+ all_mtimes.update(self._read_mtime_map_file(found_package))
170
+ return all_mtimes
171
+
172
+ def _read_mtime_map_file(self, found_package: FoundPackage) -> Dict[str, float]:
173
+ meta_cache_filename = self.file_system.join(
174
+ self.cache_dir, self._namer.make_meta_file_name(found_package)
175
+ )
176
+ try:
177
+ serialized = self.file_system.read(meta_cache_filename)
178
+ except FileNotFoundError:
179
+ logger.info(f"No cache file: {meta_cache_filename}.")
180
+ return {}
181
+ try:
182
+ deserialized = json.loads(serialized)
183
+ logger.info(f"Used cache meta file {meta_cache_filename}.")
184
+ return deserialized
185
+ except json.JSONDecodeError:
186
+ logger.warning(f"Could not use corrupt cache file {meta_cache_filename}.")
187
+ return {}
188
+
189
+ def _build_data_map(self) -> None:
190
+ self._data_map = self._read_data_map_file()
191
+
192
+ def _read_data_map_file(self) -> Dict[Module, Set[DirectImport]]:
193
+ data_cache_filename = self.file_system.join(
194
+ self.cache_dir,
195
+ self._namer.make_data_file_name(
196
+ found_packages=self.found_packages,
197
+ include_external_packages=self.include_external_packages,
198
+ exclude_type_checking_imports=self.exclude_type_checking_imports,
199
+ ),
200
+ )
201
+ try:
202
+ serialized = self.file_system.read(data_cache_filename)
203
+ except FileNotFoundError:
204
+ logger.info(f"No cache file: {data_cache_filename}.")
205
+ return {}
206
+
207
+ # Deserialize to primitives.
208
+ try:
209
+ deserialized_json = json.loads(serialized)
210
+ logger.info(f"Used cache data file {data_cache_filename}.")
211
+ except json.JSONDecodeError:
212
+ logger.warning(f"Could not use corrupt cache file {data_cache_filename}.")
213
+ return {}
214
+
215
+ primitives_map: PrimitiveFormat = self._to_primitives_data_map(deserialized_json)
216
+
217
+ return {
218
+ Module(name=name): {
219
+ DirectImport(
220
+ importer=Module(name),
221
+ imported=Module(import_data[0]),
222
+ line_number=int(import_data[1]), # type: ignore
223
+ line_contents=import_data[2],
224
+ )
225
+ for import_data in imports_data
226
+ }
227
+ for name, imports_data in primitives_map.items()
228
+ }
229
+
230
+ def _build_data_cache_filename(self, found_package: FoundPackage) -> str:
231
+ return self.file_system.join(self.cache_dir, f"{found_package.name}.data.json")
232
+
233
+ def _to_primitives_data_map(self, deserialized_json: object) -> PrimitiveFormat:
234
+ """
235
+ Convert the deserialized json from a data file to a narrower schema.
236
+
237
+ Anything that doesn't fit the schema will be removed.
238
+ """
239
+ if not isinstance(deserialized_json, dict):
240
+ return {}
241
+
242
+ primitives_map: PrimitiveFormat = {}
243
+
244
+ for key, value in deserialized_json.items():
245
+ if not isinstance(key, str):
246
+ continue
247
+ if not isinstance(value, list):
248
+ continue
249
+ primitive_imports = []
250
+ for deserialized_import in value:
251
+ try:
252
+ [imported, line_number, line_contents] = deserialized_import
253
+ except ValueError:
254
+ continue
255
+ try:
256
+ primitive_imports.append(
257
+ (
258
+ str(imported),
259
+ int(line_number) if line_number else None,
260
+ str(line_contents),
261
+ )
262
+ )
263
+ except TypeError:
264
+ continue
265
+
266
+ primitives_map[key] = primitive_imports
267
+
268
+ return primitives_map
269
+
270
+ def _write_marker_files_if_not_already_there(self) -> None:
271
+ marker_files_info = (
272
+ (".gitignore", "# Automatically created by Grimp.\n*"),
273
+ (
274
+ "CACHEDIR.TAG",
275
+ (
276
+ "Signature: 8a477f597d28d172789f06886806bc55\n"
277
+ "# This file is a cache directory tag automatically created by Grimp.\n"
278
+ "# For information about cache directory tags see https://bford.info/cachedir/"
279
+ ),
280
+ ),
281
+ )
282
+
283
+ for filename, contents in marker_files_info:
284
+ full_filename = self.file_system.join(self.cache_dir, filename)
285
+ if not self.file_system.exists(full_filename):
286
+ self.file_system.write(full_filename, contents)
@@ -0,0 +1,46 @@
1
+ import os
2
+ import tokenize
3
+ from typing import Iterator, List, Tuple
4
+
5
+ from grimp.application.ports.filesystem import AbstractFileSystem
6
+
7
+
8
+ class FileSystem(AbstractFileSystem):
9
+ """
10
+ Abstraction around file system calls.
11
+ """
12
+
13
+ @property
14
+ def sep(self) -> str:
15
+ return os.sep
16
+
17
+ def dirname(self, filename: str) -> str:
18
+ return os.path.dirname(filename)
19
+
20
+ def walk(self, directory_name: str) -> Iterator[Tuple[str, List[str], List[str]]]:
21
+ yield from os.walk(directory_name)
22
+
23
+ def join(self, *components: str) -> str:
24
+ return os.path.join(*components)
25
+
26
+ def split(self, file_name: str) -> Tuple[str, str]:
27
+ return os.path.split(file_name)
28
+
29
+ def read(self, file_name: str) -> str:
30
+ # Use tokenize.open to give us a better chance of successfully decoding
31
+ # source code in a non-ascii compatible encoding.
32
+ with tokenize.open(file_name) as file:
33
+ return file.read()
34
+
35
+ def exists(self, file_name: str) -> bool:
36
+ return os.path.isfile(file_name)
37
+
38
+ def get_mtime(self, file_name: str) -> float:
39
+ return os.path.getmtime(file_name)
40
+
41
+ def write(self, file_name: str, contents: str) -> None:
42
+ dirname = os.path.dirname(file_name)
43
+ if not os.path.exists(dirname):
44
+ os.makedirs(dirname)
45
+ with open(file_name, "w") as file:
46
+ print(contents, file=file)