woolly 0.3.0__tar.gz → 0.5.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.
Files changed (64) hide show
  1. {woolly-0.3.0 → woolly-0.5.0}/PKG-INFO +3 -1
  2. woolly-0.5.0/examples/template/example_template.md +16 -0
  3. {woolly-0.3.0 → woolly-0.5.0}/pyproject.toml +5 -0
  4. {woolly-0.3.0 → woolly-0.5.0}/tests/conftest.py +49 -0
  5. {woolly-0.3.0 → woolly-0.5.0}/tests/functional/test_cli.py +83 -0
  6. woolly-0.5.0/tests/unit/test_commands/test_check.py +440 -0
  7. {woolly-0.3.0 → woolly-0.5.0}/tests/unit/test_languages/test_base.py +312 -0
  8. {woolly-0.3.0 → woolly-0.5.0}/tests/unit/test_languages/test_python.py +266 -9
  9. {woolly-0.3.0 → woolly-0.5.0}/tests/unit/test_languages/test_rust.py +119 -10
  10. {woolly-0.3.0 → woolly-0.5.0}/tests/unit/test_optional_dependencies.py +39 -45
  11. {woolly-0.3.0 → woolly-0.5.0}/tests/unit/test_reporters/test_json.py +86 -0
  12. {woolly-0.3.0 → woolly-0.5.0}/tests/unit/test_reporters/test_markdown.py +65 -0
  13. woolly-0.5.0/tests/unit/test_reporters/test_stdout.py +187 -0
  14. woolly-0.5.0/tests/unit/test_reporters/test_template.py +358 -0
  15. {woolly-0.3.0 → woolly-0.5.0}/uv.lock +104 -0
  16. {woolly-0.3.0 → woolly-0.5.0}/woolly/cache.py +21 -6
  17. woolly-0.5.0/woolly/commands/check.py +604 -0
  18. woolly-0.5.0/woolly/http.py +53 -0
  19. {woolly-0.3.0 → woolly-0.5.0}/woolly/languages/base.py +187 -10
  20. woolly-0.5.0/woolly/languages/python.py +413 -0
  21. {woolly-0.3.0 → woolly-0.5.0}/woolly/languages/rust.py +59 -1
  22. {woolly-0.3.0 → woolly-0.5.0}/woolly/reporters/__init__.py +15 -1
  23. {woolly-0.3.0 → woolly-0.5.0}/woolly/reporters/base.py +25 -0
  24. {woolly-0.3.0 → woolly-0.5.0}/woolly/reporters/json.py +102 -3
  25. {woolly-0.3.0 → woolly-0.5.0}/woolly/reporters/markdown.py +83 -9
  26. woolly-0.5.0/woolly/reporters/stdout.py +208 -0
  27. woolly-0.5.0/woolly/reporters/template.py +245 -0
  28. woolly-0.3.0/tests/unit/test_commands/test_check.py +0 -285
  29. woolly-0.3.0/tests/unit/test_reporters/test_stdout.py +0 -104
  30. woolly-0.3.0/woolly/commands/check.py +0 -422
  31. woolly-0.3.0/woolly/http.py +0 -34
  32. woolly-0.3.0/woolly/languages/python.py +0 -200
  33. woolly-0.3.0/woolly/reporters/stdout.py +0 -75
  34. {woolly-0.3.0 → woolly-0.5.0}/.coverage +0 -0
  35. {woolly-0.3.0 → woolly-0.5.0}/.github/workflows/lint.yml +0 -0
  36. {woolly-0.3.0 → woolly-0.5.0}/.github/workflows/publish.yml +0 -0
  37. {woolly-0.3.0 → woolly-0.5.0}/.github/workflows/tests.yml +0 -0
  38. {woolly-0.3.0 → woolly-0.5.0}/.gitignore +0 -0
  39. {woolly-0.3.0 → woolly-0.5.0}/.python-version +0 -0
  40. {woolly-0.3.0 → woolly-0.5.0}/LICENSE +0 -0
  41. {woolly-0.3.0 → woolly-0.5.0}/README.md +0 -0
  42. {woolly-0.3.0 → woolly-0.5.0}/tests/__init__.py +0 -0
  43. {woolly-0.3.0 → woolly-0.5.0}/tests/functional/__init__.py +0 -0
  44. {woolly-0.3.0 → woolly-0.5.0}/tests/functional/test_optional_flag.py +0 -0
  45. {woolly-0.3.0 → woolly-0.5.0}/tests/unit/__init__.py +0 -0
  46. {woolly-0.3.0 → woolly-0.5.0}/tests/unit/test_cache.py +0 -0
  47. {woolly-0.3.0 → woolly-0.5.0}/tests/unit/test_commands/__init__.py +0 -0
  48. {woolly-0.3.0 → woolly-0.5.0}/tests/unit/test_commands/test_other_commands.py +0 -0
  49. {woolly-0.3.0 → woolly-0.5.0}/tests/unit/test_debug.py +0 -0
  50. {woolly-0.3.0 → woolly-0.5.0}/tests/unit/test_languages/__init__.py +0 -0
  51. {woolly-0.3.0 → woolly-0.5.0}/tests/unit/test_languages/test_registry.py +0 -0
  52. {woolly-0.3.0 → woolly-0.5.0}/tests/unit/test_progress.py +0 -0
  53. {woolly-0.3.0 → woolly-0.5.0}/tests/unit/test_reporters/__init__.py +0 -0
  54. {woolly-0.3.0 → woolly-0.5.0}/tests/unit/test_reporters/test_base.py +0 -0
  55. {woolly-0.3.0 → woolly-0.5.0}/tests/unit/test_reporters/test_registry.py +0 -0
  56. {woolly-0.3.0 → woolly-0.5.0}/woolly/__init__.py +0 -0
  57. {woolly-0.3.0 → woolly-0.5.0}/woolly/__main__.py +0 -0
  58. {woolly-0.3.0 → woolly-0.5.0}/woolly/commands/__init__.py +0 -0
  59. {woolly-0.3.0 → woolly-0.5.0}/woolly/commands/clear_cache.py +0 -0
  60. {woolly-0.3.0 → woolly-0.5.0}/woolly/commands/list_formats.py +0 -0
  61. {woolly-0.3.0 → woolly-0.5.0}/woolly/commands/list_languages.py +0 -0
  62. {woolly-0.3.0 → woolly-0.5.0}/woolly/debug.py +0 -0
  63. {woolly-0.3.0 → woolly-0.5.0}/woolly/languages/__init__.py +0 -0
  64. {woolly-0.3.0 → woolly-0.5.0}/woolly/progress.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: woolly
