java-dependency-analyzer 1.0.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.
Files changed (31) hide show
  1. java_dependency_analyzer/__init__.py +11 -0
  2. java_dependency_analyzer/cache/__init__.py +11 -0
  3. java_dependency_analyzer/cache/db.py +101 -0
  4. java_dependency_analyzer/cache/vulnerability_cache.py +156 -0
  5. java_dependency_analyzer/cli.py +394 -0
  6. java_dependency_analyzer/models/__init__.py +11 -0
  7. java_dependency_analyzer/models/dependency.py +80 -0
  8. java_dependency_analyzer/models/report.py +108 -0
  9. java_dependency_analyzer/parsers/__init__.py +11 -0
  10. java_dependency_analyzer/parsers/base.py +150 -0
  11. java_dependency_analyzer/parsers/gradle_dep_tree_parser.py +125 -0
  12. java_dependency_analyzer/parsers/gradle_parser.py +206 -0
  13. java_dependency_analyzer/parsers/maven_dep_tree_parser.py +123 -0
  14. java_dependency_analyzer/parsers/maven_parser.py +182 -0
  15. java_dependency_analyzer/reporters/__init__.py +11 -0
  16. java_dependency_analyzer/reporters/base.py +33 -0
  17. java_dependency_analyzer/reporters/html_reporter.py +82 -0
  18. java_dependency_analyzer/reporters/json_reporter.py +52 -0
  19. java_dependency_analyzer/reporters/templates/report.html +406 -0
  20. java_dependency_analyzer/resolvers/__init__.py +11 -0
  21. java_dependency_analyzer/resolvers/transitive.py +276 -0
  22. java_dependency_analyzer/scanners/__init__.py +11 -0
  23. java_dependency_analyzer/scanners/base.py +102 -0
  24. java_dependency_analyzer/scanners/ghsa_scanner.py +204 -0
  25. java_dependency_analyzer/scanners/osv_scanner.py +167 -0
  26. java_dependency_analyzer/util/__init__.py +11 -0
  27. java_dependency_analyzer/util/logger.py +48 -0
  28. java_dependency_analyzer-1.0.0.dist-info/METADATA +193 -0
  29. java_dependency_analyzer-1.0.0.dist-info/RECORD +31 -0
  30. java_dependency_analyzer-1.0.0.dist-info/WHEEL +4 -0
  31. java_dependency_analyzer-1.0.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,406 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Java Dependency Vulnerability Report</title>
