finwave-wavefront 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ .DS_Store
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alexander Barnhill / Operational Ecology
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.
@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.4
2
+ Name: finwave-wavefront
3
+ Version: 0.1.0
4
+ Summary: Official Python client for fetching finwave datasets over the dataset-API handshake.
5
+ Project-URL: Homepage, https://operationalecology.io
6
+ Project-URL: Source, https://github.com/Operational-Ecology/Wavefront
7
+ Project-URL: finwave, https://finwave.io
8
+ Author-email: Alexander Barnhill <alex.c.barnhill@gmail.com>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: conservation,datasets,finwave,photo-identification,wildlife,yolo
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Scientific/Engineering
17
+ Classifier: Topic :: Scientific/Engineering :: Image Recognition
18
+ Requires-Python: >=3.9
19
+ Requires-Dist: httpx>=0.24
20
+ Provides-Extra: test
21
+ Requires-Dist: pytest>=7; extra == 'test'
22
+ Requires-Dist: respx>=0.20; extra == 'test'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # wavefront
26
+
27
+ The official Python client for **[Finwave](https://finwave.io)** datasets.
28
+
29
+ Finwave serves frozen, versioned wildlife photo-identification and detector
30
+ datasets behind a small handshake API. `wavefront` turns that into one call.
31
+
32
+ ```bash
33
+ pip install finwave-wavefront
34
+ ```
35
+
36
+ ## Quick start
37
+
38
+ ```python
39
+ import wavefront
40
+
41
+ # the API key is read from $FW_API_TOKEN (or passed as api_key=...)
42
+ ds = wavefront.fetch("a7673931-9810-4c52-9654-1c9b1fafb63d", format="yolo")
43
+
44
+ print(ds.path) # extracted, ready to train on
45
+ print(ds.classes) # ['fluke']
46
+ print(ds.num_images) # 497
47
+ print(ds.fingerprint) # content hash — record it next to any model you train
48
+ ```
49
+
50
+ `ds` is path-like, so it drops straight into a trainer:
51
+
52
+ ```python
53
+ from ultralytics import YOLO
54
+ YOLO("yolo11n.pt").train(data=f"{ds.path}/data.yaml")
55
+ ```
56
+
57
+ ### Pre-flight without downloading
58
+
59
+ ```python
60
+ m = wavefront.manifest("a7673931-9810-4c52-9654-1c9b1fafb63d")
61
+ print(m.name, m.sample_count, m.available_formats) # Flukes v1 497 ['Yolo']
62
+ ```
63
+
64
+ ### A reusable client
65
+
66
+ ```python
67
+ from wavefront import Client
68
+ client = Client(api_key="...", base_url="https://finwave.io")
69
+ ds = client.fetch(dataset_id, format="yolo", dest="./data/flukes")
70
+ ```
71
+
72
+ ### Command line
73
+
74
+ ```bash
75
+ export FW_API_TOKEN=...
76
+ wavefront manifest a7673931-9810-4c52-9654-1c9b1fafb63d
77
+ wavefront fetch a7673931-9810-4c52-9654-1c9b1fafb63d --format yolo --dest ./data/flukes
78
+ ```
79
+
80
+ ## How it works
81
+
82
+ 1. `GET /manifest` — cheap metadata + which export formats are ready.
83
+ 2. `GET ?format=…` — a **handshake** that mints a short-lived signed download URL.
84
+ 3. Download that URL → a zip → extract → a `Dataset`.
85
+
86
+ Downloads are **cached by content fingerprint**, so re-fetching a frozen
87
+ version is a no-op. The key needs the dataset-download scope.
88
+
89
+ ## Authentication
90
+
91
+ Provide the key explicitly (`fetch(..., api_key=...)`) or set **`FW_API_TOKEN`**.
92
+ For compatibility, `WAVEFRONT_API_KEY`, `FINWAVE_DATASET_API_KEY` and
93
+ `DATASET_API_KEY` are also accepted (in that order).
94
+
95
+ ## Errors
96
+
97
+ All errors subclass `wavefront.WavefrontError`:
98
+
99
+ | Exception | When |
100
+ |---|---|
101
+ | `AuthError` | key missing / rejected (401/403) |
102
+ | `DatasetNotFoundError` | no such version, or not visible to the key (404) |
103
+ | `FormatNotAvailableError` | the version exists but that export hasn't been generated yet (`.available` lists what is) |
104
+ | `APIError` | any other non-success response |
105
+
106
+ ## License
107
+
108
+ MIT © Alexander Barnhill / [Operational Ecology](https://operationalecology.io).
109
+ A partnership artifact between finwave and Operational Ecology.
@@ -0,0 +1,57 @@
1
+ # PROTOCOL — wavefront
2
+
3
+ Decision log for the finwave dataset client. Governance: [`../CLAUDE.md`](../CLAUDE.md).
4
+
5
+ ## Mission
6
+
7
+ ### One-line — the official, public Python client for fetching finwave datasets
8
+ Status: Committed — 2026-06-21
9
+ finwave already exposes a dataset handshake API (`/api/datasets-api/{id}`); every
10
+ consumer was re-implementing the manifest → handshake → SAS-download → unzip
11
+ dance by hand. `wavefront` is the single, supported way to do it. It is
12
+ OpEco's **first public** project (PyPI + public GitHub) and a deliberate
13
+ finwave × Operational Ecology partnership artifact, both authored by AB.
14
+
15
+ ## Scope
16
+
17
+ ### v1 fetches frozen versions in declared export formats; it does not create them
18
+ Status: Committed — 2026-06-21
19
+ The client is read/download only. Generating an export (e.g. producing the YOLO
20
+ bundle for a frozen version) is an admin action on the Hub and is out of scope —
21
+ hence `FormatNotAvailableError` carries `.available` rather than trying to
22
+ trigger generation. The dataset-API key is download-scoped only.
23
+
24
+ ## Design
25
+
26
+ ### Caching is keyed by the server's content fingerprint, not by id alone
27
+ Status: Committed — 2026-06-21
28
+ Versions are frozen, so a fingerprint pins exact bytes. A completed extraction
29
+ writes a `.wavefront-complete` marker containing the fingerprint; a later fetch
30
+ with a matching marker is a no-op. This makes `fetch` idempotent and cheap to
31
+ call in a training loop without a manual "already downloaded?" guard.
32
+
33
+ ### `Dataset.fingerprint` is surfaced as first-class provenance
34
+ Status: Committed — 2026-06-21
35
+ Every fetched dataset exposes the version fingerprint so a downstream training
36
+ run can record exactly which data produced a model (aligns with OpEco Rule 2,
37
+ source-code/data consistency). The client does not recompute the hash
38
+ client-side (the algorithm is server-owned); it carries the declared one.
39
+
40
+ ### API-key precedence: explicit arg → `FW_API_TOKEN` → `WAVEFRONT_API_KEY` → `FINWAVE_DATASET_API_KEY` → `DATASET_API_KEY`
41
+ Status: Committed — 2026-06-21
42
+ `FW_API_TOKEN` is the canonical name; the rest are accepted so existing
43
+ finwave/finprint workspace environments keep working unchanged.
44
+
45
+ ## Deferred
46
+
47
+ ### Streaming-to-disk integrity check beyond fingerprint consistency
48
+ Status: Open — 2026-06-21
49
+ The handshake and manifest fingerprints are checked for consistency, but the
50
+ downloaded bytes are not independently re-hashed against a per-file digest (the
51
+ export bundle does not yet ship one). Revisit if the Hub starts emitting
52
+ per-artifact `sha256` so `IntegrityError` can be raised on a real mismatch.
53
+
54
+ ### Additional export formats (COCO, PascalVoc)
55
+ Status: Parked — 2026-06-21
56
+ Format aliases are mapped, but only YOLO is produced by the Hub in v1. The
57
+ client already accepts any format string and lets the server decide.
@@ -0,0 +1,85 @@
1
+ # wavefront
2
+
3
+ The official Python client for **[Finwave](https://finwave.io)** datasets.
4
+
5
+ Finwave serves frozen, versioned wildlife photo-identification and detector
6
+ datasets behind a small handshake API. `wavefront` turns that into one call.
7
+
8
+ ```bash
9
+ pip install finwave-wavefront
10
+ ```
11
+
12
+ ## Quick start
13
+
14
+ ```python
15
+ import wavefront
16
+
17
+ # the API key is read from $FW_API_TOKEN (or passed as api_key=...)
18
+ ds = wavefront.fetch("a7673931-9810-4c52-9654-1c9b1fafb63d", format="yolo")
19
+
20
+ print(ds.path) # extracted, ready to train on
21
+ print(ds.classes) # ['fluke']
22
+ print(ds.num_images) # 497
23
+ print(ds.fingerprint) # content hash — record it next to any model you train
24
+ ```
25
+
26
+ `ds` is path-like, so it drops straight into a trainer:
27
+
28
+ ```python
29
+ from ultralytics import YOLO
30
+ YOLO("yolo11n.pt").train(data=f"{ds.path}/data.yaml")
31
+ ```
32
+
33
+ ### Pre-flight without downloading
34
+
35
+ ```python
36
+ m = wavefront.manifest("a7673931-9810-4c52-9654-1c9b1fafb63d")
37
+ print(m.name, m.sample_count, m.available_formats) # Flukes v1 497 ['Yolo']
38
+ ```
39
+
40
+ ### A reusable client
41
+
42
+ ```python
43
+ from wavefront import Client
44
+ client = Client(api_key="...", base_url="https://finwave.io")
45
+ ds = client.fetch(dataset_id, format="yolo", dest="./data/flukes")
46
+ ```
47
+
48
+ ### Command line
49
+
50
+ ```bash
51
+ export FW_API_TOKEN=...
52
+ wavefront manifest a7673931-9810-4c52-9654-1c9b1fafb63d
53
+ wavefront fetch a7673931-9810-4c52-9654-1c9b1fafb63d --format yolo --dest ./data/flukes
54
+ ```
55
+
56
+ ## How it works
57
+
58
+ 1. `GET /manifest` — cheap metadata + which export formats are ready.
59
+ 2. `GET ?format=…` — a **handshake** that mints a short-lived signed download URL.
60
+ 3. Download that URL → a zip → extract → a `Dataset`.
61
+
62
+ Downloads are **cached by content fingerprint**, so re-fetching a frozen
63
+ version is a no-op. The key needs the dataset-download scope.
64
+
65
+ ## Authentication
66
+
67
+ Provide the key explicitly (`fetch(..., api_key=...)`) or set **`FW_API_TOKEN`**.
68
+ For compatibility, `WAVEFRONT_API_KEY`, `FINWAVE_DATASET_API_KEY` and
69
+ `DATASET_API_KEY` are also accepted (in that order).
70
+
71
+ ## Errors
72
+
73
+ All errors subclass `wavefront.WavefrontError`:
74
+
75
+ | Exception | When |
76
+ |---|---|
77
+ | `AuthError` | key missing / rejected (401/403) |
78
+ | `DatasetNotFoundError` | no such version, or not visible to the key (404) |
79
+ | `FormatNotAvailableError` | the version exists but that export hasn't been generated yet (`.available` lists what is) |
80
+ | `APIError` | any other non-success response |
81
+
82
+ ## License
83
+
84
+ MIT © Alexander Barnhill / [Operational Ecology](https://operationalecology.io).
85
+ A partnership artifact between finwave and Operational Ecology.
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "finwave-wavefront"
7
+ version = "0.1.0"
8
+ description = "Official Python client for fetching finwave datasets over the dataset-API handshake."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Alexander Barnhill", email = "alex.c.barnhill@gmail.com" }]
13
+ keywords = ["finwave", "wildlife", "photo-identification", "datasets", "conservation", "yolo"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Science/Research",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Scientific/Engineering",
20
+ "Topic :: Scientific/Engineering :: Image Recognition",
21
+ ]
22
+ dependencies = ["httpx>=0.24"]
23
+
24
+ [project.urls]
25
+ Homepage = "https://operationalecology.io"
26
+ Source = "https://github.com/Operational-Ecology/Wavefront"
27
+ "finwave" = "https://finwave.io"
28
+
29
+ [project.optional-dependencies]
30
+ test = ["pytest>=7", "respx>=0.20"]
31
+
32
+ [project.scripts]
33
+ wavefront = "wavefront.__main__:main"
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["src/wavefront"]
@@ -0,0 +1,69 @@
1
+ """wavefront — the official Python client for finwave datasets.
2
+
3
+ finwave (https://finwave.io) serves frozen, versioned wildlife photo-ID and
4
+ detector datasets behind a small handshake API. ``wavefront`` turns that into
5
+ one call:
6
+
7
+ >>> import wavefront
8
+ >>> ds = wavefront.fetch("a7673931-9810-4c52-9654-1c9b1fafb63d", format="yolo")
9
+ >>> ds.path, ds.classes, ds.num_images
10
+ (PosixPath('.../Yolo-81f97dec8667'), ['fluke'], 497)
11
+
12
+ The key is read from the ``FW_API_TOKEN`` environment variable (or passed
13
+ explicitly as ``api_key=``); ``WAVEFRONT_API_KEY``, ``FINWAVE_DATASET_API_KEY``
14
+ and ``DATASET_API_KEY`` are also accepted for compatibility. For repeated or
15
+ configured use, construct a :class:`Client`.
16
+
17
+ Every step logs on the ``wavefront`` logger. The library attaches a
18
+ ``NullHandler`` and never configures logging itself — enable output with
19
+ ``logging.basicConfig(level=logging.INFO)`` in your application.
20
+
21
+ Built by Operational Ecology (https://operationalecology.io).
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import logging
26
+ from typing import Optional
27
+
28
+ from .client import API_KEY_ENV, DEFAULT_BASE_URL, Client
29
+
30
+ logging.getLogger("wavefront").addHandler(logging.NullHandler())
31
+ from .exceptions import (
32
+ APIError,
33
+ AuthError,
34
+ DatasetNotFoundError,
35
+ FormatNotAvailableError,
36
+ IntegrityError,
37
+ WavefrontError,
38
+ )
39
+ from .models import Dataset, Manifest
40
+
41
+ __version__ = "0.1.0"
42
+ __all__ = [
43
+ "fetch",
44
+ "manifest",
45
+ "Client",
46
+ "Dataset",
47
+ "Manifest",
48
+ "WavefrontError",
49
+ "AuthError",
50
+ "DatasetNotFoundError",
51
+ "FormatNotAvailableError",
52
+ "IntegrityError",
53
+ "APIError",
54
+ "DEFAULT_BASE_URL",
55
+ "API_KEY_ENV",
56
+ "__version__",
57
+ ]
58
+
59
+
60
+ def fetch(dataset_version_id: str, *, format: str = "yolo",
61
+ api_key: Optional[str] = None, base_url: str = DEFAULT_BASE_URL, **kwargs) -> Dataset:
62
+ """Fetch + extract a dataset version with a one-off client. See :meth:`Client.fetch`."""
63
+ return Client(api_key, base_url=base_url).fetch(dataset_version_id, format=format, **kwargs)
64
+
65
+
66
+ def manifest(dataset_version_id: str, *,
67
+ api_key: Optional[str] = None, base_url: str = DEFAULT_BASE_URL) -> Manifest:
68
+ """Return a dataset version's manifest with a one-off client. See :meth:`Client.manifest`."""
69
+ return Client(api_key, base_url=base_url).manifest(dataset_version_id)
@@ -0,0 +1,85 @@
1
+ """Command-line interface: ``wavefront fetch|manifest <id>``."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import logging
6
+ import sys
7
+
8
+ from . import __version__, _art
9
+ from .client import Client
10
+ from .exceptions import WavefrontError
11
+
12
+
13
+ def _fmt_bytes(n: int) -> str:
14
+ for unit in ("B", "KB", "MB", "GB"):
15
+ if n < 1024 or unit == "GB":
16
+ return f"{n:.0f}{unit}" if unit == "B" else f"{n/1:.0f}{unit}"
17
+ n /= 1024
18
+ return f"{n:.0f}B"
19
+
20
+
21
+ def main(argv=None) -> int:
22
+ p = argparse.ArgumentParser(prog="wavefront", description="Fetch finwave datasets.")
23
+ p.add_argument("--version", action="version", version=f"wavefront {__version__}")
24
+ p.add_argument("--api-key", default=None, help="overrides $FW_API_TOKEN")
25
+ p.add_argument("--base-url", default=None, help="finwave base URL")
26
+ p.add_argument("-v", "--verbose", action="store_true", help="debug-level logging")
27
+ p.add_argument("-q", "--quiet", action="store_true", help="warnings and errors only")
28
+ p.add_argument("--no-art", action="store_true", help="disable the wave animation")
29
+ sub = p.add_subparsers(dest="cmd", required=False)
30
+
31
+ m = sub.add_parser("manifest", help="print a version's metadata + formats")
32
+ m.add_argument("dataset_version_id")
33
+
34
+ f = sub.add_parser("fetch", help="download + extract a dataset version")
35
+ f.add_argument("dataset_version_id")
36
+ f.add_argument("--format", default="yolo")
37
+ f.add_argument("--dest", default=None, help="extract dir (default: cache)")
38
+ f.add_argument("--force", action="store_true", help="ignore cache")
39
+
40
+ args = p.parse_args(argv)
41
+ level = logging.WARNING if args.quiet else (logging.DEBUG if args.verbose else logging.INFO)
42
+ logging.basicConfig(level=level, format="%(message)s", stream=sys.stderr)
43
+ show_art = not (args.no_art or args.quiet)
44
+ if args.cmd is None: # bare `wavefront` → wave + wordmark
45
+ if show_art:
46
+ _art.banner()
47
+ return 0
48
+ kw = {}
49
+ if args.base_url:
50
+ kw["base_url"] = args.base_url
51
+ try:
52
+ client = Client(args.api_key, **kw)
53
+ if args.cmd == "manifest":
54
+ mf = client.manifest(args.dataset_version_id)
55
+ print(f"{mf.name} (v{mf.version_number})")
56
+ print(f" samples: {mf.sample_count} annotations: {mf.annotation_count}")
57
+ print(f" formats: {mf.available_formats or '(none generated yet)'}")
58
+ print(f" fingerprint: {mf.fingerprint}")
59
+ return 0
60
+ if args.cmd == "fetch":
61
+ if show_art:
62
+ _art.wave()
63
+ last = [0.0]
64
+
65
+ def prog(got, total):
66
+ pct = f" {100*got/total:.0f}%" if total else ""
67
+ if got - last[0] >= (1 << 23) or got == total: # ~8MB steps
68
+ print(f"\r downloading {_fmt_bytes(got)}{pct}", end="", file=sys.stderr)
69
+ last[0] = got
70
+
71
+ ds = client.fetch(args.dataset_version_id, format=args.format,
72
+ dest=args.dest, force=args.force, progress=prog)
73
+ print("", file=sys.stderr)
74
+ print(ds.path)
75
+ print(f" {ds.num_images} images, {ds.num_labels} labels, classes={ds.classes}",
76
+ file=sys.stderr)
77
+ return 0
78
+ except WavefrontError as e:
79
+ print(f"error: {e}", file=sys.stderr)
80
+ return 1
81
+ return 0
82
+
83
+
84
+ if __name__ == "__main__":
85
+ raise SystemExit(main())
@@ -0,0 +1,107 @@
1
+ """A small finwave-blue wave flourish for the terminal. Purely cosmetic.
2
+
3
+ Animates a travelling, foam-tipped wave in the finwave palette. No-ops on
4
+ non-TTY streams, under ``NO_COLOR``, or for dumb terminals, so it never
5
+ corrupts piped or logged output.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ import os
11
+ import shutil
12
+ import sys
13
+ import time
14
+ from typing import Optional
15
+
16
+ # finwave palette, deep water → crest (mirrors the logo's blue gradient)
17
+ _GRAD = [
18
+ (14, 63, 133), (21, 88, 184), (31, 111, 230),
19
+ (59, 143, 255), (93, 158, 255), (130, 185, 255),
20
+ ]
21
+ _FOAM = (224, 238, 255)
22
+ _BLOCKS = " ▁▂▃▄▅▆▇█"
23
+
24
+
25
+ def supported(stream) -> bool:
26
+ return (
27
+ hasattr(stream, "isatty")
28
+ and stream.isatty()
29
+ and not os.environ.get("NO_COLOR")
30
+ and not os.environ.get("WAVEFRONT_NO_ART")
31
+ and os.environ.get("TERM", "") not in ("", "dumb")
32
+ )
33
+
34
+
35
+ def _rgb(rgb) -> str:
36
+ return f"\x1b[38;2;{rgb[0]};{rgb[1]};{rgb[2]}m"
37
+
38
+
39
+ def _surface(width: int, rows: int, phase: float) -> list[float]:
40
+ """Water height in cells (0..rows) per column — two summed travelling waves."""
41
+ out = []
42
+ for x in range(width):
43
+ h = 0.52 + 0.30 * math.sin(x * 0.26 - phase) + 0.14 * math.sin(x * 0.11 + phase * 0.6)
44
+ out.append(max(0.0, min(1.0, h)) * rows)
45
+ return out
46
+
47
+
48
+ def _render_frame(width: int, rows: int, phase: float) -> str:
49
+ surf = _surface(width, rows, phase)
50
+ lines = []
51
+ for r in range(rows): # r = 0 is the top row
52
+ depth_from_surface = r # 0 near crest → lighter
53
+ line = []
54
+ for x in range(width):
55
+ band = surf[x] - (rows - 1 - r) # cells of water in this row (>1 = full)
56
+ if band <= 0:
57
+ line.append(" ")
58
+ continue
59
+ crest = band < 1.0 # the topmost filled cell
60
+ block = _BLOCKS[min(8, max(1, int(round(band * 8))))] if crest else "█"
61
+ if crest:
62
+ color = _FOAM
63
+ else:
64
+ gi = min(len(_GRAD) - 1, depth_from_surface)
65
+ color = _GRAD[len(_GRAD) - 1 - gi] if False else _GRAD[gi]
66
+ line.append(_rgb(color) + block)
67
+ lines.append("".join(line) + "\x1b[0m")
68
+ return "\n".join(lines)
69
+
70
+
71
+ def wave(stream=None, *, duration: float = 1.3, fps: int = 30,
72
+ width: Optional[int] = None, rows: int = 4) -> None:
73
+ """Play the wave flourish, then leave the terminal clean."""
74
+ stream = stream or sys.stderr
75
+ if not supported(stream):
76
+ return
77
+ cols = shutil.get_terminal_size((80, 24)).columns
78
+ w = min(width or cols - 2, 60)
79
+ frames = max(1, int(duration * fps))
80
+ stream.write("\x1b[?25l") # hide cursor
81
+ try:
82
+ for f in range(frames):
83
+ stream.write(_render_frame(w, rows, f / fps * 6.5))
84
+ if f < frames - 1:
85
+ stream.write(f"\x1b[{rows - 1}A\r") # back to top of the wave
86
+ stream.flush()
87
+ time.sleep(1.0 / fps)
88
+ stream.write("\n")
89
+ finally:
90
+ stream.write("\x1b[?25h\x1b[0m") # restore cursor + reset
91
+ stream.flush()
92
+
93
+
94
+ def banner(stream=None) -> None:
95
+ """A one-shot wave + wordmark, used by the bare ``wavefront`` command."""
96
+ from . import __version__
97
+ stream = stream or sys.stderr
98
+ wave(stream, duration=1.1)
99
+ if supported(stream):
100
+ stream.write(_rgb(_GRAD[3]) + " wavefront " + _rgb(_GRAD[5])
101
+ + f"v{__version__}\x1b[0m " + "\x1b[2mfinwave datasets, one call\x1b[0m\n")
102
+ else:
103
+ stream.write(f"wavefront v{__version__} — finwave datasets, one call\n")
104
+
105
+
106
+ if __name__ == "__main__": # quick visual check: `python -m wavefront._art`
107
+ wave(sys.stdout, duration=3.0)