3
- Version: 0.3.0
3
+ Version: 0.5.0
4
4
  Summary: Check if package dependencies are available in Fedora. Supports Rust, Python, and more.
5
5
  Author-email: Rodolfo Olivieri <rodolfo.olivieri3@gmail.com>
6
6
  License-File: LICENSE
@@ -26,6 +26,8 @@ Requires-Dist: cyclopts>=4.3.0
26
26
  Requires-Dist: httpx>=0.28.0
27
27
  Requires-Dist: pydantic>=2.10.0
28
28
  Requires-Dist: rich>=14.2.0
29
+ Provides-Extra: template
30
+ Requires-Dist: jinja2>=3.1.0; extra == 'template'
29
31
  Description-Content-Type: text/markdown
30
32
 
31
33
  # 🐑 Woolly
@@ -0,0 +1,16 @@
1
+ Report for {{ root_package }}
2
+
3
+ Generated: {{ timestamp }}
4
+ Language: {{ language }}
5
+
6
+ ## Summary
7
+ - Total: {{ total_dependencies }}
8
+ - Packaged: {{ packaged_count }}
9
+ - Missing: {{ missing_count }}
10
+
11
+ {% if missing_packages %}
12
+ ## Missing Packages
13
+ {% for pkg in missing_packages %}
14
+ - {{ pkg }}
15
+ {% endfor %}
16
+ {% endif %}
@@ -37,6 +37,11 @@ classifiers = [
37
37
  "Topic :: Utilities",
38
38
  ]
39
39
 
40
+ [project.optional-dependencies]
41
+ template = [
42
+ "jinja2>=3.1.0",
43
+ ]
44
+
40
45
  [project.url]
41
46
  "Source code" = "https://github.com/r0x0d/woolly"
42
47
  "Bug Tracker" = "https://github.com/r0x0d/woolly/issues"
@@ -24,6 +24,10 @@ def temp_cache_dir(tmp_path, monkeypatch):
24
24
  cache_dir = tmp_path / "cache"
25
25
  cache_dir.mkdir()
26
26
  monkeypatch.setattr("woolly.cache.CACHE_DIR", cache_dir)
