diff --git a/CHANGELOG.md b/CHANGELOG.md index 921228c..138d282 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,26 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.5.1] - 2026-05-28 + +### Fixed + +- **`click` declared as an explicit dependency** — `dirplot` imports `click` directly in + `_overview.py`, but relied on it being pulled in transitively by `typer`. Starting around + typer 0.12, click became optional in some typer builds, causing `ModuleNotFoundError: No module + named 'click'` when running the installed tool under Python 3.14. `click>=8.0` is now a direct + dependency. + +### Added + +- **SVG snapshot output for `git` and `hg`** — both commands now accept `--output file.svg` for + single-frame snapshots, producing an interactive SVG (hover tooltips, CSS highlight) identical to + `map`'s SVG output. Animation output (APNG/MP4) is unchanged and still requires `.png`/`.mp4`/`.mov`. + +- **Remote URL support for `hg`** — `dirplot hg` now accepts `https://`, `http://`, and `ssh://` + URLs directly (e.g. `dirplot hg https://hg.reportlab.com/hg-public/reportlab --inline`). The + repository is cloned into a temporary directory automatically, mirroring the GitHub URL support + already available in `dirplot git`. ## [0.5.0] - 2026-05-20 diff --git a/pyproject.toml b/pyproject.toml index 30acf71..a5d6e47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "dirplot" -version = "0.5.0" +version = "0.5.1" description = "Nested treemap visualizations for directory trees and archives" readme = "README.md" license = {text = "MIT"} @@ -37,6 +37,7 @@ dependencies = [ "py7zr>=0.20", "rarfile>=4.0", "squarify>=0.4", + "click>=8.0", "typer>=0.9", "watchdog>=6.0.0", ] diff --git a/src/dirplot/commands/vcs.py b/src/dirplot/commands/vcs.py index 50ad3fe..06feb73 100644 --- a/src/dirplot/commands/vcs.py +++ b/src/dirplot/commands/vcs.py @@ -410,8 +410,8 @@ def git_cmd( "Error: animation --output must be a .png (APNG), .mp4, or .mov file.", err=True ) raise typer.Exit(1) - if not is_animation and output.suffix.lower() != ".png": - typer.echo("Error: snapshot --output must be a .png file.", err=True) + if not is_animation and output.suffix.lower() not in {".png", ".svg"}: + typer.echo("Error: snapshot --output must be a .png or .svg file.", err=True) raise typer.Exit(1) period_dt: datetime | None = None @@ -706,31 +706,48 @@ def git_cmd( if logscale > 1: apply_log_sizes(node, logscale) - from dirplot.render_png import create_treemap - hl: dict[str, str] | None = None if highlight: abs_paths = [(repo / rel).as_posix() for rel in files] hl = resolve_highlight_specs(highlight, abs_paths) - png_buf = create_treemap( - node, - width_px, - height_px, - font_size, - colormap, - None, - cushion, - title_suffix=f"sha:{sha[:8]} {datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M')}", - dark=dark, - logscale=logscale, - highlights=hl, - ) + use_svg = output is not None and output.suffix.lower() == ".svg" + if use_svg: + from dirplot.svg_render import create_treemap_svg + + out_buf = create_treemap_svg( + node, + width_px, + height_px, + font_size, + colormap, + None, + cushion, + depth, + dark, + highlights=hl, + ) + else: + from dirplot.render_png import create_treemap + + out_buf = create_treemap( + node, + width_px, + height_px, + font_size, + colormap, + None, + cushion, + title_suffix=f"sha:{sha[:8]} {datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M')}", # noqa: E501 + dark=dark, + logscale=logscale, + highlights=hl, + ) if inline: - display_inline(png_buf, cols=inline_cols) + display_inline(out_buf, cols=inline_cols) else: assert output is not None - output.write_bytes(png_buf.read()) + output.write_bytes(out_buf.read()) if not quiet: typer.echo(f"Wrote {output}", err=True) @@ -865,6 +882,7 @@ def hg_cmd( """ import shutil import subprocess + import tempfile from datetime import datetime if not shutil.which("hg"): @@ -919,8 +937,8 @@ def hg_cmd( "Error: animation --output must be a .png (APNG), .mp4, or .mov file.", err=True ) raise typer.Exit(1) - if not is_animation and output.suffix.lower() != ".png": - typer.echo("Error: snapshot --output must be a .png file.", err=True) + if not is_animation and output.suffix.lower() not in {".png", ".svg"}: + typer.echo("Error: snapshot --output must be a .png or .svg file.", err=True) raise typer.Exit(1) period_dt: datetime | None = None @@ -932,15 +950,41 @@ def hg_cmd( raise typer.Exit(1) from exc _at_ref: str | None = None - if "@" in repo_arg: - repo_path_str, _, _at_ref = repo_arg.partition("@") + _hg_tmpdir: tempfile.TemporaryDirectory[str] | None = None + repo: Path + + _is_remote_url = repo_arg.startswith(("https://", "http://", "ssh://")) + + if _is_remote_url: + if "@" in repo_arg: + _url, _, _at_ref = repo_arg.partition("@") + else: + _url = repo_arg + _repo_name = _url.rstrip("/").split("/")[-1] or "repo" + _hg_tmpdir = tempfile.TemporaryDirectory(prefix="dirplot-hg-") + _clone_dir = Path(_hg_tmpdir.name) / _repo_name + clone_cmd = ["hg", "clone"] + if _at_ref: + clone_cmd += ["--rev", _at_ref] + clone_cmd += [_url, str(_clone_dir)] + if not quiet: + typer.echo(f"Cloning {_url} ...", err=True) + try: + subprocess.run(clone_cmd, check=True, capture_output=True, text=True) + except subprocess.CalledProcessError as exc: + typer.echo(f"Error cloning repository: {exc.stderr.strip()}", err=True) + raise typer.Exit(1) from exc + repo = _clone_dir else: - repo_path_str = repo_arg - repo = Path(repo_path_str).resolve() + if "@" in repo_arg: + repo_path_str, _, _at_ref = repo_arg.partition("@") + else: + repo_path_str = repo_arg + repo = Path(repo_path_str).resolve() - if not (repo / ".hg").exists(): - typer.echo(f"Error: not a Mercurial repository: {repo}", err=True) - raise typer.Exit(1) + if not (repo / ".hg").exists(): + typer.echo(f"Error: not a Mercurial repository: {repo}", err=True) + raise typer.Exit(1) if canvas is not None: try: @@ -1129,30 +1173,47 @@ def hg_cmd( if logscale > 1: apply_log_sizes(node_tree, logscale) - from dirplot.render_png import create_treemap - hl_hg: dict[str, str] | None = None if highlight: abs_paths = [(repo / rel).as_posix() for rel in files] hl_hg = resolve_highlight_specs(highlight, abs_paths) - png_buf = create_treemap( - node_tree, - width_px, - height_px, - font_size, - colormap, - None, - cushion, - title_suffix=f"rev:{node_id[:8]} {datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M')}", # noqa: E501 - dark=dark, - logscale=logscale, - highlights=hl_hg, - ) + use_svg = output is not None and output.suffix.lower() == ".svg" + if use_svg: + from dirplot.svg_render import create_treemap_svg + + out_buf = create_treemap_svg( + node_tree, + width_px, + height_px, + font_size, + colormap, + None, + cushion, + depth, + dark, + highlights=hl_hg, + ) + else: + from dirplot.render_png import create_treemap + + out_buf = create_treemap( + node_tree, + width_px, + height_px, + font_size, + colormap, + None, + cushion, + title_suffix=f"rev:{node_id[:8]} {datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M')}", # noqa: E501 + dark=dark, + logscale=logscale, + highlights=hl_hg, + ) if inline: - display_inline(png_buf, cols=inline_cols) + display_inline(out_buf, cols=inline_cols) else: assert output is not None - output.write_bytes(png_buf.read()) + output.write_bytes(out_buf.read()) if not quiet: typer.echo(f"Wrote {output}", err=True) diff --git a/tests/test_git_local.py b/tests/test_git_local.py index ea4eba4..08e3a85 100644 --- a/tests/test_git_local.py +++ b/tests/test_git_local.py @@ -67,6 +67,31 @@ def git(*args: str) -> None: return repo +def test_git_local_snapshot_svg(local_repo: Path, tmp_path: Path) -> None: + """dirplot git renders a snapshot as SVG when --output ends in .svg.""" + out = tmp_path / "out.svg" + result = runner.invoke( + app, + ["git", str(local_repo), "--output", str(out), "--canvas", "200x150"], + ) + assert result.exit_code == 0, result.output + assert out.exists() + assert out.stat().st_size > 0 + content = out.read_text() + assert " None: + """A snapshot --output with an unsupported extension exits 1.""" + out = tmp_path / "out.jpg" + result = runner.invoke( + app, + ["git", str(local_repo), "--output", str(out), "--canvas", "200x150"], + ) + assert result.exit_code == 1 + assert ".png" in result.output and ".svg" in result.output + + def test_git_local_at_branch(local_repo: Path, tmp_path: Path) -> None: """dirplot git path@branch renders the branch without --range.""" out = tmp_path / "out.png" diff --git a/tests/test_hg_local.py b/tests/test_hg_local.py index 32e5cce..2618653 100644 --- a/tests/test_hg_local.py +++ b/tests/test_hg_local.py @@ -62,6 +62,31 @@ def local_hg_repo(tmp_path: Path) -> Path: return repo +def test_hg_local_static_svg(local_hg_repo: Path, tmp_path: Path) -> None: + """dirplot hg renders a static SVG for the final changeset.""" + out = tmp_path / "out.svg" + result = runner.invoke( + app, + ["hg", str(local_hg_repo), "--output", str(out), "--canvas", "200x150"], + ) + assert result.exit_code == 0, result.output + assert out.exists() + assert out.stat().st_size > 0 + content = out.read_text() + assert " None: + """A snapshot --output with an unsupported extension exits 1.""" + out = tmp_path / "out.jpg" + result = runner.invoke( + app, + ["hg", str(local_hg_repo), "--output", str(out), "--canvas", "200x150"], + ) + assert result.exit_code == 1 + assert ".png" in result.output and ".svg" in result.output + + def test_hg_local_static_png(local_hg_repo: Path, tmp_path: Path) -> None: """dirplot hg renders a static PNG for the final changeset.""" out = tmp_path / "out.png" diff --git a/uv.lock b/uv.lock index 49f1ea1..29c4566 100644 --- a/uv.lock +++ b/uv.lock @@ -607,9 +607,10 @@ wheels = [ [[package]] name = "dirplot" -version = "0.5.0" +version = "0.5.1" source = { editable = "." } dependencies = [ + { name = "click" }, { name = "cmap" }, { name = "drawsvg" }, { name = "pillow" }, @@ -641,6 +642,7 @@ ssh = [ [package.metadata] requires-dist = [ { name = "boto3", marker = "extra == 's3'", specifier = ">=1.26" }, + { name = "click", specifier = ">=8.0" }, { name = "cmap", specifier = ">=0.4" }, { name = "drawsvg", specifier = ">=2.4.1" }, { name = "libarchive-c", marker = "extra == 'libarchive'", specifier = ">=5.0" },