7
+ <style>
8
+ body { font-family: 'Segoe UI', Arial, sans-serif; margin: 0; padding: 20px; background: #f5f5f5; color: #333; }
9
+ h1 { color: #2c3e50; border-bottom: 3px solid #e74c3c; padding-bottom: 10px; }
10
+ h2 { color: #2c3e50; margin-top: 30px; }
11
+ .summary { display: flex; gap: 20px; flex-wrap: wrap; margin: 20px 0; }
12
+ .card {
13
+ background: #fff; border-radius: 8px; padding: 20px; min-width: 150px;
14
+ box-shadow: 0 2px 4px rgba(0,0,0,.1); text-align: center;
15
+ cursor: pointer; transition: box-shadow 0.2s, transform 0.15s, border 0.15s;
16
+ border: 2px solid transparent; user-select: none;
17
+ }
18
+ .card:hover { box-shadow: 0 4px 14px rgba(0,0,0,.18); transform: translateY(-2px); }
19
+ .card.active { border-color: #2980b9; box-shadow: 0 0 0 3px rgba(41,128,185,.25); }
20
+ .card .number { font-size: 2.5em; font-weight: bold; }
21
+ .card.danger .number { color: #e74c3c; }
22
+ .card.warning .number { color: #f39c12; }
23
+ .card.safe .number { color: #27ae60; }
24
+ .card .label { font-size: 0.85em; color: #666; margin-top: 4px; }
25
+ .meta { color: #666; font-size: 0.9em; margin-bottom: 20px; }
26
+ .filter-notice {
27
+ display: none; background: #eaf3fb; border-left: 4px solid #2980b9;
28
+ padding: 8px 14px; margin-bottom: 12px; border-radius: 4px;
29
+ font-size: 0.9em; color: #2c3e50;
30
+ }
31
+ .filter-notice.visible { display: flex; align-items: center; gap: 10px; }
32
+ .filter-notice button {
33
+ background: none; border: none; color: #2980b9; cursor: pointer;
34
+ font-size: 0.95em; padding: 0; text-decoration: underline;
35
+ }
36
+ table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,.1); }
37
+ thead { background: #2c3e50; color: #fff; }
38
+ th, td { padding: 12px 16px; text-align: left; border-bottom: 1px solid #eee; }
39
+ tr:last-child td { border-bottom: none; }
40
+ tr:hover td { background: #f9f9f9; }
41
+ tr.hidden { display: none; }
42
+ .badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.8em; font-weight: bold; }
43
+ .badge-critical { background: #e74c3c; color: #fff; }
44
+ .badge-high { background: #e67e22; color: #fff; }
45
+ .badge-medium { background: #f39c12; color: #fff; }
46
+ .badge-low { background: #3498db; color: #fff; }
47
+ .badge-unknown { background: #95a5a6; color: #fff; }
48
+ .no-vulns { color: #27ae60; font-style: italic; }
49
+ a { color: #2980b9; text-decoration: none; }
50
+ a:hover { text-decoration: underline; }
51
+ details summary { cursor: pointer; font-weight: bold; color: #2c3e50; }
52
+ .vuln-row { background: #fff8f8; }
53
+ .vuln-cve { font-family: monospace; font-weight: bold; }
54
+ .footer { margin-top: 40px; text-align: center; color: #aaa; font-size: 0.8em; }
55
+ /* Dependency tree visuals */
56
+ .dep-name { white-space: nowrap; }
57
+ .dep-root > a { font-weight: 600; color: #2c3e50; }
58
+ .dep-root-dot { color: #2c3e50; margin-right: 6px; font-size: 1.1em; }
59
+ .tree-prefix { font-family: 'Courier New', monospace; color: #bbb; font-size: 0.9em; letter-spacing: 0; white-space: pre; }
60
+ .tree-connector { color: #aaa; font-family: 'Courier New', monospace; margin-right: 4px; }
61
+ tr[data-depth="0"] td:first-child { background: #fafafa; }
62
+ tr[data-depth="1"] td:first-child { background: #f5f8ff; }
63
+ tr[data-depth="2"] td:first-child { background: #f0f5ff; }
64
+ /* Collapsible tree */
65
+ .collapsed-hidden { display: none; }
66
+ .collapse-toggle {
67
+ display: inline-block; width: 16px; text-align: center;
68
+ cursor: pointer; color: #999; font-size: 0.85em;
69
+ user-select: none; margin-right: 2px;
70
+ transition: transform 0.15s;
71
+ }
72
+ .collapse-toggle:hover { color: #2980b9; }
73
+ .tree-controls { display: flex; gap: 8px; margin-bottom: 10px; }
74
+ .btn-tree {
75
+ background: #fff; border: 1px solid #ccc; border-radius: 4px;
76
+ padding: 4px 12px; font-size: 0.85em; cursor: pointer; color: #2c3e50;
77
+ transition: background 0.15s, border-color 0.15s;
78
+ }
79
+ .btn-tree:hover { background: #eaf3fb; border-color: #2980b9; color: #2980b9; }
80
+ </style>
81
+ </head>
82
+ <body>
83
+ <h1>Java Dependency Vulnerability Report</h1>
84
+ <p class="meta">
85
+ <strong>Source:</strong> {{ result.source_file }}&nbsp;&nbsp;
86
+ <strong>Scanned at:</strong> {{ result.scanned_at }}
87
+ </p>
88
+
89
+ <h2>Summary</h2>
90
+ <div class="summary">
91
+ <div class="card active" data-filter="all">
92
+ <div class="number">{{ result.total_dependencies }}</div>
93
+ <div class="label">Total Dependencies</div>
94
+ </div>
95
+ <div class="card {% if result.total_vulnerabilities > 0 %}danger{% else %}safe{% endif %}" data-filter="has-vulns">
96
+ <div class="number">{{ result.total_vulnerabilities }}</div>
97
+ <div class="label">Total Vulnerabilities</div>
98
+ </div>
99
+ <div class="card {% if result.vulnerable_dependencies|length > 0 %}warning{% else %}safe{% endif %}" data-filter="vuln-deps">
100
+ <div class="number">{{ result.vulnerable_dependencies|length }}</div>
101
+ <div class="label">Vulnerable Dependencies</div>
102
+ </div>
103
+ </div>
104
+
105
+ {% if result.total_vulnerabilities == 0 %}
106
+ <p style="color: #27ae60; font-size: 1.1em;">&#x2714; No vulnerabilities found across all dependencies.</p>
107
+ {% endif %}
108
+
109
+ <h2>Dependency Tree</h2>
110
+ <div class="filter-notice" id="filter-notice">
111
+ <span id="filter-notice-text"></span>
112
+ <button onclick="resetFilter()">Show all</button>
113
+ </div>
114
+ <div id="dep-tree-section">
115
+ <div class="tree-controls" id="tree-controls">
116
+ <button class="btn-tree" onclick="expandAll()">Expand All</button>
117
+ <button class="btn-tree" onclick="collapseAll()">Collapse All</button>
118
+ </div>
119
+ <table id="dep-table">
120
+ <thead>
121
+ <tr>
122
+ <th>Dependency</th>
123
+ <th>Version</th>
124
+ <th>Scope</th>
125
+ <th>Vulnerabilities</th>
126
+ </tr>
127
+ </thead>
128
+ <tbody>
129
+ {% for dep in all_deps %}
130
+ <tr class="dep-row" data-depth="{{ dep.depth }}" data-vuln-count="{{ dep.vulnerabilities | length }}">
131
+ <td class="dep-name">
132
+ {% if dep.depth == 0 %}
133
+ <span class="dep-root">
134
+ <span class="dep-root-dot">&#9679;</span>
135
+ <a href="https://repo1.maven.org/maven2/{{ dep.group_id | replace('.', '/') }}/{{ dep.artifact_id }}/{{ dep.version }}/"
136
+ target="_blank" rel="noopener noreferrer">{{ dep.group_id }}:{{ dep.artifact_id }}</a>
137
+ </span>
138
+ {% else %}
139
+ <span class="tree-prefix">{{ ' ' * (dep.depth - 1) }}</span><span class="tree-connector">└─</span><a href="https://repo1.maven.org/maven2/{{ dep.group_id | replace('.', '/') }}/{{ dep.artifact_id }}/{{ dep.version }}/"
140
+ target="_blank" rel="noopener noreferrer">{{ dep.group_id }}:{{ dep.artifact_id }}</a>
141
+ {% endif %}
142
+ </td>
143
+ <td>{{ dep.version }}</td>
144
+ <td>{{ dep.scope }}</td>
145
+ <td>
146
+ {% if dep.vulnerabilities %}
147
+ {% for vuln in dep.vulnerabilities %}
148
+ <details>
149
+ <summary>
150
+ <span class="vuln-cve">{{ vuln.cve_id }}</span>
151
+ <span class="badge badge-{{ vuln.severity | lower | replace('cvss', '') | replace(' ', '') | truncate(8, true, '') }}">
152
+ {{ vuln.severity }}
153
+ </span>
154
+ </summary>
155
+ <p>{{ vuln.summary }}</p>
156
+ {% if vuln.affected_versions %}
157
+ <p><strong>Affected versions:</strong> {{ vuln.affected_versions | join(', ') }}</p>
158
+ {% endif %}
159
+ {% if vuln.reference_url %}
160
+ <p><a href="{{ vuln.reference_url }}" target="_blank" rel="noopener noreferrer">
161
+ More info &rarr;
162
+ </a></p>
163
+ {% endif %}
164
+ <p><em>Source: {{ vuln.source }}</em></p>
165
+ </details>
166
+ {% endfor %}
167
+ {% else %}
168
+ <span class="no-vulns">None found</span>
169
+ {% endif %}
170
+ </td>
171
+ </tr>
172
+ {% endfor %}
173
+ </tbody>
174
+ </table>
175
+ </div><!-- #dep-tree-section -->
176
+
177
+ <div id="vuln-list-section" style="display:none;">
178
+ {% if result.vulnerable_dependencies %}
179
+ <table>
180
+ <thead>
181
+ <tr>
182
+ <th>Dependency</th>
183
+ <th>Version</th>
184
+ <th>Scope</th>
185
+ <th>Vulnerabilities</th>
186
+ </tr>
187
+ </thead>
188
+ <tbody>
189
+ {% for dep in result.vulnerable_dependencies %}
190
+ <tr>
191
+ <td>
192
+ <a href="https://repo1.maven.org/maven2/{{ dep.group_id | replace('.', '/') }}/{{ dep.artifact_id }}/{{ dep.version }}/"
193
+ target="_blank" rel="noopener noreferrer">{{ dep.group_id }}:{{ dep.artifact_id }}</a>
194
+ </td>
195
+ <td>{{ dep.version }}</td>
196
+ <td>{{ dep.scope }}</td>
197
+ <td>
198
+ {% for vuln in dep.vulnerabilities %}
199
+ <details>
200
+ <summary>
201
+ <span class="vuln-cve">{{ vuln.cve_id }}</span>
202
+ <span class="badge badge-{{ vuln.severity | lower | replace('cvss', '') | replace(' ', '') | truncate(8, true, '') }}">
203
+ {{ vuln.severity }}
204
+ </span>
205
+ </summary>
206
+ <p>{{ vuln.summary }}</p>
207
+ {% if vuln.affected_versions %}
208
+ <p><strong>Affected versions:</strong> {{ vuln.affected_versions | join(', ') }}</p>
209
+ {% endif %}
210
+ {% if vuln.reference_url %}
211
+ <p><a href="{{ vuln.reference_url }}" target="_blank" rel="noopener noreferrer">
212
+ More info &rarr;
213
+ </a></p>
214
+ {% endif %}
215
+ <p><em>Source: {{ vuln.source }}</em></p>
216
+ </details>
217
+ {% endfor %}
218
+ </td>
219
+ </tr>
220
+ {% endfor %}
221
+ </tbody>
222
+ </table>
223
+ {% else %}
224
+ <p style="color: #27ae60; font-size: 1.1em;">&#x2714; No vulnerable dependencies found.</p>
225
+ {% endif %}
226
+ </div><!-- #vuln-list-section -->
227
+
228
+ <div class="footer">
229
+ Generated by Java Dependency Analyzer v1.0.0 &mdash; Ron Webb
230
+ </div>
231
+
232
+ <script>
233
+ const rows = Array.from(document.querySelectorAll('.dep-row'));
234
+ const notice = document.getElementById('filter-notice');
235
+ const noticeText = document.getElementById('filter-notice-text');
236
+ const treeControls = document.getElementById('tree-controls');
237
+
238
+ // ── Collapse state ──────────────────────────────────────────────────────────
239
+ const collapsedRows = new Set(); // indices of rows the user collapsed
240
+ let filterActive = false;
241
+
242
+ function getDepth(row) {
243
+ return parseInt(row.dataset.depth || '0', 10);
244
+ }
245
+
246
+ /** Return indices of all consecutive descendants of rows[i] (depth > rows[i].depth). */
247
+ function getDescendantIndices(i) {
248
+ const parentDepth = getDepth(rows[i]);
249
+ const indices = [];
250
+ for (let j = i + 1; j < rows.length; j++) {
251
+ if (getDepth(rows[j]) > parentDepth) {
252
+ indices.push(j);
253
+ } else {
254
+ break;
255
+ }
256
+ }
257
+ return indices;
258
+ }
259
+
260
+ /** Collapse rows[i]: hide all descendants, update toggle icon. */
261
+ function collapseRow(i) {
262
+ collapsedRows.add(i);
263
+ getDescendantIndices(i).forEach(j => {
264
+ rows[j].classList.add('collapsed-hidden');
265
+ });
266
+ const toggle = rows[i].querySelector('.collapse-toggle');
267
+ if (toggle) toggle.textContent = '▶';
268
+ }
269
+
270
+ /** Expand rows[i]: show direct children (but not children of sub-collapsed rows). */
271
+ function expandRow(i) {
272
+ collapsedRows.delete(i);
273
+ const parentDepth = getDepth(rows[i]);
274
+ for (let j = i + 1; j < rows.length; j++) {
275
+ const d = getDepth(rows[j]);
276
+ if (d <= parentDepth) break;
277
+ if (d === parentDepth + 1) {
278
+ // direct child — always visible
279
+ rows[j].classList.remove('collapsed-hidden');
280
+ } else {
281
+ // deeper descendant — only visible if its own parent-chain is not collapsed
282
+ const isInsideCollapsed = [...collapsedRows].some(ci => {
283
+ if (ci >= j) return false;
284
+ const desc = getDescendantIndices(ci);
285
+ return desc.includes(j);
286
+ });
287
+ if (isInsideCollapsed) {
288
+ rows[j].classList.add('collapsed-hidden');
289
+ } else {
290
+ rows[j].classList.remove('collapsed-hidden');
291
+ }
292
+ }
293
+ }
294
+ const toggle = rows[i].querySelector('.collapse-toggle');
295
+ if (toggle) toggle.textContent = '▼';
296
+ }
297
+
298
+ function toggleRow(i) {
299
+ if (collapsedRows.has(i)) {
300
+ expandRow(i);
301
+ } else {
302
+ collapseRow(i);
303
+ }
304
+ }
305
+
306
+ function collapseAll() {
307
+ rows.forEach((row, i) => {
308
+ if (row.dataset.hasChildren === 'true') collapseRow(i);
309
+ });
310
+ }
311
+
312
+ function expandAll() {
313
+ collapsedRows.clear();
314
+ rows.forEach(row => row.classList.remove('collapsed-hidden'));
315
+ rows.forEach(row => {
316
+ const toggle = row.querySelector('.collapse-toggle');
317
+ if (toggle) toggle.textContent = '▼';
318
+ });
319
+ }
320
+
321
+ /** Re-apply collapsed-hidden state (e.g. after filter reset). */
322
+ function renderCollapsedState() {
323
+ rows.forEach(row => row.classList.remove('collapsed-hidden'));
324
+ collapsedRows.forEach(i => {
325
+ getDescendantIndices(i).forEach(j => {
326
+ rows[j].classList.add('collapsed-hidden');
327
+ });
328
+ const toggle = rows[i].querySelector('.collapse-toggle');
329
+ if (toggle) toggle.textContent = '▶';
330
+ });
331
+ }
332
+
333
+ // ── Filter ──────────────────────────────────────────────────────────────────
334
+ const depTreeSection = document.getElementById('dep-tree-section');
335
+ const vulnListSection = document.getElementById('vuln-list-section');
336
+
337
+ function applyFilter(filterType, labelText) {
338
+ if (filterType === 'all') {
339
+ rows.forEach(row => row.classList.remove('hidden'));
340
+ filterActive = false;
341
+ depTreeSection.style.display = '';
342
+ vulnListSection.style.display = 'none';
343
+ treeControls.style.display = '';
344
+ notice.classList.remove('visible');
345
+ renderCollapsedState();
346
+ } else if (filterType === 'vuln-deps') {
347
+ // Show plain deduplicated vulnerable-deps list; hide the tree
348
+ depTreeSection.style.display = 'none';
349
+ vulnListSection.style.display = '';
350
+ filterActive = true;
351
+ noticeText.textContent = `Showing ${labelText}`;
352
+ notice.classList.add('visible');
353
+ } else {
354
+ // has-vulns: filter the tree rows in-place
355
+ depTreeSection.style.display = '';
356
+ vulnListSection.style.display = 'none';
357
+ rows.forEach(row => row.classList.remove('collapsed-hidden'));
358
+ rows.forEach(row => {
359
+ const vulnCount = parseInt(row.dataset.vulnCount || '0', 10);
360
+ if (vulnCount > 0) {
361
+ row.classList.remove('hidden');
362
+ } else {
363
+ row.classList.add('hidden');
364
+ }
365
+ });
366
+ filterActive = true;
367
+ treeControls.style.display = 'none';
368
+ const visible = rows.filter(r => !r.classList.contains('hidden')).length;
369
+ noticeText.textContent = `Showing ${visible} ${labelText}`;
370
+ notice.classList.add('visible');
371
+ }
372
+ }
373
+
374
+ function resetFilter() {
375
+ document.querySelectorAll('.card').forEach(c => c.classList.remove('active'));
376
+ document.querySelector('.card[data-filter="all"]').classList.add('active');
377
+ applyFilter('all', '');
378
+ }
379
+
380
+ document.querySelectorAll('.card[data-filter]').forEach(card => {
381
+ card.addEventListener('click', function () {
382
+ document.querySelectorAll('.card').forEach(c => c.classList.remove('active'));
383
+ this.classList.add('active');
384
+ const filter = this.dataset.filter;
385
+ const label = this.querySelector('.label').textContent;
386
+ applyFilter(filter, label.toLowerCase());
387
+ });
388
+ });
389
+
390
+ // ── Initialise collapse toggles ─────────────────────────────────────────────
391
+ rows.forEach((row, i) => {
392
+ const nextRow = rows[i + 1];
393
+ if (nextRow && getDepth(nextRow) > getDepth(row)) {
394
+ row.dataset.hasChildren = 'true';
395
+ const toggle = document.createElement('span');
396
+ toggle.className = 'collapse-toggle';
397
+ toggle.textContent = '▼';
398
+ toggle.addEventListener('click', e => { e.stopPropagation(); toggleRow(i); });
399
+ const depNameCell = row.querySelector('.dep-name');
400
+ depNameCell.insertBefore(toggle, depNameCell.firstChild);
401
+ }
402
+ });
403
+ // Default: all expanded (nothing in collapsedRows, no collapsed-hidden classes)
404
+ </script>
405
+ </body>
406
+ </html>
@@ -0,0 +1,11 @@
1
+ """
2
+ resolvers package.
3
+
4
+ Provides transitive dependency resolution from Maven Central.
5
+
6
+ :author: Ron Webb
7
+ :since: 1.0.0
8
+ """
9
+
10
+ __author__ = "Ron Webb"
11
+ __since__ = "1.0.0"
@@ -0,0 +1,276 @@
1
+ """
2
+ transitive module.
3
+
4
+ Resolves transitive dependencies by fetching POMs from Maven Central.
5
+
6
+ :author: Ron Webb
7
+ :since: 1.0.0
8
+ """
9
+
10
+ import re
11
+
12
+ import httpx
13
+ from lxml import etree
14
+
15
+ from ..models.dependency import Dependency
16
+ from ..parsers.base import RUNTIME_SCOPES
17
+ from ..util.logger import setup_logger
18
+
19
+ __author__ = "Ron Webb"
20
+ __since__ = "1.0.0"
21
+
22
+ _logger = setup_logger(__name__)
23
+
24
+ _MAVEN_CENTRAL = "https://repo1.maven.org/maven2"
25
+ _MAX_DEPTH = 5
26
+
27
+ # Scopes that do NOT propagate transitively to the consumer
28
+ _NON_TRANSITIVE_SCOPES = frozenset({"test", "provided", "system", "import"})
29
+
30
+
31
+ class TransitiveResolver:
32
+ """
33
+ Resolves transitive dependencies by recursively fetching POM files from Maven Central.
34
+
35
+ Uses an in-memory cache to avoid redundant network requests for the same artifact.
36
+
37
+ :author: Ron Webb
38
+ :since: 1.0.0
39
+ """
40
+
41
+ def __init__(self, client: httpx.Client | None = None) -> None:
42
+ """
43
+ Initialise the resolver with an optional shared httpx client.
44
+
45
+ :author: Ron Webb
46
+ :since: 1.0.0
47
+ """
48
+ self._client = client or httpx.Client(timeout=30)
49
+ self._cache: dict[str, list[Dependency]] = {}
50
+
51
+ def resolve(
52
+ self,
53
+ dependency: Dependency,
54
+ depth: int = 0,
55
+ _visited: set[str] | None = None,
56
+ ) -> Dependency:
57
+ """
58
+ Recursively resolve transitive dependencies for the given dependency.
59
+
60
+ Modifies the dependency in-place by populating
61
+ ``dependency.transitive_dependencies`` and setting depth on children.
62
+
63
+ Uses a *visited* set to skip any coordinate that has already been
64
+ processed in the current resolution tree, preventing infinite loops
65
+ caused by circular dependency declarations.
66
+
67
+ :author: Ron Webb
68
+ :since: 1.0.0
69
+ """
70
+ if _visited is None:
71
+ _visited = set()
72
+
73
+ if depth >= _MAX_DEPTH:
74
+ _logger.debug("Max depth %d reached at %s", _MAX_DEPTH, dependency.coordinates)
75
+ return dependency
76
+
77
+ key = dependency.coordinates
78
+ if key in _visited:
79
+ _logger.debug("Already visited, skipping: %s", key)
80
+ return dependency
81
+ _visited.add(key)
82
+
83
+ if key in self._cache:
84
+ dependency.transitive_dependencies = [
85
+ Dependency(
86
+ group_id=d.group_id,
87
+ artifact_id=d.artifact_id,
88
+ version=d.version,
89
+ scope=d.scope,
90
+ depth=depth + 1,
91
+ )
92
+ for d in self._cache[key]
93
+ ]
94
+ return dependency
95
+
96
+ pom_url = self._pom_url(dependency)
97
+ pom_content = self._fetch_pom(pom_url)
98
+ if pom_content is None:
99
+ return dependency
100
+
101
+ direct_children = self._parse_pom_dependencies(pom_content, dependency)
102
+ self._cache[key] = direct_children
103
+
104
+ for child in direct_children:
105
+ child.depth = depth + 1
106
+ self.resolve(child, depth + 1, _visited)
107
+
108
+ dependency.transitive_dependencies = direct_children
109
+ return dependency
110
+
111
+ def resolve_all(self, dependencies: list[Dependency]) -> list[Dependency]:
112
+ """
113
+ Resolve transitive dependencies for a list of direct dependencies.
114
+
115
+ A single visited set is shared across all top-level dependencies so
116
+ that any coordinate already resolved in a previous branch is not
117
+ fetched or recursed into again.
118
+
119
+ :author: Ron Webb
120
+ :since: 1.0.0
121
+ """
122
+ visited: set[str] = set()
123
+ for dep in dependencies:
124
+ self.resolve(dep, depth=0, _visited=visited)
125
+ return dependencies
126
+
127
+ def _pom_url(self, dep: Dependency) -> str:
128
+ """
129
+ Build the Maven Central POM URL for the given dependency.
130
+
131
+ :author: Ron Webb
132
+ :since: 1.0.0
133
+ """
134
+ return (
135
+ f"{_MAVEN_CENTRAL}/{dep.maven_path}"
136
+ f"/{dep.artifact_id}-{dep.version}.pom"
137
+ )
138
+
139
+ def _fetch_pom(self, url: str) -> bytes | None:
140
+ """
141
+ Fetch the POM file at the given URL; return raw bytes or None on failure.
142
+
143
+ :author: Ron Webb
144
+ :since: 1.0.0
145
+ """
146
+ try:
147
+ response = self._client.get(url)
148
+ if response.status_code == 200:
149
+ return response.content
150
+ _logger.debug("POM not found (HTTP %d): %s", response.status_code, url)
151
+ except httpx.RequestError as exc:
152
+ _logger.warning("Network error fetching POM %s: %s", url, exc)
153
+ return None
154
+
155
+ def _parse_pom_dependencies(
156
+ self, pom_content: bytes, parent: Dependency
157
+ ) -> list[Dependency]:
158
+ """
159
+ Parse a POM's <dependencies> section and return runtime-scoped children.
160
+
161
+ :author: Ron Webb
162
+ :since: 1.0.0
163
+ """
164
+ try:
165
+ root = etree.fromstring(pom_content) # nosec B320 # pylint: disable=c-extension-no-member
166
+ except etree.XMLSyntaxError as exc: # pylint: disable=c-extension-no-member
167
+ _logger.warning("Could not parse POM for %s: %s", parent.coordinates, exc)
168
+ return []
169
+
170
+ ns_map = {"m": "http://maven.apache.org/POM/4.0.0"}
171
+ ns_prefix = "m:" if root.tag.startswith("{") else ""
172
+
173
+ def find(node: etree._Element, tag: str) -> etree._Element | None:
174
+ return node.find(f"{ns_prefix}{tag}", ns_map) if ns_prefix else node.find(tag)
175
+
176
+ def text(node: etree._Element, tag: str) -> str:
177
+ child = find(node, tag)
178
+ return child.text.strip() if child is not None and child.text else ""
179
+
180
+ # Extract properties for version variable substitution
181
+ properties = self._extract_pom_properties(root, ns_prefix, ns_map)
182
+ # Add parent version as fallback
183
+ properties.setdefault("project.version", parent.version)
184
+ properties.setdefault("version", parent.version)
185
+
186
+ deps_container = find(root, "dependencies")
187
+ if deps_container is None:
188
+ return []
189
+
190
+ children: list[Dependency] = []
191
+ for dep_el in (
192
+ deps_container.findall(f"{ns_prefix}dependency", ns_map)
193
+ if ns_prefix
194
+ else deps_container.findall("dependency")
195
+ ):
196
+ child = self._parse_dep_el(dep_el, properties, text)
197
+ if child is not None:
198
+ children.append(child)
199
+
200
+ return children
201
+
202
+ def _extract_pom_properties(
203
+ self,
204
+ root: etree._Element,
205
+ ns_prefix: str,
206
+ ns_map: dict[str, str],
207
+ ) -> dict[str, str]:
208
+ """
209
+ Extract <properties> entries from a POM element.
210
+
211
+ :author: Ron Webb
212
+ :since: 1.0.0
213
+ """
214
+ props: dict[str, str] = {}
215
+ props_el = (
216
+ root.find("m:properties", ns_map)
217
+ if ns_prefix
218
+ else root.find("properties")
219
+ )
220
+ if props_el is not None:
221
+ for child in props_el:
222
+ if not isinstance(child.tag, str): # skip comments and PIs
223
+ continue
224
+ local = etree.QName(child.tag).localname # pylint: disable=c-extension-no-member
225
+ if child.text:
226
+ props[local] = child.text.strip()
227
+ return props
228
+
229
+ def _parse_dep_el(
230
+ self,
231
+ dep_el: etree._Element,
232
+ properties: dict[str, str],
233
+ text_fn, # callable is fine; typing via Callable is imported from collections.abc
234
+ ) -> Dependency | None:
235
+ """
236
+ Parse a single dependency XML element into a Dependency, or return None to skip it.
237
+
238
+ :author: Ron Webb
239
+ :since: 1.0.0
240
+ """
241
+ group_id = self._resolve_property(text_fn(dep_el, "groupId"), properties)
242
+ artifact_id = self._resolve_property(text_fn(dep_el, "artifactId"), properties)
243
+ version = self._resolve_property(text_fn(dep_el, "version"), properties)
244
+ scope = text_fn(dep_el, "scope") or "compile"
245
+ optional_raw = text_fn(dep_el, "optional")
246
+ optional = optional_raw.lower() == "true"
247
+
248
+ if not group_id or not artifact_id or not version:
249
+ return None
250
+ if scope in _NON_TRANSITIVE_SCOPES:
251
+ return None
252
+ if optional:
253
+ return None
254
+ if scope not in RUNTIME_SCOPES:
255
+ return None
256
+
257
+ return Dependency(
258
+ group_id=group_id,
259
+ artifact_id=artifact_id,
260
+ version=version,
261
+ scope=scope,
262
+ )
263
+
264
+ def _resolve_property(self, value: str, properties: dict[str, str]) -> str:
265
+ """
266
+ Replace ${key} placeholders using the provided properties dictionary.
267
+
268
+ :author: Ron Webb
269
+ :since: 1.0.0
270
+ """
271
+ if not value or "${" not in value:
272
+ return value
273
+ for match in re.finditer(r"\$\{([^}]+)\}", value):
274
+ key = match.group(1)
275
+ value = value.replace(match.group(0), properties.get(key, ""))
276
+ return value