timing-cli 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.
- timing_cli/__init__.py +3 -0
- timing_cli/analysis.py +161 -0
- timing_cli/api.py +311 -0
- timing_cli/cli.py +348 -0
- timing_cli/config.py +92 -0
- timing_cli/db.py +287 -0
- timing_cli/models.py +89 -0
- timing_cli/output.py +78 -0
- timing_cli/rules.py +101 -0
- timing_cli/serve.py +207 -0
- timing_cli/timing_predicates.py +229 -0
- timing_cli-0.1.0.dist-info/METADATA +218 -0
- timing_cli-0.1.0.dist-info/RECORD +16 -0
- timing_cli-0.1.0.dist-info/WHEEL +4 -0
- timing_cli-0.1.0.dist-info/entry_points.txt +2 -0
- timing_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
timing_cli/cli.py
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""Typer CLI for timing-cli."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import date, datetime, time, timedelta
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from timing_cli import __version__
|
|
10
|
+
from timing_cli.analysis import aggregate, summarize_by_project
|
|
11
|
+
from timing_cli.api import TimingApiClient, TimingApiError
|
|
12
|
+
from timing_cli.config import Config, load_config
|
|
13
|
+
from timing_cli.db import (
|
|
14
|
+
TimingDatabaseError,
|
|
15
|
+
date_range,
|
|
16
|
+
list_app_usage,
|
|
17
|
+
list_projects,
|
|
18
|
+
list_timing_predicate_rules,
|
|
19
|
+
open_db,
|
|
20
|
+
)
|
|
21
|
+
from timing_cli.models import TimeEntrySuggestion
|
|
22
|
+
from timing_cli.output import console, err_console, render_suggestions, render_summary, render_usage
|
|
23
|
+
from timing_cli.rules import UNASSIGNED, Classifier
|
|
24
|
+
|
|
25
|
+
app = typer.Typer(
|
|
26
|
+
name="timing",
|
|
27
|
+
help="Read the local Timing.app database and generate/push time entries.",
|
|
28
|
+
no_args_is_help=True,
|
|
29
|
+
add_completion=False,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _version_callback(value: bool) -> None:
|
|
34
|
+
if value:
|
|
35
|
+
console.print(f"timing-cli {__version__}")
|
|
36
|
+
raise typer.Exit()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@app.callback()
|
|
40
|
+
def main(
|
|
41
|
+
version: bool = typer.Option(
|
|
42
|
+
False, "--version", callback=_version_callback, is_eager=True, help="Show version and exit"
|
|
43
|
+
),
|
|
44
|
+
) -> None:
|
|
45
|
+
"""timing-cli - local Timing.app activity to Timing time entries."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _resolve_window(
|
|
49
|
+
date_opt: str | None,
|
|
50
|
+
from_opt: str | None,
|
|
51
|
+
to_opt: str | None,
|
|
52
|
+
) -> tuple[datetime, datetime]:
|
|
53
|
+
"""Resolve a local [start, end) window from the CLI date options.
|
|
54
|
+
|
|
55
|
+
Precedence: explicit --from/--to override --date; --date selects a whole
|
|
56
|
+
local day; with nothing given, defaults to today.
|
|
57
|
+
"""
|
|
58
|
+
try:
|
|
59
|
+
if from_opt or to_opt:
|
|
60
|
+
start = (
|
|
61
|
+
datetime.fromisoformat(from_opt).astimezone()
|
|
62
|
+
if from_opt
|
|
63
|
+
else _day_start(date.today())
|
|
64
|
+
)
|
|
65
|
+
end = (
|
|
66
|
+
datetime.fromisoformat(to_opt).astimezone()
|
|
67
|
+
if to_opt
|
|
68
|
+
else datetime.now().astimezone()
|
|
69
|
+
)
|
|
70
|
+
else:
|
|
71
|
+
day = date.fromisoformat(date_opt) if date_opt else date.today()
|
|
72
|
+
start = _day_start(day)
|
|
73
|
+
end = start + timedelta(days=1)
|
|
74
|
+
except ValueError as exc:
|
|
75
|
+
raise typer.BadParameter("Use ISO-8601 dates, e.g. 2026-07-05") from exc
|
|
76
|
+
|
|
77
|
+
if end <= start:
|
|
78
|
+
raise typer.BadParameter("--to must be after --from")
|
|
79
|
+
return start, end
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _day_start(day: date) -> datetime:
|
|
83
|
+
return datetime.combine(day, time.min).astimezone()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _load() -> Config:
|
|
87
|
+
return load_config()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _exit_with_error(message: str) -> None:
|
|
91
|
+
err_console.print(f"[red]{message}[/red]")
|
|
92
|
+
raise typer.Exit(1)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
DateOpt = typer.Option(None, "--date", "-d", help="Local day YYYY-MM-DD (default: today)")
|
|
96
|
+
FromOpt = typer.Option(None, "--from", "-f", help="Start datetime (ISO 8601), overrides --date")
|
|
97
|
+
ToOpt = typer.Option(None, "--to", "-t", help="End datetime (ISO 8601), overrides --date")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@app.command()
|
|
101
|
+
def info() -> None:
|
|
102
|
+
"""Show the database location and the recorded activity date range."""
|
|
103
|
+
cfg = _load()
|
|
104
|
+
console.print(f"Database: [cyan]{cfg.db_path}[/cyan]")
|
|
105
|
+
try:
|
|
106
|
+
with open_db(cfg.db_path) as conn:
|
|
107
|
+
rng = date_range(conn)
|
|
108
|
+
projects = list_projects(conn, include_archived=False)
|
|
109
|
+
except TimingDatabaseError as exc:
|
|
110
|
+
_exit_with_error(str(exc))
|
|
111
|
+
if rng:
|
|
112
|
+
console.print(
|
|
113
|
+
f"Recorded: [green]{rng[0]:%Y-%m-%d}[/green] -> "
|
|
114
|
+
f"[green]{rng[1]:%Y-%m-%d}[/green]"
|
|
115
|
+
)
|
|
116
|
+
console.print(f"Active projects: {len(projects)}")
|
|
117
|
+
console.print(f"API token: {'set' if cfg.resolved_token() else '[yellow]not set[/yellow]'}")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@app.command()
|
|
121
|
+
def projects(
|
|
122
|
+
remote: bool = typer.Option(False, "--remote", help="List projects from the Web API instead"),
|
|
123
|
+
archived: bool = typer.Option(False, "--archived", help="Include archived projects"),
|
|
124
|
+
) -> None:
|
|
125
|
+
"""List projects (local database by default, or the Web API with --remote)."""
|
|
126
|
+
cfg = _load()
|
|
127
|
+
if remote:
|
|
128
|
+
try:
|
|
129
|
+
with TimingApiClient(cfg.api_base_url, cfg.resolved_token()) as client:
|
|
130
|
+
for p in client.list_projects(hide_archived=not archived):
|
|
131
|
+
chain = " / ".join(p.get("title_chain") or [p.get("title", "")])
|
|
132
|
+
console.print(f"[magenta]{p.get('self')}[/magenta] {chain}")
|
|
133
|
+
except TimingApiError as exc:
|
|
134
|
+
err_console.print(f"[red]{exc}[/red]")
|
|
135
|
+
raise typer.Exit(1) from exc
|
|
136
|
+
return
|
|
137
|
+
try:
|
|
138
|
+
with open_db(cfg.db_path) as conn:
|
|
139
|
+
for p in list_projects(conn, include_archived=archived):
|
|
140
|
+
marker = " [dim](archived)[/dim]" if p.is_archived else ""
|
|
141
|
+
console.print(f"[magenta]{p.id}[/magenta] {p.title}{marker}")
|
|
142
|
+
except TimingDatabaseError as exc:
|
|
143
|
+
_exit_with_error(str(exc))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@app.command()
|
|
147
|
+
def usage(
|
|
148
|
+
date_opt: str | None = DateOpt,
|
|
149
|
+
from_opt: str | None = FromOpt,
|
|
150
|
+
to_opt: str | None = ToOpt,
|
|
151
|
+
project_id: int | None = typer.Option(
|
|
152
|
+
None,
|
|
153
|
+
"--project",
|
|
154
|
+
"-p",
|
|
155
|
+
help="Filter by local project id",
|
|
156
|
+
),
|
|
157
|
+
) -> None:
|
|
158
|
+
"""Show raw automatically tracked app usage for a window."""
|
|
159
|
+
cfg = _load()
|
|
160
|
+
start, end = _resolve_window(date_opt, from_opt, to_opt)
|
|
161
|
+
try:
|
|
162
|
+
with open_db(cfg.db_path) as conn:
|
|
163
|
+
slices = list_app_usage(conn, start, end, project_id=project_id)
|
|
164
|
+
except TimingDatabaseError as exc:
|
|
165
|
+
_exit_with_error(str(exc))
|
|
166
|
+
render_usage(slices)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@app.command()
|
|
170
|
+
def summary(
|
|
171
|
+
date_opt: str | None = DateOpt,
|
|
172
|
+
from_opt: str | None = FromOpt,
|
|
173
|
+
to_opt: str | None = ToOpt,
|
|
174
|
+
include_unassigned: bool = typer.Option(
|
|
175
|
+
True, "--unassigned/--no-unassigned", help="Include time not mapped to any project"
|
|
176
|
+
),
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Show total tracked time per project for a window."""
|
|
179
|
+
cfg = _load()
|
|
180
|
+
start, end = _resolve_window(date_opt, from_opt, to_opt)
|
|
181
|
+
try:
|
|
182
|
+
with open_db(cfg.db_path) as conn:
|
|
183
|
+
slices = list_app_usage(conn, start, end)
|
|
184
|
+
timing_rules = list_timing_predicate_rules(conn)
|
|
185
|
+
except TimingDatabaseError as exc:
|
|
186
|
+
_exit_with_error(str(exc))
|
|
187
|
+
classifier = Classifier(cfg.rules, timing_rules=timing_rules)
|
|
188
|
+
summaries = summarize_by_project(slices, classifier, include_unassigned=include_unassigned)
|
|
189
|
+
render_summary(summaries, title=f"Project summary {start:%Y-%m-%d} .. {end:%Y-%m-%d}")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@app.command()
|
|
193
|
+
def suggest(
|
|
194
|
+
date_opt: str | None = DateOpt,
|
|
195
|
+
from_opt: str | None = FromOpt,
|
|
196
|
+
to_opt: str | None = ToOpt,
|
|
197
|
+
include_unassigned: bool = typer.Option(
|
|
198
|
+
False, "--unassigned/--no-unassigned", help="Also suggest entries for unassigned time"
|
|
199
|
+
),
|
|
200
|
+
) -> None:
|
|
201
|
+
"""Show suggested time entries aggregated from app usage (does not write)."""
|
|
202
|
+
cfg = _load()
|
|
203
|
+
start, end = _resolve_window(date_opt, from_opt, to_opt)
|
|
204
|
+
try:
|
|
205
|
+
with open_db(cfg.db_path) as conn:
|
|
206
|
+
slices = list_app_usage(conn, start, end)
|
|
207
|
+
timing_rules = list_timing_predicate_rules(conn)
|
|
208
|
+
except TimingDatabaseError as exc:
|
|
209
|
+
_exit_with_error(str(exc))
|
|
210
|
+
classifier = Classifier(cfg.rules, timing_rules=timing_rules)
|
|
211
|
+
suggestions = aggregate(
|
|
212
|
+
slices,
|
|
213
|
+
classifier,
|
|
214
|
+
min_block_seconds=cfg.min_block_seconds,
|
|
215
|
+
gap_merge_seconds=cfg.gap_merge_seconds,
|
|
216
|
+
include_unassigned=include_unassigned,
|
|
217
|
+
)
|
|
218
|
+
render_suggestions(suggestions)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@app.command()
|
|
222
|
+
def push(
|
|
223
|
+
date_opt: str | None = DateOpt,
|
|
224
|
+
from_opt: str | None = FromOpt,
|
|
225
|
+
to_opt: str | None = ToOpt,
|
|
226
|
+
yes: bool = typer.Option(
|
|
227
|
+
False,
|
|
228
|
+
"--yes",
|
|
229
|
+
"-y",
|
|
230
|
+
help="Actually create entries (default: dry-run)",
|
|
231
|
+
),
|
|
232
|
+
replace: bool = typer.Option(False, "--replace", help="Replace overlapping existing entries"),
|
|
233
|
+
include_unassigned: bool = typer.Option(
|
|
234
|
+
False, "--unassigned/--no-unassigned", help="Also push unassigned time"
|
|
235
|
+
),
|
|
236
|
+
) -> None:
|
|
237
|
+
"""Create Timing time entries from suggestions via the Web API.
|
|
238
|
+
|
|
239
|
+
Defaults to a dry-run. Pass --yes to actually create entries. Non-unassigned
|
|
240
|
+
projects must resolve to one unique Web-API project before anything is
|
|
241
|
+
written.
|
|
242
|
+
"""
|
|
243
|
+
cfg = _load()
|
|
244
|
+
start, end = _resolve_window(date_opt, from_opt, to_opt)
|
|
245
|
+
try:
|
|
246
|
+
with open_db(cfg.db_path) as conn:
|
|
247
|
+
slices = list_app_usage(conn, start, end)
|
|
248
|
+
timing_rules = list_timing_predicate_rules(conn)
|
|
249
|
+
except TimingDatabaseError as exc:
|
|
250
|
+
_exit_with_error(str(exc))
|
|
251
|
+
classifier = Classifier(cfg.rules, timing_rules=timing_rules)
|
|
252
|
+
suggestions = aggregate(
|
|
253
|
+
slices,
|
|
254
|
+
classifier,
|
|
255
|
+
min_block_seconds=cfg.min_block_seconds,
|
|
256
|
+
gap_merge_seconds=cfg.gap_merge_seconds,
|
|
257
|
+
include_unassigned=include_unassigned,
|
|
258
|
+
)
|
|
259
|
+
render_suggestions(suggestions)
|
|
260
|
+
|
|
261
|
+
if not suggestions:
|
|
262
|
+
console.print("[yellow]Nothing to push.[/yellow]")
|
|
263
|
+
return
|
|
264
|
+
if not yes:
|
|
265
|
+
console.print(
|
|
266
|
+
f"[yellow]Dry-run:[/yellow] would create {len(suggestions)} entries. "
|
|
267
|
+
"Re-run with --yes to push."
|
|
268
|
+
)
|
|
269
|
+
return
|
|
270
|
+
|
|
271
|
+
try:
|
|
272
|
+
with TimingApiClient(cfg.api_base_url, cfg.resolved_token()) as client:
|
|
273
|
+
ref_cache: dict[tuple[int | None, tuple[str, ...], str], str | None] = {}
|
|
274
|
+
planned: list[tuple[TimeEntrySuggestion, str | None]] = []
|
|
275
|
+
unmapped: list[str] = []
|
|
276
|
+
|
|
277
|
+
for s in suggestions:
|
|
278
|
+
project_ref = None
|
|
279
|
+
if s.project_title != UNASSIGNED:
|
|
280
|
+
key = (s.project_id, tuple(s.project_title_chain), s.project_title)
|
|
281
|
+
if key not in ref_cache:
|
|
282
|
+
ref_cache[key] = client.resolve_project_ref(
|
|
283
|
+
s.project_title,
|
|
284
|
+
title_chain=s.project_title_chain,
|
|
285
|
+
project_id=s.project_id,
|
|
286
|
+
overrides=cfg.project_mappings,
|
|
287
|
+
)
|
|
288
|
+
project_ref = ref_cache[key]
|
|
289
|
+
if project_ref is None:
|
|
290
|
+
label = " / ".join(s.project_title_chain) or s.project_title
|
|
291
|
+
unmapped.append(label)
|
|
292
|
+
planned.append((s, project_ref))
|
|
293
|
+
|
|
294
|
+
if unmapped:
|
|
295
|
+
projects = ", ".join(sorted(set(unmapped)))
|
|
296
|
+
_exit_with_error(
|
|
297
|
+
"Could not map local projects to Timing Web API projects: "
|
|
298
|
+
f"{projects}. Add [project_mappings] entries or rename projects."
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
existing_entries = [] if replace else client.list_time_entries(start, end)
|
|
302
|
+
created = 0
|
|
303
|
+
skipped = 0
|
|
304
|
+
for s, project_ref in planned:
|
|
305
|
+
if not replace and client.has_matching_time_entry(
|
|
306
|
+
existing_entries,
|
|
307
|
+
s.start,
|
|
308
|
+
s.end,
|
|
309
|
+
s.title,
|
|
310
|
+
project_ref,
|
|
311
|
+
):
|
|
312
|
+
skipped += 1
|
|
313
|
+
continue
|
|
314
|
+
client.create_time_entry(
|
|
315
|
+
start=s.start,
|
|
316
|
+
end=s.end,
|
|
317
|
+
project_ref=project_ref,
|
|
318
|
+
title=s.title,
|
|
319
|
+
notes=s.notes,
|
|
320
|
+
replace_existing=replace,
|
|
321
|
+
)
|
|
322
|
+
created += 1
|
|
323
|
+
message = f"Created {created} time entries."
|
|
324
|
+
if skipped:
|
|
325
|
+
message += f" Skipped {skipped} existing entries."
|
|
326
|
+
console.print(f"[green]{message}[/green]")
|
|
327
|
+
except TimingApiError as exc:
|
|
328
|
+
err_console.print(f"[red]{exc}[/red]")
|
|
329
|
+
raise typer.Exit(1) from exc
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@app.command()
|
|
333
|
+
def serve(
|
|
334
|
+
transport: str = typer.Option("stdio", "--transport", help="MCP transport: stdio or http"),
|
|
335
|
+
host: str = typer.Option("127.0.0.1", "--host", help="Bind host for http transport"),
|
|
336
|
+
port: int = typer.Option(8321, "--port", help="Bind port for http transport"),
|
|
337
|
+
) -> None:
|
|
338
|
+
"""Run the Timing MCP server so agents (e.g. Hermes) can query it."""
|
|
339
|
+
from timing_cli.serve import run_server
|
|
340
|
+
|
|
341
|
+
try:
|
|
342
|
+
run_server(transport=transport, host=host, port=port)
|
|
343
|
+
except ValueError as exc:
|
|
344
|
+
_exit_with_error(str(exc))
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
if __name__ == "__main__":
|
|
348
|
+
app()
|
timing_cli/config.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Configuration: database location, Web-API access, and classification rules.
|
|
2
|
+
|
|
3
|
+
Config is loaded from ``~/.config/timing-cli/config.toml`` when present, else
|
|
4
|
+
sensible defaults are used. The Web-API token can also come from the
|
|
5
|
+
``TIMING_API_KEY`` environment variable (which takes precedence).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import tomllib
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, Field
|
|
15
|
+
|
|
16
|
+
# Default location of the Timing.app Core-Data store on macOS.
|
|
17
|
+
DEFAULT_DB_PATH = (
|
|
18
|
+
Path.home()
|
|
19
|
+
/ "Library"
|
|
20
|
+
/ "Application Support"
|
|
21
|
+
/ "info.eurocomp.Timing2"
|
|
22
|
+
/ "SQLite.db"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
DEFAULT_API_BASE_URL = "https://web.timingapp.com/api/v1"
|
|
26
|
+
|
|
27
|
+
CONFIG_PATH = Path.home() / ".config" / "timing-cli" / "config.toml"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Rule(BaseModel):
|
|
31
|
+
"""A classification rule mapping app usage onto a project.
|
|
32
|
+
|
|
33
|
+
A rule matches when every provided pattern matches (case-insensitive
|
|
34
|
+
substring for ``app``/``bundle_id``, regex for ``title``/``path``). The
|
|
35
|
+
first matching rule (in list order) wins.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
project: str = Field(description="Target project title the match maps to")
|
|
39
|
+
app: str | None = None
|
|
40
|
+
bundle_id: str | None = None
|
|
41
|
+
title: str | None = Field(default=None, description="Regex matched against the window title")
|
|
42
|
+
path: str | None = Field(default=None, description="Regex matched against the document path")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Config(BaseModel):
|
|
46
|
+
"""Runtime configuration for timing-cli."""
|
|
47
|
+
|
|
48
|
+
db_path: Path = DEFAULT_DB_PATH
|
|
49
|
+
api_base_url: str = DEFAULT_API_BASE_URL
|
|
50
|
+
api_token: str | None = None
|
|
51
|
+
mcp_http_token: str | None = None
|
|
52
|
+
|
|
53
|
+
# Aggregation tuning (see analysis.aggregate).
|
|
54
|
+
min_block_seconds: int = Field(
|
|
55
|
+
default=120,
|
|
56
|
+
description="Drop aggregated blocks shorter than this many seconds",
|
|
57
|
+
)
|
|
58
|
+
gap_merge_seconds: int = Field(
|
|
59
|
+
default=300,
|
|
60
|
+
description="Merge same-project slices separated by a gap up to this many seconds",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
rules: list[Rule] = Field(default_factory=list)
|
|
64
|
+
project_mappings: dict[str, str] = Field(default_factory=dict)
|
|
65
|
+
|
|
66
|
+
def resolved_token(self) -> str | None:
|
|
67
|
+
"""Return the API token, preferring the environment variable."""
|
|
68
|
+
return os.environ.get("TIMING_API_KEY") or self.api_token
|
|
69
|
+
|
|
70
|
+
def resolved_mcp_http_token(self) -> str | None:
|
|
71
|
+
"""Return the MCP HTTP bearer token, preferring the environment variable."""
|
|
72
|
+
return os.environ.get("TIMING_MCP_TOKEN") or self.mcp_http_token
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def load_config(path: Path | None = None) -> Config:
|
|
76
|
+
"""Load configuration from disk, falling back to defaults.
|
|
77
|
+
|
|
78
|
+
Unknown keys are ignored so the config format can evolve without breaking
|
|
79
|
+
older files.
|
|
80
|
+
"""
|
|
81
|
+
cfg_path = path or CONFIG_PATH
|
|
82
|
+
if not cfg_path.exists():
|
|
83
|
+
return Config()
|
|
84
|
+
|
|
85
|
+
with cfg_path.open("rb") as fh:
|
|
86
|
+
data = tomllib.load(fh)
|
|
87
|
+
|
|
88
|
+
rules = [Rule(**r) for r in data.pop("rules", [])]
|
|
89
|
+
known = {k: v for k, v in data.items() if k in Config.model_fields}
|
|
90
|
+
if "db_path" in known:
|
|
91
|
+
known["db_path"] = Path(known["db_path"]).expanduser()
|
|
92
|
+
return Config(rules=rules, **known)
|