grimp 3.17__tar.gz → 3.19__tar.gz

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.
Files changed (52) hide show
  1. grimp-3.19/AUTHORS.rst +16 -0
  2. grimp-3.19/LICENSE +21 -0
  3. {grimp-3.17 → grimp-3.19}/PKG-INFO +2 -3
  4. {grimp-3.17 → grimp-3.19}/pyproject.toml +3 -3
  5. {grimp-3.17 → grimp-3.19}/rust/src/errors.rs +10 -1
  6. {grimp-3.17 → grimp-3.19}/rust/src/filesystem.rs +71 -22
  7. {grimp-3.17 → grimp-3.19}/rust/src/graph/cycle_breakers.rs +50 -22
  8. {grimp-3.17 → grimp-3.19}/rust/src/graph/mod.rs +36 -21
  9. {grimp-3.17 → grimp-3.19}/rust/src/import_parsing.rs +21 -18
  10. {grimp-3.17 → grimp-3.19}/rust/src/import_scanning.rs +1 -1
  11. {grimp-3.17 → grimp-3.19}/src/grimp/__init__.py +1 -1
  12. {grimp-3.17 → grimp-3.19}/src/grimp/application/graph.py +10 -6
  13. {grimp-3.17 → grimp-3.19}/src/grimp/application/ports/timing.py +2 -1
  14. {grimp-3.17 → grimp-3.19}/README.rst +0 -0
  15. {grimp-3.17 → grimp-3.19}/rust/Cargo.lock +0 -0
  16. {grimp-3.17 → grimp-3.19}/rust/Cargo.toml +0 -0
  17. {grimp-3.17 → grimp-3.19}/rust/build.rs +0 -0
  18. {grimp-3.17 → grimp-3.19}/rust/rust-toolchain.toml +0 -0
  19. {grimp-3.17 → grimp-3.19}/rust/src/caching.rs +0 -0
  20. {grimp-3.17 → grimp-3.19}/rust/src/exceptions.rs +0 -0
  21. {grimp-3.17 → grimp-3.19}/rust/src/graph/direct_import_queries.rs +0 -0
  22. {grimp-3.17 → grimp-3.19}/rust/src/graph/graph_manipulation.rs +0 -0
  23. {grimp-3.17 → grimp-3.19}/rust/src/graph/hierarchy_queries.rs +0 -0
  24. {grimp-3.17 → grimp-3.19}/rust/src/graph/higher_order_queries.rs +0 -0
  25. {grimp-3.17 → grimp-3.19}/rust/src/graph/import_chain_queries.rs +0 -0
  26. {grimp-3.17 → grimp-3.19}/rust/src/graph/pathfinding.rs +0 -0
  27. {grimp-3.17 → grimp-3.19}/rust/src/lib.rs +0 -0
  28. {grimp-3.17 → grimp-3.19}/rust/src/module_expressions.rs +0 -0
  29. {grimp-3.17 → grimp-3.19}/rust/src/module_finding.rs +0 -0
  30. {grimp-3.17 → grimp-3.19}/rust/tests/large.rs +0 -0
  31. {grimp-3.17 → grimp-3.19}/rust/tests/large_graph.json +0 -0
  32. {grimp-3.17 → grimp-3.19}/src/grimp/adaptors/__init__.py +0 -0
  33. {grimp-3.17 → grimp-3.19}/src/grimp/adaptors/caching.py +0 -0
  34. {grimp-3.17 → grimp-3.19}/src/grimp/adaptors/filesystem.py +0 -0
  35. {grimp-3.17 → grimp-3.19}/src/grimp/adaptors/modulefinder.py +0 -0
  36. {grimp-3.17 → grimp-3.19}/src/grimp/adaptors/packagefinder.py +0 -0
  37. {grimp-3.17 → grimp-3.19}/src/grimp/adaptors/timing.py +0 -0
  38. {grimp-3.17 → grimp-3.19}/src/grimp/application/__init__.py +0 -0
  39. {grimp-3.17 → grimp-3.19}/src/grimp/application/config.py +0 -0
  40. {grimp-3.17 → grimp-3.19}/src/grimp/application/ports/__init__.py +0 -0
  41. {grimp-3.17 → grimp-3.19}/src/grimp/application/ports/caching.py +0 -0
  42. {grimp-3.17 → grimp-3.19}/src/grimp/application/ports/filesystem.py +0 -0
  43. {grimp-3.17 → grimp-3.19}/src/grimp/application/ports/modulefinder.py +0 -0
  44. {grimp-3.17 → grimp-3.19}/src/grimp/application/ports/packagefinder.py +0 -0
  45. {grimp-3.17 → grimp-3.19}/src/grimp/application/scanning.py +0 -0
  46. {grimp-3.17 → grimp-3.19}/src/grimp/application/usecases.py +0 -0
  47. {grimp-3.17 → grimp-3.19}/src/grimp/domain/__init__.py +0 -0
  48. {grimp-3.17 → grimp-3.19}/src/grimp/domain/analysis.py +0 -0
  49. {grimp-3.17 → grimp-3.19}/src/grimp/domain/valueobjects.py +0 -0
  50. {grimp-3.17 → grimp-3.19}/src/grimp/exceptions.py +0 -0
  51. {grimp-3.17 → grimp-3.19}/src/grimp/main.py +0 -0
  52. {grimp-3.17 → grimp-3.19}/src/grimp/py.typed +0 -0
