furu 0.0.1__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.
- furu/__init__.py +82 -0
- furu/adapters/__init__.py +3 -0
- furu/adapters/submitit.py +195 -0
- furu/config.py +98 -0
- furu/core/__init__.py +4 -0
- furu/core/furu.py +999 -0
- furu/core/list.py +123 -0
- furu/dashboard/__init__.py +9 -0
- furu/dashboard/__main__.py +7 -0
- furu/dashboard/api/__init__.py +7 -0
- furu/dashboard/api/models.py +170 -0
- furu/dashboard/api/routes.py +135 -0
- furu/dashboard/frontend/dist/assets/index-CbdDfSOZ.css +1 -0
- furu/dashboard/frontend/dist/assets/index-DDv_TYB_.js +67 -0
- furu/dashboard/frontend/dist/favicon.svg +10 -0
- furu/dashboard/frontend/dist/index.html +22 -0
- furu/dashboard/main.py +134 -0
- furu/dashboard/scanner.py +931 -0
- furu/errors.py +76 -0
- furu/migrate.py +48 -0
- furu/migration.py +926 -0
- furu/runtime/__init__.py +27 -0
- furu/runtime/env.py +8 -0
- furu/runtime/logging.py +301 -0
- furu/runtime/tracebacks.py +64 -0
- furu/serialization/__init__.py +20 -0
- furu/serialization/migrations.py +246 -0
- furu/serialization/serializer.py +233 -0
- furu/storage/__init__.py +32 -0
- furu/storage/metadata.py +282 -0
- furu/storage/migration.py +81 -0
- furu/storage/state.py +1107 -0
- furu-0.0.1.dist-info/METADATA +502 -0
- furu-0.0.1.dist-info/RECORD +36 -0
- furu-0.0.1.dist-info/WHEEL +4 -0
- furu-0.0.1.dist-info/entry_points.txt +2 -0
furu/runtime/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Runtime helpers for furu (logging, .env loading, tracebacks).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .env import load_env
|
|
6
|
+
from .logging import (
|
|
7
|
+
configure_logging,
|
|
8
|
+
current_holder,
|
|
9
|
+
current_log_dir,
|
|
10
|
+
enter_holder,
|
|
11
|
+
get_logger,
|
|
12
|
+
log,
|
|
13
|
+
write_separator,
|
|
14
|
+
)
|
|
15
|
+
from .tracebacks import _print_colored_traceback
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"_print_colored_traceback",
|
|
19
|
+
"configure_logging",
|
|
20
|
+
"current_holder",
|
|
21
|
+
"current_log_dir",
|
|
22
|
+
"enter_holder",
|
|
23
|
+
"get_logger",
|
|
24
|
+
"load_env",
|
|
25
|
+
"log",
|
|
26
|
+
"write_separator",
|
|
27
|
+
]
|
furu/runtime/env.py
ADDED
furu/runtime/logging.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import contextvars
|
|
3
|
+
import datetime
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import threading
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Generator, Protocol
|
|
9
|
+
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
from ..config import FURU_CONFIG
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _HolderProtocol(Protocol):
|
|
16
|
+
"""Protocol for objects that can be used as logging context holders."""
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def furu_dir(self) -> Path: ...
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# A holder is either a Path directly or an object with a furu_dir attribute
|
|
23
|
+
HolderType = Path | _HolderProtocol
|
|
24
|
+
|
|
25
|
+
_FURU_HOLDER_STACK: contextvars.ContextVar[tuple[HolderType, ...]] = (
|
|
26
|
+
contextvars.ContextVar("furu_holder_stack", default=())
|
|
27
|
+
)
|
|
28
|
+
_FURU_LOG_LOCK = threading.Lock()
|
|
29
|
+
_FURU_CONSOLE_LOCK = threading.Lock()
|
|
30
|
+
|
|
31
|
+
_LOAD_OR_CREATE_PREFIX = "load_or_create"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _strip_load_or_create_decision_suffix(message: str) -> str:
|
|
35
|
+
"""
|
|
36
|
+
Strip a trailing `(<decision>)` suffix from `load_or_create ...` console lines.
|
|
37
|
+
|
|
38
|
+
This keeps detailed decision info in file logs, but makes console output cleaner.
|
|
39
|
+
"""
|
|
40
|
+
if not message.startswith(_LOAD_OR_CREATE_PREFIX):
|
|
41
|
+
return message
|
|
42
|
+
if not message.endswith(")"):
|
|
43
|
+
return message
|
|
44
|
+
idx = message.rfind(" (")
|
|
45
|
+
if idx == -1:
|
|
46
|
+
return message
|
|
47
|
+
|
|
48
|
+
decision = message[idx + 2 : -1]
|
|
49
|
+
if decision == "create" or "->" in decision:
|
|
50
|
+
return message[:idx]
|
|
51
|
+
return message
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _holder_to_log_dir(holder: HolderType) -> Path:
|
|
55
|
+
if isinstance(holder, Path):
|
|
56
|
+
base_dir = holder
|
|
57
|
+
else:
|
|
58
|
+
directory = getattr(holder, "furu_dir", None)
|
|
59
|
+
if not isinstance(directory, Path):
|
|
60
|
+
raise TypeError(
|
|
61
|
+
"holder must be a pathlib.Path or have a .furu_dir: pathlib.Path attribute"
|
|
62
|
+
)
|
|
63
|
+
base_dir = directory
|
|
64
|
+
return base_dir / ".furu"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@contextlib.contextmanager
|
|
68
|
+
def enter_holder(holder: HolderType) -> Generator[None, None, None]:
|
|
69
|
+
"""
|
|
70
|
+
Push a holder object onto the logging stack for this context.
|
|
71
|
+
|
|
72
|
+
Furu calls this automatically during `load_or_create()`, so nested
|
|
73
|
+
dependencies will log to the active dependency's folder and then revert.
|
|
74
|
+
"""
|
|
75
|
+
configure_logging()
|
|
76
|
+
stack = _FURU_HOLDER_STACK.get()
|
|
77
|
+
token = _FURU_HOLDER_STACK.set((*stack, holder))
|
|
78
|
+
try:
|
|
79
|
+
yield
|
|
80
|
+
finally:
|
|
81
|
+
_FURU_HOLDER_STACK.reset(token)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def current_holder() -> HolderType | None:
|
|
85
|
+
"""Return the current holder object for logging, if any."""
|
|
86
|
+
stack = _FURU_HOLDER_STACK.get()
|
|
87
|
+
return stack[-1] if stack else None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def current_log_dir() -> Path:
|
|
91
|
+
"""Return the directory logs should be written to for this context."""
|
|
92
|
+
holder = current_holder()
|
|
93
|
+
if holder is None:
|
|
94
|
+
return FURU_CONFIG.base_root
|
|
95
|
+
return _holder_to_log_dir(holder)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class _FuruLogFormatter(logging.Formatter):
|
|
99
|
+
def formatTime( # noqa: N802 - keep logging.Formatter API
|
|
100
|
+
self, record: logging.LogRecord, datefmt: str | None = None
|
|
101
|
+
) -> str:
|
|
102
|
+
dt = datetime.datetime.fromtimestamp(record.created, tz=datetime.timezone.utc)
|
|
103
|
+
return dt.isoformat(timespec="seconds")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class _FuruContextFileHandler(logging.Handler):
|
|
107
|
+
"""
|
|
108
|
+
A logging handler that writes to `current_log_dir() / "furu.log"` at emit-time.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
112
|
+
message = self.format(record)
|
|
113
|
+
|
|
114
|
+
directory = current_log_dir()
|
|
115
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
|
|
117
|
+
log_path = directory / "furu.log"
|
|
118
|
+
with _FURU_LOG_LOCK:
|
|
119
|
+
with log_path.open("a", encoding="utf-8") as fp:
|
|
120
|
+
fp.write(f"{message}\n")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class _FuruScopeFilter(logging.Filter):
|
|
124
|
+
"""
|
|
125
|
+
Capture all logs while inside a holder context.
|
|
126
|
+
|
|
127
|
+
Outside a holder context, only capture logs from the `furu` logger namespace.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
131
|
+
if current_holder() is not None:
|
|
132
|
+
return True
|
|
133
|
+
return record.name == "furu" or record.name.startswith("furu.")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class _FuruFileFilter(logging.Filter):
|
|
137
|
+
"""Filter out records intended for console only."""
|
|
138
|
+
|
|
139
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
140
|
+
return not bool(getattr(record, "furu_console_only", False))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class _FuruConsoleFilter(logging.Filter):
|
|
144
|
+
"""Only show furu namespace logs on console."""
|
|
145
|
+
|
|
146
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
147
|
+
if bool(getattr(record, "furu_file_only", False)):
|
|
148
|
+
return False
|
|
149
|
+
return record.name == "furu" or record.name.startswith("furu.")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _console_level() -> int:
|
|
153
|
+
level = os.getenv("FURU_LOG_LEVEL", "INFO").upper()
|
|
154
|
+
return logging.getLevelNamesMapping().get(level, logging.INFO)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class _FuruRichConsoleHandler(logging.Handler):
|
|
158
|
+
def __init__(self, *, level: int) -> None:
|
|
159
|
+
super().__init__(level=level)
|
|
160
|
+
from rich.console import Console # type: ignore
|
|
161
|
+
|
|
162
|
+
self._console = Console(stderr=True)
|
|
163
|
+
|
|
164
|
+
@staticmethod
|
|
165
|
+
def _format_location(record: logging.LogRecord) -> str:
|
|
166
|
+
# Use caller location if available (for load_or_create messages)
|
|
167
|
+
caller_file = getattr(record, "furu_caller_file", None)
|
|
168
|
+
caller_line = getattr(record, "furu_caller_line", None)
|
|
169
|
+
if caller_file is not None and caller_line is not None:
|
|
170
|
+
filename = Path(caller_file).name
|
|
171
|
+
return f"[{filename}:{caller_line}]"
|
|
172
|
+
filename = Path(record.pathname).name if record.pathname else "<unknown>"
|
|
173
|
+
return f"[{filename}:{record.lineno}]"
|
|
174
|
+
|
|
175
|
+
@staticmethod
|
|
176
|
+
def _format_message_text(record: logging.LogRecord) -> Text:
|
|
177
|
+
message = _strip_load_or_create_decision_suffix(record.getMessage())
|
|
178
|
+
action_color = getattr(record, "furu_action_color", None)
|
|
179
|
+
if isinstance(action_color, str) and message.startswith(_LOAD_OR_CREATE_PREFIX):
|
|
180
|
+
prefix = _LOAD_OR_CREATE_PREFIX
|
|
181
|
+
rest = message[len(prefix) :]
|
|
182
|
+
text = Text()
|
|
183
|
+
text.append(prefix, style=action_color)
|
|
184
|
+
text.append(rest)
|
|
185
|
+
return text
|
|
186
|
+
return Text(message)
|
|
187
|
+
|
|
188
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
189
|
+
level_style = self._level_style(record.levelno)
|
|
190
|
+
timestamp = datetime.datetime.fromtimestamp(
|
|
191
|
+
record.created, tz=datetime.timezone.utc
|
|
192
|
+
).strftime("%H:%M:%S")
|
|
193
|
+
|
|
194
|
+
location = self._format_location(record)
|
|
195
|
+
|
|
196
|
+
line = Text()
|
|
197
|
+
line.append(timestamp, style="dim")
|
|
198
|
+
line.append(" ")
|
|
199
|
+
line.append(location, style=level_style)
|
|
200
|
+
line.append(" ")
|
|
201
|
+
line.append_text(self._format_message_text(record))
|
|
202
|
+
|
|
203
|
+
with _FURU_CONSOLE_LOCK:
|
|
204
|
+
self._console.print(line)
|
|
205
|
+
|
|
206
|
+
if record.exc_info:
|
|
207
|
+
from rich.traceback import Traceback # type: ignore
|
|
208
|
+
|
|
209
|
+
exc_type, exc_value, tb = record.exc_info
|
|
210
|
+
if exc_type is not None and exc_value is not None and tb is not None:
|
|
211
|
+
with _FURU_CONSOLE_LOCK:
|
|
212
|
+
self._console.print(
|
|
213
|
+
Traceback.from_exception(
|
|
214
|
+
exc_type, exc_value, tb, show_locals=False
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
@staticmethod
|
|
219
|
+
def _level_style(levelno: int) -> str:
|
|
220
|
+
if levelno >= logging.ERROR:
|
|
221
|
+
return "red"
|
|
222
|
+
if levelno >= logging.WARNING:
|
|
223
|
+
return "yellow"
|
|
224
|
+
if levelno >= logging.INFO:
|
|
225
|
+
return "blue"
|
|
226
|
+
return "magenta"
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def configure_logging() -> None:
|
|
230
|
+
"""
|
|
231
|
+
Install context-aware file logging + rich console logging (idempotent).
|
|
232
|
+
|
|
233
|
+
With this installed, any stdlib logger (e.g. `logging.getLogger(__name__)`)
|
|
234
|
+
that propagates to the root logger will be written to the current holder's
|
|
235
|
+
`furu.log` while a holder is active.
|
|
236
|
+
"""
|
|
237
|
+
root = logging.getLogger()
|
|
238
|
+
if not any(isinstance(h, _FuruContextFileHandler) for h in root.handlers):
|
|
239
|
+
handler = _FuruContextFileHandler(level=logging.DEBUG)
|
|
240
|
+
handler.addFilter(_FuruScopeFilter())
|
|
241
|
+
handler.addFilter(_FuruFileFilter())
|
|
242
|
+
handler.setFormatter(
|
|
243
|
+
_FuruLogFormatter(
|
|
244
|
+
"%(asctime)s [%(levelname)s] %(name)s %(filename)s:%(lineno)d %(message)s"
|
|
245
|
+
)
|
|
246
|
+
)
|
|
247
|
+
root.addHandler(handler)
|
|
248
|
+
|
|
249
|
+
if not any(isinstance(h, _FuruRichConsoleHandler) for h in root.handlers):
|
|
250
|
+
console = _FuruRichConsoleHandler(level=_console_level())
|
|
251
|
+
console.addFilter(_FuruConsoleFilter())
|
|
252
|
+
root.addHandler(console)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def get_logger() -> logging.Logger:
|
|
256
|
+
"""
|
|
257
|
+
Return the default furu logger.
|
|
258
|
+
|
|
259
|
+
It is configured with a context-aware file handler that routes log records to
|
|
260
|
+
the current holder's directory (see `enter_holder()`).
|
|
261
|
+
"""
|
|
262
|
+
configure_logging()
|
|
263
|
+
logger = logging.getLogger("furu")
|
|
264
|
+
logger.setLevel(logging.DEBUG)
|
|
265
|
+
return logger
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def log(message: str, *, level: str = "INFO") -> Path:
|
|
269
|
+
"""
|
|
270
|
+
Log a message to the current holder's `furu.log` via stdlib `logging`.
|
|
271
|
+
|
|
272
|
+
If no holder is active, logs to `FURU_CONFIG.base_root / "furu.log"`.
|
|
273
|
+
Returns the path written to.
|
|
274
|
+
"""
|
|
275
|
+
directory = current_log_dir()
|
|
276
|
+
log_path = directory / "furu.log"
|
|
277
|
+
|
|
278
|
+
level_no = logging.getLevelNamesMapping().get(level.upper())
|
|
279
|
+
if level_no is None:
|
|
280
|
+
raise ValueError(f"Unknown log level: {level!r}")
|
|
281
|
+
|
|
282
|
+
configure_logging()
|
|
283
|
+
get_logger().log(level_no, message)
|
|
284
|
+
return log_path
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def write_separator(line: str = "------------------") -> Path:
|
|
288
|
+
"""
|
|
289
|
+
Write a raw separator line to the current holder's `furu.log`.
|
|
290
|
+
|
|
291
|
+
This bypasses standard formatting so repeated `load_or_create()` calls are easy to spot.
|
|
292
|
+
"""
|
|
293
|
+
directory = current_log_dir()
|
|
294
|
+
log_path = directory / "furu.log"
|
|
295
|
+
|
|
296
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
297
|
+
|
|
298
|
+
with _FURU_LOG_LOCK:
|
|
299
|
+
with log_path.open("a", encoding="utf-8") as fp:
|
|
300
|
+
fp.write(f"{line}\n")
|
|
301
|
+
return log_path
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.traceback import Traceback
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def format_traceback(exc: BaseException) -> str:
|
|
9
|
+
"""
|
|
10
|
+
Format an exception traceback for writing to logs.
|
|
11
|
+
|
|
12
|
+
Uses Rich traceback (box-drawn, readable).
|
|
13
|
+
"""
|
|
14
|
+
buffer = io.StringIO()
|
|
15
|
+
console = Console(file=buffer, record=True, width=120)
|
|
16
|
+
tb = Traceback.from_exception(
|
|
17
|
+
type(exc),
|
|
18
|
+
exc,
|
|
19
|
+
exc.__traceback__,
|
|
20
|
+
show_locals=False,
|
|
21
|
+
width=120,
|
|
22
|
+
extra_lines=3,
|
|
23
|
+
theme="monokai",
|
|
24
|
+
word_wrap=False,
|
|
25
|
+
)
|
|
26
|
+
console.print(tb)
|
|
27
|
+
return console.export_text(styles=False).rstrip()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _print_colored_traceback(exc: BaseException) -> None:
|
|
31
|
+
"""
|
|
32
|
+
Print a full, colored traceback to stderr.
|
|
33
|
+
Uses rich for pretty formatting.
|
|
34
|
+
"""
|
|
35
|
+
console = Console(stderr=True)
|
|
36
|
+
tb = Traceback.from_exception(
|
|
37
|
+
type(exc),
|
|
38
|
+
exc,
|
|
39
|
+
exc.__traceback__,
|
|
40
|
+
show_locals=False, # flip True if you want locals
|
|
41
|
+
width=None, # auto width
|
|
42
|
+
extra_lines=3, # a bit more context
|
|
43
|
+
theme="monokai", # pick your fave; 'ansi_dark' is nice too
|
|
44
|
+
word_wrap=False,
|
|
45
|
+
)
|
|
46
|
+
console.print(tb)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _install_rich_uncaught_exceptions() -> None:
|
|
50
|
+
from rich.traceback import install as _rich_install # type: ignore
|
|
51
|
+
|
|
52
|
+
_rich_install(show_locals=False)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_RICH_UNCAUGHT_ENABLED = os.getenv("FURU_RICH_UNCAUGHT_TRACEBACKS", "").lower() in {
|
|
56
|
+
"",
|
|
57
|
+
"1",
|
|
58
|
+
"true",
|
|
59
|
+
"yes",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# Enable rich tracebacks for uncaught exceptions by default (opt-out via env var).
|
|
63
|
+
if _RICH_UNCAUGHT_ENABLED:
|
|
64
|
+
_install_rich_uncaught_exceptions()
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
|
|
3
|
+
from .migrations import (
|
|
4
|
+
FieldAdd,
|
|
5
|
+
FieldRename,
|
|
6
|
+
MIGRATION_REGISTRY,
|
|
7
|
+
MigrationSpec,
|
|
8
|
+
Transform,
|
|
9
|
+
)
|
|
10
|
+
from .serializer import FuruSerializer
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"BaseModel",
|
|
14
|
+
"FieldAdd",
|
|
15
|
+
"FieldRename",
|
|
16
|
+
"FuruSerializer",
|
|
17
|
+
"MIGRATION_REGISTRY",
|
|
18
|
+
"MigrationSpec",
|
|
19
|
+
"Transform",
|
|
20
|
+
]
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Callable, Generic, TypeVar
|
|
5
|
+
|
|
6
|
+
from ..runtime.logging import get_logger
|
|
7
|
+
from .serializer import JsonValue
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
_T = TypeVar("_T")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class MigrationContext:
|
|
15
|
+
fields: dict[str, JsonValue]
|
|
16
|
+
from_class: str
|
|
17
|
+
to_class: str
|
|
18
|
+
from_version: float
|
|
19
|
+
to_version: float
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class FieldRename:
|
|
24
|
+
old: str
|
|
25
|
+
new: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class FieldAdd(Generic[_T]):
|
|
30
|
+
name: str
|
|
31
|
+
default: _T | None = None
|
|
32
|
+
default_factory: Callable[[MigrationContext], _T] | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class Transform:
|
|
37
|
+
func: Callable[[dict[str, JsonValue]], dict[str, JsonValue]]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
MigrationStep = FieldRename | FieldAdd[JsonValue] | Transform
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class MigrationSpec:
|
|
45
|
+
from_class: str
|
|
46
|
+
from_version: float
|
|
47
|
+
to_version: float
|
|
48
|
+
steps: list[MigrationStep]
|
|
49
|
+
to_class: str | None = None
|
|
50
|
+
note: str | None = None
|
|
51
|
+
|
|
52
|
+
def default_value_for(self, name: str, data: dict[str, JsonValue]) -> JsonValue:
|
|
53
|
+
for step in self.steps:
|
|
54
|
+
if isinstance(step, FieldAdd) and step.name == name:
|
|
55
|
+
if step.default_factory is None:
|
|
56
|
+
return step.default
|
|
57
|
+
context = MigrationContext(
|
|
58
|
+
fields={k: v for k, v in data.items() if k != "__class__"},
|
|
59
|
+
from_class=self.from_class,
|
|
60
|
+
to_class=self.to_class or self.from_class,
|
|
61
|
+
from_version=self.from_version,
|
|
62
|
+
to_version=self.to_version,
|
|
63
|
+
)
|
|
64
|
+
return step.default_factory(context)
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class MigrationRegistry:
|
|
69
|
+
def __init__(self) -> None:
|
|
70
|
+
self._specs: dict[tuple[str, float], MigrationSpec] = {}
|
|
71
|
+
|
|
72
|
+
def register(
|
|
73
|
+
self, spec: MigrationSpec, *, default_to_class: str | None = None
|
|
74
|
+
) -> None:
|
|
75
|
+
to_class = spec.to_class or default_to_class
|
|
76
|
+
if to_class is None:
|
|
77
|
+
raise ValueError("MigrationSpec.to_class is required")
|
|
78
|
+
key = (spec.from_class, spec.from_version)
|
|
79
|
+
if key in self._specs:
|
|
80
|
+
raise ValueError(
|
|
81
|
+
f"Duplicate migration for {spec.from_class}@{spec.from_version}"
|
|
82
|
+
)
|
|
83
|
+
normalized = MigrationSpec(
|
|
84
|
+
from_class=spec.from_class,
|
|
85
|
+
from_version=spec.from_version,
|
|
86
|
+
to_version=spec.to_version,
|
|
87
|
+
steps=spec.steps,
|
|
88
|
+
to_class=to_class,
|
|
89
|
+
note=spec.note,
|
|
90
|
+
)
|
|
91
|
+
self._specs[key] = normalized
|
|
92
|
+
|
|
93
|
+
def resolve_chain(
|
|
94
|
+
self,
|
|
95
|
+
*,
|
|
96
|
+
from_class: str,
|
|
97
|
+
from_version: float,
|
|
98
|
+
to_class: str | None = None,
|
|
99
|
+
to_version: float | None = None,
|
|
100
|
+
) -> list[MigrationSpec]:
|
|
101
|
+
chain: list[MigrationSpec] = []
|
|
102
|
+
current_class = from_class
|
|
103
|
+
current_version = from_version
|
|
104
|
+
visited: set[tuple[str, float]] = set()
|
|
105
|
+
|
|
106
|
+
while True:
|
|
107
|
+
key = (current_class, current_version)
|
|
108
|
+
if key in visited:
|
|
109
|
+
raise ValueError(
|
|
110
|
+
f"Migration loop detected for {current_class}@{current_version}"
|
|
111
|
+
)
|
|
112
|
+
visited.add(key)
|
|
113
|
+
|
|
114
|
+
spec = self._specs.get(key)
|
|
115
|
+
if spec is None:
|
|
116
|
+
break
|
|
117
|
+
chain.append(spec)
|
|
118
|
+
current_class = spec.to_class or spec.from_class
|
|
119
|
+
current_version = spec.to_version
|
|
120
|
+
|
|
121
|
+
if to_class is not None and to_version is not None:
|
|
122
|
+
if current_class == to_class and current_version == to_version:
|
|
123
|
+
break
|
|
124
|
+
|
|
125
|
+
if to_class is not None and to_version is not None:
|
|
126
|
+
if current_class != to_class or current_version != to_version:
|
|
127
|
+
raise ValueError(
|
|
128
|
+
f"No migration path from {from_class}@{from_version} to {to_class}@{to_version}"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
if len(chain) > 1:
|
|
132
|
+
get_logger().warning(
|
|
133
|
+
"migration: chain length %s from %s@%s",
|
|
134
|
+
len(chain),
|
|
135
|
+
from_class,
|
|
136
|
+
from_version,
|
|
137
|
+
)
|
|
138
|
+
return chain
|
|
139
|
+
|
|
140
|
+
def apply_chain(
|
|
141
|
+
self,
|
|
142
|
+
data: dict[str, JsonValue],
|
|
143
|
+
*,
|
|
144
|
+
to_class: str | None = None,
|
|
145
|
+
to_version: float | None = None,
|
|
146
|
+
) -> tuple[dict[str, JsonValue], list[MigrationSpec]]:
|
|
147
|
+
from_class = _require_class_name(data)
|
|
148
|
+
from_version = _get_version(data)
|
|
149
|
+
chain = self.resolve_chain(
|
|
150
|
+
from_class=from_class,
|
|
151
|
+
from_version=from_version,
|
|
152
|
+
to_class=to_class,
|
|
153
|
+
to_version=to_version,
|
|
154
|
+
)
|
|
155
|
+
result = dict(data)
|
|
156
|
+
for spec in chain:
|
|
157
|
+
result = _apply_spec(result, spec)
|
|
158
|
+
result = _apply_nested_migrations(result, registry=self)
|
|
159
|
+
return result, chain
|
|
160
|
+
|
|
161
|
+
def has_migration(self, from_class: str, from_version: float) -> bool:
|
|
162
|
+
return (from_class, from_version) in self._specs
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
MIGRATION_REGISTRY = MigrationRegistry()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _require_class_name(data: dict[str, JsonValue]) -> str:
|
|
169
|
+
class_name = data.get("__class__")
|
|
170
|
+
if not isinstance(class_name, str):
|
|
171
|
+
raise ValueError("Serialized Furu object missing __class__")
|
|
172
|
+
return class_name
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _get_version(data: dict[str, JsonValue]) -> float:
|
|
176
|
+
version_value = data.get("furu_version")
|
|
177
|
+
if isinstance(version_value, (float, int)):
|
|
178
|
+
return float(version_value)
|
|
179
|
+
return 0.0
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _apply_spec(
|
|
183
|
+
data: dict[str, JsonValue], spec: MigrationSpec
|
|
184
|
+
) -> dict[str, JsonValue]:
|
|
185
|
+
result = dict(data)
|
|
186
|
+
result["__class__"] = spec.to_class or spec.from_class
|
|
187
|
+
|
|
188
|
+
for step in spec.steps:
|
|
189
|
+
if isinstance(step, FieldRename):
|
|
190
|
+
if step.old in result:
|
|
191
|
+
result[step.new] = result.pop(step.old)
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
if isinstance(step, FieldAdd):
|
|
195
|
+
if step.name not in result:
|
|
196
|
+
context = MigrationContext(
|
|
197
|
+
fields={k: v for k, v in result.items() if k != "__class__"},
|
|
198
|
+
from_class=spec.from_class,
|
|
199
|
+
to_class=result["__class__"],
|
|
200
|
+
from_version=spec.from_version,
|
|
201
|
+
to_version=spec.to_version,
|
|
202
|
+
)
|
|
203
|
+
if step.default_factory is not None:
|
|
204
|
+
result[step.name] = step.default_factory(context)
|
|
205
|
+
else:
|
|
206
|
+
result[step.name] = step.default
|
|
207
|
+
continue
|
|
208
|
+
|
|
209
|
+
if isinstance(step, Transform):
|
|
210
|
+
result = step.func(result)
|
|
211
|
+
continue
|
|
212
|
+
|
|
213
|
+
raise TypeError(f"Unsupported migration step: {step}")
|
|
214
|
+
|
|
215
|
+
result["furu_version"] = spec.to_version
|
|
216
|
+
return result
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _apply_nested_migrations(
|
|
220
|
+
data: dict[str, JsonValue], *, registry: MigrationRegistry
|
|
221
|
+
) -> dict[str, JsonValue]:
|
|
222
|
+
result: dict[str, JsonValue] = {}
|
|
223
|
+
for key, value in data.items():
|
|
224
|
+
if isinstance(value, dict) and "__class__" in value:
|
|
225
|
+
nested = value
|
|
226
|
+
if registry.has_migration(
|
|
227
|
+
_require_class_name(nested), _get_version(nested)
|
|
228
|
+
):
|
|
229
|
+
migrated, _ = registry.apply_chain(nested)
|
|
230
|
+
result[key] = migrated
|
|
231
|
+
continue
|
|
232
|
+
result[key] = _apply_nested_migrations(nested, registry=registry)
|
|
233
|
+
continue
|
|
234
|
+
if isinstance(value, list):
|
|
235
|
+
result[key] = [
|
|
236
|
+
_apply_nested_migrations(item, registry=registry)
|
|
237
|
+
if isinstance(item, dict)
|
|
238
|
+
else item
|
|
239
|
+
for item in value
|
|
240
|
+
]
|
|
241
|
+
continue
|
|
242
|
+
if isinstance(value, dict):
|
|
243
|
+
result[key] = _apply_nested_migrations(value, registry=registry)
|
|
244
|
+
continue
|
|
245
|
+
result[key] = value
|
|
246
|
+
return result
|