woolly 0.3.0__py3-none-any.whl → 0.5.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.
woolly/cache.py CHANGED
@@ -14,6 +14,10 @@ CACHE_DIR = Path.home() / ".cache" / "woolly"
14
14
  DEFAULT_CACHE_TTL = 86400 * 7 # 7 days
15
15
  FEDORA_CACHE_TTL = 86400 # 1 day for Fedora repoquery data
16
16
 
17
+ # Track which namespace directories have already been created so that
18
+ # ``mkdir`` is only called once per namespace per process lifetime.
19
+ _ensured_namespaces: set[str] = set()
20
+
17
21
 
18
22
  class CacheEntry(BaseModel):
19
23
  """A cached value with timestamp."""
@@ -23,17 +27,28 @@ class CacheEntry(BaseModel):
23
27
 
24
28
 
25
29
  def ensure_cache_dir() -> None:
26
- """Create cache directory if it doesn't exist."""
30
+ """Create the top-level cache directory if it doesn't exist.
31
+
32
+ This is idempotent but cheap after the first call because the
33
+ directory will already exist.
34
+ """
27
35
  CACHE_DIR.mkdir(parents=True, exist_ok=True)
28
36
 
29
37
 
30
38
  def get_cache_path(namespace: str, key: str) -> Path:
31
- """Get path for a cache entry."""
32
- ensure_cache_dir()
33
- ns_dir = CACHE_DIR / namespace
34
- ns_dir.mkdir(exist_ok=True)
39
+ """Get path for a cache entry.
40
+
41
+ Namespace directories are only created once per process lifetime
42
+ to avoid repeated filesystem calls.
43
+ """
44
+ if namespace not in _ensured_namespaces:
45
+ ensure_cache_dir()
46
+ ns_dir = CACHE_DIR / namespace
47
+ ns_dir.mkdir(exist_ok=True)
48
+ _ensured_namespaces.add(namespace)
49
+
35
50
  safe_key = hashlib.md5(key.encode()).hexdigest()
36
- return ns_dir / f"{safe_key}.json"
51
+ return CACHE_DIR / namespace / f"{safe_key}.json"
37
52
 
38
53
 
39
54
  def read_cache(namespace: str, key: str, ttl: int = DEFAULT_CACHE_TTL) -> Optional[Any]:
woolly/commands/check.py CHANGED
@@ -2,21 +2,36 @@
2
2
  Check command - analyze package dependencies for Fedora availability.
3
3
  """
4
4
 
5
+ import fnmatch
6
+ from concurrent.futures import ThreadPoolExecutor, as_completed
7
+ from pathlib import Path
5
8
  from typing import Annotated, Optional
6
9
 
7
10
  import cyclopts
8
11
  from pydantic import BaseModel, Field
12
+ from rich.panel import Panel
13
+ from rich.text import Text
9
14
  from rich.tree import Tree
10
15
 
11
16
  from woolly.cache import CACHE_DIR
12
17
  from woolly.commands import app, console
13
18
  from woolly.debug import get_log_file, log, log_package_check, setup_logger
14
19
  from woolly.languages import get_available_languages, get_provider
15
- from woolly.languages.base import LanguageProvider
20
+ from woolly.languages.base import Dependency, FeatureInfo, LanguageProvider
16
21
  from woolly.progress import ProgressTracker
17
22
  from woolly.reporters import ReportData, get_available_formats, get_reporter
18
23
 
19
24
 
25
+ class DevBuildDepStatus(BaseModel):
26
+ """Status of a dev or build dependency."""
27
+
28
+ name: str
29
+ version_requirement: str
30
+ is_packaged: bool
31
+ fedora_versions: list[str] = Field(default_factory=list)
32
+ fedora_packages: list[str] = Field(default_factory=list)
33
+
34
+
20
35
  class TreeStats(BaseModel):
21
36
  """Statistics collected from dependency tree analysis."""
22
37
 
@@ -29,6 +44,39 @@ class TreeStats(BaseModel):
29
44
  optional_packaged: int = 0
30
45
  optional_missing: int = 0
31
46
  optional_missing_list: list[str] = Field(default_factory=list)
47
+ dev_total: int = 0
48
+ dev_packaged: int = 0
49
+ dev_missing: int = 0
50
+ build_total: int = 0
51
+ build_packaged: int = 0
52
+ build_missing: int = 0
53
+
54
+
55
+ def _compute_stats_from_visited(visited: dict) -> TreeStats:
56
+ """Compute statistics directly from the *visited* dict built during tree traversal.
57
+
58
+ This replaces the old ``collect_stats`` approach that parsed Rich
59
+ markup strings, which was fragile and wasteful. The *visited* dict
60
+ already contains all the structured information we need:
61
+ ``{package_name: (is_packaged, version, is_optional)}``.
62
+ """
63
+ stats = TreeStats()
64
+ for pkg_name, (is_packaged, _version, is_optional) in visited.items():
65
+ stats.total += 1
66
+ if is_optional:
67
+ stats.optional_total += 1
68
+ if is_packaged:
69
+ stats.packaged += 1
70
+ stats.packaged_list.append(pkg_name)
71
+ if is_optional:
72
+ stats.optional_packaged += 1
73
+ else:
74
+ stats.missing += 1
75
+ stats.missing_list.append(pkg_name)
76
+ if is_optional:
77
+ stats.optional_missing += 1
78
+ stats.optional_missing_list.append(pkg_name)
79
+ return stats
32
80
 
33
81
 
34
82
  def build_tree(
@@ -41,6 +89,7 @@ def build_tree(
41
89
  tracker: Optional[ProgressTracker] = None,
42
90
  include_optional: bool = False,
43
91
  is_optional_dep: bool = False,
92
+ exclude_patterns: Optional[list[str]] = None,
44
93
  ):
45
94
  """
46
95
  Recursively build a dependency tree for a package.
@@ -55,6 +104,7 @@ def build_tree(
55
104
  Specific version, or None for latest.
56
105
  visited
57
106
  Dict of already-visited packages mapping to their status.
107
+ Each value is a tuple ``(is_packaged, version, is_optional)``.
58
108
  depth
59
109
  Current recursion depth.
60
110
  max_depth
@@ -65,6 +115,8 @@ def build_tree(
65
115
  If True, include optional dependencies in the analysis.
66
116
  is_optional_dep
67
117
  If True, this package is an optional dependency.
118
+ exclude_patterns
119
+ List of glob patterns to exclude from the dependency tree.
68
120
 
69
121
  Returns
70
122
  -------
@@ -81,7 +133,7 @@ def build_tree(
81
133
  return f"[dim]{package_name}{optional_marker} (max depth reached)[/dim]"
82
134
 
83
135
  if package_name in visited:
84
- is_packaged, cached_version = visited[package_name]
136
+ is_packaged, cached_version, _ = visited[package_name]
85
137
  log_package_check(
86
138
  package_name,
87
139
  "Skip (already visited)",
@@ -97,10 +149,12 @@ def build_tree(
97
149
 
98
150
  log_package_check(package_name, "Fetching version", source=provider.registry_name)
99
151
 
152
+ # Fetch full package info to get version and license
153
+ pkg_info = None
100
154
  if version is None:
101
- version = provider.get_latest_version(package_name)
102
- if version is None:
103
- visited[package_name] = (False, None)
155
+ pkg_info = provider.fetch_package_info(package_name)
156
+ if pkg_info is None:
157
+ visited[package_name] = (False, None, is_optional_dep)
104
158
  log_package_check(
105
159
  package_name, "Not found", source=provider.registry_name, result="error"
106
160
  )
@@ -108,12 +162,19 @@ def build_tree(
108
162
  f"[bold red]{package_name}[/bold red]{optional_marker} • "
109
163
  f"[red]not found on {provider.registry_name}[/red]"
110
164
  )
165
+ version = pkg_info.latest_version
166
+ else:
167
+ pkg_info = provider.fetch_package_info(package_name)
168
+
169
+ license_str = ""
170
+ if pkg_info and pkg_info.license:
171
+ license_str = f" [magenta]({pkg_info.license})[/magenta]"
111
172
 
112
173
  log_package_check(package_name, "Checking Fedora", source="dnf repoquery")
113
174
 
114
175
  # Check Fedora packaging status
115
176
  status = provider.check_fedora_packaging(package_name)
116
- visited[package_name] = (status.is_packaged, version)
177
+ visited[package_name] = (status.is_packaged, version, is_optional_dep)
117
178
 
118
179
  if status.is_packaged:
119
180
  log_package_check(
@@ -128,14 +189,14 @@ def build_tree(
128
189
  ver_str = ", ".join(status.versions) if status.versions else "unknown"
129
190
  pkg_str = ", ".join(status.package_names) if status.package_names else ""
130
191
  label = (
131
- f"[bold]{package_name}[/bold] [dim]v{version}[/dim]{optional_marker} • "
192
+ f"[bold]{package_name}[/bold] [dim]v{version}[/dim]{license_str}{optional_marker} • "
132
193
  f"[green]✓ packaged[/green] [dim]({ver_str})[/dim]"
133
194
  )
134
195
  if pkg_str:
135
196
  label += f" [dim cyan][{pkg_str}][/dim cyan]"
136
197
  else:
137
198
  label = (
138
- f"[bold]{package_name}[/bold] [dim]v{version}[/dim]{optional_marker} • "
199
+ f"[bold]{package_name}[/bold] [dim]v{version}[/dim]{license_str}{optional_marker} • "
139
200
  f"[red]✗ not packaged[/red]"
140
201
  )
141
202
 
@@ -156,6 +217,12 @@ def build_tree(
156
217
  tracker.update(package_name, discovered=len(deps))
157
218
 
158
219
  for dep_name, _dep_req, dep_is_optional in deps:
220
+ # Skip dependencies matching exclude patterns
221
+ if exclude_patterns:
222
+ if any(fnmatch.fnmatch(dep_name, pattern) for pattern in exclude_patterns):
223
+ log(f"Filtered out dependency: {dep_name}", level="info", depth=depth)
224
+ continue
225
+
159
226
  child = build_tree(
160
227
  provider,
161
228
  dep_name,
@@ -166,6 +233,7 @@ def build_tree(
166
233
  tracker,
167
234
  include_optional=include_optional,
168
235
  is_optional_dep=dep_is_optional,
236
+ exclude_patterns=exclude_patterns,
169
237
  )
170
238
  if isinstance(child, str):
171
239
  node.add(child)
@@ -179,72 +247,22 @@ def build_tree(
179
247
  return node
180
248
 
181
249
 
182
- def collect_stats(tree, stats: Optional[TreeStats] = None) -> TreeStats:
183
- """Walk the tree and collect statistics."""
184
- if stats is None:
185
- stats = TreeStats()
250
+ def _check_fedora_for_dep(
251
+ provider: LanguageProvider, dep: Dependency
252
+ ) -> DevBuildDepStatus:
253
+ """Check Fedora packaging status for a single dev/build dependency.
186
254
 
187
- def walk(t):
188
- if isinstance(t, str):
189
- stats.total += 1
190
- is_optional = "(optional)" in t
191
- if is_optional:
192
- stats.optional_total += 1
193
- if "not packaged" in t or "not found" in t:
194
- stats.missing += 1
195
- # Handle both [bold] and [bold red] formats
196
- if "[/bold]" in t:
197
- name = t.split("[/bold]")[0].split("]")[-1]
198
- elif "[/bold red]" in t:
199
- name = t.split("[/bold red]")[0].split("[bold red]")[-1]
200
- else:
201
- name = t.split()[0]
202
- stats.missing_list.append(name)
203
- if is_optional:
204
- stats.optional_missing += 1
205
- stats.optional_missing_list.append(name)
206
- elif "packaged" in t:
207
- stats.packaged += 1
208
- if is_optional:
209
- stats.optional_packaged += 1
210
- return
211
-
212
- if hasattr(t, "label"):
213
- label = str(t.label)
214
- stats.total += 1
215
- is_optional = "(optional)" in label
216
- if is_optional:
217
- stats.optional_total += 1
218
- if "not packaged" in label or "not found" in label:
219
- stats.missing += 1
220
- # Handle both [bold] and [bold red] formats
221
- if "[bold]" in label and "[bold red]" not in label:
222
- name = label.split("[/bold]")[0].split("[bold]")[-1]
223
- elif "[bold red]" in label:
224
- name = label.split("[/bold red]")[0].split("[bold red]")[-1]
225
- else:
226
- name = "unknown"
227
- stats.missing_list.append(name)
228
- if is_optional:
229
- stats.optional_missing += 1
230
- stats.optional_missing_list.append(name)
231
- elif "packaged" in label:
232
- stats.packaged += 1
233
- name = (
234
- label.split("[/bold]")[0].split("[bold]")[-1]
235
- if "[bold]" in label
236
- else "unknown"
237
- )
238
- stats.packaged_list.append(name)
239
- if is_optional:
240
- stats.optional_packaged += 1
241
-
242
- if hasattr(t, "children"):
243
- for child in t.children:
244
- walk(child)
245
-
246
- walk(tree)
247
- return stats
255
+ This is extracted as a standalone function so it can be submitted to
256
+ a :class:`~concurrent.futures.ThreadPoolExecutor`.
257
+ """
258
+ fedora_status = provider.check_fedora_packaging(dep.name)
259
+ return DevBuildDepStatus(
260
+ name=dep.name,
261
+ version_requirement=dep.version_requirement,
262
+ is_packaged=fedora_status.is_packaged,
263
+ fedora_versions=fedora_status.versions,
264
+ fedora_packages=fedora_status.package_names,
265
+ )
248
266
 
249
267
 
250
268
  @app.command(name="check")
@@ -306,6 +324,42 @@ def check(
306
324
  help="Report format: stdout, json, markdown. Use 'list-formats' for all options.",
307
325
  ),
308
326
  ] = "stdout",
327
+ missing_only: Annotated[
328
+ bool,
329
+ cyclopts.Parameter(
330
+ ("--missing-only", "-m"),
331
+ negative=(),
332
+ help="Only display packages that are missing from Fedora.",
333
+ ),
334
+ ] = False,
335
+ exclude: Annotated[
336
+ tuple[str, ...],
337
+ cyclopts.Parameter(
338
+ ("--exclude", "-e"),
339
+ help="Glob pattern(s) to exclude dependencies (e.g., '*-windows', 'win*'). Can be specified multiple times.",
340
+ ),
341
+ ] = (),
342
+ template: Annotated[
343
+ Optional[str],
344
+ cyclopts.Parameter(
345
+ ("--template", "-t"),
346
+ help="Path to a Jinja2 template file for custom report format. Only used with --report=template.",
347
+ ),
348
+ ] = None,
349
+ release: Annotated[
350
+ Optional[str],
351
+ cyclopts.Parameter(
352
+ ("--release", "-R"),
353
+ help="Fedora release version to check against (e.g., '41', '42', 'rawhide').",
354
+ ),
355
+ ] = None,
356
+ repos: Annotated[
357
+ tuple[str, ...],
358
+ cyclopts.Parameter(
359
+ ("--repos",),
360
+ help="Fedora repo(s) to query (e.g., 'fedora', 'updates', 'updates-testing'). Can be specified multiple times.",
361
+ ),
362
+ ] = (),
309
363
  ):
310
364
  """Check if a package's dependencies are available in Fedora.
311
365
 
@@ -327,6 +381,16 @@ def check(
327
381
  Enable verbose debug logging.
328
382
  report
329
383
  Output format for the report.
384
+ missing_only
385
+ Only display packages that are missing from Fedora.
386
+ exclude
387
+ Glob pattern(s) to exclude dependencies from the analysis.
388
+ template
389
+ Path to a Jinja2 template file for custom report format.
390
+ release
391
+ Fedora release version to check against (e.g., '41', 'rawhide').
392
+ repos
393
+ Fedora repo(s) to query (e.g., 'fedora', 'updates', 'updates-testing').
330
394
  """
331
395
  # Get the language provider
332
396
  provider = get_provider(lang)
@@ -336,12 +400,40 @@ def check(
336
400
  raise SystemExit(1)
337
401
 
338
402
  # Get the reporter
339
- reporter = get_reporter(report, console=console)
403
+ template_path = Path(template) if template else None
404
+
405
+ # Validate template reporter requirements
406
+ if report.lower() in ("template", "tpl", "jinja", "jinja2"):
407
+ if template_path is None:
408
+ console.print("[red]Template reporter requires --template parameter.[/red]")
409
+ console.print(
410
+ "Example: woolly check mypackage --report=template --template=my_template.md"
411
+ )
412
+ raise SystemExit(1)
413
+ if not template_path.exists():
414
+ console.print(f"[red]Template file not found: {template_path}[/red]")
415
+ raise SystemExit(1)
416
+ elif template_path is not None:
417
+ console.print(
418
+ "[yellow]Warning: --template is only used with --report=template[/yellow]"
419
+ )
420
+
421
+ reporter = get_reporter(report, console=console, template_path=template_path)
340
422
  if reporter is None:
341
423
  console.print(f"[red]Unknown report format: {report}[/red]")
342
424
  console.print(f"Available formats: {', '.join(get_available_formats())}")
343
425
  raise SystemExit(1)
344
426
 
427
+ # Convert exclude tuple to list for consistency
428
+ exclude_patterns = list(exclude) if exclude else None
429
+
430
+ # Configure Fedora release / repo targeting on the provider
431
+ fedora_repos_list = list(repos) if repos else None
432
+ if release:
433
+ provider.fedora_release = release
434
+ if fedora_repos_list:
435
+ provider.fedora_repos = fedora_repos_list
436
+
345
437
  # Initialize logging
346
438
  setup_logger(debug=debug)
347
439
  log(
@@ -352,30 +444,66 @@ def check(
352
444
  include_optional=optional,
353
445
  debug=debug,
354
446
  report_format=report,
447
+ exclude_patterns=exclude_patterns,
448
+ fedora_release=release,
449
+ fedora_repos=fedora_repos_list,
355
450
  )
356
451
 
357
- console.print(
358
- f"\n[bold underline]Analyzing {provider.display_name} package:[/] {package}"
359
- )
452
+ # ── Fetch root package info once (reused for license, version,
453
+ # features, and dev/build deps – avoids redundant calls) ──
454
+ root_info = provider.fetch_package_info(package)
455
+ root_license = root_info.license if root_info else None
456
+ resolved_version = version or (root_info.latest_version if root_info else None)
457
+
458
+ header = Text()
459
+ header.append(package, style="bold cyan")
460
+ header.append(f" ({provider.display_name})\n", style="dim")
461
+ header.append(f"Registry: {provider.registry_name}\n", style="dim")
462
+ header.append(f"Cache: {CACHE_DIR}", style="dim")
463
+ if release:
464
+ header.append("\n")
465
+ header.append(f"Release: {release}", style="dim")
466
+ if fedora_repos_list:
467
+ header.append("\n")
468
+ header.append(f"Repos: {', '.join(fedora_repos_list)}", style="dim")
360
469
  if optional:
361
- console.print("[yellow]Including optional dependencies[/yellow]")
362
- console.print(f"[dim]Registry: {provider.registry_name}[/dim]")
363
- console.print(f"[dim]Cache directory: {CACHE_DIR}[/dim]")
470
+ header.append("\n")
471
+ header.append("Including optional dependencies", style="yellow")
472
+ if exclude_patterns:
473
+ header.append("\n")
474
+ header.append(
475
+ f"Excluding dependencies matching: {', '.join(exclude_patterns)}",
476
+ style="yellow",
477
+ )
478
+
364
479
  console.print()
480
+ console.print(
481
+ Panel(
482
+ header,
483
+ title="[bold]Analyzing Dependencies[/bold]",
484
+ border_style="blue",
485
+ padding=(0, 1),
486
+ )
487
+ )
365
488
 
366
489
  tracker = None if no_progress else ProgressTracker(console)
367
490
 
368
491
  if tracker:
369
492
  tracker.start(f"Analyzing {provider.display_name} dependencies")
370
493
 
494
+ # Shared visited dict – build_tree populates it, then we derive stats.
495
+ visited: dict[str, tuple[bool, Optional[str], bool]] = {}
496
+
371
497
  try:
372
498
  tree = build_tree(
373
499
  provider,
374
500
  package,
375
501
  version,
502
+ visited=visited,
376
503
  max_depth=max_depth,
377
504
  tracker=tracker,
378
505
  include_optional=optional,
506
+ exclude_patterns=exclude_patterns,
379
507
  )
380
508
  if tracker:
381
509
  tracker.finish()
@@ -386,8 +514,49 @@ def check(
386
514
 
387
515
  console.print()
388
516
 
389
- # Collect statistics
390
- stats = collect_stats(tree)
517
+ # ── Collect statistics directly from the visited dict ──
518
+ stats = _compute_stats_from_visited(visited)
519
+
520
+ # Fetch features/extras for the root package
521
+ features: list[FeatureInfo] = []
522
+ if resolved_version:
523
+ features = provider.fetch_features(package, resolved_version)
524
+
525
+ # ── Fetch dev and build deps in one call, check Fedora in parallel ──
526
+ dev_deps_status: list[DevBuildDepStatus] = []
527
+ build_deps_status: list[DevBuildDepStatus] = []
528
+
529
+ if resolved_version:
530
+ # Single fetch_dependencies call partitioned by kind
531
+ _normal, dev_deps, build_deps = provider.get_all_dependencies(
532
+ package, resolved_version, include_optional=optional
533
+ )
534
+
535
+ all_devbuild: list[Dependency] = dev_deps + build_deps
536
+ if all_devbuild:
537
+ # Check Fedora status for dev/build deps in parallel
538
+ results: dict[str, DevBuildDepStatus] = {}
539
+ with ThreadPoolExecutor(max_workers=4) as executor:
540
+ future_to_dep = {
541
+ executor.submit(_check_fedora_for_dep, provider, dep): dep
542
+ for dep in all_devbuild
543
+ }
544
+ for future in as_completed(future_to_dep):
545
+ dep = future_to_dep[future]
546
+ results[dep.name] = future.result()
547
+
548
+ # Preserve original ordering
549
+ for dep in dev_deps:
550
+ dev_deps_status.append(results[dep.name])
551
+ for dep in build_deps:
552
+ build_deps_status.append(results[dep.name])
553
+
554
+ stats.dev_total = len(dev_deps_status)
555
+ stats.dev_packaged = sum(1 for d in dev_deps_status if d.is_packaged)
556
+ stats.dev_missing = sum(1 for d in dev_deps_status if not d.is_packaged)
557
+ stats.build_total = len(build_deps_status)
558
+ stats.build_packaged = sum(1 for d in build_deps_status if d.is_packaged)
559
+ stats.build_missing = sum(1 for d in build_deps_status if not d.is_packaged)
391
560
 
392
561
  # Create report data
393
562
  report_data = ReportData(
@@ -407,6 +576,19 @@ def check(
407
576
  optional_packaged=stats.optional_packaged,
408
577
  optional_missing=stats.optional_missing,
409
578
  optional_missing_packages=stats.optional_missing_list,
579
+ missing_only=missing_only,
580
+ root_license=root_license,
581
+ features=features,
582
+ dev_dependencies=[d.model_dump() for d in dev_deps_status],
583
+ build_dependencies=[d.model_dump() for d in build_deps_status],
584
+ dev_total=stats.dev_total,
585
+ dev_packaged=stats.dev_packaged,
586
+ dev_missing=stats.dev_missing,
587
+ build_total=stats.build_total,
588
+ build_packaged=stats.build_packaged,
589
+ build_missing=stats.build_missing,
590
+ fedora_release=release,
591
+ fedora_repos=fedora_repos_list,
410
592
  )
411
593
 
412
594
  # Generate report
woolly/http.py CHANGED
@@ -3,6 +3,8 @@ Shared HTTP client configuration for API requests.
3
3
 
4
4
  This module provides centralized HTTP configuration including
5
5
  the User-Agent header and common request settings.
6
+ Uses a lazily-initialized ``httpx.Client`` for connection pooling
7
+ and keep-alive across repeated requests to the same host.
6
8
  """
7
9
 
8
10
  import httpx
@@ -17,18 +19,35 @@ DEFAULT_HEADERS = {
17
19
  "User-Agent": f"woolly/{VERSION} ({PROJECT_URL})",
18
20
  }
19
21
 
22
+ # Lazily-initialized shared client for connection pooling
23
+ _client: httpx.Client | None = None
24
+
25
+
26
+ def _get_client() -> httpx.Client:
27
+ """Return the shared ``httpx.Client``, creating it on first use."""
28
+ global _client
29
+ if _client is None:
30
+ _client = httpx.Client(headers=DEFAULT_HEADERS)
31
+ return _client
32
+
20
33
 
21
34
  def get(url: str, **kwargs) -> httpx.Response:
22
35
  """
23
36
  Make a GET request with default headers.
24
37
 
38
+ Uses a shared ``httpx.Client`` so that TCP connections are
39
+ reused across requests to the same host.
40
+
25
41
  Args:
26
42
  url: The URL to request.
27
- **kwargs: Additional arguments passed to httpx.get().
43
+ **kwargs: Additional arguments passed to ``client.get()``.
28
44
 
29
45
  Returns:
30
46
  httpx.Response object.
31
47
  """
32
48
  headers = kwargs.pop("headers", {})
33
- merged_headers = {**DEFAULT_HEADERS, **headers}
34
- return httpx.get(url, headers=merged_headers, **kwargs)
49
+ client = _get_client()
50
+ if headers:
51
+ merged_headers = {**DEFAULT_HEADERS, **headers}
52
+ return client.get(url, headers=merged_headers, **kwargs)
53
+ return client.get(url, **kwargs)