grimp-3.19/AUTHORS.rst ADDED
@@ -0,0 +1,16 @@
1
+
2
+ Authors
3
+ =======
4
+
5
+ * David Seddon - https://seddonym.me
6
+ * Kevin Amado - https://github.com/kamadorueda
7
+ * Matthew Gamble - https://github.com/mwgamble
8
+ * NetworkX developers - The shortest path algorithm was adapted from the NetworkX library https://networkx.org/.
9
+ * Peter Byfield - https://github.com/Peter554
10
+ * Shane Smiskol - https://github.com/sshane
11
+ * Andreas Rammhold - https://github.com/andir
12
+ * Nicholas Bunn - https://github.com/NicholasBunn
13
+ * Nathan McDougall - https://github.com/nathanjmcdougall
14
+ * Oleksandr Zaiats - https://github.com/z4y4ts
15
+ * Nikhil Dabas - https://github.com/ndabas
16
+ * Pierre-Yves Le Borgne - https://github.com/pylaterreur
grimp-3.19/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ BSD 2-Clause License
2
+
3
+ Copyright (c) 2018, David Seddon
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following
7
+ conditions are met:
8
+
9
+ * Redistributions of source code must retain the above copyright notice, this list of conditions and the following
10
+ disclaimer.
11
+
12
+ * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following
13
+ disclaimer in the documentation and/or other materials provided with the distribution.
14
+
15
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
16
+ INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
17
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
18
+ EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
19
+ USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
20
+ STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
21
+ ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: grimp
3
- Version: 3.17
3
+ Version: 3.19
4
4
  Classifier: Development Status :: 5 - Production/Stable
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: BSD License
@@ -8,7 +8,6 @@ Classifier: Operating System :: Unix
8
8
  Classifier: Operating System :: POSIX
9
9
  Classifier: Operating System :: Microsoft :: Windows
10
10
  Classifier: Programming Language :: Python
11
- Classifier: Programming Language :: Python :: 3.10
12
11
  Classifier: Programming Language :: Python :: 3.11
13
12
  Classifier: Programming Language :: Python :: 3.12
14
13
  Classifier: Programming Language :: Python :: 3.13
@@ -22,7 +21,7 @@ License-File: AUTHORS.rst
22
21
  Summary: Builds a queryable graph of the imports within one or more Python packages.
23
22
  Author-email: David Seddon <david@seddonym.me>
24
23
  License: BSD 2-Clause License
25
- Requires-Python: >=3.10
24
+ Requires-Python: >=3.11
26
25
  Description-Content-Type: text/x-rst; charset=UTF-8
27
26
  Project-URL: Documentation, https://grimp.readthedocs.io/
28
27
  Project-URL: Source-code, https://github.com/python-grimp/grimp/
