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.
@@ -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
@@ -0,0 +1,8 @@
1
+ def load_env() -> None:
2
+ from dotenv import load_dotenv
3
+
4
+ load_dotenv()
5
+
6
+
7
+ # Preserve previous behavior: attempt to load `.env` at import-time.
8
+ load_env()
@@ -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