visin 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.
visin/__init__.py ADDED
@@ -0,0 +1,42 @@
1
+ """Report training runs to a Visin instance, and read them back.
2
+
3
+ import visin
4
+
5
+ with visin.init("unet baseline", project="road-seg") as run:
6
+ for epoch in range(1, epochs + 1):
7
+ train_loss, val_loss, val_iou = train_one_epoch()
8
+ run.log_epoch(epoch, train={"loss": train_loss}, val={"loss": val_loss, "mean_iou": val_iou})
9
+
10
+ ``init`` reads ``VISIN_URL`` and ``VISIN_TOKEN``. With neither set it returns a
11
+ run that reports nothing, so the same script still runs on a laptop.
12
+ """
13
+
14
+ from ._internal.config import Settings, read_settings
15
+ from ._internal.console import enable_console_logging
16
+ from ._version import __version__
17
+ from .api import Api, flatten
18
+ from .errors import ApiError, ConfigurationError, TransportError, VisinError
19
+ from .offline import SyncResult, pending, sync
20
+ from .run import Run, epoch_uuid_for, init
21
+ from .system import system_info, system_metrics
22
+
23
+ __all__ = [
24
+ "init",
25
+ "Run",
26
+ "Api",
27
+ "sync",
28
+ "pending",
29
+ "SyncResult",
30
+ "epoch_uuid_for",
31
+ "flatten",
32
+ "system_info",
33
+ "system_metrics",
34
+ "enable_console_logging",
35
+ "Settings",
36
+ "read_settings",
37
+ "VisinError",
38
+ "ApiError",
39
+ "TransportError",
40
+ "ConfigurationError",
41
+ "__version__",
42
+ ]
visin/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,9 @@
1
+ """How the package works: not part of its public API, and free to change in any release.
2
+
3
+ config settings from the environment and arguments
4
+ transport HTTP: retries that know which requests may repeat, errors, signed uploads
5
+ serialize NumPy scalars, tensors and NaN into JSON Visin accepts
6
+ reports each kind of report as data, and how it is delivered
7
+ sender the background thread reports are sent from, in order
8
+ spool reports kept on disk, and sending them later
9
+ """
@@ -0,0 +1,112 @@
1
+ """Where configuration comes from.
2
+
3
+ There is no default API URL, on purpose. The integrations this package replaces
4
+ each hard-coded one instance's hostname, so a copy taken into a new project
5
+ reported into somebody else's Visin until the line was noticed. Unset means
6
+ disabled, which is loud in the log and harmless, rather than wrong and silent.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import tempfile
13
+ from dataclasses import dataclass, replace
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from ..errors import ConfigurationError
18
+
19
+ # The first name of each is the one Visin's documentation uses. The others are
20
+ # what earlier drafts of this package read, and scripts already export them.
21
+ ENV_URL = ("VISIN_URL", "VISIN_API_URL")
22
+ ENV_TOKEN = ("VISIN_TOKEN", "VISIN_API_TOKEN")
23
+ ENV_TRAINING_UUID = ("VISIN_TRAINING_UUID",)
24
+ ENV_PROJECT = ("VISIN_PROJECT", "VISIN_PROJECT_ID")
25
+ ENV_VERIFY = "VISIN_VERIFY_SSL"
26
+ ENV_MODE = "VISIN_MODE"
27
+ ENV_DIR = "VISIN_DIR"
28
+
29
+ MODES = ("online", "offline", "disabled")
30
+
31
+
32
+ def _first(names: tuple[str, ...]) -> str | None:
33
+ for name in names:
34
+ value = os.getenv(name)
35
+ if value and value.strip():
36
+ return value.strip()
37
+ return None
38
+
39
+
40
+ def _flag(name: str, default: bool) -> bool:
41
+ raw = os.getenv(name)
42
+ if raw is None:
43
+ return default
44
+ return raw.strip().lower() not in {"0", "false", "no", "off"}
45
+
46
+
47
+ def default_directory() -> Path:
48
+ """Where reports wait when they cannot be sent: ``~/.visin``.
49
+
50
+ Home rather than the working directory because on a cluster the login node,
51
+ where ``visin sync`` runs, shares home with the compute nodes and usually
52
+ shares nothing else.
53
+ """
54
+ try:
55
+ return Path.home() / ".visin"
56
+ except RuntimeError: # no HOME, as in some minimal containers
57
+ return Path(tempfile.gettempdir()) / "visin"
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class Settings:
62
+ url: str | None
63
+ token: str | None
64
+ training_uuid: str | None
65
+ project: str | None
66
+ verify_ssl: bool
67
+ mode: str
68
+ directory: Path
69
+
70
+ @property
71
+ def configured(self) -> bool:
72
+ """Enough to talk to a server at all.
73
+
74
+ The training UUID is not part of this: a run that registers itself has
75
+ none yet, and that is the normal case when a script is launched by hand.
76
+ """
77
+ return bool(self.url and self.token)
78
+
79
+ @property
80
+ def effective_mode(self) -> str:
81
+ """What a run will actually do: online without a server is disabled."""
82
+ if self.mode == "online" and not self.configured:
83
+ return "disabled"
84
+ return self.mode
85
+
86
+
87
+ def read_settings(**overrides: Any) -> Settings:
88
+ """Read the environment. Keyword arguments that are not ``None`` win over it."""
89
+ directory = os.getenv(ENV_DIR)
90
+ settings = Settings(
91
+ url=_first(ENV_URL),
92
+ token=_first(ENV_TOKEN),
93
+ training_uuid=_first(ENV_TRAINING_UUID),
94
+ project=_first(ENV_PROJECT),
95
+ # Verification stays on unless someone deliberately turns it off for a
96
+ # self-signed dev instance. The scripts this replaces disabled it
97
+ # globally and silenced the warning, which also disabled it in
98
+ # production.
99
+ verify_ssl=_flag(ENV_VERIFY, True),
100
+ mode=(os.getenv(ENV_MODE) or "online").strip().lower(),
101
+ directory=Path(directory).expanduser() if directory else default_directory(),
102
+ )
103
+ given = {key: value for key, value in overrides.items() if value is not None}
104
+ if "directory" in given:
105
+ given["directory"] = Path(given["directory"]).expanduser()
106
+ if "mode" in given:
107
+ given["mode"] = str(given["mode"]).strip().lower()
108
+ if given:
109
+ settings = replace(settings, **given)
110
+ if settings.mode not in MODES:
111
+ raise ConfigurationError(f"{ENV_MODE}={settings.mode!r}; expected one of: {', '.join(MODES)}")
112
+ return settings
@@ -0,0 +1,31 @@
1
+ """Showing visin's log lines on the console."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import sys
7
+ from typing import TextIO
8
+
9
+ _MARK = "_visin_console"
10
+
11
+
12
+ def enable_console_logging(level: int | str = logging.INFO, stream: TextIO | None = None) -> None:
13
+ """Print visin's log lines: the run it created or resumed, anything it could
14
+ not send, and its summary at ``finish``.
15
+
16
+ visin logs through :mod:`logging` under the ``visin`` logger and, as a
17
+ library should, adds no handler of its own, so without this only warnings
18
+ reach the console. Pass ``stream=sys.stdout`` to keep the lines in order
19
+ with a script's own ``print`` output. Calling it again replaces the handler
20
+ rather than printing every line twice.
21
+ """
22
+ logger = logging.getLogger("visin")
23
+ for handler in list(logger.handlers):
24
+ if getattr(handler, _MARK, False):
25
+ logger.removeHandler(handler)
26
+ handler = logging.StreamHandler(stream or sys.stderr)
27
+ handler.setFormatter(logging.Formatter("%(message)s"))
28
+ setattr(handler, _MARK, True)
29
+ logger.addHandler(handler)
30
+ logger.setLevel(level)
31
+ logger.propagate = False
@@ -0,0 +1,187 @@
1
+ """What a run writes, as data, and how each kind of write is delivered.
2
+
3
+ Every write a run makes is first an *op*: a small JSON-able dict naming its
4
+ kind and carrying its request body. A live run delivers ops as it goes; an
5
+ offline run, or a live one that lost the server, writes them to disk and
6
+ ``visin sync`` delivers them later through this same function. One code path
7
+ means an op replayed a week later lands exactly as it would have at the time.
8
+
9
+ Delivery is made safe to repeat wherever the API allows it. A run, an epoch and
10
+ a test result carry a caller-generated UUID, so a repeat is answered 409 and
11
+ counted as delivered. Benchmarks and configs carry none, so those are the only
12
+ ops a lost answer can duplicate.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ import os
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+ from typing import Any
22
+ from urllib.parse import quote
23
+
24
+ from ..errors import ApiError, ConfigurationError
25
+ from .transport import HttpClient
26
+
27
+ logger = logging.getLogger("visin")
28
+
29
+ KINDS = frozenset({"create_run", "epoch", "test_result", "benchmark", "config", "visualization", "update"})
30
+
31
+
32
+ @dataclass
33
+ class DeliveryContext:
34
+ """What delivering one run's ops needs to remember between ops."""
35
+
36
+ files: Path | None = None
37
+ training_ids: dict[str, str] = field(default_factory=dict)
38
+ # Attempts per request, when a caller cannot afford the client's full
39
+ # budget: a run's blocking creation, or a catch-up that will be retried
40
+ # in a minute anyway. None means the client's own.
41
+ retries: int | None = None
42
+ # Runs a create_run found already registered: resumed, not new.
43
+ existing: set[str] = field(default_factory=set)
44
+ # Epochs the server answered 409 for: recorded before this delivery.
45
+ repeated: set[str] = field(default_factory=set)
46
+
47
+
48
+ def training_id(client: HttpClient, context: DeliveryContext, training_uuid: str) -> str:
49
+ """The database id of a run, which ``PUT /trainings/:id`` needs."""
50
+ known = context.training_ids.get(training_uuid)
51
+ if known:
52
+ return known
53
+ found = client.request("GET", f"/trainings/uuid/{quote(training_uuid, safe='')}", retries=context.retries)
54
+ ident = (found or {}).get("_id")
55
+ if not ident:
56
+ raise ApiError(f"no training with uuid {training_uuid}", status=404)
57
+ context.training_ids[training_uuid] = ident
58
+ return str(ident)
59
+
60
+
61
+ def _already_there(exc: ApiError) -> bool:
62
+ return exc.status == 409
63
+
64
+
65
+ def deliver(client: HttpClient, op: dict[str, Any], context: DeliveryContext) -> Any:
66
+ kind = op.get("op")
67
+ body = op.get("body") or {}
68
+ if kind == "create_run":
69
+ return _create_run(client, body, context)
70
+ if kind == "epoch":
71
+ return _post_once(client, "/epochs/upload", body, context)
72
+ if kind == "test_result":
73
+ return _post_once(client, "/test-results/upload", body, context)
74
+ if kind == "benchmark":
75
+ return client.request("POST", "/benchmarks/upload", json=body, retries=context.retries)
76
+ if kind == "config":
77
+ return _config(client, op, body, context)
78
+ if kind == "visualization":
79
+ return _visualization(client, op, body, context)
80
+ if kind == "update":
81
+ ident = training_id(client, context, op["training_uuid"])
82
+ return client.request("PUT", f"/trainings/{ident}", json=body, retries=context.retries)
83
+ raise ValueError(f"unknown report kind {kind!r}; was it written by a newer visin?")
84
+
85
+
86
+ def _post_once(client: HttpClient, path: str, body: dict[str, Any], context: DeliveryContext) -> Any:
87
+ try:
88
+ return client.request("POST", path, json=body, idempotent=True, retries=context.retries)
89
+ except ApiError as exc:
90
+ # The server refuses a repeat of a UUID it already has. That is exactly
91
+ # the outcome a retry should produce, so it counts as delivered.
92
+ if _already_there(exc):
93
+ logger.debug("visin: %s already recorded", path)
94
+ if body.get("epoch_uuid") and path == "/epochs/upload":
95
+ context.repeated.add(str(body["epoch_uuid"]))
96
+ return None
97
+ raise
98
+
99
+
100
+ def _create_run(client: HttpClient, body: dict[str, Any], context: DeliveryContext) -> Any:
101
+ uuid = body["uuid"]
102
+ try:
103
+ training = client.request("POST", "/trainings", json=body, idempotent=True, retries=context.retries)
104
+ except ApiError as exc:
105
+ if not _already_there(exc):
106
+ raise
107
+ # Created by an earlier attempt, or by whatever launched this process.
108
+ context.existing.add(uuid)
109
+ training = client.request("GET", f"/trainings/uuid/{quote(uuid, safe='')}", retries=context.retries)
110
+ ident = (training or {}).get("_id")
111
+ if ident:
112
+ context.training_ids[uuid] = str(ident)
113
+ return training
114
+
115
+
116
+ def _config(client: HttpClient, op: dict[str, Any], body: dict[str, Any], context: DeliveryContext) -> Any:
117
+ config = client.request("POST", "/configs/upload", json=body, retries=context.retries)
118
+ config_id = (config or {}).get("_id")
119
+ training_uuid = op.get("training_uuid")
120
+ if config_id and training_uuid:
121
+ # A config nobody points at is a row in a library. Linking it is what
122
+ # makes it this run's config, shown on the run's page.
123
+ ident = training_id(client, context, training_uuid)
124
+ client.request("PUT", f"/trainings/{ident}", json={"configId": config_id}, retries=context.retries)
125
+ return config
126
+
127
+
128
+ def _visualization(
129
+ client: HttpClient, op: dict[str, Any], body: dict[str, Any], context: DeliveryContext
130
+ ) -> Any:
131
+ path = staged_path(op, context)
132
+ if not path.exists():
133
+ raise ConfigurationError(f"the file for visualization {body.get('filename')!r} is gone: {path}")
134
+ grant = client.request(
135
+ "POST",
136
+ "/visualizations/upload-url",
137
+ json={
138
+ "epoch_uuid": body["epoch_uuid"],
139
+ "filename": body["filename"],
140
+ "type": body["type"],
141
+ "mimetype": body["mimetype"],
142
+ },
143
+ retries=context.retries,
144
+ )
145
+ try:
146
+ upload_url, visualization_uuid, file_id = (
147
+ grant["uploadUrl"],
148
+ grant["visualization_uuid"],
149
+ grant["fileId"],
150
+ )
151
+ except (KeyError, TypeError) as exc:
152
+ raise ApiError(f"upload-url answered without {exc}", status=None) from exc
153
+ client.put_file(upload_url, str(path), body["mimetype"], retries=context.retries)
154
+ record = {
155
+ "epoch_uuid": body["epoch_uuid"],
156
+ "visualization_uuid": visualization_uuid,
157
+ "filename": body["filename"],
158
+ "type": body["type"],
159
+ "fileId": file_id,
160
+ "mimetype": body["mimetype"],
161
+ # The exact byte count: the server refuses a record whose size differs
162
+ # from the file it received.
163
+ "size": os.path.getsize(path),
164
+ }
165
+ if body.get("metadata") is not None:
166
+ record["metadata"] = body["metadata"]
167
+ return _post_once(client, "/visualizations", record, context)
168
+
169
+
170
+ def staged_path(op: dict[str, Any], context: DeliveryContext) -> Path:
171
+ """Where the file an op uploads lives: its staged copy, or the original."""
172
+ staged = op.get("staged")
173
+ if staged and context.files is not None:
174
+ return context.files / str(staged)
175
+ return Path(str(op.get("path") or ""))
176
+
177
+
178
+ def discard_staged(op: dict[str, Any], context: DeliveryContext) -> None:
179
+ """Remove an op's staged copy once nothing will need it again."""
180
+ if op.get("op") != "visualization" or not op.get("staged") or context.files is None:
181
+ return
182
+ try:
183
+ (context.files / op["staged"]).unlink()
184
+ except FileNotFoundError:
185
+ pass
186
+ except OSError as exc: # pragma: no cover - a read-only or vanished directory
187
+ logger.debug("visin: could not remove staged file: %s", exc)
@@ -0,0 +1,143 @@
1
+ """A background sender, so ``log_epoch`` costs the training loop nothing.
2
+
3
+ An epoch POST across a university network takes anywhere from 20ms to several
4
+ seconds, and it happens at exactly the moment the loop wants to start the next
5
+ epoch. Doing it inline makes reporting a tax on training time; worse, a
6
+ degraded Visin instance would slow the run down rather than merely losing
7
+ metrics.
8
+
9
+ So work is queued and a daemon thread drains it. The cost of that choice is
10
+ that failures surface late, which is why `flush` exists and why `Run.finish`
11
+ calls it.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import queue
18
+ import threading
19
+ from typing import Callable
20
+
21
+ logger = logging.getLogger("visin")
22
+
23
+
24
+ class _Barrier:
25
+ """A marker the worker signals when it reaches this point in the queue."""
26
+
27
+ __slots__ = ("event",)
28
+
29
+ def __init__(self) -> None:
30
+ self.event = threading.Event()
31
+
32
+
33
+ class Sender:
34
+ """Serialises queued work onto one thread.
35
+
36
+ One thread, not a pool: the writes are ordered (a run is created before its
37
+ epochs, an epoch exists before its visualizations) and ordering is cheaper
38
+ to guarantee than to recover from.
39
+ """
40
+
41
+ def __init__(self, max_queue: int = 10_000):
42
+ self._queue: queue.Queue[Callable[[], None] | _Barrier | None] = queue.Queue(maxsize=max_queue)
43
+ self._thread = threading.Thread(target=self._drain, name="visin-sender", daemon=True)
44
+ self._stopping = threading.Event()
45
+ self._abandoned = threading.Event()
46
+ self._started = False
47
+ self._lock = threading.Lock()
48
+ self.dropped = 0
49
+ self.failed = 0
50
+ # Work handed back by a stop that could not wait for it: never attempted.
51
+ self.leftover: list[Callable[[], None]] = []
52
+
53
+ def _ensure_started(self) -> None:
54
+ with self._lock:
55
+ if not self._started:
56
+ self._thread.start()
57
+ self._started = True
58
+
59
+ def submit(self, work: Callable[[], None]) -> bool:
60
+ """Queue one unit of work. Never blocks the caller.
61
+
62
+ A full queue drops the item rather than blocking the training loop.
63
+ Losing a metric is a bad outcome; stalling training because a metrics
64
+ server is unreachable is a worse one, and the drop is counted so
65
+ `finish` can report it. Returns whether the work was queued.
66
+ """
67
+ if self._stopping.is_set():
68
+ return False
69
+ self._ensure_started()
70
+ try:
71
+ self._queue.put_nowait(work)
72
+ except queue.Full:
73
+ self.dropped += 1
74
+ logger.warning("visin: queue full, dropped a report (%d dropped so far)", self.dropped)
75
+ return False
76
+ return True
77
+
78
+ def _drain(self) -> None:
79
+ while True:
80
+ item = self._queue.get()
81
+ try:
82
+ if item is None:
83
+ return
84
+ if isinstance(item, _Barrier):
85
+ item.event.set()
86
+ continue
87
+ if self._abandoned.is_set():
88
+ self.leftover.append(item)
89
+ continue
90
+ item()
91
+ except Exception as exc: # noqa: BLE001 - a reporting failure must not kill the thread
92
+ self.failed += 1
93
+ logger.warning("visin: report failed: %s", exc)
94
+ finally:
95
+ self._queue.task_done()
96
+
97
+ def flush(self, timeout: float = 30.0) -> bool:
98
+ """Block until everything queued so far has been attempted.
99
+
100
+ Implemented with a barrier rather than ``Queue.join`` because join has
101
+ no timeout, and a hung flush at the end of a training run would turn a
102
+ finished job into one the scheduler eventually kills.
103
+ """
104
+ if not self._started or not self._thread.is_alive():
105
+ return True # nothing queued will ever run, so there is nothing to wait for
106
+ barrier = _Barrier()
107
+ try:
108
+ self._queue.put_nowait(barrier)
109
+ except queue.Full:
110
+ return False
111
+ if not barrier.event.wait(timeout):
112
+ logger.warning("visin: flush timed out after %.0fs; some reports may be unsent", timeout)
113
+ return False
114
+ return True
115
+
116
+ def stop(self, timeout: float = 30.0) -> bool:
117
+ """Flush, then retire the thread.
118
+
119
+ When the flush times out, whatever is still queued is not attempted: it
120
+ is moved to ``leftover``, so the caller can keep it for later rather
121
+ than lose it with the process. The item in flight, if any, finishes on
122
+ its own.
123
+ """
124
+ flushed = self.flush(timeout)
125
+ self._stopping.set()
126
+ if not self._started:
127
+ return flushed
128
+ if not flushed:
129
+ self._abandoned.set()
130
+ while True:
131
+ try:
132
+ item = self._queue.get_nowait()
133
+ except queue.Empty:
134
+ break
135
+ if item is not None and not isinstance(item, _Barrier):
136
+ self.leftover.append(item)
137
+ self._queue.task_done()
138
+ try:
139
+ self._queue.put_nowait(None)
140
+ except queue.Full: # pragma: no cover - just drained
141
+ pass
142
+ self._thread.join(timeout=5.0 if flushed else 0.1)
143
+ return flushed
@@ -0,0 +1,85 @@
1
+ """Turning what a training loop has in hand into JSON Visin will accept.
2
+
3
+ A loop's metrics are rarely plain floats. They are NumPy scalars, zero-dimensional
4
+ tensors, the odd ``Decimal``; ``json.dumps`` refuses every one of them, and in
5
+ a background sender that refusal turns into a metric that silently never
6
+ arrives. Converting here, at the call, keeps the mistake visible and the
7
+ common cases working.
8
+
9
+ NaN and infinity have no JSON spelling. Sent anyway, the body is not valid JSON
10
+ and the server refuses the whole epoch, so they become ``null``, which Visin
11
+ already reads as "not measured" and charts as a gap.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import dataclasses
17
+ import enum
18
+ import math
19
+ import uuid
20
+ from collections.abc import Mapping
21
+ from datetime import date, datetime
22
+ from decimal import Decimal
23
+ from pathlib import PurePath
24
+ from typing import Any, Callable
25
+
26
+
27
+ def to_jsonable(
28
+ value: Any,
29
+ nonfinite: list[str] | None = None,
30
+ path: str = "",
31
+ fallback: Callable[[Any], Any] | None = None,
32
+ ) -> Any:
33
+ """Return ``value`` as plain JSON types.
34
+
35
+ ``nonfinite``, when given, collects the dotted path of every NaN or
36
+ infinity that was replaced by ``None``, so the caller can say so.
37
+ ``fallback`` converts what nothing else can; without it, that raises.
38
+ """
39
+ if value is None or isinstance(value, (bool, str)):
40
+ return value
41
+ if isinstance(value, int):
42
+ return value
43
+ if isinstance(value, float):
44
+ if math.isfinite(value):
45
+ return value
46
+ if nonfinite is not None:
47
+ nonfinite.append(path or "<value>")
48
+ return None
49
+ if isinstance(value, Mapping):
50
+ return {
51
+ str(key): to_jsonable(item, nonfinite, f"{path}.{key}" if path else str(key), fallback)
52
+ for key, item in value.items()
53
+ }
54
+ if isinstance(value, (list, tuple, set, frozenset)):
55
+ return [
56
+ to_jsonable(item, nonfinite, f"{path}[{index}]", fallback) for index, item in enumerate(value)
57
+ ]
58
+ if isinstance(value, (datetime, date)):
59
+ return value.isoformat()
60
+ if isinstance(value, Decimal):
61
+ return to_jsonable(float(value), nonfinite, path, fallback)
62
+ if isinstance(value, (uuid.UUID, PurePath)):
63
+ return str(value)
64
+ if isinstance(value, enum.Enum):
65
+ return to_jsonable(value.value, nonfinite, path, fallback)
66
+ if dataclasses.is_dataclass(value) and not isinstance(value, type):
67
+ return to_jsonable(dataclasses.asdict(value), nonfinite, path, fallback)
68
+ # NumPy scalars and arrays, and PyTorch/TensorFlow tensors, all offer
69
+ # ``tolist``: a scalar comes back as a Python number, an array as nested
70
+ # lists. Duck-typed so none of those libraries has to be installed.
71
+ tolist = getattr(value, "tolist", None)
72
+ if callable(tolist):
73
+ return to_jsonable(tolist(), nonfinite, path, fallback)
74
+ item = getattr(value, "item", None)
75
+ if callable(item):
76
+ return to_jsonable(item(), nonfinite, path, fallback)
77
+ numpy = getattr(value, "numpy", None) # an eager TensorFlow tensor
78
+ if callable(numpy):
79
+ return to_jsonable(numpy(), nonfinite, path, fallback)
80
+ if fallback is not None:
81
+ return fallback(value)
82
+ raise TypeError(
83
+ f"visin cannot send a {type(value).__name__}"
84
+ f"{f' (at {path})' if path else ''}; convert it to a number, string, list or dict"
85
+ )