27
+ # Reset the ensured-namespaces set so that the new CACHE_DIR is used
28
+ import woolly.cache as _cache_mod
29
+
30
+ _cache_mod._ensured_namespaces.clear()
27
31
  return cache_dir
28
32
 
29
33
 
@@ -119,6 +123,7 @@ def mock_crates_io_response():
119
123
  "description": "A serialization framework for Rust",
120
124
  "homepage": "https://serde.rs",
121
125
  "repository": "https://github.com/serde-rs/serde",
126
+ "license": "MIT OR Apache-2.0",
122
127
  }
123
128
  }
124
129
 
@@ -154,12 +159,55 @@ def mock_pypi_response():
154
159
  "summary": "Python HTTP for Humans",
155
160
  "home_page": "https://requests.readthedocs.io",
156
161
  "project_url": "https://github.com/psf/requests",
162
+ "license": "Apache-2.0",
163
+ "license_expression": None,
164
+ "provides_extra": ["socks"],
165
+ "requires_dist": [
166
+ "charset-normalizer<4,>=2",
167
+ "idna<4,>=2.5",
168
+ "urllib3<3,>=1.21.1",
169
+ "certifi>=2017.4.17",
170
+ "PySocks!=1.5.7,>=1.5.6; extra == 'socks'",
171
+ ],
172
+ }
173
+ }
174
+
175
+
176
+ @pytest.fixture
177
+ def mock_crates_io_version_response():
178
+ """Create a mock crates.io version API response (for features)."""
179
+ return {
180
+ "version": {
181
+ "num": "1.0.200",
182
+ "features": {
183
+ "default": ["std", "derive"],
184
+ "std": [],
185
+ "derive": ["serde_derive"],
186
+ "alloc": [],
187
+ },
188
+ }
189
+ }
190
+
191
+
192
+ @pytest.fixture
193
+ def mock_pypi_version_response():
194
+ """Create a mock PyPI version API response (for extras/features)."""
195
+ return {
196
+ "info": {
197
+ "name": "requests",
198
+ "version": "2.31.0",
199
+ "summary": "Python HTTP for Humans",
200
+ "license": "Apache-2.0",
201
+ "license_expression": None,
202
+ "provides_extra": ["socks", "security"],
157
203
  "requires_dist": [
158
204
  "charset-normalizer<4,>=2",
159
205
  "idna<4,>=2.5",
160
206
  "urllib3<3,>=1.21.1",
161
207
  "certifi>=2017.4.17",
162
208
  "PySocks!=1.5.7,>=1.5.6; extra == 'socks'",
209
+ "pyOpenSSL>=0.14; extra == 'security'",
210
+ "cryptography>=1.3.4; extra == 'security'",
163
211
  ],
164
212
  }
165
213
  }
@@ -174,6 +222,7 @@ def mock_pypi_response():
174
222
  def mock_console():
175
223
  """Create a mock Rich console."""
176
224
  console = MagicMock(spec=Console)
225
+ console.width = 120 # Wide enough for side-by-side layout
177
226
  return console
178
227
 
179
228
 
@@ -34,6 +34,7 @@ class TestCliHelp:
34
34
  assert result.returncode == 0
35
35
  assert "package" in result.stdout.lower()
36
36
  assert "--lang" in result.stdout
37
+ assert "--missing-only" in result.stdout
37
38
 
38
39
 
39
40
  class TestListLanguages:
@@ -263,6 +264,32 @@ class TestCheckCommandPython:
263
264
  class TestCheckCommandOptions:
264
265
  """Tests for check command options."""
265
266
 
267
+ @pytest.mark.functional
268
+ @pytest.mark.slow
269
+ def test_missing_only_option(self, cli_runner, tmp_path, monkeypatch):
270
+ """Good path: missing-only option is accepted."""
271
+ monkeypatch.setenv("HOME", str(tmp_path))
272
+
273
+ result = cli_runner(
274
+ "check", "cfg-if", "--lang", "rust", "--missing-only", "--no-progress"
275
+ )
276
+
277
+ assert result.returncode == 0
278
+ # Should not show the dependency tree when --missing-only is used
279
+ assert "Dependency Tree:" not in result.stdout
280
+
281
+ @pytest.mark.functional
282
+ @pytest.mark.slow
283
+ def test_missing_only_short_option(self, cli_runner, tmp_path, monkeypatch):
284
+ """Good path: missing-only short option (-m) is accepted."""
285
+ monkeypatch.setenv("HOME", str(tmp_path))
286
+
287
+ result = cli_runner("check", "cfg-if", "--lang", "rust", "-m", "--no-progress")
288
+
289
+ assert result.returncode == 0
290
+ # Should not show the dependency tree when -m is used
291
+ assert "Dependency Tree:" not in result.stdout
292
+
266
293
  @pytest.mark.functional
