uncycle 0.1.0__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.
@@ -0,0 +1,32 @@
1
+ name: ci
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ matrix:
13
+ python-version: ["3.9", "3.13", "3.14"]
14
+ steps:
15
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
16
+ - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
17
+ with:
18
+ python-version: ${{ matrix.python-version }}
19
+ - run: pip install -e '.[dev]'
20
+ - run: pytest
21
+
22
+ lint:
23
+ runs-on: ubuntu-latest
24
+ steps:
25
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
26
+ - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
27
+ with:
28
+ python-version: '3.13'
29
+ - run: pip install -e '.[dev]'
30
+ - run: ruff check
31
+ - run: ruff format --diff
32
+ - run: mypy
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ /dist/
4
+ /.venv/
5
+ /.mypy_cache/
6
+ /.pytest_cache/
7
+ /.ruff_cache/
uncycle-0.1.0/LICENSE ADDED
@@ -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.
uncycle-0.1.0/PKG-INFO ADDED
@@ -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,97 @@
1
+ # uncycle
2
+
3
+ A Python tool to find the fewest `import` statements to remove to break all circular imports.
4
+
5
+ ![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)
6
+
7
+ It gives you short and actionable feedback to structure your Python package better.
8
+
9
+ It works by computing the so-called [Feedback Arc Set][1] on the graph of Python modules (nodes) and import statements (edges).
10
+
11
+ ## Usage
12
+
13
+ ```
14
+ uncycle [--exclude REGEX] [--inline] [--baseline OLD] [--dump-graph FILE] PACKAGE
15
+ ```
16
+
17
+ ### Listing problematic import statements
18
+
19
+ Use `uncycle path/to/pkg` to list the minimal import statements to delete to break all circular imports:
20
+
21
+ ```console
22
+ $ uncycle werkzeug-3.1.8/src/werkzeug
23
+ werkzeug-3.1.8/src/werkzeug/http.py:1442: imports werkzeug.datastructures
24
+ werkzeug-3.1.8/src/werkzeug/http.py:1443: imports werkzeug.sansio.http
25
+ 2 dependencies to remove
26
+ ```
27
+
28
+ ### Finding regressions
29
+
30
+ Use `--baseline` to see whether a new commit or version regresses the number of dependencies to remove:
31
+
32
+ ```console
33
+ $ uncycle Werkzeug-2.2.0/src/werkzeug --baseline Werkzeug-2.1.2/src/werkzeug
34
+ Werkzeug-2.2.0/src/werkzeug/http.py:1305: imports werkzeug.sansio.http
35
+ dependencies to remove increased from 1 to 2
36
+ ```
37
+
38
+ This check is useful in CI:
39
+
40
+ ```yaml
41
+ - uses: actions/checkout@v5
42
+ with: { ref: "${{ github.event.pull_request.base.sha }}", path: old }
43
+ - uses: actions/checkout@v5
44
+ with: { path: new }
45
+ - run: pip install uncycle
46
+ - run: uncycle new/src/mypkg --baseline old/src/mypkg
47
+ ```
48
+
49
+ ## Install
50
+
51
+ ```
52
+ pip install uncycle
53
+ ```
54
+
55
+ ## Options
56
+
57
+ - `--exclude REGEX`: exclude certain modules, for example: `'^app\.(vendor|tests)\b'`.
58
+ - `--inline`: also count imports inside functions and classes.
59
+ - `--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.
60
+ - `--dump-graph FILE`: write the import graph to `FILE` (`-` for stdout) instead of solving it.
61
+ - `--format json|text`: format of the dumped graph. Defaults to `text` when `FILE` ends in `.txt`, otherwise `json`.
62
+
63
+ ## Exit status
64
+
65
+ - `0`: the dependencies were listed, or no more of them have to go than in the baseline.
66
+ - `1`: more dependencies have to go than in the baseline.
67
+ - `2`: a file could not be read or parsed, or the arguments were invalid.
68
+
69
+ ## Python API
70
+
71
+ ```python
72
+ import uncycle
73
+
74
+ graph = uncycle.build_graph("src/app", exclude=r"^app\.tests\b")
75
+ fas = uncycle.minimum_feedback_arc_set(graph)
76
+ print(graph.names(fas)) # [('app.db', 'app.models')]
77
+ ```
78
+
79
+ ## The import graph
80
+
81
+ 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`.
82
+
83
+ ## Notes
84
+
85
+ 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.
86
+
87
+ 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.
88
+
89
+ ## See also
90
+
91
+ - 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.
92
+ - The minimum is exact, computed with [clingo](https://potassco.org/clingo/).
93
+
94
+ [1]: https://en.wikipedia.org/wiki/Feedback_arc_set
95
+ [2]: https://pylint.readthedocs.io/en/stable/user_guide/messages/refactor/cyclic-import.html
96
+ [3]: https://github.com/bndr/pycycle
97
+ [4]: https://github.com/seddonym/import-linter
@@ -0,0 +1,38 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="560" height="220" viewBox="0 0 560 220" font-family="monospace" font-size="13">
2
+ <defs>
3
+ <marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto">
4
+ <path d="M0,0 L10,5 L0,10 z" fill="#8b8b8b"/>
5
+ </marker>
6
+ <marker id="arrow-red" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto">
7
+ <path d="M0,0 L10,5 L0,10 z" fill="#d9534f"/>
8
+ </marker>
9
+ </defs>
10
+
11
+ <!-- edges -->
12
+ <g stroke="#8b8b8b" stroke-width="1.5" fill="none" marker-end="url(#arrow)">
13
+ <line x1="115" y1="110" x2="178" y2="110"/> <!-- app.views -> app.models -->
14
+ <line x1="290" y1="110" x2="428" y2="110"/> <!-- app.models -> app.db -->
15
+ <line x1="250" y1="125" x2="320" y2="174"/> <!-- app.models -> app.auth -->
16
+ <line x1="400" y1="175" x2="468" y2="126"/> <!-- app.auth -> app.db -->
17
+ <line x1="480" y1="45" x2="480" y2="93"/> <!-- app.cli -> app.db -->
18
+ </g>
19
+ <!-- app.db -> app.models: registers the tables before create_all, and closes both cycles -->
20
+ <path d="M 420,95 Q 360,45 300,95" fill="none" stroke="#d9534f" stroke-width="1.5" stroke-dasharray="6 4" marker-end="url(#arrow-red)"/>
21
+ <text x="360" y="64" text-anchor="middle" fill="#d9534f" font-size="11">remove</text>
22
+
23
+ <!-- nodes -->
24
+ <g fill="none" stroke="#8b8b8b" stroke-width="1.5">
25
+ <rect x="15" y="95" width="100" height="30" rx="4"/>
26
+ <rect x="180" y="95" width="110" height="30" rx="4"/>
27
+ <rect x="430" y="95" width="100" height="30" rx="4"/>
28
+ <rect x="305" y="175" width="110" height="30" rx="4"/>
29
+ <rect x="430" y="15" width="100" height="30" rx="4"/>
30
+ </g>
31
+ <g fill="#8b8b8b" text-anchor="middle">
32
+ <text x="65" y="115">app.views</text>
33
+ <text x="235" y="115">app.models</text>
34
+ <text x="480" y="115">app.db</text>
35
+ <text x="360" y="195">app.auth</text>
36
+ <text x="480" y="35">app.cli</text>
37
+ </g>
38
+ </svg>
@@ -0,0 +1,41 @@
1
+ [project]
2
+ name = "uncycle"
3
+ description = "Find the fewest import statements to remove to break all circular imports"
4
+ readme = "README.md"
5
+ requires-python = ">=3.9"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "Harmen Stoppels", email = "me@harmenstoppels.nl" }]
9
+ keywords = ["imports", "circular imports", "feedback arc set", "static analysis"]
10
+ classifiers = [
11
+ "Environment :: Console",
12
+ "Intended Audience :: Developers",
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3 :: Only",
15
+ "Topic :: Software Development :: Quality Assurance",
16
+ ]
17
+ dependencies = ["clingo"]
18
+ dynamic = ["version"]
19
+
20
+ [project.urls]
21
+ Homepage = "https://github.com/haampie/uncycle"
22
+ Repository = "https://github.com/haampie/uncycle"
23
+
24
+ [project.optional-dependencies]
25
+ dev = ["pytest", "ruff", "mypy"]
26
+
27
+ [project.scripts]
28
+ uncycle = "uncycle.cli:main"
29
+
30
+ [build-system]
31
+ requires = ["hatchling"]
32
+ build-backend = "hatchling.build"
33
+
34
+ [tool.hatch.version]
35
+ path = "uncycle/__init__.py"
36
+
37
+ [tool.mypy]
38
+ files = ["uncycle"]
39
+
40
+ [tool.pytest.ini_options]
41
+ testpaths = ["tests"]
@@ -0,0 +1,21 @@
1
+ import pytest
2
+
3
+
4
+ @pytest.fixture
5
+ def tree(tmp_path):
6
+ """Write a package from ``{"pkg/a.py": "import pkg.b"}`` and return its directory."""
7
+
8
+ def make(files):
9
+ root = None
10
+ for name, source in files.items():
11
+ path = tmp_path / name
12
+ path.parent.mkdir(parents=True, exist_ok=True)
13
+ if isinstance(source, bytes):
14
+ path.write_bytes(source)
15
+ else:
16
+ path.write_text(source)
17
+ if root is None:
18
+ root = name.split("/")[0]
19
+ return str(tmp_path / root)
20
+
21
+ return make
@@ -0,0 +1,208 @@
1
+ import json
2
+ import re
3
+ import sys
4
+
5
+ import pytest
6
+
7
+ from uncycle import cli
8
+
9
+ CYCLE = {
10
+ "pkg/__init__.py": "",
11
+ "pkg/a.py": "import pkg.b",
12
+ "pkg/b.py": "import pkg.a",
13
+ "pkg/lonely.py": "",
14
+ }
15
+
16
+
17
+ @pytest.fixture
18
+ def run(monkeypatch):
19
+ """Run the command line with plain output, even on GitHub Actions."""
20
+ monkeypatch.setenv("NO_COLOR", "1")
21
+
22
+ def go(*argv):
23
+ monkeypatch.setattr(sys, "argv", ["uncycle", *argv])
24
+ return cli.main()
25
+
26
+ return go
27
+
28
+
29
+ def test_graph_to_stdout(tree, run, capsys):
30
+ assert run(tree(CYCLE), "--dump-graph", "-") == 0
31
+ data = json.loads(capsys.readouterr().out)
32
+ assert data["nodes"] == ["pkg", "pkg.a", "pkg.b", "pkg.lonely"]
33
+ assert data["edges"] == [[1, 2], [2, 1]]
34
+
35
+
36
+ @pytest.mark.parametrize("name,first", [("g.json", "{"), ("g.txt", "4")])
37
+ def test_the_format_follows_the_output_name(tree, run, tmp_path, name, first):
38
+ out = tmp_path / name
39
+ assert run(tree(CYCLE), "--dump-graph", str(out)) == 0
40
+ assert out.read_text().startswith(first)
41
+
42
+
43
+ def test_a_dumped_graph_can_be_read_back(tree, run, tmp_path, capsys):
44
+ out = tmp_path / "g.txt"
45
+ assert run(tree(CYCLE), "--dump-graph", str(out)) == 0
46
+ capsys.readouterr()
47
+ assert run(str(out)) == 0
48
+ lines = capsys.readouterr().out.splitlines()
49
+ assert re.fullmatch(r"pkg\.[ab]: imports pkg\.[ab]", lines[0])
50
+ assert lines[-1] == "1 dependency to remove"
51
+
52
+
53
+ def test_extraction_flags_do_not_apply_to_a_graph_file(tree, run, tmp_path):
54
+ out = tmp_path / "g.json"
55
+ run(tree(CYCLE), "--dump-graph", str(out))
56
+ with pytest.raises(SystemExit) as excinfo:
57
+ run(str(out), "--inline")
58
+ assert excinfo.value.code == 2
59
+
60
+
61
+ def test_solve(tree, run, capsys):
62
+ assert run(tree(CYCLE)) == 0
63
+ lines = capsys.readouterr().out.splitlines()
64
+ assert re.search(r"pkg/[ab]\.py:1: imports pkg\.[ab]$", lines[0])
65
+ assert lines[-1] == "1 dependency to remove"
66
+
67
+
68
+ def test_every_statement_behind_an_edge_is_listed(tree, run, capsys):
69
+ """a -> b is on both cycles, so it is the unique answer, and it has two statements."""
70
+ d = tree(
71
+ {
72
+ "pkg/__init__.py": "",
73
+ "pkg/a.py": "import pkg.b\nfrom pkg import b\n",
74
+ "pkg/b.py": "import pkg.a\nimport pkg.c\n",
75
+ "pkg/c.py": "import pkg.a",
76
+ }
77
+ )
78
+ assert run(d) == 0
79
+ lines = capsys.readouterr().out.splitlines()
80
+ assert lines[0].endswith("pkg/a.py:1: imports pkg.b")
81
+ assert lines[1].endswith("pkg/a.py:2: imports pkg.b")
82
+ assert lines[2] == "1 dependency (2 import statements) to remove"
83
+
84
+
85
+ def test_no_cycles(tree, run, capsys):
86
+ assert run(tree({"pkg/__init__.py": "", "pkg/a.py": "import pkg"})) == 0
87
+ assert capsys.readouterr().out == "0 dependencies to remove\n"
88
+
89
+
90
+ def test_a_syntax_error_in_the_package(tree, run, capsys):
91
+ assert run(tree({"pkg/__init__.py": "", "pkg/bad.py": "def (\n"})) == 2
92
+ assert "bad.py" in capsys.readouterr().err
93
+
94
+
95
+ def test_compare_unchanged(tree, run, capsys):
96
+ d = tree(CYCLE)
97
+ assert run(d, "--baseline", d) == 0
98
+ assert (
99
+ capsys.readouterr().out.splitlines()[-1]
100
+ == "dependencies to remove unchanged at 1"
101
+ )
102
+
103
+
104
+ def test_compare_improved(tmp_path, run, capsys):
105
+ for name, source in {
106
+ "old/pkg/__init__.py": "",
107
+ "old/pkg/a.py": "import pkg.b",
108
+ "old/pkg/b.py": "import pkg.a",
109
+ "new/pkg/__init__.py": "",
110
+ "new/pkg/a.py": "import pkg.b",
111
+ "new/pkg/b.py": "",
112
+ }.items():
113
+ path = tmp_path / name
114
+ path.parent.mkdir(parents=True, exist_ok=True)
115
+ path.write_text(source)
116
+ assert (
117
+ run(str(tmp_path / "new" / "pkg"), "--baseline", str(tmp_path / "old" / "pkg"))
118
+ == 0
119
+ )
120
+ assert capsys.readouterr().out == "dependencies to remove decreased from 1 to 0\n"
121
+
122
+
123
+ def test_compare_worse(tmp_path, run, capsys):
124
+ for name, source in {
125
+ "old/pkg/__init__.py": "",
126
+ "old/pkg/a.py": "",
127
+ "old/pkg/b.py": "",
128
+ "new/pkg/__init__.py": "",
129
+ "new/pkg/a.py": "import pkg.b",
130
+ "new/pkg/b.py": "import pkg.a",
131
+ }.items():
132
+ path = tmp_path / name
133
+ path.parent.mkdir(parents=True, exist_ok=True)
134
+ path.write_text(source)
135
+ assert (
136
+ run(str(tmp_path / "new" / "pkg"), "--baseline", str(tmp_path / "old" / "pkg"))
137
+ == 1
138
+ )
139
+ lines = capsys.readouterr().out.splitlines()
140
+ assert re.search(r"new/pkg/[ab]\.py:1: imports pkg\.[ab]$", lines[0])
141
+ assert lines[-1] == "dependencies to remove increased from 0 to 1"
142
+
143
+
144
+ def test_compare_when_a_blamed_edge_is_gone(tmp_path, run, capsys):
145
+ """The old solution names pkg.a -> pkg.b, which the new tree does not have at all."""
146
+ for name, source in {
147
+ "old/pkg/__init__.py": "",
148
+ "old/pkg/a.py": "import pkg.b",
149
+ "old/pkg/b.py": "import pkg.a",
150
+ "old/pkg/c.py": "",
151
+ "new/pkg/__init__.py": "",
152
+ "new/pkg/a.py": "",
153
+ "new/pkg/b.py": "import pkg.c",
154
+ "new/pkg/c.py": "import pkg.b",
155
+ }.items():
156
+ path = tmp_path / name
157
+ path.parent.mkdir(parents=True, exist_ok=True)
158
+ path.write_text(source)
159
+ assert (
160
+ run(str(tmp_path / "new" / "pkg"), "--baseline", str(tmp_path / "old" / "pkg"))
161
+ == 0
162
+ )
163
+ assert (
164
+ capsys.readouterr().out.splitlines()[-1]
165
+ == "dependencies to remove unchanged at 1"
166
+ )
167
+
168
+
169
+ @pytest.mark.parametrize(
170
+ "env,colored",
171
+ [
172
+ ({}, False),
173
+ ({"GITHUB_ACTIONS": "1"}, True),
174
+ ({"GITHUB_ACTIONS": "1", "NO_COLOR": "1"}, False),
175
+ ],
176
+ )
177
+ def test_color(monkeypatch, env, colored):
178
+ monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
179
+ monkeypatch.delenv("NO_COLOR", raising=False)
180
+ for key, value in env.items():
181
+ monkeypatch.setenv(key, value)
182
+ assert ("\033[" in cli.colorize("hi", "1")) is colored
183
+
184
+
185
+ def test_a_module_file_instead_of_its_package(tree, run, capsys):
186
+ d = tree(CYCLE)
187
+ assert (
188
+ run(
189
+ d + "/a.py",
190
+ )
191
+ == 2
192
+ )
193
+ assert "is a module; pass its package directory" in capsys.readouterr().err
194
+
195
+
196
+ def test_a_file_that_is_not_a_graph_names_the_file(tmp_path, run, capsys):
197
+ f = tmp_path / "notes.txt"
198
+ f.write_text("hello\n")
199
+ assert run(str(f)) == 2
200
+ assert capsys.readouterr().err.startswith(f"uncycle: {f}: not a graph file")
201
+
202
+
203
+ def test_a_relative_import_above_the_package_warns_on_stderr(tree, run, capsys):
204
+ d = tree({"pkg/__init__.py": "", "pkg/m.py": "from ... import x\n"})
205
+ assert run(d) == 0
206
+ out, err = capsys.readouterr()
207
+ assert out == "0 dependencies to remove\n"
208
+ assert err.startswith("uncycle: warning: ") and "m.py:1" in err
@@ -0,0 +1,89 @@
1
+ import graphlib
2
+ import random
3
+
4
+ from uncycle import Graph, build_graph, minimum_feedback_arc_set
5
+
6
+
7
+ def is_acyclic(graph, removed=()):
8
+ skip = set(removed)
9
+ predecessors = {i: [] for i in range(len(graph.nodes))}
10
+ for src, dst in graph.edges:
11
+ if (src, dst) not in skip:
12
+ predecessors[dst].append(src)
13
+ try:
14
+ graphlib.TopologicalSorter(predecessors).prepare()
15
+ except graphlib.CycleError:
16
+ return False
17
+ return True
18
+
19
+
20
+ def test_an_empty_graph():
21
+ assert minimum_feedback_arc_set(Graph([], [])) == []
22
+
23
+
24
+ def test_nodes_without_edges():
25
+ assert minimum_feedback_arc_set(Graph(["a", "b"], [])) == []
26
+
27
+
28
+ def test_a_dag_needs_no_removals():
29
+ assert (
30
+ minimum_feedback_arc_set(Graph(["a", "b", "c"], [(0, 1), (1, 2), (0, 2)])) == []
31
+ )
32
+
33
+
34
+ def test_a_cycle_costs_one_edge():
35
+ graph = Graph(["a", "b", "c"], [(0, 1), (1, 2), (2, 0)])
36
+ fas = minimum_feedback_arc_set(graph)
37
+ assert len(fas) == 1
38
+ assert set(fas) <= set(graph.edges)
39
+ assert is_acyclic(graph, fas)
40
+
41
+
42
+ def test_disjoint_cycles_cost_one_edge_each():
43
+ graph = Graph(list("abcdef"), [(0, 1), (1, 2), (2, 0), (3, 4), (4, 5), (5, 3)])
44
+ assert len(minimum_feedback_arc_set(graph)) == 2
45
+
46
+
47
+ def test_cycles_sharing_an_edge_cost_one_edge():
48
+ """Both cycles run through a -> b, so deleting that one edge is enough."""
49
+ graph = Graph(list("abcd"), [(0, 1), (1, 2), (2, 0), (1, 3), (3, 0)])
50
+ assert minimum_feedback_arc_set(graph) == [(0, 1)]
51
+
52
+
53
+ def test_the_result_always_breaks_every_cycle():
54
+ rng = random.Random(0)
55
+ for _ in range(20):
56
+ n = rng.randint(2, 8)
57
+ edges = sorted(
58
+ {
59
+ (i, j)
60
+ for i in range(n)
61
+ for j in range(n)
62
+ if i != j and rng.random() < 0.4
63
+ }
64
+ )
65
+ graph = Graph([str(i) for i in range(n)], edges)
66
+ assert is_acyclic(graph, minimum_feedback_arc_set(graph))
67
+
68
+
69
+ def test_a_re_exported_import_is_cut_once(tree):
70
+ """pkg exposes pkg.y, which imports pkg.foo, which imports three modules that import
71
+ pkg. One statement in pkg/y.py breaks every cycle; the re-export in pkg never counts."""
72
+ d = tree(
73
+ {
74
+ "pkg/__init__.py": "from . import y",
75
+ "pkg/y.py": "import pkg.foo",
76
+ "pkg/foo.py": "import pkg.a\nimport pkg.b\nimport pkg.c",
77
+ "pkg/a.py": "import pkg",
78
+ "pkg/b.py": "import pkg",
79
+ "pkg/c.py": "import pkg",
80
+ }
81
+ )
82
+ graph = build_graph(d)
83
+ assert graph.names(minimum_feedback_arc_set(graph)) == [("pkg.y", "pkg.foo")]
84
+
85
+
86
+ def test_a_package_never_drops_the_import_of_its_own_submodule(tree):
87
+ d = tree({"pkg/__init__.py": "from . import y", "pkg/y.py": "import pkg"})
88
+ graph = build_graph(d)
89
+ assert graph.names(minimum_feedback_arc_set(graph)) == [("pkg.y", "pkg")]