uncycle 0.1.0__py3-none-any.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.
uncycle/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """Find the fewest import statements to remove to break all circular imports."""
2
+
3
+ from .fas import minimum_feedback_arc_set
4
+ from .graph import Edge, Graph, build_graph
5
+ from .io import FORMATS, read_graph, write_graph
6
+
7
+ __version__ = "0.1.0"
8
+
9
+ __all__ = [
10
+ "FORMATS",
11
+ "Edge",
12
+ "Graph",
13
+ "build_graph",
14
+ "minimum_feedback_arc_set",
15
+ "read_graph",
16
+ "write_graph",
17
+ ]
uncycle/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
uncycle/cli.py ADDED
@@ -0,0 +1,183 @@
1
+ """List the fewest import statements to remove to break all circular imports in a Python package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+ from collections.abc import Iterable
9
+
10
+ from .fas import minimum_feedback_arc_set
11
+ from .graph import Edge, Graph, build_graph
12
+ from .io import FORMATS, read_graph, write_graph
13
+
14
+ BOLD, RED, GREEN, GREY = "1", "31", "32", "90"
15
+
16
+
17
+ def colorize(text: str, *codes: str) -> str:
18
+ if "NO_COLOR" in os.environ:
19
+ return text
20
+ if not (sys.stdout.isatty() or "GITHUB_ACTIONS" in os.environ):
21
+ return text
22
+ return f"\033[{';'.join(codes)}m{text}\033[0m"
23
+
24
+
25
+ def display(path: str) -> str:
26
+ """A path relative to the working directory when that is inside it, else as is."""
27
+ try:
28
+ relative = os.path.relpath(path)
29
+ except ValueError: # another drive on Windows
30
+ return path
31
+ return path if relative.startswith("..") else relative
32
+
33
+
34
+ def lines(graph: Graph, edges: Iterable[Edge]) -> list[str]:
35
+ """One ``path:line: imports module`` line per import statement behind the given edges,
36
+ in file order; ``module: imports module`` for a graph that has no locations."""
37
+ keyed: list[tuple[tuple[str, int, str], str]] = []
38
+ for edge in edges:
39
+ src, dst = graph.names([edge])[0]
40
+ where = graph.locations.get(edge)
41
+ if not where:
42
+ keyed.append(((src, 0, dst), f"{src}: imports {dst}"))
43
+ for path, line in where or ():
44
+ shown = display(path)
45
+ keyed.append(((shown, line, dst), f"{shown}:{line}: imports {dst}"))
46
+ return [text for _, text in sorted(keyed)]
47
+
48
+
49
+ def print_lines(graph: Graph, edges: Iterable[Edge], *codes: str) -> None:
50
+ for line in lines(graph, edges):
51
+ print(colorize(line, *codes))
52
+
53
+
54
+ def dependencies(n: int) -> str:
55
+ return f"{n} {'dependency' if n == 1 else 'dependencies'}"
56
+
57
+
58
+ def summary(graph: Graph, fas: Iterable[Edge]) -> str:
59
+ """``2 dependencies to remove``, and when they span more import statements than that,
60
+ ``11 dependencies (14 import statements) to remove``."""
61
+ fas = list(fas)
62
+ statements = len(lines(graph, fas))
63
+ if statements == len(fas):
64
+ return f"{dependencies(len(fas))} to remove"
65
+ return f"{dependencies(len(fas))} ({statements} import statements) to remove"
66
+
67
+
68
+ def compare(old: Graph, new: Graph) -> int:
69
+ """Print the import statements this change added to the solution, and the count."""
70
+ old_fas = minimum_feedback_arc_set(old)
71
+ new_fas = minimum_feedback_arc_set(new)
72
+ before, after = len(old_fas), len(new_fas)
73
+ difference = after - before
74
+
75
+ if difference <= 0:
76
+ print_lines(new, new_fas, GREY)
77
+ if difference == 0:
78
+ change = f"dependencies to remove unchanged at {after}"
79
+ else:
80
+ change = f"dependencies to remove decreased from {before} to {after}"
81
+ print(colorize(change, GREEN, BOLD))
82
+ return 0
83
+
84
+ # Solve the new graph again without the edges the old solution already blamed, so what is
85
+ # left to blame is what this change introduced. A heuristic: the old solution is not
86
+ # necessarily a subset of the new graph's edges.
87
+ excluded = set(new.indices(old.names(old_fas)))
88
+ blamed = minimum_feedback_arc_set(
89
+ Graph(new.nodes, [e for e in new.edges if e not in excluded], new.locations)
90
+ )
91
+ print_lines(new, blamed, RED)
92
+
93
+ # Breaking exactly those is not necessarily the cheapest way back to the old count.
94
+ if len(blamed) > difference:
95
+ print(f"removing any {difference} of the following would undo the increase:")
96
+ print_lines(new, new_fas, GREY)
97
+ change = f"dependencies to remove increased from {before} to {after}"
98
+ print(colorize(change, RED, BOLD))
99
+ return 1
100
+
101
+
102
+ def load(
103
+ path: str, exclude: str | None, inline: bool, parser: argparse.ArgumentParser
104
+ ) -> Graph:
105
+ """A package directory to analyze, or a graph file dumped earlier."""
106
+ if os.path.isdir(path):
107
+ return build_graph(path, exclude, inline, warn)
108
+ if path.endswith(".py"):
109
+ raise ValueError(f"{path}: is a module; pass its package directory instead")
110
+ if exclude is not None or inline:
111
+ parser.error(f"--exclude and --inline do not apply to a graph file: {path}")
112
+ try:
113
+ with open(path, encoding="utf-8") as f:
114
+ return read_graph(f)
115
+ except ValueError as e:
116
+ raise ValueError(f"{path}: {e}") from None
117
+
118
+
119
+ def warn(message: str) -> None:
120
+ print(colorize(f"uncycle: warning: {message}", GREY), file=sys.stderr)
121
+
122
+
123
+ def main() -> int:
124
+ parser = argparse.ArgumentParser(prog="uncycle", description=__doc__)
125
+ parser.add_argument(
126
+ "package",
127
+ metavar="PACKAGE",
128
+ help="a package directory, or a graph file written by --dump-graph",
129
+ )
130
+ parser.add_argument(
131
+ "--exclude",
132
+ metavar="REGEX",
133
+ help="regex searched in module names to leave out of the graph; a package that "
134
+ "matches is pruned along with everything under it",
135
+ )
136
+ parser.add_argument(
137
+ "--inline",
138
+ action="store_true",
139
+ help="include imports inside functions and classes",
140
+ )
141
+ parser.add_argument(
142
+ "--baseline",
143
+ metavar="OLD",
144
+ help="an older version of the package: list the import statements this version "
145
+ "added to the problem, and exit 1 if more dependencies have to go than before",
146
+ )
147
+ parser.add_argument(
148
+ "--dump-graph",
149
+ metavar="FILE",
150
+ help="write the import graph to FILE (- for stdout) instead of solving it",
151
+ )
152
+ parser.add_argument(
153
+ "--format",
154
+ choices=FORMATS,
155
+ help="format of the dumped graph; default: text if FILE ends in .txt, else json",
156
+ )
157
+ args = parser.parse_args()
158
+
159
+ try:
160
+ graph = load(args.package, args.exclude, args.inline, parser)
161
+
162
+ if args.dump_graph is not None:
163
+ file = args.dump_graph
164
+ format = args.format or ("text" if file.endswith(".txt") else "json")
165
+ if file == "-":
166
+ write_graph(graph, sys.stdout, format)
167
+ else:
168
+ with open(file, "w", encoding="utf-8") as f:
169
+ write_graph(graph, f, format)
170
+ return 0
171
+
172
+ if args.baseline is not None:
173
+ return compare(
174
+ load(args.baseline, args.exclude, args.inline, parser), graph
175
+ )
176
+
177
+ fas = minimum_feedback_arc_set(graph)
178
+ print_lines(graph, fas, GREY)
179
+ print(colorize(summary(graph, fas), BOLD))
180
+ return 0
181
+ except (OSError, SyntaxError, ValueError) as e:
182
+ print(f"uncycle: {e}", file=sys.stderr)
183
+ return 2
uncycle/fas.py ADDED
@@ -0,0 +1,43 @@
1
+ """Compute a minimum feedback arc set with clingo."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import clingo
6
+
7
+ from .graph import Edge, Graph
8
+
9
+ #: Drop as few edges as possible to make the graph acyclic. #edge is clingo's acyclicity
10
+ #: propagator: it rejects any model whose edges form a cycle. An edge from a package to
11
+ #: one of its own submodules is fixed: the point of the package is to expose the
12
+ #: submodule, so dropping that import is not a real option. A cycle cannot consist of
13
+ #: fixed edges alone, since names strictly lengthen along one, so a model always exists.
14
+ ENCODING = """\
15
+ #defined fixed/2.
16
+ { del(X,Y) } :- edge(X,Y), not fixed(X,Y).
17
+ #edge (X,Y) : edge(X,Y), not del(X,Y).
18
+ #minimize { 1,X,Y : del(X,Y) }.
19
+ #show del/2.
20
+ """
21
+
22
+
23
+ def minimum_feedback_arc_set(graph: Graph) -> list[Edge]:
24
+ """A smallest set of edges whose removal makes the graph acyclic."""
25
+ if not graph.edges:
26
+ return []
27
+ ctl = clingo.Control(["--opt-strategy=usc"])
28
+ facts = "".join(f"edge({src},{dst})." for src, dst in graph.edges)
29
+ facts += "".join(
30
+ f"fixed({src},{dst})."
31
+ for src, dst in graph.edges
32
+ if graph.nodes[dst].startswith(f"{graph.nodes[src]}.")
33
+ )
34
+ ctl.add("base", [], ENCODING + facts)
35
+ ctl.ground([("base", [])])
36
+ fas: list[Edge] = []
37
+ with ctl.solve(yield_=True) as handle:
38
+ for model in handle:
39
+ arguments = (x.arguments for x in model.symbols(shown=True))
40
+ fas = [(a.number, b.number) for a, b in arguments]
41
+ if not handle.get().exhausted:
42
+ raise RuntimeError("clingo did not prove this set minimal")
43
+ return sorted(fas)
uncycle/graph.py ADDED
@@ -0,0 +1,200 @@
1
+ """Build the import graph of a Python package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import dataclasses
7
+ import os
8
+ import re
9
+ import warnings
10
+ from collections.abc import Callable, Iterable
11
+ from importlib.util import resolve_name
12
+
13
+ #: an edge as a pair of indices into :attr:`Graph.nodes`
14
+ Edge = tuple[int, int]
15
+ #: where an import statement is: absolute file path and 1-based line number
16
+ Location = tuple[str, int]
17
+
18
+
19
+ @dataclasses.dataclass
20
+ class Graph:
21
+ """A module import graph: sorted module names, edges as index pairs into them, and per
22
+ edge the import statements behind it, which a graph read from a file does not have."""
23
+
24
+ nodes: list[str]
25
+ edges: list[Edge]
26
+ locations: dict[Edge, list[Location]] = dataclasses.field(
27
+ default_factory=dict, compare=False
28
+ )
29
+
30
+ def names(self, edges: Iterable[Edge]) -> list[tuple[str, str]]:
31
+ """The given edges as pairs of module names."""
32
+ return [(self.nodes[i], self.nodes[j]) for i, j in edges]
33
+
34
+ def indices(self, named: Iterable[tuple[str, str]]) -> list[Edge]:
35
+ """Edges by name in this graph's index space; edges it does not have are dropped."""
36
+ index = {name: i for i, name in enumerate(self.nodes)}
37
+ edges = set(self.edges)
38
+ pairs = ((index[a], index[b]) for a, b in named if a in index and b in index)
39
+ return sorted(edge for edge in pairs if edge in edges)
40
+
41
+
42
+ def _is_type_checking(test: ast.expr) -> bool:
43
+ if isinstance(test, ast.Name):
44
+ return test.id == "TYPE_CHECKING"
45
+ return isinstance(test, ast.Attribute) and test.attr == "TYPE_CHECKING"
46
+
47
+
48
+ def _is_main(test: ast.expr) -> bool:
49
+ """``__name__ == "__main__"``, in either order."""
50
+ if not isinstance(test, ast.Compare) or len(test.ops) != 1:
51
+ return False
52
+ if not isinstance(test.ops[0], ast.Eq):
53
+ return False
54
+ sides = (test.left, test.comparators[0])
55
+ name = any(isinstance(s, ast.Name) and s.id == "__name__" for s in sides)
56
+ main = any(isinstance(s, ast.Constant) and s.value == "__main__" for s in sides)
57
+ return name and main
58
+
59
+
60
+ def _runs_on_import(test: ast.expr) -> bool:
61
+ """Whether the body of ``if test:`` can run while the module is being imported."""
62
+ return not _is_type_checking(test) and not _is_main(test)
63
+
64
+
65
+ _SCOPES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
66
+
67
+
68
+ def collect_imports(
69
+ tree: ast.AST,
70
+ resolve: Callable[[str, str], str],
71
+ current_pkg: str,
72
+ inline: bool,
73
+ warn: Callable[[str], None],
74
+ path: str,
75
+ ) -> dict[str, set[int]]:
76
+ """The modules a module imports, each with the lines of the statements that do so.
77
+
78
+ Iterative rather than a NodeVisitor, since a generated file with a very long
79
+ expression nests deeper than the recursion limit."""
80
+ imported: dict[str, set[int]] = {}
81
+ stack: list[ast.AST] = [tree]
82
+ while stack:
83
+ node = stack.pop()
84
+ if isinstance(node, ast.Import):
85
+ # import statements are always absolute
86
+ for alias in node.names:
87
+ imported.setdefault(alias.name, set()).add(node.lineno)
88
+ elif isinstance(node, ast.ImportFrom):
89
+ # from imports can be relative, and the alias can be a submodule or attribute
90
+ try:
91
+ module = resolve_name(
92
+ "." * node.level + (node.module or ""), current_pkg
93
+ )
94
+ except ImportError:
95
+ # such a statement fails at runtime too; it is in a template or dead code
96
+ warn(
97
+ f"{path}:{node.lineno}: relative import beyond the package, skipped"
98
+ )
99
+ continue
100
+ for alias in node.names:
101
+ imported.setdefault(resolve(module, alias.name), set()).add(node.lineno)
102
+ elif isinstance(node, ast.If) and not _runs_on_import(node.test):
103
+ # the body of if TYPE_CHECKING and of if __name__ == "__main__" does not run
104
+ # on import, but the else branch of either does
105
+ stack.extend(node.orelse)
106
+ elif isinstance(node, _SCOPES) and not inline:
107
+ continue
108
+ else:
109
+ stack.extend(ast.iter_child_nodes(node))
110
+ return imported
111
+
112
+
113
+ def _is_package_dir(entry: os.DirEntry[str]) -> bool:
114
+ return (
115
+ entry.name.isidentifier()
116
+ and entry.is_dir(follow_symlinks=False)
117
+ and os.path.isfile(os.path.join(entry.path, "__init__.py"))
118
+ )
119
+
120
+
121
+ def _is_module_file(entry: os.DirEntry[str]) -> bool:
122
+ return entry.is_file(follow_symlinks=False) and entry.name.endswith(".py")
123
+
124
+
125
+ def build_graph(
126
+ package_dir: str,
127
+ exclude: str | None = None,
128
+ inline: bool = False,
129
+ warn: Callable[[str], None] = warnings.warn,
130
+ ) -> Graph:
131
+ """The import graph of a package. Modules whose name matches the ``exclude`` regex are left
132
+ out; ``inline`` includes imports inside functions and classes. Statements that cannot be
133
+ imports of anything, such as a relative import above the package, go to ``warn``."""
134
+ package_dir = os.path.abspath(package_dir)
135
+ root, pkg = os.path.split(package_dir)
136
+ if not pkg.isidentifier():
137
+ raise ValueError(f"{package_dir}: {pkg!r} is not a valid package name")
138
+ if not os.path.isfile(os.path.join(package_dir, "__init__.py")):
139
+ raise ValueError(f"{package_dir}: not a package, it has no __init__.py")
140
+
141
+ cache: dict[tuple[str, str], str] = {}
142
+
143
+ def resolve(module: str, attr: str) -> str:
144
+ """``from foo import bar`` is ``foo.bar`` when bar is a submodule, else ``foo``."""
145
+ key = (module, attr)
146
+ if key not in cache:
147
+ path = os.path.join(root, module.replace(".", os.sep), attr)
148
+ is_module = os.path.isfile(f"{path}.py") or os.path.isfile(
149
+ os.path.join(path, "__init__.py")
150
+ )
151
+ cache[key] = f"{module}.{attr}" if is_module else module
152
+ return cache[key]
153
+
154
+ inside = re.compile(rf"{re.escape(pkg)}(\.|$)").match
155
+ excluded = re.compile(exclude).search if exclude else lambda _: False
156
+
157
+ def keep(module: str) -> bool:
158
+ """The graph holds this package's own modules, minus the excluded ones."""
159
+ return bool(inside(module)) and not excluded(module)
160
+
161
+ stack = [package_dir]
162
+ modules: set[str] = set()
163
+ edges: dict[tuple[str, str], list[Location]] = {}
164
+
165
+ while stack:
166
+ sub_pkg_dir = stack.pop()
167
+ subpkg = os.path.relpath(sub_pkg_dir, root).replace(os.sep, ".")
168
+
169
+ if not keep(subpkg):
170
+ continue
171
+
172
+ with os.scandir(sub_pkg_dir) as it:
173
+ for entry in it:
174
+ if _is_package_dir(entry):
175
+ stack.append(entry.path)
176
+ elif _is_module_file(entry):
177
+ if entry.name == "__init__.py":
178
+ current = subpkg
179
+ else:
180
+ current = f"{subpkg}.{entry.name[:-3]}"
181
+ if not keep(current):
182
+ continue
183
+ modules.add(current)
184
+ # bytes, so that ast honors a PEP 263 coding cookie
185
+ with open(entry.path, "rb") as f:
186
+ tree = ast.parse(f.read(), filename=entry.path)
187
+ imported = collect_imports(
188
+ tree, resolve, subpkg, inline, warn, entry.path
189
+ )
190
+ for m, lines in imported.items():
191
+ # a self-loop is a cycle no reshuffling of imports can break
192
+ if m != current and keep(m):
193
+ edges.setdefault((current, m), []).extend(
194
+ (entry.path, line) for line in sorted(lines)
195
+ )
196
+
197
+ nodes = sorted(modules | {dst for _, dst in edges})
198
+ index = {node: i for i, node in enumerate(nodes)}
199
+ locations = {(index[src], index[dst]): where for (src, dst), where in edges.items()}
200
+ return Graph(nodes, sorted(locations), locations)
uncycle/io.py ADDED
@@ -0,0 +1,55 @@
1
+ """Read and write import graphs and their feedback arc sets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import TextIO
7
+
8
+ from .graph import Graph
9
+
10
+ #: the graph file formats, see the README
11
+ FORMATS = ("json", "text")
12
+
13
+
14
+ def write_graph(graph: Graph, f: TextIO, format: str = "json") -> None:
15
+ if format == "json":
16
+ json.dump(
17
+ {"nodes": graph.nodes, "edges": [list(edge) for edge in graph.edges]}, f
18
+ )
19
+ f.write("\n")
20
+ elif format == "text":
21
+ print(len(graph.nodes), file=f)
22
+ for node in graph.nodes:
23
+ print(node, file=f)
24
+ print(len(graph.edges), file=f)
25
+ for src, dst in graph.edges:
26
+ print(src, dst, file=f)
27
+ else:
28
+ raise ValueError(f"unknown graph format: {format}")
29
+
30
+
31
+ def read_graph(f: TextIO) -> Graph:
32
+ """Read a graph in either format; the first character tells them apart."""
33
+ text = f.read()
34
+ if text.lstrip().startswith("{"):
35
+ data = json.loads(text)
36
+ return Graph(list(data["nodes"]), [(int(a), int(b)) for a, b in data["edges"]])
37
+ words = text.split()
38
+ if not words or not words[0].isdigit():
39
+ raise ValueError(
40
+ "not a graph file: expected json, or a node count on the first line"
41
+ )
42
+ node_count = int(words[0])
43
+ nodes = words[1 : 1 + node_count]
44
+ try:
45
+ edge_count = int(words[1 + node_count])
46
+ numbers = [
47
+ int(x) for x in words[2 + node_count : 2 + node_count + 2 * edge_count]
48
+ ]
49
+ except (IndexError, ValueError):
50
+ raise ValueError(
51
+ f"truncated graph file: it declares {node_count} nodes"
52
+ ) from None
53
+ if len(numbers) != 2 * edge_count:
54
+ raise ValueError(f"truncated graph file: it declares {edge_count} edges")
55
+ return Graph(nodes, list(zip(numbers[::2], numbers[1::2])))
@@ -0,0 +1,120 @@
1
+ Metadata-Version: 2.4
2
+ Name: uncycle
3
+ Version: 0.1.0
4
+ Summary: Find the fewest import statements to remove to break all circular imports
5
+ Project-URL: Homepage, https://github.com/haampie/uncycle
6
+ Project-URL: Repository, https://github.com/haampie/uncycle
7
+ Author-email: Harmen Stoppels <me@harmenstoppels.nl>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: circular imports,feedback arc set,imports,static analysis
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Topic :: Software Development :: Quality Assurance
16
+ Requires-Python: >=3.9
17
+ Requires-Dist: clingo
18
+ Provides-Extra: dev
19
+ Requires-Dist: mypy; extra == 'dev'
20
+ Requires-Dist: pytest; extra == 'dev'
21
+ Requires-Dist: ruff; extra == 'dev'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # uncycle
25
+
26
+ A Python tool to find the fewest `import` statements to remove to break all circular imports.
27
+
28
+ ![A five-module import graph with two cycles; one edge, shown dashed, breaks both](https://raw.githubusercontent.com/haampie/uncycle/main/docs/feedback-arc-set.svg)
29
+
30
+ It gives you short and actionable feedback to structure your Python package better.
31
+
32
+ It works by computing the so-called [Feedback Arc Set][1] on the graph of Python modules (nodes) and import statements (edges).
33
+
34
+ ## Usage
35
+
36
+ ```
37
+ uncycle [--exclude REGEX] [--inline] [--baseline OLD] [--dump-graph FILE] PACKAGE
38
+ ```
39
+
40
+ ### Listing problematic import statements
41
+
42
+ Use `uncycle path/to/pkg` to list the minimal import statements to delete to break all circular imports:
43
+
44
+ ```console
45
+ $ uncycle werkzeug-3.1.8/src/werkzeug
46
+ werkzeug-3.1.8/src/werkzeug/http.py:1442: imports werkzeug.datastructures
47
+ werkzeug-3.1.8/src/werkzeug/http.py:1443: imports werkzeug.sansio.http
48
+ 2 dependencies to remove
49
+ ```
50
+
51
+ ### Finding regressions
52
+
53
+ Use `--baseline` to see whether a new commit or version regresses the number of dependencies to remove:
54
+
55
+ ```console
56
+ $ uncycle Werkzeug-2.2.0/src/werkzeug --baseline Werkzeug-2.1.2/src/werkzeug
57
+ Werkzeug-2.2.0/src/werkzeug/http.py:1305: imports werkzeug.sansio.http
58
+ dependencies to remove increased from 1 to 2
59
+ ```
60
+
61
+ This check is useful in CI:
62
+
63
+ ```yaml
64
+ - uses: actions/checkout@v5
65
+ with: { ref: "${{ github.event.pull_request.base.sha }}", path: old }
66
+ - uses: actions/checkout@v5
67
+ with: { path: new }
68
+ - run: pip install uncycle
69
+ - run: uncycle new/src/mypkg --baseline old/src/mypkg
70
+ ```
71
+
72
+ ## Install
73
+
74
+ ```
75
+ pip install uncycle
76
+ ```
77
+
78
+ ## Options
79
+
80
+ - `--exclude REGEX`: exclude certain modules, for example: `'^app\.(vendor|tests)\b'`.
81
+ - `--inline`: also count imports inside functions and classes.
82
+ - `--baseline OLD`: an older version of the package. Lists the import statements this version added to the problem, and exits 1 if more dependencies have to go than before.
83
+ - `--dump-graph FILE`: write the import graph to `FILE` (`-` for stdout) instead of solving it.
84
+ - `--format json|text`: format of the dumped graph. Defaults to `text` when `FILE` ends in `.txt`, otherwise `json`.
85
+
86
+ ## Exit status
87
+
88
+ - `0`: the dependencies were listed, or no more of them have to go than in the baseline.
89
+ - `1`: more dependencies have to go than in the baseline.
90
+ - `2`: a file could not be read or parsed, or the arguments were invalid.
91
+
92
+ ## Python API
93
+
94
+ ```python
95
+ import uncycle
96
+
97
+ graph = uncycle.build_graph("src/app", exclude=r"^app\.tests\b")
98
+ fas = uncycle.minimum_feedback_arc_set(graph)
99
+ print(graph.names(fas)) # [('app.db', 'app.models')]
100
+ ```
101
+
102
+ ## The import graph
103
+
104
+ The import graph is constructed statically using AST parsing. Imports under `if TYPE_CHECKING` and `if __name__ == "__main__"` are dropped. Dynamic imports inside functions and classes only count with `--inline`.
105
+
106
+ ## Notes
107
+
108
+ Imports of *submodules* are never reported to make things actionable. Consider a module `foo` that imports a submodule `foo.bar` to re-export some of its API: it's practically impossible to eliminate this import. Technically this means that we're computing a constrained version of the feedback arc set.
109
+
110
+ Also notice there are typically many optimal solutions, but only one (arbitrary) solution is printed. For example a trivial cycle `a -> b -> c -> a` can be made acyclic by removing any edge.
111
+
112
+ ## See also
113
+
114
+ - pylint's [`cyclic-import`][2], [pycycle][3] and [import-linter][4] report every cycle they find, one chain of modules per cycle. In a package with many cycles that is a long list; `uncycle` reports the few imports that break all of them.
115
+ - The minimum is exact, computed with [clingo](https://potassco.org/clingo/).
116
+
117
+ [1]: https://en.wikipedia.org/wiki/Feedback_arc_set
118
+ [2]: https://pylint.readthedocs.io/en/stable/user_guide/messages/refactor/cyclic-import.html
119
+ [3]: https://github.com/bndr/pycycle
120
+ [4]: https://github.com/seddonym/import-linter
@@ -0,0 +1,11 @@
1
+ uncycle/__init__.py,sha256=pfp50H-fNxFFBZI1Wl9AwHTHstyHr_LNe9QDTm16h6M,383
2
+ uncycle/__main__.py,sha256=E6Gls0DNz8GQK2K-kOUIx8cYhgANW_CH54VKrfCfs14,52
3
+ uncycle/cli.py,sha256=IzbvcIYxS69jNFnqBDofZJ9OnwyakiaF-MrKF3m4MEw,6664
4
+ uncycle/fas.py,sha256=YJzKFXq9y-PVD1ikgNp6EiwLuA5KhHOpXoYgVNAAV5M,1629
5
+ uncycle/graph.py,sha256=Zpmbyxo2JbnUxuHTqssG3E5rLvEsRoY_5g3jcXN8pWs,7960
6
+ uncycle/io.py,sha256=UR1JcZPbEBuDjA0vp9iD6I3Ewvf-9JNG1pzsy27t2Us,1842
7
+ uncycle-0.1.0.dist-info/METADATA,sha256=kU0ZwzkVKTiD2fpGH06S9W5l8GrDhFLG62-cVmqu8rI,4794
8
+ uncycle-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
9
+ uncycle-0.1.0.dist-info/entry_points.txt,sha256=eMkcGHg6KFDkG-rzN5V4d_D7_OxLKxnjGSEQK5CjnAQ,45
10
+ uncycle-0.1.0.dist-info/licenses/LICENSE,sha256=__SLj47S7iVBRcTgxVBsHkJLf-2jfkCcn41E-10ixSw,1072
11
+ uncycle-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ uncycle = uncycle.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Harmen Stoppels
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.