267
294
  @pytest.mark.slow
268
295
  def test_max_depth_option(self, cli_runner, tmp_path, monkeypatch):
@@ -275,6 +302,62 @@ class TestCheckCommandOptions:
275
302
 
276
303
  assert result.returncode == 0
277
304
 
305
+ @pytest.mark.functional
306
+ @pytest.mark.slow
307
+ def test_exclude_option(self, cli_runner, tmp_path, monkeypatch):
308
+ """Good path: exclude option is accepted and displayed."""
309
+ monkeypatch.setenv("HOME", str(tmp_path))
310
+
311
+ result = cli_runner(
312
+ "check",
313
+ "cfg-if",
314
+ "--lang",
315
+ "rust",
316
+ "--exclude",
317
+ "windows*",
318
+ "--no-progress",
319
+ )
320
+
321
+ assert result.returncode == 0
322
+ assert "Excluding dependencies matching" in result.stdout
323
+ assert "windows*" in result.stdout
324
+
325
+ @pytest.mark.functional
326
+ @pytest.mark.slow
327
+ def test_exclude_multiple_patterns(self, cli_runner, tmp_path, monkeypatch):
328
+ """Good path: multiple exclude patterns are accepted."""
329
+ monkeypatch.setenv("HOME", str(tmp_path))
330
+
331
+ result = cli_runner(
332
+ "check",
333
+ "cfg-if",
334
+ "--lang",
335
+ "rust",
336
+ "--exclude",
337
+ "windows*",
338
+ "--exclude",
339
+ "*-sys",
340
+ "--no-progress",
341
+ )
342
+
343
+ assert result.returncode == 0
344
+ assert "Excluding dependencies matching" in result.stdout
345
+ assert "windows*" in result.stdout
346
+ assert "*-sys" in result.stdout
347
+
348
+ @pytest.mark.functional
349
+ @pytest.mark.slow
350
+ def test_exclude_short_option(self, cli_runner, tmp_path, monkeypatch):
351
+ """Good path: -e short option works."""
352
+ monkeypatch.setenv("HOME", str(tmp_path))
353
+
354
+ result = cli_runner(
355
+ "check", "cfg-if", "--lang", "rust", "-e", "win*", "--no-progress"
356
+ )
357
+
358
+ assert result.returncode == 0
359
+ assert "Excluding dependencies matching" in result.stdout
360
+
278
361
  @pytest.mark.functional
279
362
  @pytest.mark.slow
280
363
  def test_debug_option(self, cli_runner, tmp_path, monkeypatch):
@@ -0,0 +1,440 @@
1
+ """
2
+ Unit tests for woolly.commands.check module.
3
+
4
+ Tests cover:
5
+ - Good path: tree building, stats collection
6
+ - Critical path: dependency traversal, visited tracking
7
+ - Bad path: unknown languages, max depth
8
+ """
9
+
10
+ from unittest.mock import MagicMock
11
+
12
+ import pytest
13
+ from rich.tree import Tree
14
+
15
+ from woolly.commands.check import TreeStats, _compute_stats_from_visited, build_tree
16
+ from woolly.languages.base import (
17
+ Dependency,
18
+ FedoraPackageStatus,
19
+ LanguageProvider,
20
+ PackageInfo,
21
+ )
22
+
23
+
24
+ class MockProvider(LanguageProvider):
25
+ """Mock provider for testing."""
26
+
27
+ name = "mock"
28
+ display_name = "Mock"
29
+ registry_name = "mock.io"
30
+ fedora_provides_prefix = "mock"
31
+ cache_namespace = "mock"
32
+
33
+ def __init__(self):
34
+ self.packages = {}
35
+ self.dependencies = {}
36
+ self.fedora_status = {}
37
+
38
+ def fetch_package_info(self, package_name: str):
39
+ return self.packages.get(package_name)
40
+
41
+ def fetch_dependencies(self, package_name: str, version: str):
42
+ return self.dependencies.get(f"{package_name}:{version}", [])
43
+
44
+ def fetch_features(self, package_name: str, version: str):
45
+ return []
46
+
47
+ def check_fedora_packaging(self, package_name: str):
48
+ return self.fedora_status.get(
49
+ package_name, FedoraPackageStatus(is_packaged=False)
50
+ )
51
+
52
+
53
+ class TestBuildTree:
54
+ """Tests for build_tree function."""
55
+
56
+ @pytest.fixture
57
+ def provider(self):
58
+ """Create a mock provider."""
59
+ p = MockProvider()
60
+ # Set up a simple package
61
+ p.packages["root"] = PackageInfo(name="root", latest_version="1.0.0")
62
+ p.fedora_status["root"] = FedoraPackageStatus(
63
+ is_packaged=True, versions=["1.0.0"], package_names=["mock-root"]
64
+ )
65
+ return p
66
+
67
+ @pytest.mark.unit
68
+ def test_returns_tree_for_packaged_package(self, provider):
69
+ """Good path: returns Tree for packaged package."""
70
+ tree = build_tree(provider, "root")
71
+
72
+ assert isinstance(tree, Tree)
73
+ label = str(tree.label)
74
+ assert "root" in label
75
+ assert "packaged" in label
76
+
77
+ @pytest.mark.unit
78
+ def test_returns_tree_for_not_packaged_package(self, provider):
79
+ """Good path: returns Tree for not packaged package."""
80
+ provider.packages["missing"] = PackageInfo(
81
+ name="missing", latest_version="1.0.0"
82
+ )
83
+ provider.fedora_status["missing"] = FedoraPackageStatus(is_packaged=False)
84
+
85
+ tree = build_tree(provider, "missing")
86
+
87
+ assert isinstance(tree, Tree)
88
+ label = str(tree.label)
89
+ assert "not packaged" in label
90
+
91
+ @pytest.mark.unit
92
+ def test_returns_string_for_not_found_package(self, provider):
93
+ """Bad path: returns string for package not in registry."""
94
+ result = build_tree(provider, "nonexistent")
95
+
96
+ assert isinstance(result, str)
97
+ assert "not found" in result
98
+
99
+ @pytest.mark.unit
100
+ def test_tracks_visited_packages(self, provider):
101
+ """Critical path: tracks visited packages."""
102
+ visited = {}
103
+
104
+ build_tree(provider, "root", visited=visited)
105
+
106
+ assert "root" in visited
107
+ assert visited["root"][0] is True # is_packaged
108
+
109
+ @pytest.mark.unit
110
+ def test_returns_visited_marker_for_duplicate(self, provider):
111
+ """Critical path: returns visited marker for already-visited packages."""
112
+ visited = {"root": (True, "1.0.0", False)}
113
+
114
+ result = build_tree(provider, "root", visited=visited)
115
+
116
+ assert isinstance(result, str)
117
+ assert "already visited" in result
118
+
119
+ @pytest.mark.unit
120
+ def test_respects_max_depth(self, provider):
121
+ """Critical path: respects max depth limit."""
122
+ result = build_tree(provider, "root", depth=100, max_depth=50)
123
+
124
+ assert isinstance(result, str)
125
+ assert "max depth" in result
126
+
127
+ @pytest.mark.unit
128
+ def test_recurses_into_dependencies(self, provider):
129
+ """Critical path: recurses into dependencies."""
130
+ # Set up package with dependency
131
+ provider.packages["parent"] = PackageInfo(name="parent", latest_version="1.0.0")
132
+ provider.packages["child"] = PackageInfo(name="child", latest_version="2.0.0")
133
+ provider.dependencies["parent:1.0.0"] = [
134
+ Dependency(name="child", version_requirement="^2.0", kind="normal")
135
+ ]
136
+ provider.fedora_status["parent"] = FedoraPackageStatus(
137
+ is_packaged=True, versions=["1.0.0"]
138
+ )
139
+ provider.fedora_status["child"] = FedoraPackageStatus(
140
+ is_packaged=True, versions=["2.0.0"]
141
+ )
142
+
143
+ tree = build_tree(provider, "parent")
144
+
145
+ # Should have a child
146
+ assert isinstance(tree, Tree)
147
+ assert len(tree.children) > 0
148
+
149
+ @pytest.mark.unit
150
+ def test_uses_provided_version(self, provider):
151
+ """Good path: uses provided version instead of latest."""
152
+ provider.packages["pkg"] = PackageInfo(name="pkg", latest_version="2.0.0")
153
+ provider.fedora_status["pkg"] = FedoraPackageStatus(
154
+ is_packaged=True, versions=["1.0.0"]
155
+ )
156
+
157
+ tree = build_tree(provider, "pkg", version="1.0.0")
158
+
159
+ assert isinstance(tree, Tree)
160
+ label = str(tree.label)
161
+ assert "1.0.0" in label
162
+
163
+ @pytest.mark.unit
164
+ def test_includes_license_in_label(self, provider):
165
+ """Good path: license is shown in tree label."""
166
+ provider.packages["licensed-pkg"] = PackageInfo(
167
+ name="licensed-pkg", latest_version="1.0.0", license="MIT"
168
+ )
169
+ provider.fedora_status["licensed-pkg"] = FedoraPackageStatus(
170
+ is_packaged=True, versions=["1.0.0"]
171
+ )
172
+
173
+ tree = build_tree(provider, "licensed-pkg")
174
+
175
+ assert isinstance(tree, Tree)
176
+ label = str(tree.label)
177
+ assert "MIT" in label
178
+
179
+ @pytest.mark.unit
180
+ def test_no_license_marker_when_no_license(self, provider):
181
+ """Good path: no license marker when package has no license."""
182
+ tree = build_tree(provider, "root")
183
+
184
+ assert isinstance(tree, Tree)
185
+ label = str(tree.label)
186
+ # License info from mock_package_info is None, so no license marker
187
+ assert "(None)" not in label
188
+
189
+ @pytest.mark.unit
190
+ def test_updates_progress_tracker(self, provider):
191
+ """Good path: updates progress tracker when provided."""
192
+ tracker = MagicMock()
193
+ provider.packages["pkg"] = PackageInfo(name="pkg", latest_version="1.0.0")
194
+ provider.fedora_status["pkg"] = FedoraPackageStatus(
195
+ is_packaged=True, versions=["1.0.0"]
196
+ )
197
+
198
+ build_tree(provider, "pkg", tracker=tracker)
199
+
200
+ tracker.update.assert_called()
201
+
202
+ @pytest.mark.unit
203
+ def test_excludes_dependencies_matching_pattern(self, provider):
204
+ """Good path: excludes dependencies matching glob patterns."""
205
+ # Set up package with dependencies
206
+ provider.packages["parent"] = PackageInfo(name="parent", latest_version="1.0.0")
207
+ provider.packages["child"] = PackageInfo(name="child", latest_version="2.0.0")
208
+ provider.packages["windows-sys"] = PackageInfo(
209
+ name="windows-sys", latest_version="0.52.0"
210
+ )
211
+ provider.dependencies["parent:1.0.0"] = [
212
+ Dependency(name="child", version_requirement="^2.0", kind="normal"),
213
+ Dependency(name="windows-sys", version_requirement="^0.52", kind="normal"),
214
+ ]
215
+ provider.fedora_status["parent"] = FedoraPackageStatus(
216
+ is_packaged=True, versions=["1.0.0"]
217
+ )
218
+ provider.fedora_status["child"] = FedoraPackageStatus(
219
+ is_packaged=True, versions=["2.0.0"]
220
+ )
221
+ provider.fedora_status["windows-sys"] = FedoraPackageStatus(is_packaged=False)
222
+
223
+ tree = build_tree(provider, "parent", exclude_patterns=["windows*"])
224
+
225
+ # Should only have child, not windows-sys
226
+ assert isinstance(tree, Tree)
227
+ assert len(tree.children) == 1
228
+ child_label = str(tree.children[0].label)
229
+ assert "child" in child_label
230
+ # windows-sys should be excluded
231
+ for child in tree.children:
232
+ label = str(child.label) if hasattr(child, "label") else str(child)
233
+ assert "windows" not in label
234
+
235
+ @pytest.mark.unit
236
+ def test_excludes_multiple_patterns(self, provider):
237
+ """Good path: excludes dependencies matching multiple glob patterns."""
238
+ provider.packages["parent"] = PackageInfo(name="parent", latest_version="1.0.0")
239
+ provider.packages["good-dep"] = PackageInfo(
240
+ name="good-dep", latest_version="1.0.0"
241
+ )
242
+ provider.packages["win-dep"] = PackageInfo(
243
+ name="win-dep", latest_version="1.0.0"
244
+ )
245
+ provider.packages["macos-dep"] = PackageInfo(
246
+ name="macos-dep", latest_version="1.0.0"
247
+ )
248
+ provider.dependencies["parent:1.0.0"] = [
249
+ Dependency(name="good-dep", version_requirement="^1.0", kind="normal"),
250
+ Dependency(name="win-dep", version_requirement="^1.0", kind="normal"),
251
+ Dependency(name="macos-dep", version_requirement="^1.0", kind="normal"),
252
+ ]
253
+ provider.fedora_status["parent"] = FedoraPackageStatus(
254
+ is_packaged=True, versions=["1.0.0"]
255
+ )
256
+ provider.fedora_status["good-dep"] = FedoraPackageStatus(
257
+ is_packaged=True, versions=["1.0.0"]
258
+ )
259
+ provider.fedora_status["win-dep"] = FedoraPackageStatus(is_packaged=False)
260
+ provider.fedora_status["macos-dep"] = FedoraPackageStatus(is_packaged=False)
261
+
262
+ tree = build_tree(provider, "parent", exclude_patterns=["win*", "macos*"])
263
+
264
+ # Should only have good-dep
265
+ assert isinstance(tree, Tree)
266
+ assert len(tree.children) == 1
267
+ child_label = str(tree.children[0].label)
268
+ assert "good-dep" in child_label
269
+
270
+ @pytest.mark.unit
271
+ def test_exclude_patterns_applied_recursively(self, provider):
272
+ """Good path: exclude patterns are applied at all depth levels."""
273
+ provider.packages["root"] = PackageInfo(name="root", latest_version="1.0.0")
274
+ provider.packages["child"] = PackageInfo(name="child", latest_version="1.0.0")
275
+ provider.packages["windows-inner"] = PackageInfo(
276
+ name="windows-inner", latest_version="1.0.0"
277
+ )
278
+ provider.dependencies["root:1.0.0"] = [
279
+ Dependency(name="child", version_requirement="^1.0", kind="normal"),
280
+ ]
281
+ provider.dependencies["child:1.0.0"] = [
282
+ Dependency(name="windows-inner", version_requirement="^1.0", kind="normal"),
283
+ ]
284
+ provider.fedora_status["root"] = FedoraPackageStatus(
285
+ is_packaged=True, versions=["1.0.0"]
286
+ )
287
+ provider.fedora_status["child"] = FedoraPackageStatus(
288
+ is_packaged=True, versions=["1.0.0"]
289
+ )
290
+ provider.fedora_status["windows-inner"] = FedoraPackageStatus(is_packaged=False)
291
+
292
+ tree = build_tree(provider, "root", exclude_patterns=["windows*"])
293
+
294
+ # root -> child (no windows-inner)
295
+ assert isinstance(tree, Tree)
296
+ assert len(tree.children) == 1
297
+ child_tree = tree.children[0]
298
+ assert isinstance(child_tree, Tree)
299
+ # child should have no children (windows-inner was filtered)
300
+ assert len(child_tree.children) == 0
301
+
302
+ @pytest.mark.unit
303
+ def test_no_exclusion_when_patterns_none(self, provider):
304
+ """Good path: no exclusion when patterns is None."""
305
+ provider.packages["parent"] = PackageInfo(name="parent", latest_version="1.0.0")
306
+ provider.packages["child"] = PackageInfo(name="child", latest_version="1.0.0")
307
+ provider.dependencies["parent:1.0.0"] = [
308
+ Dependency(name="child", version_requirement="^1.0", kind="normal"),
309
+ ]
310
+ provider.fedora_status["parent"] = FedoraPackageStatus(
311
+ is_packaged=True, versions=["1.0.0"]
312
+ )
313
+ provider.fedora_status["child"] = FedoraPackageStatus(
314
+ is_packaged=True, versions=["1.0.0"]
315
+ )
316
+
317
+ tree = build_tree(provider, "parent", exclude_patterns=None)
318
+
319
+ assert isinstance(tree, Tree)
320
+ assert len(tree.children) == 1
321
+
322
+
323
+ class TestComputeStatsFromVisited:
324
+ """Tests for _compute_stats_from_visited function."""
325
+
326
+ @pytest.mark.unit
327
+ def test_returns_tree_stats_model(self):
328
+ """Good path: returns TreeStats model."""
329
+ visited = {"root": (True, "1.0.0", False)}
330
+
331
+ stats = _compute_stats_from_visited(visited)
332
+
333
+ assert isinstance(stats, TreeStats)
334
+
335
+ @pytest.mark.unit
336
+ def test_counts_packaged(self):
337
+ """Good path: counts packaged packages."""
338
+ visited = {"root": (True, "1.0.0", False)}
339
+
340
+ stats = _compute_stats_from_visited(visited)
341
+
342
+ assert stats.packaged == 1
343
+
344
+ @pytest.mark.unit
345
+ def test_counts_missing(self):
346
+ """Good path: counts missing packages."""
347
+ visited = {"root": (False, "1.0.0", False)}
348
+
349
+ stats = _compute_stats_from_visited(visited)
350
+
351
+ assert stats.missing == 1
352
+
353
+ @pytest.mark.unit
354
+ def test_collects_missing_list(self):
355
+ """Good path: collects list of missing packages."""
356
+ visited = {"missing-pkg": (False, "1.0.0", False)}
357
+
358
+ stats = _compute_stats_from_visited(visited)
359
+
360
+ assert len(stats.missing_list) == 1
361
+ assert "missing-pkg" in stats.missing_list
362
+
363
+ @pytest.mark.unit
364
+ def test_collects_packaged_list(self):
365
+ """Good path: collects list of packaged packages."""
366
+ visited = {"pkg": (True, "1.0.0", False)}
367
+
368
+ stats = _compute_stats_from_visited(visited)
369
+
370
+ assert len(stats.packaged_list) == 1
371
+ assert "pkg" in stats.packaged_list
372
+
373
+ @pytest.mark.unit
374
+ def test_counts_not_found_as_missing(self):
375
+ """Good path: counts not-found packages (version=None) as missing."""
376
+ visited = {"unknown": (False, None, False)}
377
+
378
+ stats = _compute_stats_from_visited(visited)
379
+
380
+ assert stats.missing == 1
381
+ assert "unknown" in stats.missing_list
382
+
383
+ @pytest.mark.unit
384
+ def test_multiple_packages(self):
385
+ """Critical path: counts all packages from visited dict."""
386
+ visited = {
387
+ "root": (True, "1.0.0", False),
388
+ "child1": (True, "1.0.0", False),
389
+ "child2": (False, "1.0.0", False),
390
+ }
391
+
392
+ stats = _compute_stats_from_visited(visited)
393
+
394
+ assert stats.total == 3
395
+ assert stats.packaged == 2
396
+ assert stats.missing == 1
397
+
398
+ @pytest.mark.unit
399
+ def test_empty_visited(self):
400
+ """Good path: handles empty visited dict."""
401
+ stats = _compute_stats_from_visited({})
402
+
403
+ assert stats.total == 0
404
+ assert stats.packaged == 0
405
+ assert stats.missing == 0
406
+ assert stats.missing_list == []
407
+ assert stats.packaged_list == []
408
+
409
+ @pytest.mark.unit
410
+ def test_has_dev_build_stats(self):
411
+ """Good path: stats model has dev/build dependency stats (zeroed by default)."""
412
+ visited = {"pkg": (True, "1.0.0", False)}
413
+
414
+ stats = _compute_stats_from_visited(visited)
415
+
416
+ assert hasattr(stats, "dev_total")
417
+ assert hasattr(stats, "dev_packaged")
418
+ assert hasattr(stats, "dev_missing")
419
+ assert hasattr(stats, "build_total")
420
+ assert hasattr(stats, "build_packaged")
421
+ assert hasattr(stats, "build_missing")
422
+ assert stats.dev_total == 0
423
+ assert stats.build_total == 0
424
+
425
+ @pytest.mark.unit
426
+ def test_optional_dependency_tracking(self):
427
+ """Good path: tracks optional dependency statistics."""
428
+ visited = {
429
+ "required": (True, "1.0.0", False),
430
+ "opt-packaged": (True, "2.0.0", True),
431
+ "opt-missing": (False, "3.0.0", True),
432
+ }
433
+
434
+ stats = _compute_stats_from_visited(visited)
435
+
436
+ assert stats.total == 3
437
+ assert stats.optional_total == 2
438
+ assert stats.optional_packaged == 1
439
+ assert stats.optional_missing == 1
440
+ assert "opt-missing" in stats.optional_missing_list