@@ -8,13 +8,13 @@ module-name = "grimp._rustgrimp"
8
8
 
9
9
  [project]
10
10
  name = "grimp"
11
- version = "3.17"
11
+ version = "3.19"
12
12
  license = {text = "BSD 2-Clause License"}
13
13
  description = "Builds a queryable graph of the imports within one or more Python packages."
14
14
  authors = [
15
15
  {name = "David Seddon", email = "david@seddonym.me"},
16
16
  ]
17
- requires-python = ">=3.10"
17
+ requires-python = ">=3.11"
18
18
  dependencies = []
19
19
  classifiers = [
20
20
  "Development Status :: 5 - Production/Stable",
@@ -24,7 +24,6 @@ classifiers = [
24
24
  "Operating System :: POSIX",
25
25
  "Operating System :: Microsoft :: Windows",
26
26
  "Programming Language :: Python",
27
- "Programming Language :: Python :: 3.10",
28
27
  "Programming Language :: Python :: 3.11",
29
28
  "Programming Language :: Python :: 3.12",
30
29
  "Programming Language :: Python :: 3.13",
@@ -60,6 +59,7 @@ dev = [
60
59
  "requests==2.32.3",
61
60
  "sqlalchemy==2.0.35",
62
61
  "google-cloud-audit-log==0.3.0",
62
+ "pyupgrade>=3.21.2",
63
63
  ]
64
64
  docs = [
65
65
  "sphinx>=7.4.7",
@@ -1,6 +1,6 @@
1
1
  use crate::exceptions;
2
2
  use pyo3::PyErr;
3
- use pyo3::exceptions::PyValueError;
3
+ use pyo3::exceptions::{PyFileNotFoundError, PyUnicodeError, PyValueError};
4
4
  use ruff_python_parser::ParseError as RuffParseError;
5
5
  use thiserror::Error;
6
6
 
@@ -39,6 +39,12 @@ pub enum GrimpError {
39
39
 
40
40
  #[error("Cache file {0} was written by a different version of Grimp.")]
41
41
  CacheVersionMismatch(String),
42
+
43
+ #[error("{0}")]
44
+ FileNotFound(String),
45
+
46
+ #[error("{0}")]
47
+ UndecodableFile(String),
42
48
  }
43
49
 
44
50
  pub type GrimpResult<T> = Result<T, GrimpError>;
@@ -61,6 +67,9 @@ impl From<GrimpError> for PyErr {
61
67
  GrimpError::CacheVersionMismatch(_) => {
62
68
  exceptions::CacheVersionMismatch::new_err(value.to_string())
63
69
  }
70
+ GrimpError::FileNotFound(_) => PyFileNotFoundError::new_err(value.to_string()),
71
+ // Not UnicodeDecodeError, as that can't be created from just a message.
72
+ GrimpError::UndecodableFile(_) => PyUnicodeError::new_err(value.to_string()),
64
73
  }
65
74
  }
66
75
  }
@@ -1,5 +1,6 @@
1
+ use crate::errors::{GrimpError, GrimpResult};
1
2
  use itertools::Itertools;
2
- use pyo3::exceptions::{PyFileNotFoundError, PyTypeError, PyUnicodeDecodeError};
3
+ use pyo3::exceptions::PyTypeError;
3
4
  use pyo3::prelude::*;
4
5
  use regex::Regex;
5
6
  use std::collections::HashMap;
@@ -14,6 +15,48 @@ use unindent::unindent;
14
15
  static ENCODING_RE: LazyLock<Regex> =
15
16
  LazyLock::new(|| Regex::new(r"^[ \t\f]*#.*?coding[:=][ \t]*([-_.a-zA-Z0-9]+)").unwrap());
16
17
 
18
+ /// An encoding named in a Python source file's encoding declaration.
19
+ enum Encoding {
20
+ /// True Latin-1 (ISO-8859-1): a straight byte-to-code-point mapping. This is handled
21
+ /// specially because encoding_rs follows the WHATWG standard, under which the `iso-8859-1`
22
+ /// label resolves to the Windows-1252 decoder instead (the two differ in the 0x80..=0x9f
23
+ /// range), and because Windows-1252 has undefined bytes that true Latin-1 decodes fine.
24
+ Latin1,
25
+ /// Any other encoding, delegated to encoding_rs.
26
+ Other(&'static encoding_rs::Encoding),
27
+ }
28
+
29
+ /// Look up the encoding named in a Python source file's encoding declaration.
30
+ ///
31
+ /// encoding_rs only knows the WHATWG labels, but Python accepts other spellings too, such as
32
+ /// `latin-1`, `utf_8`, `utf-8-sig` or `euc_jp`.
33
+ fn lookup_encoding(name: &str) -> Option<Encoding> {
34
+ // Normalize the name like Python does: see `_get_normal_name` in Lib/tokenize.py, which
35
+ // special cases UTF-8 and Latin-1. Python's codec lookup also ignores the difference
36
+ // between underscores and hyphens.
37
+ //
38
+ // We check these special cases before deferring to encoding_rs, because some of the Latin-1
39
+ // aliases (e.g. `iso-8859-1`) are WHATWG labels that encoding_rs would otherwise resolve to
40
+ // the Windows-1252 decoder.
41
+ let normalized = name.to_ascii_lowercase().replace('_', "-");
42
+ let is_spelling_of = |canonical: &str| {
43
+ normalized == canonical || normalized.starts_with(&format!("{canonical}-"))
44
+ };
45
+ if ["latin-1", "iso-8859-1", "iso-latin-1"]
46
+ .into_iter()
47
+ .any(is_spelling_of)
48
+ {
49
+ return Some(Encoding::Latin1);
50
+ }
51
+ if is_spelling_of("utf-8") {
52
+ return encoding_rs::Encoding::for_label(b"utf-8").map(Encoding::Other);
53
+ }
54
+ // Try the name as given, then its normalized form.
55
+ encoding_rs::Encoding::for_label(name.as_bytes())
56
+ .or_else(|| encoding_rs::Encoding::for_label(normalized.as_bytes()))
57
+ .map(Encoding::Other)
58
+ }
59
+
17
60
  pub trait FileSystem: Send + Sync {
18
61
  fn sep(&self) -> &str;
19
62
 
@@ -23,7 +66,7 @@ pub trait FileSystem: Send + Sync {
23
66
 
24
67
  fn exists(&self, file_name: &str) -> bool;
25
68
 
26
- fn read(&self, file_name: &str) -> PyResult<String>;
69
+ fn read(&self, file_name: &str) -> GrimpResult<String>;
27
70
 
28
71
  fn write(&mut self, file_name: &str, contents: &str) -> PyResult<()>;
29
72
  }
@@ -83,7 +126,7 @@ impl FileSystem for RealBasicFileSystem {
83
126
  Path::new(file_name).is_file()
84
127
  }
85
128
 
86
- fn read(&self, file_name: &str) -> PyResult<String> {
129
+ fn read(&self, file_name: &str) -> GrimpResult<String> {
87
130
  // Python files are assumed UTF-8 by default (PEP 686), but they can specify an alternative
88
131
  // encoding, which we need to take into account here.
89
132
  // See https://peps.python.org/pep-0263/
@@ -92,7 +135,7 @@ impl FileSystem for RealBasicFileSystem {
92
135
 
93
136
  let path = Path::new(file_name);
94
137
  let bytes = fs::read(path).map_err(|e| {
95
- PyFileNotFoundError::new_err(format!("Failed to read file {file_name}: {e}"))
138
+ GrimpError::FileNotFound(format!("Failed to read file {file_name}: {e}"))
96
139
  })?;
97
140
 
98
141
  let s = String::from_utf8_lossy(&bytes);
@@ -110,24 +153,30 @@ impl FileSystem for RealBasicFileSystem {
110
153
  }
111
154
 
112
155
  if let Some(enc_name) = detected_encoding {
113
- let encoding =
114
- encoding_rs::Encoding::for_label(enc_name.as_bytes()).ok_or_else(|| {
115
- PyUnicodeDecodeError::new_err(format!(
116
- "Failed to decode file {file_name} (unknown encoding '{enc_name}')"
117
- ))
118
- })?;
119
- let (decoded_s, _, had_errors) = encoding.decode(&bytes);
120
- if had_errors {
121
- Err(PyUnicodeDecodeError::new_err(format!(
122
- "Failed to decode file {file_name} with encoding '{enc_name}'"
123
- )))
124
- } else {
125
- Ok(decoded_s.into_owned())
156
+ let encoding = lookup_encoding(&enc_name).ok_or_else(|| {
157
+ GrimpError::UndecodableFile(format!(
158
+ "Failed to decode file {file_name} (unknown encoding '{enc_name}')"
159
+ ))
160
+ })?;
161
+ match encoding {
162
+ // True Latin-1 maps every byte to the code point of the same value and so can
163
+ // never fail.
164
+ Encoding::Latin1 => Ok(bytes.iter().map(|&b| b as char).collect()),
165
+ Encoding::Other(encoding) => {
166
+ let (decoded_s, _, had_errors) = encoding.decode(&bytes);
167
+ if had_errors {
168
+ Err(GrimpError::UndecodableFile(format!(
169
+ "Failed to decode file {file_name} with encoding '{enc_name}'"
170
+ )))
171
+ } else {
172
+ Ok(decoded_s.into_owned())
173
+ }
174
+ }
126
175
  }
127
176
  } else {
128
177
  // Default to UTF-8 if no encoding is specified
129
178
  String::from_utf8(bytes).map_err(|e| {
130
- PyUnicodeDecodeError::new_err(format!(
179
+ GrimpError::UndecodableFile(format!(
131
180
  "Failed to decode file {file_name} as UTF-8: {e}"
132
181
  ))
133
182
  })
@@ -173,7 +222,7 @@ impl PyRealBasicFileSystem {
173
222
  }
174
223
 
175
224
  fn read(&self, file_name: &str) -> PyResult<String> {
176
- self.inner.read(file_name)
225
+ Ok(self.inner.read(file_name)?)
177
226
  }
178
227
 
179
228
  fn write(&mut self, file_name: &str, contents: &str) -> PyResult<()> {
@@ -253,11 +302,11 @@ impl FileSystem for FakeBasicFileSystem {
253
302
  self.contents.lock().unwrap().contains_key(file_name)
254
303
  }
255
304
 
256
- fn read(&self, file_name: &str) -> PyResult<String> {
305
+ fn read(&self, file_name: &str) -> GrimpResult<String> {
257
306
  let contents = self.contents.lock().unwrap();
258
307
  match contents.get(file_name) {
259
308
  Some(file_contents) => Ok(file_contents.clone()),
260
- None => Err(PyFileNotFoundError::new_err(format!(
309
+ None => Err(GrimpError::FileNotFound(format!(
261
310
  "No such file: {file_name}"
262
311
  ))),
263
312
  }
@@ -301,7 +350,7 @@ impl PyFakeBasicFileSystem {
301
350
  }
302
351
 
303
352
  fn read(&self, file_name: &str) -> PyResult<String> {
304
- self.inner.read(file_name)
353
+ Ok(self.inner.read(file_name)?)
305
354
  }
306
355
 
307
356
  fn write(&mut self, file_name: &str, contents: &str) -> PyResult<()> {
@@ -1,6 +1,6 @@
1
- use crate::errors::GrimpResult;
1
+ use crate::errors::{GrimpError, GrimpResult};
2
2
 
3
- use crate::graph::{Graph, ModuleToken};
3
+ use crate::graph::{Graph, ModuleIterator, ModuleToken};
4
4
  use rustc_hash::{FxHashMap, FxHashSet};
5
5
  use slotmap::SecondaryMap;
6
6
  use std::cmp::Ordering;
@@ -9,17 +9,45 @@ impl Graph {
9
9
  pub fn nominate_cycle_breakers(
10
10
  &self,
11
11
  package: ModuleToken,
12
+ additional_packages: &[ModuleToken],
12
13
  ) -> GrimpResult<FxHashSet<(ModuleToken, ModuleToken)>> {
13
- // Get children of this package.
14
- let children = match self.module_children.get(package) {
15
- Some(children) => children,
16
- None => return Ok(FxHashSet::default()),
17
- };
18
- if children.len() < 2 {
14
+ if additional_packages.is_empty() {
15
+ // A single package was passed: the nodes of the graph are its children.
16
+ // Children of a single package can never overlap, so no need to check.
17
+ let children = match self.module_children.get(package) {
18
+ Some(children) => children.clone(),
19
+ None => return Ok(FxHashSet::default()),
20
+ };
21
+ self.nominate_cycle_breakers_for_nodes(&children)
22
+ } else {
23
+ // Multiple packages were passed: the packages themselves are the nodes.
24
+ let mut nodes: FxHashSet<ModuleToken> = FxHashSet::default();
25
+ nodes.insert(package);
26
+ nodes.extend(additional_packages.iter().copied());
27
+
28
+ // The packages must not overlap: if one is a descendant of another, the edges
29
+ // between them (as packages) would be ill-defined.
30
+ for node in &nodes {
31
+ for descendant in self.get_module_descendants(*node).tokens() {
32
+ if nodes.contains(&descendant) {
33
+ return Err(GrimpError::SharedDescendants);
34
+ }
35
+ }
36
+ }
37
+
38
+ self.nominate_cycle_breakers_for_nodes(&nodes)
39
+ }
40
+ }
41
+
42
+ fn nominate_cycle_breakers_for_nodes(
43
+ &self,
44
+ nodes: &FxHashSet<ModuleToken>,
45
+ ) -> GrimpResult<FxHashSet<(ModuleToken, ModuleToken)>> {
46
+ if nodes.len() < 2 {
19
47
  return Ok(FxHashSet::default());
20
48
  }
21
49
  let (orig_imports, orig_reverse_imports, edge_weights) =
22
- self.build_graph_maps_of_children(children);
50
+ self.build_graph_maps_of_nodes(nodes);
23
51
 
24
52
  // Make a copy of the graph. We'll iteratively remove nodes from this.
25
53
  let mut working_imports = orig_imports.clone();
@@ -166,9 +194,9 @@ impl Graph {
166
194
  // with empty sets if they have no imports to / from.
167
195
  // They are denormalized into two maps for fast lookups.
168
196
  #[allow(clippy::type_complexity)]
169
- fn build_graph_maps_of_children(
197
+ fn build_graph_maps_of_nodes(
170
198
  &self,
171
- children: &FxHashSet<ModuleToken>,
199
+ nodes: &FxHashSet<ModuleToken>,
172
200
  ) -> (
173
201
  SecondaryMap<ModuleToken, FxHashSet<ModuleToken>>, // Imports
174
202
  SecondaryMap<ModuleToken, FxHashSet<ModuleToken>>, // Reverse imports
@@ -180,25 +208,25 @@ impl Graph {
180
208
  SecondaryMap::default();
181
209
  let mut weights = FxHashMap::default();
182
210
 
183
- for child in children {
184
- orig_imports.insert(*child, FxHashSet::default());
185
- orig_reverse_imports.insert(*child, FxHashSet::default());
211
+ for node in nodes {
212
+ orig_imports.insert(*node, FxHashSet::default());
213
+ orig_reverse_imports.insert(*node, FxHashSet::default());
186
214
  }
187
- // Get number of imports between each child.
215
+ // Get number of imports between each node.
188
216
  // Add as edges.
189
- for child_a in children {
190
- for child_b in children.iter().filter(|child| *child != child_a) {
191
- // Get number of imports from child_a to child_b (as package).
217
+ for node_a in nodes {
218
+ for node_b in nodes.iter().filter(|node| *node != node_a) {
219
+ // Get number of imports from node_a to node_b (as package).
192
220
  let num_imports_from_a_to_b = self
193
- .find_direct_imports_between(*child_a, *child_b, true)
221
+ .find_direct_imports_between(*node_a, *node_b, true)
194
222
  .unwrap()
195
223
  .len();
196
224
 
197
225
  if num_imports_from_a_to_b > 0 {
198
226
  // a depends on b - add it as an edge in our temporary graph.
199
- orig_imports[*child_a].insert(*child_b);
200
- orig_reverse_imports[*child_b].insert(*child_a);
201
- weights.insert((*child_a, *child_b), num_imports_from_a_to_b);
227
+ orig_imports[*node_a].insert(*node_b);
228
+ orig_reverse_imports[*node_b].insert(*node_a);
229
+ weights.insert((*node_a, *node_b), num_imports_from_a_to_b);
202
230
  }
203
231
  }
204
232
  }
@@ -198,6 +198,33 @@ impl GraphWrapper {
198
198
 
199
199
  PyTuple::new(py, python_dependencies)
200
200
  }
201
+
202
+ fn convert_cycle_breakers_to_python<'py>(
203
+ &self,
204
+ py: Python<'py>,
205
+ cycle_breakers: FxHashSet<(ModuleToken, ModuleToken)>,
206
+ ) -> PyResult<Bound<'py, PySet>> {
207
+ PySet::new(
208
+ py,
209
+ cycle_breakers
210
+ .into_iter()
211
+ .map(|(importer, imported)| {
212
+ let importer = self._graph.get_module(importer).unwrap();
213
+ let imported = self._graph.get_module(imported).unwrap();
214
+ Import::new(importer.name(), imported.name())
215
+ })
216
+ .map(|import| {
217
+ PyTuple::new(
218
+ py,
219
+ [
220
+ import.importer.into_py_any(py).unwrap(),
221
+ import.imported.into_py_any(py).unwrap(),
222
+ ],
223
+ )
224
+ .unwrap()
225
+ }),
226
+ )
227
+ }
201
228
  }
202
229
 
203
230
  /// Wrapper around the Graph struct that integrates with Python.
@@ -642,29 +669,17 @@ impl GraphWrapper {
642
669
  &self,
643
670
  py: Python<'py>,
644
671
  package: &str,
672
+ additional_packages: Vec<String>,
645
673
  ) -> PyResult<Bound<'py, PySet>> {
646
674
  let package = self.get_visible_module_by_name(package)?.token();
647
- let cycle_breakers = self._graph.nominate_cycle_breakers(package)?;
648
- PySet::new(
649
- py,
650
- cycle_breakers
651
- .into_iter()
652
- .map(|(importer, imported)| {
653
- let importer = self._graph.get_module(importer).unwrap();
654
- let imported = self._graph.get_module(imported).unwrap();
655
- Import::new(importer.name(), imported.name())
656
- })
657
- .map(|import| {
658
- PyTuple::new(
659
- py,
660
- [
661
- import.importer.into_py_any(py).unwrap(),
662
- import.imported.into_py_any(py).unwrap(),
663
- ],
664
- )
665
- .unwrap()
666
- }),
667
- )
675
+ let additional_packages = additional_packages
676
+ .iter()
677
+ .map(|package| Ok(self.get_visible_module_by_name(package)?.token()))
678
+ .collect::<PyResult<Vec<_>>>()?;
679
+ let cycle_breakers = self
680
+ ._graph
681
+ .nominate_cycle_breakers(package, &additional_packages)?;
682
+ self.convert_cycle_breakers_to_python(py, cycle_breakers)
668
683
  }
669
684
 
670
685
  #[pyo3(name = "clone")]
@@ -122,25 +122,19 @@ impl<'a> StatementVisitor<'a> for Visitor<'a> {
122
122
  }
123
123
  walk_stmt(self, stmt);
124
124
  }
125
- Stmt::If(if_stmt) => match if_stmt.test.as_ref() {
126
- Expr::Name(expr) => {
127
- if expr.id == "TYPE_CHECKING" {
128
- self.typechecking_only = true;
129
- walk_stmt(self, stmt);
130
- self.typechecking_only = false;
131
- } else {
132
- walk_stmt(self, stmt);
133
- }
125
+ Stmt::If(if_stmt) => {
126
+ // Only the branches guarded by `TYPE_CHECKING` are type checking only. The other
127
+ // branches (e.g. `else`) run when `TYPE_CHECKING` is false, that is, at runtime.
128
+ let outer_typechecking_only = self.typechecking_only;
129
+ self.typechecking_only = outer_typechecking_only || is_type_checking(&if_stmt.test);
130
+ self.visit_body(&if_stmt.body);
131
+ for clause in &if_stmt.elif_else_clauses {
132
+ self.typechecking_only = outer_typechecking_only
133
+ || clause.test.as_ref().is_some_and(is_type_checking);
134
+ self.visit_elif_else_clause(clause);
134
135
  }
135
- Expr::Attribute(expr) if expr.attr.id == "TYPE_CHECKING" => {
136
- self.typechecking_only = true;
137
- walk_stmt(self, stmt);
138
- self.typechecking_only = false;
139
- }
140
- _ => {
141
- walk_stmt(self, stmt);
142
- }
143
- },
136
+ self.typechecking_only = outer_typechecking_only;
137
+ }
144
138
  _ => {
145
139
  walk_stmt(self, stmt);
146
140
  }
@@ -148,6 +142,15 @@ impl<'a> StatementVisitor<'a> for Visitor<'a> {
148
142
  }
149
143
  }
150
144
 
145
+ /// Whether the expression is `TYPE_CHECKING` or `<something>.TYPE_CHECKING`.
146
+ fn is_type_checking(expr: &Expr) -> bool {
147
+ match expr {
148
+ Expr::Name(name) => name.id == "TYPE_CHECKING",
149
+ Expr::Attribute(attribute) => attribute.attr.id == "TYPE_CHECKING",
150
+ _ => false,
151
+ }
152
+ }
153
+
151
154
  #[cfg(test)]
152
155
  mod tests {
153
156
  use super::parse_imports_from_code;
@@ -130,7 +130,7 @@ fn scan_for_imports_no_py_single_module(
130
130
  let found_package_for_module = found_packages_by_module[module];
131
131
  let module_filename =
132
132
  _determine_module_filename(module, found_package_for_module, file_system).unwrap();
133
- let module_contents = file_system.read(&module_filename).unwrap();
133
+ let module_contents = file_system.read(&module_filename)?;
134
134
  let imported_objects =
135
135
  import_parsing::parse_imports_from_code(&module_contents, &module_filename)?;
136
136
 
@@ -1,4 +1,4 @@
1
- __version__ = "3.17"
1
+ __version__ = "3.19"
2
2
 
3
3
  from .application.graph import DetailedImport, Import, ImportGraph
4
4
  from .domain.analysis import PackageDependency, Route
@@ -476,14 +476,18 @@ class ImportGraph:
476
476
 
477
477
  return _dependencies_from_tuple(result)
478
478
 
479
- def nominate_cycle_breakers(self, package: str) -> set[ImportTuple]:
479
+ def nominate_cycle_breakers(self, package: str, *additional_packages: str) -> set[ImportTuple]:
480
480
  """
481
- Identify a set of imports that, if removed, would make the package locally acyclic.
481
+ Identify a set of imports that, if removed, would make the supplied packages acyclic.
482
+
483
+ For more information see the docs in usage.rst.
482
484
  """
483
- if not self._rustgraph.contains_module(package):
484
- msg = f'"{package}" not present in the graph.'
485
- raise ModuleNotPresent(msg)
486
- return self._rustgraph.nominate_cycle_breakers(package)
485
+ for package_ in (package, *additional_packages):
486
+ if not self._rustgraph.contains_module(package_):
487
+ msg = f'"{package_}" not present in the graph.'
488
+ raise ModuleNotPresent(msg)
489
+
490
+ return self._rustgraph.nominate_cycle_breakers(package, additional_packages)
487
491
 
488
492
  # Dunder methods
489
493
  # --------------
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import abc
4
4
  from types import TracebackType
5
+ from typing import Self
5
6
 
6
7
 
7
8
  class Timer(abc.ABC):
@@ -22,7 +23,7 @@ class Timer(abc.ABC):
22
23
  # We use a stack so context managers can be nested.
23
24
  self._start_stack: list[float] = []
24
25
 
25
- def __enter__(self) -> Timer:
26
+ def __enter__(self) -> Self:
26
27
  self._start_stack.append(self.get_current_time())
27
28
  return self
28
29
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes