dark-matter-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dark_matter_cli/__init__.py +35 -0
- dark_matter_cli/cli.py +295 -0
- dark_matter_cli/core.py +304 -0
- dark_matter_cli/display.py +131 -0
- dark_matter_cli/homebrew.py +320 -0
- dark_matter_cli/logger.py +21 -0
- dark_matter_cli/py.typed +0 -0
- dark_matter_cli-0.1.0.dist-info/METADATA +200 -0
- dark_matter_cli-0.1.0.dist-info/RECORD +12 -0
- dark_matter_cli-0.1.0.dist-info/WHEEL +4 -0
- dark_matter_cli-0.1.0.dist-info/entry_points.txt +2 -0
- dark_matter_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""A programmatic pipeline for recursive storage profiling."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import importlib.metadata
|
|
5
|
+
|
|
6
|
+
__version__ = "unknown"
|
|
7
|
+
with contextlib.suppress(importlib.metadata.PackageNotFoundError):
|
|
8
|
+
__version__ = importlib.metadata.version("dark-matter-cli")
|
|
9
|
+
|
|
10
|
+
from .core import (
|
|
11
|
+
build_analysis_dataframe,
|
|
12
|
+
build_compare_analysis_dataframe,
|
|
13
|
+
build_compare_theoretical_dataframe,
|
|
14
|
+
build_explain_analysis_dataframe,
|
|
15
|
+
build_explain_theoretical_dataframe,
|
|
16
|
+
build_targeted_analysis_dataframe,
|
|
17
|
+
build_targeted_theoretical_dataframe,
|
|
18
|
+
build_theoretical_dataframe,
|
|
19
|
+
)
|
|
20
|
+
from .homebrew import get_brew_metadata, get_brew_prefix, get_theoretical_catalog
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"__version__",
|
|
24
|
+
"build_analysis_dataframe",
|
|
25
|
+
"build_compare_analysis_dataframe",
|
|
26
|
+
"build_compare_theoretical_dataframe",
|
|
27
|
+
"build_explain_analysis_dataframe",
|
|
28
|
+
"build_explain_theoretical_dataframe",
|
|
29
|
+
"build_targeted_analysis_dataframe",
|
|
30
|
+
"build_targeted_theoretical_dataframe",
|
|
31
|
+
"build_theoretical_dataframe",
|
|
32
|
+
"get_brew_metadata",
|
|
33
|
+
"get_brew_prefix",
|
|
34
|
+
"get_theoretical_catalog",
|
|
35
|
+
]
|
dark_matter_cli/cli.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import sys
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
from dark_matter_cli import (
|
|
12
|
+
__version__,
|
|
13
|
+
build_analysis_dataframe,
|
|
14
|
+
build_compare_analysis_dataframe,
|
|
15
|
+
build_compare_theoretical_dataframe,
|
|
16
|
+
build_explain_analysis_dataframe,
|
|
17
|
+
build_explain_theoretical_dataframe,
|
|
18
|
+
build_targeted_analysis_dataframe,
|
|
19
|
+
build_targeted_theoretical_dataframe,
|
|
20
|
+
build_theoretical_dataframe,
|
|
21
|
+
display,
|
|
22
|
+
get_brew_metadata,
|
|
23
|
+
get_brew_prefix,
|
|
24
|
+
get_theoretical_catalog,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
app = typer.Typer(
|
|
28
|
+
help="Analyze dependency bloat and storage mass of macOS Homebrew installations.",
|
|
29
|
+
add_completion=False,
|
|
30
|
+
)
|
|
31
|
+
console = Console()
|
|
32
|
+
|
|
33
|
+
state = {"verbose": False}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ExportFormat(StrEnum):
|
|
37
|
+
"""Supported serialized output formats for the export command."""
|
|
38
|
+
|
|
39
|
+
csv = "csv"
|
|
40
|
+
json = "json"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ExportSource(StrEnum):
|
|
44
|
+
"""Supported theoretical or physical data sources for the export command."""
|
|
45
|
+
|
|
46
|
+
installed = "installed"
|
|
47
|
+
catalog = "catalog"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@contextlib.contextmanager
|
|
51
|
+
def handle_pipeline_errors(active_console: Console = console) -> Iterator[None]:
|
|
52
|
+
"""Centralize exception catching and CLI exit codes."""
|
|
53
|
+
try:
|
|
54
|
+
yield
|
|
55
|
+
except (RuntimeError, ValueError) as e:
|
|
56
|
+
active_console.print(f"[bold red]Pipeline Error:[/bold red] {e}")
|
|
57
|
+
raise typer.Exit(code=1) from e
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def load_ecosystem_context(
|
|
61
|
+
source: ExportSource, active_console: Console, target_msg: str | None = None
|
|
62
|
+
) -> tuple[Path, dict[str, Any], bool]:
|
|
63
|
+
"""Resolve the prefix and ingest the metadata payload while managing UI state."""
|
|
64
|
+
prefix = get_brew_prefix()
|
|
65
|
+
is_theoretical = source == ExportSource.catalog
|
|
66
|
+
|
|
67
|
+
if not is_theoretical:
|
|
68
|
+
msg = target_msg or "[yellow]Scanning physical disk footprint...[/yellow]"
|
|
69
|
+
with active_console.status(msg):
|
|
70
|
+
metadata = get_brew_metadata()
|
|
71
|
+
else:
|
|
72
|
+
msg = target_msg or "[yellow]Computing theoretical ecosystem DAG...[/yellow]"
|
|
73
|
+
with active_console.status(msg):
|
|
74
|
+
metadata = get_theoretical_catalog(prefix)
|
|
75
|
+
|
|
76
|
+
return prefix, metadata, is_theoretical
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def version_callback(value: bool) -> None:
|
|
80
|
+
"""Print the version and exit eagerly."""
|
|
81
|
+
if value:
|
|
82
|
+
console.print(f"dark-matter version [bold cyan]{__version__}[/bold cyan]")
|
|
83
|
+
raise typer.Exit()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@app.callback()
|
|
87
|
+
def main(
|
|
88
|
+
verbose: bool = typer.Option(
|
|
89
|
+
False, "--verbose", "-v", help="Enable verbose logging."
|
|
90
|
+
),
|
|
91
|
+
version: bool = typer.Option(
|
|
92
|
+
None,
|
|
93
|
+
"--version",
|
|
94
|
+
help="Show the application version and exit.",
|
|
95
|
+
callback=version_callback,
|
|
96
|
+
is_eager=True,
|
|
97
|
+
),
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Configure global CLI state."""
|
|
100
|
+
state["verbose"] = verbose
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@app.command()
|
|
104
|
+
def analyze(
|
|
105
|
+
sort_by: str = typer.Option("ratio", "--sort", "-s", help="Sorting metric."),
|
|
106
|
+
top_n: int = typer.Option(20, "--top", "-n", help="Number of packages to display."),
|
|
107
|
+
fractional: bool = typer.Option(True, "--fractional/--standard"),
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Execute the storage bloat analysis pipeline."""
|
|
110
|
+
console.print("[bold cyan]Initializing Dark Matter pipeline...[/bold cyan]")
|
|
111
|
+
|
|
112
|
+
with handle_pipeline_errors():
|
|
113
|
+
prefix, metadata, _ = load_ecosystem_context(ExportSource.installed, console)
|
|
114
|
+
|
|
115
|
+
formulae_count = len(metadata.get("formulae", []))
|
|
116
|
+
casks_count = len(metadata.get("casks", []))
|
|
117
|
+
console.print(
|
|
118
|
+
f"Successfully parsed [bold]{formulae_count}[/bold] formulae and [bold]{casks_count}[/bold] casks."
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
with console.status("[yellow]Computing fractional attribution DAG...[/yellow]"):
|
|
122
|
+
df = build_analysis_dataframe(metadata, prefix)
|
|
123
|
+
|
|
124
|
+
display.render_bloat_table(
|
|
125
|
+
df, sort_by=sort_by, top_n=top_n, fractional=fractional
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@app.command()
|
|
130
|
+
def leaderboard(
|
|
131
|
+
sort_by: str = typer.Option("ratio", "--sort", "-s", help="Sorting metric."),
|
|
132
|
+
top_n: int = typer.Option(20, "--top", "-n", help="Number of packages to display."),
|
|
133
|
+
arch: str = typer.Option(
|
|
134
|
+
"arm64_tahoe", "--arch", "-a", help="Target architecture."
|
|
135
|
+
),
|
|
136
|
+
) -> None:
|
|
137
|
+
"""Evaluate the complete theoretical ecosystem leaderboard."""
|
|
138
|
+
console.print("[bold cyan]Accessing local ecosystem cache...[/bold cyan]")
|
|
139
|
+
|
|
140
|
+
with handle_pipeline_errors():
|
|
141
|
+
_, metadata, _ = load_ecosystem_context(ExportSource.catalog, console)
|
|
142
|
+
|
|
143
|
+
with console.status("[yellow]Processing massive theoretical DAG...[/yellow]"):
|
|
144
|
+
df = build_theoretical_dataframe(metadata, arch=arch)
|
|
145
|
+
|
|
146
|
+
display.render_bloat_table(
|
|
147
|
+
df, sort_by=sort_by, top_n=top_n, fractional=True, is_theoretical=True
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@app.command()
|
|
152
|
+
def inspect(
|
|
153
|
+
package: str = typer.Argument(..., help="The specific package to analyze."),
|
|
154
|
+
source: ExportSource = typer.Option(ExportSource.installed, "--source", "-s"),
|
|
155
|
+
arch: str = typer.Option("arm64_tahoe", "--arch", "-a"),
|
|
156
|
+
) -> None:
|
|
157
|
+
"""Evaluate the bloat of a single target package."""
|
|
158
|
+
console.print(f"[bold cyan]Inspecting bloat for '{package}'...[/bold cyan]")
|
|
159
|
+
|
|
160
|
+
with handle_pipeline_errors():
|
|
161
|
+
prefix, metadata, is_theoretical = load_ecosystem_context(
|
|
162
|
+
source,
|
|
163
|
+
console,
|
|
164
|
+
target_msg=f"[yellow]Resolving data for {package}...[/yellow]",
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
if is_theoretical:
|
|
168
|
+
df = build_targeted_theoretical_dataframe(
|
|
169
|
+
metadata, target=package, arch=arch
|
|
170
|
+
)
|
|
171
|
+
else:
|
|
172
|
+
df = build_targeted_analysis_dataframe(metadata, prefix, target=package)
|
|
173
|
+
|
|
174
|
+
display.render_bloat_table(
|
|
175
|
+
df, sort_by="ratio", top_n=1, fractional=True, is_theoretical=is_theoretical
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@app.command()
|
|
180
|
+
def compare(
|
|
181
|
+
packages: list[str] = typer.Argument(..., help="The specific packages to compare."),
|
|
182
|
+
sort_by: str = typer.Option("ratio", "--sort", "-s"),
|
|
183
|
+
source: ExportSource = typer.Option(ExportSource.installed, "--source", "-s"),
|
|
184
|
+
arch: str = typer.Option("arm64_tahoe", "--arch", "-a"),
|
|
185
|
+
) -> None:
|
|
186
|
+
"""Evaluate and compare the bloat of multiple packages."""
|
|
187
|
+
pkg_list_str = ", ".join(packages)
|
|
188
|
+
console.print(f"[bold cyan]Comparing bloat for: {pkg_list_str}[/bold cyan]")
|
|
189
|
+
|
|
190
|
+
with handle_pipeline_errors():
|
|
191
|
+
prefix, metadata, is_theoretical = load_ecosystem_context(
|
|
192
|
+
source, console, target_msg="[yellow]Resolving data for targets...[/yellow]"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
if is_theoretical:
|
|
196
|
+
df = build_compare_theoretical_dataframe(
|
|
197
|
+
metadata, targets=packages, arch=arch
|
|
198
|
+
)
|
|
199
|
+
else:
|
|
200
|
+
df = build_compare_analysis_dataframe(metadata, prefix, targets=packages)
|
|
201
|
+
|
|
202
|
+
display.render_bloat_table(
|
|
203
|
+
df,
|
|
204
|
+
sort_by=sort_by,
|
|
205
|
+
top_n=len(packages),
|
|
206
|
+
fractional=True,
|
|
207
|
+
is_theoretical=is_theoretical,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@app.command()
|
|
212
|
+
def export(
|
|
213
|
+
source: ExportSource = typer.Option(ExportSource.installed, "--source", "-s"),
|
|
214
|
+
export_format: ExportFormat = typer.Option(ExportFormat.csv, "--format", "-f"),
|
|
215
|
+
arch: str = typer.Option("arm64_tahoe", "--arch", "-a"),
|
|
216
|
+
) -> None:
|
|
217
|
+
"""Export the computed bloat analysis dataframe for external pipelines."""
|
|
218
|
+
err_console = Console(stderr=True)
|
|
219
|
+
|
|
220
|
+
with handle_pipeline_errors(err_console):
|
|
221
|
+
prefix, metadata, is_theoretical = load_ecosystem_context(source, err_console)
|
|
222
|
+
|
|
223
|
+
with err_console.status(
|
|
224
|
+
"[yellow]Computing fractional attribution DAG...[/yellow]"
|
|
225
|
+
):
|
|
226
|
+
if is_theoretical:
|
|
227
|
+
df = build_theoretical_dataframe(metadata, arch=arch)
|
|
228
|
+
else:
|
|
229
|
+
df = build_analysis_dataframe(metadata, prefix)
|
|
230
|
+
|
|
231
|
+
if export_format == ExportFormat.csv:
|
|
232
|
+
sys.stdout.write(df.to_csv(index=False))
|
|
233
|
+
else:
|
|
234
|
+
sys.stdout.write(df.to_json(orient="records", indent=2))
|
|
235
|
+
sys.stdout.write("\n")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@app.command()
|
|
239
|
+
def explain(
|
|
240
|
+
package: str = typer.Argument(..., help="The specific package to analyze."),
|
|
241
|
+
source: ExportSource = typer.Option(ExportSource.installed, "--source", "-s"),
|
|
242
|
+
arch: str = typer.Option("arm64_tahoe", "--arch", "-a"),
|
|
243
|
+
) -> None:
|
|
244
|
+
"""Break down the bloat of a package by its dependencies."""
|
|
245
|
+
console.print(
|
|
246
|
+
f"[bold cyan]Explaining fractional dependencies for '{package}'...[/bold cyan]"
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
with handle_pipeline_errors():
|
|
250
|
+
prefix, metadata, is_theoretical = load_ecosystem_context(
|
|
251
|
+
source,
|
|
252
|
+
console,
|
|
253
|
+
target_msg=f"[yellow]Resolving data for {package}...[/yellow]",
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
if is_theoretical:
|
|
258
|
+
df = build_explain_theoretical_dataframe(
|
|
259
|
+
metadata, target=package, arch=arch
|
|
260
|
+
)
|
|
261
|
+
else:
|
|
262
|
+
df = build_explain_analysis_dataframe(metadata, prefix, target=package)
|
|
263
|
+
|
|
264
|
+
except ValueError as e:
|
|
265
|
+
# Intercept the specific missing package error for physical installations
|
|
266
|
+
if not is_theoretical and "not found in the local installation" in str(e):
|
|
267
|
+
# Suspend the pipeline and prompt for state pivot
|
|
268
|
+
fallback = typer.confirm(
|
|
269
|
+
f"\nPackage '{package}' is not installed. Fall back to the theoretical catalog?",
|
|
270
|
+
default=True,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
if not fallback:
|
|
274
|
+
raise typer.Exit(code=1) from None
|
|
275
|
+
|
|
276
|
+
# Mutate state to theoretical and fetch the full catalog metadata
|
|
277
|
+
is_theoretical = True
|
|
278
|
+
_, metadata, _ = load_ecosystem_context(
|
|
279
|
+
ExportSource.catalog,
|
|
280
|
+
console,
|
|
281
|
+
target_msg="[yellow]Loading theoretical catalog...[/yellow]",
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
df = build_explain_theoretical_dataframe(
|
|
285
|
+
metadata, target=package, arch=arch
|
|
286
|
+
)
|
|
287
|
+
else:
|
|
288
|
+
# Bubble up unrelated ValueErrors to the context manager
|
|
289
|
+
raise
|
|
290
|
+
|
|
291
|
+
display.render_explain_table(df, target=package, is_theoretical=is_theoretical)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
if __name__ == "__main__":
|
|
295
|
+
app()
|
dark_matter_cli/core.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import pandas as pd
|
|
5
|
+
|
|
6
|
+
from dark_matter_cli import homebrew
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _build_global_topology(
|
|
10
|
+
metadata: dict[str, Any],
|
|
11
|
+
) -> tuple[dict[str, list[str]], dict[str, set[str]], dict[str, int], dict[str, Any]]:
|
|
12
|
+
"""Parse metadata and pre-compute the immutable DAG properties."""
|
|
13
|
+
packages = metadata.get("formulae", []) + metadata.get("casks", [])
|
|
14
|
+
|
|
15
|
+
dependency_graph = {}
|
|
16
|
+
pkg_data_map = {}
|
|
17
|
+
|
|
18
|
+
for pkg in packages:
|
|
19
|
+
name = pkg["name"][0] if isinstance(pkg["name"], list) else pkg["name"]
|
|
20
|
+
dependency_graph[name] = pkg.get("dependencies", [])
|
|
21
|
+
pkg_data_map[name] = pkg
|
|
22
|
+
|
|
23
|
+
package_transitive_deps = {
|
|
24
|
+
pkg: get_all_dependencies(pkg, dependency_graph) for pkg in dependency_graph
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
# Initialize with 0 for known packages, but handle missing edge nodes dynamically
|
|
28
|
+
dependency_parent_count = dict.fromkeys(dependency_graph, 0)
|
|
29
|
+
for deps in package_transitive_deps.values():
|
|
30
|
+
for dep in deps:
|
|
31
|
+
dependency_parent_count[dep] = dependency_parent_count.get(dep, 0) + 1
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
dependency_graph,
|
|
35
|
+
package_transitive_deps,
|
|
36
|
+
dependency_parent_count,
|
|
37
|
+
pkg_data_map,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _resolve_physical_sizes(
|
|
42
|
+
dependency_graph: dict[str, list[str]], prefix: Path
|
|
43
|
+
) -> dict[str, int]:
|
|
44
|
+
"""Calculate disk footprint for installed packages."""
|
|
45
|
+
size_map = {}
|
|
46
|
+
for name in dependency_graph:
|
|
47
|
+
cellar_path = prefix / "Cellar" / name
|
|
48
|
+
cask_path = prefix / "Caskroom" / name
|
|
49
|
+
|
|
50
|
+
if cellar_path.exists():
|
|
51
|
+
size_map[name] = homebrew.get_directory_size(cellar_path)
|
|
52
|
+
elif cask_path.exists():
|
|
53
|
+
size_map[name] = homebrew.get_directory_size(cask_path)
|
|
54
|
+
else:
|
|
55
|
+
size_map[name] = 0
|
|
56
|
+
return size_map
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _resolve_theoretical_sizes(
|
|
60
|
+
resolution_set: set[str], pkg_data_map: dict[str, Any], arch: str
|
|
61
|
+
) -> dict[str, int]:
|
|
62
|
+
"""Resolve compressed bottle sizes via ghcr.io for a specific subset of packages."""
|
|
63
|
+
package_digest = {}
|
|
64
|
+
bottles = {}
|
|
65
|
+
|
|
66
|
+
for name in resolution_set:
|
|
67
|
+
pkg = pkg_data_map.get(name, {})
|
|
68
|
+
files = pkg.get("bottle", {}).get("stable", {}).get("files", {})
|
|
69
|
+
file_info = files.get(arch) or next(iter(files.values()), None)
|
|
70
|
+
|
|
71
|
+
if file_info is not None:
|
|
72
|
+
digest = file_info.get("sha256", "")
|
|
73
|
+
url = file_info.get("url", "")
|
|
74
|
+
if digest and url:
|
|
75
|
+
package_digest[name] = digest
|
|
76
|
+
bottles[digest] = url
|
|
77
|
+
|
|
78
|
+
cache = homebrew.load_bottle_size_cache()
|
|
79
|
+
resolved_sizes = homebrew.resolve_bottle_sizes(bottles, cache)
|
|
80
|
+
homebrew.save_bottle_size_cache(cache)
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
name: resolved_sizes.get(digest, 0) for name, digest in package_digest.items()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _compute_bloat_metrics(
|
|
88
|
+
targets: list[str],
|
|
89
|
+
size_map: dict[str, int],
|
|
90
|
+
package_transitive_deps: dict[str, set[str]],
|
|
91
|
+
dependency_parent_count: dict[str, int],
|
|
92
|
+
) -> pd.DataFrame:
|
|
93
|
+
"""Execute the fractional attribution math and compile the DataFrame."""
|
|
94
|
+
results = []
|
|
95
|
+
for target in targets:
|
|
96
|
+
core_size = size_map.get(target, 0)
|
|
97
|
+
target_deps = package_transitive_deps.get(target, set())
|
|
98
|
+
|
|
99
|
+
standard_recursive_size = core_size + sum(
|
|
100
|
+
size_map.get(d, 0) for d in target_deps
|
|
101
|
+
)
|
|
102
|
+
fractional_dep_size = sum(
|
|
103
|
+
size_map.get(d, 0) / dependency_parent_count.get(d, 1) for d in target_deps
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
weighted_recursive_size = core_size + fractional_dep_size
|
|
107
|
+
ratio = (weighted_recursive_size / core_size) if core_size > 0 else 1.0
|
|
108
|
+
|
|
109
|
+
results.append(
|
|
110
|
+
{
|
|
111
|
+
"Package": target,
|
|
112
|
+
"Core_Bytes": core_size,
|
|
113
|
+
"Standard_Bytes": standard_recursive_size,
|
|
114
|
+
"Weighted_Bytes": weighted_recursive_size,
|
|
115
|
+
"Bloat_Ratio": ratio,
|
|
116
|
+
"Dep_Count": len(target_deps),
|
|
117
|
+
"Is_Leaf": dependency_parent_count.get(target, 0) == 0
|
|
118
|
+
if len(targets) > 1
|
|
119
|
+
else True,
|
|
120
|
+
}
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
return pd.DataFrame(results)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _compute_explain_metrics(
|
|
127
|
+
target: str,
|
|
128
|
+
size_map: dict[str, int],
|
|
129
|
+
package_transitive_deps: dict[str, set[str]],
|
|
130
|
+
dependency_parent_count: dict[str, int],
|
|
131
|
+
is_theoretical: bool,
|
|
132
|
+
) -> pd.DataFrame:
|
|
133
|
+
"""Compile the fractional dependency cost breakdown for a target package."""
|
|
134
|
+
target_deps = package_transitive_deps.get(target, set())
|
|
135
|
+
results = []
|
|
136
|
+
|
|
137
|
+
for dep in target_deps:
|
|
138
|
+
dep_size = size_map.get(dep, 0)
|
|
139
|
+
parents = dependency_parent_count.get(dep, 1)
|
|
140
|
+
attributed_size = dep_size / parents if parents > 0 else float(dep_size)
|
|
141
|
+
|
|
142
|
+
row = {
|
|
143
|
+
"Dependency": dep,
|
|
144
|
+
"Shared_By": parents,
|
|
145
|
+
"Attributed_Bytes": attributed_size,
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if is_theoretical:
|
|
149
|
+
row["Archive_Bytes"] = dep_size
|
|
150
|
+
else:
|
|
151
|
+
row["Core_Bytes"] = dep_size
|
|
152
|
+
|
|
153
|
+
results.append(row)
|
|
154
|
+
|
|
155
|
+
df = pd.DataFrame(results)
|
|
156
|
+
if not df.empty:
|
|
157
|
+
df = df.sort_values(by="Attributed_Bytes", ascending=False)
|
|
158
|
+
|
|
159
|
+
return df
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def get_all_dependencies(
|
|
163
|
+
pkg: str, graph: dict[str, list[str]], visited: set[str] | None = None
|
|
164
|
+
) -> set[str]:
|
|
165
|
+
"""Recursively traverse the DAG to extract all transitive dependencies.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
pkg: The target package identifier.
|
|
169
|
+
graph: The complete directed acyclic graph of explicitly declared dependencies.
|
|
170
|
+
visited: The mathematical set of dependencies already encountered.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
set[str]: A set containing all deeply nested transitive dependencies.
|
|
174
|
+
"""
|
|
175
|
+
if visited is None:
|
|
176
|
+
visited = set()
|
|
177
|
+
|
|
178
|
+
if pkg not in graph:
|
|
179
|
+
return visited
|
|
180
|
+
|
|
181
|
+
for dep in graph[pkg]:
|
|
182
|
+
if dep not in visited:
|
|
183
|
+
visited.add(dep)
|
|
184
|
+
get_all_dependencies(dep, graph, visited)
|
|
185
|
+
|
|
186
|
+
return visited
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def build_analysis_dataframe(metadata: dict[str, Any], prefix: Path) -> pd.DataFrame:
|
|
190
|
+
"""Construct the primary DataFrame utilizing physical disk metrics."""
|
|
191
|
+
graph, trans_deps, in_degrees, _ = _build_global_topology(metadata)
|
|
192
|
+
size_map = _resolve_physical_sizes(graph, prefix)
|
|
193
|
+
targets = list(graph.keys())
|
|
194
|
+
|
|
195
|
+
return _compute_bloat_metrics(targets, size_map, trans_deps, in_degrees)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def build_targeted_analysis_dataframe(
|
|
199
|
+
metadata: dict[str, Any], prefix: Path, target: str
|
|
200
|
+
) -> pd.DataFrame:
|
|
201
|
+
"""Constructs a DataFrame for a single installed target."""
|
|
202
|
+
graph, trans_deps, in_degrees, _ = _build_global_topology(metadata)
|
|
203
|
+
|
|
204
|
+
if target not in graph:
|
|
205
|
+
raise ValueError(f"Package '{target}' not found in the local installation.")
|
|
206
|
+
|
|
207
|
+
size_map = _resolve_physical_sizes(graph, prefix)
|
|
208
|
+
return _compute_bloat_metrics([target], size_map, trans_deps, in_degrees)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def build_compare_analysis_dataframe(
|
|
212
|
+
metadata: dict[str, Any], prefix: Path, targets: list[str]
|
|
213
|
+
) -> pd.DataFrame:
|
|
214
|
+
"""Constructs a DataFrame for comparing multiple installed targets."""
|
|
215
|
+
graph, trans_deps, in_degrees, _ = _build_global_topology(metadata)
|
|
216
|
+
|
|
217
|
+
valid_targets = [t for t in targets if t in graph]
|
|
218
|
+
if not valid_targets:
|
|
219
|
+
raise ValueError(
|
|
220
|
+
"None of the specified packages were found in the local installation."
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
size_map = _resolve_physical_sizes(graph, prefix)
|
|
224
|
+
return _compute_bloat_metrics(valid_targets, size_map, trans_deps, in_degrees)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def build_explain_analysis_dataframe(
|
|
228
|
+
metadata: dict[str, Any], prefix: Path, target: str
|
|
229
|
+
) -> pd.DataFrame:
|
|
230
|
+
"""Constructs a dependency breakdown DataFrame for a physical target."""
|
|
231
|
+
graph, trans_deps, in_degrees, _ = _build_global_topology(metadata)
|
|
232
|
+
|
|
233
|
+
if target not in graph:
|
|
234
|
+
raise ValueError(f"Package '{target}' not found in the local installation.")
|
|
235
|
+
|
|
236
|
+
size_map = _resolve_physical_sizes(graph, prefix)
|
|
237
|
+
return _compute_explain_metrics(
|
|
238
|
+
target, size_map, trans_deps, in_degrees, is_theoretical=False
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def build_theoretical_dataframe(
|
|
243
|
+
metadata: dict[str, Any], arch: str = "arm64_tahoe"
|
|
244
|
+
) -> pd.DataFrame:
|
|
245
|
+
"""Constructs the ecosystem-wide theoretical leaderboard."""
|
|
246
|
+
graph, trans_deps, in_degrees, pkg_map = _build_global_topology(metadata)
|
|
247
|
+
|
|
248
|
+
resolution_set = set(graph.keys())
|
|
249
|
+
size_map = _resolve_theoretical_sizes(resolution_set, pkg_map, arch)
|
|
250
|
+
targets = list(graph.keys())
|
|
251
|
+
|
|
252
|
+
return _compute_bloat_metrics(targets, size_map, trans_deps, in_degrees)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def build_targeted_theoretical_dataframe(
|
|
256
|
+
metadata: dict[str, Any], target: str, arch: str = "arm64_tahoe"
|
|
257
|
+
) -> pd.DataFrame:
|
|
258
|
+
"""Constructs a DataFrame for a single target, minimizing network resolution."""
|
|
259
|
+
graph, trans_deps, in_degrees, pkg_map = _build_global_topology(metadata)
|
|
260
|
+
|
|
261
|
+
if target not in graph:
|
|
262
|
+
raise ValueError(f"Package '{target}' not found in the catalog.")
|
|
263
|
+
|
|
264
|
+
# Isolate strictly to the target's closure to prevent O(N) network lookups
|
|
265
|
+
resolution_set = {target} | trans_deps[target]
|
|
266
|
+
size_map = _resolve_theoretical_sizes(resolution_set, pkg_map, arch)
|
|
267
|
+
|
|
268
|
+
return _compute_bloat_metrics([target], size_map, trans_deps, in_degrees)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def build_compare_theoretical_dataframe(
|
|
272
|
+
metadata: dict[str, Any], targets: list[str], arch: str = "arm64_tahoe"
|
|
273
|
+
) -> pd.DataFrame:
|
|
274
|
+
"""Constructs a DataFrame for multiple targets by resolving their unioned closure."""
|
|
275
|
+
graph, trans_deps, in_degrees, pkg_map = _build_global_topology(metadata)
|
|
276
|
+
|
|
277
|
+
valid_targets = [t for t in targets if t in graph]
|
|
278
|
+
if not valid_targets:
|
|
279
|
+
raise ValueError("None of the specified packages were found in the catalog.")
|
|
280
|
+
|
|
281
|
+
# Generate the union of all target transitive dependencies
|
|
282
|
+
resolution_set = set(valid_targets)
|
|
283
|
+
for t in valid_targets:
|
|
284
|
+
resolution_set.update(trans_deps[t])
|
|
285
|
+
|
|
286
|
+
size_map = _resolve_theoretical_sizes(resolution_set, pkg_map, arch)
|
|
287
|
+
return _compute_bloat_metrics(valid_targets, size_map, trans_deps, in_degrees)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def build_explain_theoretical_dataframe(
|
|
291
|
+
metadata: dict[str, Any], target: str, arch: str = "arm64_tahoe"
|
|
292
|
+
) -> pd.DataFrame:
|
|
293
|
+
"""Constructs a dependency breakdown DataFrame using theoretical metrics."""
|
|
294
|
+
graph, trans_deps, in_degrees, pkg_map = _build_global_topology(metadata)
|
|
295
|
+
|
|
296
|
+
if target not in graph:
|
|
297
|
+
raise ValueError(f"Package '{target}' not found in the catalog.")
|
|
298
|
+
|
|
299
|
+
resolution_set = {target} | trans_deps[target]
|
|
300
|
+
size_map = _resolve_theoretical_sizes(resolution_set, pkg_map, arch)
|
|
301
|
+
|
|
302
|
+
return _compute_explain_metrics(
|
|
303
|
+
target, size_map, trans_deps, in_degrees, is_theoretical=True
|
|
304
|
+
)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
from rich.table import Table
|
|
4
|
+
|
|
5
|
+
console = Console()
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def format_bytes(size: float) -> str:
|
|
9
|
+
"""Dynamically format byte sizes into human-readable strings."""
|
|
10
|
+
if size < 1024:
|
|
11
|
+
return f"{size:.0f} B"
|
|
12
|
+
if size < 1024**2:
|
|
13
|
+
return f"{size / 1024:.1f} KB"
|
|
14
|
+
if size < 999 * (1024**2):
|
|
15
|
+
return f"{size / (1024**2):.1f} MB"
|
|
16
|
+
return f"{size / (1024**3):.2f} GB"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def render_bloat_table(
|
|
20
|
+
df: pd.DataFrame,
|
|
21
|
+
sort_by: str = "ratio",
|
|
22
|
+
top_n: int = 20,
|
|
23
|
+
fractional: bool = True,
|
|
24
|
+
is_theoretical: bool = False,
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Render the mathematical analysis as a formatted terminal table.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
df: The fully processed pandas DataFrame containing bloat metrics.
|
|
30
|
+
sort_by: The column criteria utilized for descending sort operations.
|
|
31
|
+
top_n: The maximum number of rows to print to standard output.
|
|
32
|
+
fractional: Boolean flag to display the Fractional Attribution Model data.
|
|
33
|
+
is_theoretical: Boolean flag to indicate if the data is from theoretical analysis.
|
|
34
|
+
"""
|
|
35
|
+
if df.empty or "Is_Leaf" not in df.columns:
|
|
36
|
+
console.print("[bold red]No package data available to render.[/bold red]")
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
# Filter explicitly for root invocations (leaves)
|
|
40
|
+
df = df[df["Is_Leaf"]]
|
|
41
|
+
|
|
42
|
+
# Map the CLI flag to the corresponding DataFrame column
|
|
43
|
+
sort_map = {
|
|
44
|
+
"ratio": "Bloat_Ratio",
|
|
45
|
+
"core": "Core_Bytes",
|
|
46
|
+
"recursive": "Weighted_Bytes" if fractional else "Standard_Bytes",
|
|
47
|
+
}
|
|
48
|
+
sort_col = sort_map.get(sort_by, "Bloat_Ratio")
|
|
49
|
+
|
|
50
|
+
df = df.sort_values(by=sort_col, ascending=False).head(top_n)
|
|
51
|
+
|
|
52
|
+
table = Table(
|
|
53
|
+
title="[bold]Dark Matter: Homebrew Bloat Analysis[/bold]",
|
|
54
|
+
show_header=True,
|
|
55
|
+
header_style="bold cyan",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
table.add_column("Package", style="white", no_wrap=True)
|
|
59
|
+
|
|
60
|
+
# Adjust headers based on the measurement type
|
|
61
|
+
core_label = "Archive Size" if is_theoretical else "Core Size"
|
|
62
|
+
table.add_column(core_label, justify="right", style="dim")
|
|
63
|
+
|
|
64
|
+
if fractional:
|
|
65
|
+
rec_label = "Theoretical Rec. Size" if is_theoretical else "Weighted Rec. Size"
|
|
66
|
+
table.add_column(rec_label, justify="right", style="magenta")
|
|
67
|
+
else:
|
|
68
|
+
rec_label = "Theoretical Std. Size" if is_theoretical else "Standard Rec. Size"
|
|
69
|
+
table.add_column(rec_label, justify="right", style="magenta")
|
|
70
|
+
|
|
71
|
+
table.add_column("Bloat Ratio", justify="right")
|
|
72
|
+
table.add_column("Deps", justify="right", style="dim")
|
|
73
|
+
|
|
74
|
+
for _, row in df.iterrows():
|
|
75
|
+
# Pass raw byte values to the dynamic formatter
|
|
76
|
+
core_bytes = row["Core_Bytes"]
|
|
77
|
+
rec_bytes = row["Weighted_Bytes"] if fractional else row["Standard_Bytes"]
|
|
78
|
+
|
|
79
|
+
ratio = row["Bloat_Ratio"]
|
|
80
|
+
deps = int(row["Dep_Count"])
|
|
81
|
+
|
|
82
|
+
# Color code the bloat severity
|
|
83
|
+
if ratio >= 10.0:
|
|
84
|
+
ratio_str = f"[bold red]{ratio:.1f}x[/bold red]"
|
|
85
|
+
elif ratio >= 3.0:
|
|
86
|
+
ratio_str = f"[bold yellow]{ratio:.1f}x[/bold yellow]"
|
|
87
|
+
else:
|
|
88
|
+
ratio_str = f"[green]{ratio:.1f}x[/green]"
|
|
89
|
+
|
|
90
|
+
table.add_row(
|
|
91
|
+
str(row["Package"]),
|
|
92
|
+
format_bytes(core_bytes),
|
|
93
|
+
format_bytes(rec_bytes),
|
|
94
|
+
ratio_str,
|
|
95
|
+
str(deps),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
console.print(table)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def render_explain_table(
|
|
102
|
+
df: pd.DataFrame, target: str, is_theoretical: bool = False
|
|
103
|
+
) -> None:
|
|
104
|
+
"""Render the dependency breakdown table for a specific package."""
|
|
105
|
+
if df.empty:
|
|
106
|
+
console.print(f"[green]'{target}' has no transitive dependencies.[/green]")
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
table = Table(
|
|
110
|
+
title=f"[bold]Dependency Breakdown: {target}[/bold]",
|
|
111
|
+
show_header=True,
|
|
112
|
+
header_style="bold cyan",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
size_col = "Archive_Bytes" if is_theoretical else "Core_Bytes"
|
|
116
|
+
size_label = "Archive Size" if is_theoretical else "Core Size"
|
|
117
|
+
|
|
118
|
+
table.add_column("Dependency", style="white", no_wrap=True)
|
|
119
|
+
table.add_column(size_label, justify="right", style="dim")
|
|
120
|
+
table.add_column("Shared By", justify="right", style="dim")
|
|
121
|
+
table.add_column("Attributed Size", justify="right", style="magenta")
|
|
122
|
+
|
|
123
|
+
for _, row in df.iterrows():
|
|
124
|
+
table.add_row(
|
|
125
|
+
str(row["Dependency"]),
|
|
126
|
+
format_bytes(row[size_col]),
|
|
127
|
+
str(int(row["Shared_By"])),
|
|
128
|
+
format_bytes(row["Attributed_Bytes"]),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
console.print(table)
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import subprocess
|
|
6
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("dark_matter_cli")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_brew_prefix() -> Path:
|
|
16
|
+
"""Retrieve the local Homebrew installation prefix.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
Path: The absolute path to the Homebrew prefix (e.g., /opt/homebrew).
|
|
20
|
+
|
|
21
|
+
Raises:
|
|
22
|
+
RuntimeError: If the brew prefix command fails.
|
|
23
|
+
"""
|
|
24
|
+
try:
|
|
25
|
+
result = subprocess.run(
|
|
26
|
+
["brew", "--prefix"],
|
|
27
|
+
capture_output=True,
|
|
28
|
+
text=True,
|
|
29
|
+
check=True,
|
|
30
|
+
)
|
|
31
|
+
return Path(result.stdout.strip())
|
|
32
|
+
except subprocess.CalledProcessError as e:
|
|
33
|
+
raise RuntimeError(f"Failed to resolve Homebrew prefix: {e.stderr}") from e
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_brew_metadata() -> dict[str, Any]:
|
|
37
|
+
"""Execute the brew info API to extract installed package metadata.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
dict[str, Any]: The parsed JSON payload containing formulae and casks.
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
RuntimeError: If the brew info command fails or returns invalid JSON.
|
|
44
|
+
"""
|
|
45
|
+
try:
|
|
46
|
+
result = subprocess.run(
|
|
47
|
+
["brew", "info", "--json=v2", "--installed"],
|
|
48
|
+
capture_output=True,
|
|
49
|
+
text=True,
|
|
50
|
+
check=True,
|
|
51
|
+
)
|
|
52
|
+
data: dict[str, Any] = json.loads(result.stdout)
|
|
53
|
+
return data
|
|
54
|
+
except subprocess.CalledProcessError as e:
|
|
55
|
+
raise RuntimeError(f"Failed to execute brew API: {e.stderr}") from e
|
|
56
|
+
except json.JSONDecodeError as e:
|
|
57
|
+
raise RuntimeError("Failed to parse Homebrew JSON output.") from e
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_directory_size(path: Path) -> int:
|
|
61
|
+
"""Recursively calculate the aggregate physical byte size of a directory.
|
|
62
|
+
|
|
63
|
+
Utilizes os.scandir for highly optimized filesystem traversal.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
path: The directory path to calculate.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
int: The total size in bytes. Returns 0 if the path does not exist.
|
|
70
|
+
"""
|
|
71
|
+
if not path.exists() or not path.is_dir():
|
|
72
|
+
return 0
|
|
73
|
+
|
|
74
|
+
total_size = 0
|
|
75
|
+
try:
|
|
76
|
+
with os.scandir(path) as it:
|
|
77
|
+
for entry in it:
|
|
78
|
+
if entry.is_file(follow_symlinks=False):
|
|
79
|
+
total_size += entry.stat().st_size
|
|
80
|
+
elif entry.is_dir(follow_symlinks=False):
|
|
81
|
+
total_size += get_directory_size(Path(entry.path))
|
|
82
|
+
except PermissionError:
|
|
83
|
+
# Silently ignore unreadable directories to prevent pipeline termination
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
return total_size
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _load_api_cache(json_path: Path, jws_path: Path) -> list[dict[str, Any]]:
|
|
90
|
+
if jws_path.exists():
|
|
91
|
+
with open(jws_path, encoding="utf-8") as f:
|
|
92
|
+
jws_data = json.load(f)
|
|
93
|
+
|
|
94
|
+
payload = jws_data.get("payload", "")
|
|
95
|
+
|
|
96
|
+
if not isinstance(payload, str):
|
|
97
|
+
logger.error("JWS payload is not a string!")
|
|
98
|
+
return []
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
data = json.loads(payload)
|
|
102
|
+
except json.JSONDecodeError as e:
|
|
103
|
+
logger.error(f"Failed to parse JWS payload: {e}")
|
|
104
|
+
return []
|
|
105
|
+
|
|
106
|
+
if not isinstance(data, list):
|
|
107
|
+
logger.error("API payload is not a list.")
|
|
108
|
+
return []
|
|
109
|
+
|
|
110
|
+
return data
|
|
111
|
+
|
|
112
|
+
if json_path.exists():
|
|
113
|
+
with open(json_path, encoding="utf-8") as f:
|
|
114
|
+
data = json.load(f)
|
|
115
|
+
|
|
116
|
+
if not isinstance(data, list):
|
|
117
|
+
logger.error("API cache is not a list.")
|
|
118
|
+
return []
|
|
119
|
+
|
|
120
|
+
return data
|
|
121
|
+
|
|
122
|
+
return []
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def get_theoretical_catalog(prefix: Path) -> dict[str, list[dict[str, Any]]]:
|
|
126
|
+
"""Directly ingests Homebrew's local API JSON caches, bypassing the CLI entirely.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
prefix: The base physical installation path for Homebrew.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
dict: A dictionary containing 'formulae' and 'casks' lists.
|
|
133
|
+
"""
|
|
134
|
+
# Standard location where Homebrew mirrors its online API cache locally
|
|
135
|
+
cache_dir = Path.home() / "Library/Caches/Homebrew/api"
|
|
136
|
+
|
|
137
|
+
formula_api_path = cache_dir / "formula.json"
|
|
138
|
+
formula_jws_path = cache_dir / "formula.jws.json"
|
|
139
|
+
|
|
140
|
+
cask_api_path = cache_dir / "cask.json"
|
|
141
|
+
cask_jws_path = cache_dir / "cask.jws.json"
|
|
142
|
+
|
|
143
|
+
# Fallback to internal cellar var paths if user settings vary
|
|
144
|
+
if not formula_jws_path.exists() and not formula_api_path.exists():
|
|
145
|
+
formula_api_path = prefix / "var/homebrew/api/formula.json"
|
|
146
|
+
formula_jws_path = prefix / "var/homebrew/api/formula.jws.json"
|
|
147
|
+
cask_api_path = prefix / "var/homebrew/api/cask.json"
|
|
148
|
+
cask_jws_path = prefix / "var/homebrew/api/cask.jws.json"
|
|
149
|
+
|
|
150
|
+
if not formula_jws_path.exists() and not formula_api_path.exists():
|
|
151
|
+
raise RuntimeError(
|
|
152
|
+
"Local Homebrew API cache not found. Please run `brew update` first."
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
formulae = _load_api_cache(formula_api_path, formula_jws_path)
|
|
156
|
+
casks = _load_api_cache(cask_api_path, cask_jws_path)
|
|
157
|
+
|
|
158
|
+
return {"formulae": formulae, "casks": casks}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
_GHCR_TOKEN_URL = "https://ghcr.io/token" # noqa: S105
|
|
162
|
+
_BLOB_URL_PATTERN = re.compile(r"^https://ghcr\.io/v2/(?P<repo>.+)/blobs/sha256:")
|
|
163
|
+
_BOTTLE_SIZE_CACHE_PATH = Path.home() / ".cache" / "dark-matter" / "bottle_sizes.json"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _parse_repository(url: str) -> str | None:
|
|
167
|
+
"""Extract the ghcr.io repository path from a bottle blob URL.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
url: The full blob URL, e.g.
|
|
171
|
+
'https://ghcr.io/v2/homebrew/core/wget/blobs/sha256:...'.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
str | None: The repository path (e.g. 'homebrew/core/wget'), or None
|
|
175
|
+
if the URL does not match the expected ghcr.io blob format.
|
|
176
|
+
"""
|
|
177
|
+
match = _BLOB_URL_PATTERN.match(url)
|
|
178
|
+
return match.group("repo") if match else None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _fetch_ghcr_token(repository: str, timeout: float) -> str | None:
|
|
182
|
+
"""Request an anonymous pull token scoped to a single ghcr.io repository.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
repository: The repository path (e.g. 'homebrew/core/wget').
|
|
186
|
+
timeout: Per-request timeout in seconds.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
str | None: A bearer token, or None if the request failed.
|
|
190
|
+
"""
|
|
191
|
+
params = {"service": "ghcr.io", "scope": f"repository:{repository}:pull"}
|
|
192
|
+
try:
|
|
193
|
+
resp = requests.get(_GHCR_TOKEN_URL, params=params, timeout=timeout)
|
|
194
|
+
resp.raise_for_status()
|
|
195
|
+
token = resp.json().get("token")
|
|
196
|
+
except requests.RequestException:
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
return token if isinstance(token, str) else None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _fetch_blob_size(url: str, token: str, timeout: float) -> int:
|
|
203
|
+
"""Resolve the compressed size of an OCI blob via a HEAD request.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
url: The full blob URL.
|
|
207
|
+
token: A bearer token scoped to the blob's repository.
|
|
208
|
+
timeout: Per-request timeout in seconds.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
int: The size in bytes, or 0 if the size could not be determined.
|
|
212
|
+
"""
|
|
213
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
214
|
+
try:
|
|
215
|
+
resp = requests.head(
|
|
216
|
+
url, headers=headers, timeout=timeout, allow_redirects=True
|
|
217
|
+
)
|
|
218
|
+
resp.raise_for_status()
|
|
219
|
+
return int(resp.headers.get("Content-Length", "0"))
|
|
220
|
+
except (requests.RequestException, ValueError):
|
|
221
|
+
return 0
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _fetch_bottle_size(url: str, timeout: float = 5.0) -> int:
|
|
225
|
+
"""Resolve a single bottle's compressed byte size from ghcr.io.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
url: The full blob URL for the bottle.
|
|
229
|
+
timeout: Per-request timeout in seconds.
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
int: The size in bytes, or 0 if any stage of the lookup failed.
|
|
233
|
+
"""
|
|
234
|
+
repository = _parse_repository(url)
|
|
235
|
+
if repository is None:
|
|
236
|
+
return 0
|
|
237
|
+
|
|
238
|
+
token = _fetch_ghcr_token(repository, timeout)
|
|
239
|
+
if token is None:
|
|
240
|
+
return 0
|
|
241
|
+
|
|
242
|
+
return _fetch_blob_size(url, token, timeout)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def load_bottle_size_cache() -> dict[str, int]:
|
|
246
|
+
"""Load the persisted sha256-to-size cache from disk.
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
dict[str, int]: A mapping of bottle sha256 digests to byte sizes.
|
|
250
|
+
Returns an empty dict if no cache exists or it cannot be parsed.
|
|
251
|
+
"""
|
|
252
|
+
if not _BOTTLE_SIZE_CACHE_PATH.exists():
|
|
253
|
+
return {}
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
with open(_BOTTLE_SIZE_CACHE_PATH, encoding="utf-8") as f:
|
|
257
|
+
data: dict[str, int] = json.load(f)
|
|
258
|
+
return data
|
|
259
|
+
except (OSError, json.JSONDecodeError):
|
|
260
|
+
return {}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def save_bottle_size_cache(cache: dict[str, int]) -> None:
|
|
264
|
+
"""Persist the sha256-to-size cache to disk.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
cache: A mapping of bottle sha256 digests to byte sizes.
|
|
268
|
+
"""
|
|
269
|
+
_BOTTLE_SIZE_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
270
|
+
with open(_BOTTLE_SIZE_CACHE_PATH, "w", encoding="utf-8") as f:
|
|
271
|
+
json.dump(cache, f)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def resolve_bottle_sizes(
|
|
275
|
+
bottles: dict[str, str],
|
|
276
|
+
cache: dict[str, int],
|
|
277
|
+
max_workers: int = 16,
|
|
278
|
+
timeout: float = 5.0,
|
|
279
|
+
) -> dict[str, int]:
|
|
280
|
+
"""Resolve compressed bottle sizes for a batch of sha256-keyed blob URLs.
|
|
281
|
+
|
|
282
|
+
Cached digests are returned immediately without touching the network.
|
|
283
|
+
Uncached digests are resolved concurrently via ghcr.io HEAD requests, and
|
|
284
|
+
`cache` is updated in place with any newly-resolved sizes so the caller
|
|
285
|
+
can persist it afterward.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
bottles: A mapping of bottle sha256 digest to its blob URL.
|
|
289
|
+
cache: The sha256-to-size cache, updated in place with new results.
|
|
290
|
+
max_workers: The maximum number of concurrent network requests.
|
|
291
|
+
timeout: Per-request timeout in seconds.
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
dict[str, int]: A mapping of sha256 digest to resolved byte size,
|
|
295
|
+
covering every digest present in `bottles`.
|
|
296
|
+
"""
|
|
297
|
+
resolved: dict[str, int] = {}
|
|
298
|
+
pending: dict[str, str] = {}
|
|
299
|
+
|
|
300
|
+
for digest, url in bottles.items():
|
|
301
|
+
if digest in cache:
|
|
302
|
+
resolved[digest] = cache[digest]
|
|
303
|
+
else:
|
|
304
|
+
pending[digest] = url
|
|
305
|
+
|
|
306
|
+
if not pending:
|
|
307
|
+
return resolved
|
|
308
|
+
|
|
309
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
310
|
+
future_to_digest = {
|
|
311
|
+
executor.submit(_fetch_bottle_size, url, timeout): digest
|
|
312
|
+
for digest, url in pending.items()
|
|
313
|
+
}
|
|
314
|
+
for future in as_completed(future_to_digest):
|
|
315
|
+
digest = future_to_digest[future]
|
|
316
|
+
size = future.result()
|
|
317
|
+
resolved[digest] = size
|
|
318
|
+
cache[digest] = size
|
|
319
|
+
|
|
320
|
+
return resolved
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def get_logger(name: str, verbose: bool = False) -> logging.Logger:
|
|
6
|
+
"""Create and configure a logger for the application.
|
|
7
|
+
|
|
8
|
+
Args:
|
|
9
|
+
name: Logger name.
|
|
10
|
+
verbose: Whether DEBUG logging should be enabled.
|
|
11
|
+
|
|
12
|
+
Returns:
|
|
13
|
+
A configured logger instance.
|
|
14
|
+
"""
|
|
15
|
+
logger = logging.getLogger(name)
|
|
16
|
+
logger.setLevel(logging.DEBUG if verbose else logging.INFO)
|
|
17
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
18
|
+
formatter = logging.Formatter("%(levelname)s: %(message)s")
|
|
19
|
+
handler.setFormatter(formatter)
|
|
20
|
+
logger.addHandler(handler)
|
|
21
|
+
return logger
|
dark_matter_cli/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: dark-matter-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A dependency-graph-aware storage profiler for Homebrew.
|
|
5
|
+
Project-URL: Repository, https://github.com/jacksonfergusondev/dark-matter
|
|
6
|
+
Project-URL: Issues, https://github.com/jacksonfergusondev/dark-matter/issues
|
|
7
|
+
Project-URL: PyPI, https://pypi.org/project/dark-matter-cli/
|
|
8
|
+
Author-email: Jackson Ferguson <jackson.ferguson0@gmail.com>
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 Jackson Ferguson
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
32
|
+
Classifier: Operating System :: MacOS
|
|
33
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
34
|
+
Classifier: Programming Language :: Python :: 3
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
38
|
+
Requires-Python: >=3.12
|
|
39
|
+
Requires-Dist: pandas>=3.0.3
|
|
40
|
+
Requires-Dist: requests>=2.34.0
|
|
41
|
+
Requires-Dist: rich>=15.0.0
|
|
42
|
+
Requires-Dist: typer>=0.25.1
|
|
43
|
+
Description-Content-Type: text/markdown
|
|
44
|
+
|
|
45
|
+
<!-- rumdl-disable-file first-line-heading -->
|
|
46
|
+
<div align="center">
|
|
47
|
+
|
|
48
|
+
# Dark Matter
|
|
49
|
+
|
|
50
|
+
**A dependency-graph-aware storage profiler for Homebrew.**
|
|
51
|
+
|
|
52
|
+
[](https://github.com/JacksonFergusonDev/dark-matter/actions/workflows/ci.yml)
|
|
53
|
+
[](https://www.python.org/downloads/)
|
|
54
|
+
[](https://github.com/astral-sh/ruff)
|
|
55
|
+
[](https://mypy-lang.org/)
|
|
56
|
+
[](https://github.com/j178/prek)
|
|
57
|
+
[](LICENSE)
|
|
58
|
+
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
## Why
|
|
62
|
+
|
|
63
|
+
Homebrew flattens every dependency into a single `Cellar` directory. Tools like `du` or `ncdu` can tell you a formula takes up 500 MB, but they have no concept of *why* — whether that mass belongs to the formula itself or to a shared runtime pulled in by five other packages you installed for unrelated reasons.
|
|
64
|
+
|
|
65
|
+
Dark Matter reconstructs the dependency graph Homebrew already knows about and uses it to answer a more useful question: for each package you explicitly installed, how much disk space does it actually cost you, once shared dependencies are fairly split across everything that depends on them?
|
|
66
|
+
|
|
67
|
+
## How it works
|
|
68
|
+
|
|
69
|
+
Dark Matter parses Homebrew's own JSON metadata (via `brew info --json=v2` or its local API cache), rebuilds the dependency DAG, and walks it to compute two figures per package:
|
|
70
|
+
|
|
71
|
+
- **Core size** — the package's own on-disk footprint (or, in theoretical mode, its compressed bottle archive).
|
|
72
|
+
- **Weighted recursive size** — the core size plus a *fair share* of every transitive dependency, where each shared dependency's cost is divided evenly across all the packages that depend on it.
|
|
73
|
+
|
|
74
|
+
The ratio between the two — the **Bloat Ratio** — is the headline number. A low ratio means a package is mostly self-contained; a high ratio means most of its footprint belongs to shared infrastructure it happens to require.
|
|
75
|
+
|
|
76
|
+
## Features
|
|
77
|
+
|
|
78
|
+
- **Comprehensive analysis suite**
|
|
79
|
+
- `analyze` — measures what's actually on disk, using `brew info --json=v2 --installed` and direct filesystem traversal (`os.scandir`) for exact byte counts.
|
|
80
|
+
- `leaderboard` — a theoretical mode that ranks Homebrew's *entire* formula and cask catalog from the local API cache, without requiring anything to be installed.
|
|
81
|
+
- `inspect` & `compare` — targeted O(1) theoretical resolution for individual or grouped packages without resolving the entire ecosystem payload.
|
|
82
|
+
- `explain` — breaks down a target package's bloat by attributing fractional byte costs to each of its transitive dependencies.
|
|
83
|
+
- `export` — streams the underlying DataFrames to CSV or JSON for integration into external data pipelines.
|
|
84
|
+
- **Fractional Attribution Model** — shared dependencies (`openssl`, `python`, etc.) are divided proportionally across all parent packages instead of being double-counted, giving an honest per-package cost.
|
|
85
|
+
- **Daemon-free** — no background indexing, no persistent database. Every run is a fresh, on-demand computation.
|
|
86
|
+
- **Typed and tested** — fully type-annotated (strict `mypy`), linted with `ruff`, and covered by a `pytest` suite exercising the DAG traversal, fractional math, and network resolution logic. CI runs the full suite on macOS and Ubuntu across Python 3.12 and 3.14.
|
|
87
|
+
|
|
88
|
+
## A note on theoretical measurements
|
|
89
|
+
|
|
90
|
+
`leaderboard`, `inspect`, `compare`, and `explain` rely on `Content-Length` headers from `ghcr.io` blob storage, which report *compressed* archive size, not the size a package occupies once unpacked to disk. The absolute numbers they report will therefore run lower than `analyze`'s physical measurements.
|
|
91
|
+
|
|
92
|
+
The Bloat Ratio, however, stays meaningful. Since most bottles compress with similar algorithms (gzip or zstd), the compression factor $c$ appears in both the numerator and denominator and cancels out:
|
|
93
|
+
|
|
94
|
+
$$R \approx \frac{c \cdot m_{recursive}}{c \cdot m_{core}} \approx \frac{m_{recursive}}{m_{core}}$$
|
|
95
|
+
|
|
96
|
+
So while theoretical modes shouldn't be read as precise disk-space forecasts, they are a reliable way to evaluate relative bloat without installing anything.
|
|
97
|
+
|
|
98
|
+
## Installation
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
git clone https://github.com/jacksonfergusondev/dark-matter.git
|
|
102
|
+
cd dark-matter
|
|
103
|
+
uv tool install --editable .
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Usage
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
# Analyze what's actually installed
|
|
110
|
+
dark-matter analyze
|
|
111
|
+
|
|
112
|
+
# Rank the entire Homebrew catalog by theoretical bloat
|
|
113
|
+
dark-matter leaderboard
|
|
114
|
+
|
|
115
|
+
# Evaluate a specific formula instantly
|
|
116
|
+
dark-matter inspect uv
|
|
117
|
+
|
|
118
|
+
# Break down the dependency bloat of a specific package
|
|
119
|
+
dark-matter explain uv
|
|
120
|
+
|
|
121
|
+
# Compare multiple packages side-by-side
|
|
122
|
+
dark-matter compare uv poetry pdm
|
|
123
|
+
|
|
124
|
+
# Export the entire graph to JSON for external analysis
|
|
125
|
+
dark-matter export --format json > homebrew_bloat.json
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
All commands accept the global `--verbose` / `-v` flag for debug logging, and `--version` to print the installed version.
|
|
129
|
+
|
|
130
|
+
### `analyze`
|
|
131
|
+
|
|
132
|
+
| Flag | Default | Description |
|
|
133
|
+
| --- | --- | --- |
|
|
134
|
+
| `--sort` / `-s` | `ratio` | Sort by `ratio`, `core`, or `recursive` |
|
|
135
|
+
| `--top` / `-n` | `20` | Number of packages to display |
|
|
136
|
+
| `--fractional` / `--standard` | `--fractional` | Toggle the Fractional Attribution Model |
|
|
137
|
+
|
|
138
|
+
### `leaderboard`
|
|
139
|
+
|
|
140
|
+
| Flag | Default | Description |
|
|
141
|
+
| --- | --- | --- |
|
|
142
|
+
| `--sort` / `-s` | `ratio` | Sort by `ratio`, `core`, or `recursive` |
|
|
143
|
+
| `--top` / `-n` | `20` | Number of packages to display |
|
|
144
|
+
| `--arch` / `-a` | `arm64_tahoe` | Target bottle architecture |
|
|
145
|
+
|
|
146
|
+
### `inspect`
|
|
147
|
+
|
|
148
|
+
| Argument/Flag | Default | Description |
|
|
149
|
+
| --- | --- | --- |
|
|
150
|
+
| `[PACKAGE]` | **Required** | The target package to analyze |
|
|
151
|
+
| `--source` / `-s` | `installed` | Data source to compute: `installed` or `catalog` |
|
|
152
|
+
| `--arch` / `-a` | `arm64_tahoe` | Target bottle architecture |
|
|
153
|
+
|
|
154
|
+
### `compare`
|
|
155
|
+
|
|
156
|
+
| Argument/Flag | Default | Description |
|
|
157
|
+
| --- | --- | --- |
|
|
158
|
+
| `[PACKAGES]...` | **Required** | A space-separated list of packages to compare |
|
|
159
|
+
| `--sort` / `-s` | `ratio` | Sort by `ratio`, `core`, or `recursive` |
|
|
160
|
+
| `--source` / `-s` | `installed` | Data source to compute: `installed` or `catalog` |
|
|
161
|
+
| `--arch` / `-a` | `arm64_tahoe` | Target bottle architecture |
|
|
162
|
+
|
|
163
|
+
### `explain`
|
|
164
|
+
|
|
165
|
+
| Argument/Flag | Default | Description |
|
|
166
|
+
| --- | --- | --- |
|
|
167
|
+
| `[PACKAGE]` | **Required** | The specific package to analyze |
|
|
168
|
+
| `--source` / `-s` | `installed` | Data source to compute: `installed` or `catalog` |
|
|
169
|
+
| `--arch` / `-a` | `arm64_tahoe` | Target bottle architecture |
|
|
170
|
+
|
|
171
|
+
### `export`
|
|
172
|
+
|
|
173
|
+
| Flag | Default | Description |
|
|
174
|
+
| --- | --- | --- |
|
|
175
|
+
| `--source` / `-s` | `installed` | Data source to compute: `installed` or `catalog` |
|
|
176
|
+
| `--format` / `-f` | `csv` | Output format: `csv` or `json` |
|
|
177
|
+
| `--arch` / `-a` | `arm64_tahoe` | Target bottle architecture (for `catalog` source) |
|
|
178
|
+
|
|
179
|
+
## Development
|
|
180
|
+
|
|
181
|
+
The project uses [`just`](https://github.com/casey/just) to wrap common tasks:
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
just format # ruff format + fix
|
|
185
|
+
just lint # ruff + rumdl
|
|
186
|
+
just typecheck # mypy
|
|
187
|
+
just test # pytest
|
|
188
|
+
just test-cov # pytest with coverage report
|
|
189
|
+
just ci # the full pipeline CI runs, locally
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## 📧 Contact
|
|
193
|
+
|
|
194
|
+
[](https://github.com/JacksonFergusonDev)
|
|
195
|
+
[](https://www.linkedin.com/in/jackson--ferguson/)
|
|
196
|
+
[](mailto:jackson.ferguson0@gmail.com)
|
|
197
|
+
|
|
198
|
+
## 📄 License
|
|
199
|
+
|
|
200
|
+
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
dark_matter_cli/__init__.py,sha256=cEVTCS2UBX0W5UrBSvWx1aXPjSQy2nHpAyC_DPzILpw,1102
|
|
2
|
+
dark_matter_cli/cli.py,sha256=zGIW0HV9DWY-1GrpMQQDswevtQCumBkEJrTo_2222Q8,10213
|
|
3
|
+
dark_matter_cli/core.py,sha256=lEBLpvwmHU-0jd9XpOtXbDhbWBjTrkcxdyEHqs7rfeU,10548
|
|
4
|
+
dark_matter_cli/display.py,sha256=YGxoGlufYlqafaFz3zp7v2Jx8R9drZj9-z8k-v0qIq8,4415
|
|
5
|
+
dark_matter_cli/homebrew.py,sha256=_SXlrm9HPk4hsmMt_vNkkuawmrB4OWTXunuarECYYxQ,10210
|
|
6
|
+
dark_matter_cli/logger.py,sha256=SoT7nkCRmkwh3m32dJoZ-j-RzSKgZAt1736_J2bldb0,608
|
|
7
|
+
dark_matter_cli/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
dark_matter_cli-0.1.0.dist-info/METADATA,sha256=paJ-SCRJP-390dVQ7P0uzMDqKo1hAdcnO19T-18KMpg,10199
|
|
9
|
+
dark_matter_cli-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
10
|
+
dark_matter_cli-0.1.0.dist-info/entry_points.txt,sha256=Yb1raHKzJMhV0GTzZaGfoW7ORqADVIlHXJ0mB_dn8Hk,56
|
|
11
|
+
dark_matter_cli-0.1.0.dist-info/licenses/LICENSE,sha256=29F5Dm0FvGigEbT-pzbDtV9PFLKUvQhIxmvGaRnsS74,1073
|
|
12
|
+
dark_matter_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jackson Ferguson
|
|
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.
|