booking-agent-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.
Files changed (74) hide show
  1. booking_agent/__init__.py +6 -0
  2. booking_agent/authorization.py +450 -0
  3. booking_agent/backend/__init__.py +217 -0
  4. booking_agent/backend/live.py +494 -0
  5. booking_agent/calendar/__init__.py +55 -0
  6. booking_agent/calendar/filter.py +41 -0
  7. booking_agent/calendar/google.py +582 -0
  8. booking_agent/calendar/oauth_listener.py +287 -0
  9. booking_agent/calendar/scope.py +97 -0
  10. booking_agent/circuit_breaker.py +116 -0
  11. booking_agent/config.py +51 -0
  12. booking_agent/defaults.env +50 -0
  13. booking_agent/discovery.py +188 -0
  14. booking_agent/headless.py +463 -0
  15. booking_agent/http_deadlines.py +43 -0
  16. booking_agent/http_retry.py +178 -0
  17. booking_agent/http_trace.py +428 -0
  18. booking_agent/merchant_pacing.py +73 -0
  19. booking_agent/models/__init__.py +14 -0
  20. booking_agent/models/platform.py +200 -0
  21. booking_agent/models/wire.py +868 -0
  22. booking_agent/namespace.py +53 -0
  23. booking_agent/observability.py +143 -0
  24. booking_agent/platform_profile.py +120 -0
  25. booking_agent/pop.py +347 -0
  26. booking_agent/profile_cache.py +130 -0
  27. booking_agent/registry_url.py +35 -0
  28. booking_agent/rest_client.py +1493 -0
  29. booking_agent/secure_file.py +52 -0
  30. booking_agent/settings.py +110 -0
  31. booking_agent/signals.py +130 -0
  32. booking_agent/step_timing.py +168 -0
  33. booking_agent/ucp_capabilities.py +148 -0
  34. booking_agent/ucp_checkout.py +1363 -0
  35. booking_agent/ucp_negotiation.py +184 -0
  36. booking_agent/ucp_profile.py +410 -0
  37. booking_agent/usp_methods.py +110 -0
  38. booking_agent_cli/__init__.py +3 -0
  39. booking_agent_cli/activity_log.py +106 -0
  40. booking_agent_cli/booking_poll.py +141 -0
  41. booking_agent_cli/booking_summary.py +151 -0
  42. booking_agent_cli/calendar_connection.py +196 -0
  43. booking_agent_cli/calendar_event.py +611 -0
  44. booking_agent_cli/calendar_preference.py +176 -0
  45. booking_agent_cli/catalog.py +864 -0
  46. booking_agent_cli/checkout_prereq.py +67 -0
  47. booking_agent_cli/commands/__init__.py +0 -0
  48. booking_agent_cli/commands/config.py +106 -0
  49. booking_agent_cli/commands/flow.py +6988 -0
  50. booking_agent_cli/commands/lowlevel.py +163 -0
  51. booking_agent_cli/commands/runtime.py +46 -0
  52. booking_agent_cli/config_loader.py +108 -0
  53. booking_agent_cli/engine.py +168 -0
  54. booking_agent_cli/flow_timings.py +34 -0
  55. booking_agent_cli/geo_eligibility.py +183 -0
  56. booking_agent_cli/geocode.py +233 -0
  57. booking_agent_cli/identity.py +104 -0
  58. booking_agent_cli/link_cli.py +620 -0
  59. booking_agent_cli/main.py +75 -0
  60. booking_agent_cli/output.py +199 -0
  61. booking_agent_cli/payment_helpers.py +884 -0
  62. booking_agent_cli/payment_token.py +23 -0
  63. booking_agent_cli/reschedule.py +137 -0
  64. booking_agent_cli/runtime.py +55 -0
  65. booking_agent_cli/runtime_prepare.py +129 -0
  66. booking_agent_cli/search_area.py +111 -0
  67. booking_agent_cli/session.py +837 -0
  68. booking_agent_cli/support_bundle_schema.py +557 -0
  69. booking_agent_cli/support_report.py +1210 -0
  70. booking_agent_cli/timezone_context.py +314 -0
  71. booking_agent_cli-0.1.0.dist-info/METADATA +75 -0
  72. booking_agent_cli-0.1.0.dist-info/RECORD +74 -0
  73. booking_agent_cli-0.1.0.dist-info/WHEEL +4 -0
  74. booking_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,163 @@
1
+ """Low-level operator commands.
2
+
3
+ These bypass the flow engine and do NOT read or mutate ``.usp-session.json``.
4
+ Documented as repair / debug tools only.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import json
10
+ from datetime import datetime, timedelta, timezone
11
+ from typing import List, Optional
12
+
13
+ import typer
14
+
15
+ from .. import config_loader
16
+ from ..runtime import resolve_backend
17
+
18
+ # search ---------------------------------------------------------------------
19
+
20
+ search_app = typer.Typer(help="Low-level: search the USP registry (no session writes).")
21
+
22
+
23
+ @search_app.callback(invoke_without_command=True)
24
+ def search(
25
+ ctx: typer.Context,
26
+ query: str = typer.Option(..., "--query", "-q"),
27
+ location: Optional[str] = typer.Option(
28
+ None,
29
+ "--location",
30
+ help="Optional geo filter as latitude,longitude",
31
+ ),
32
+ radius_km: Optional[float] = typer.Option(
33
+ None,
34
+ "--radius-km",
35
+ help="Search radius in km when --location is set",
36
+ ),
37
+ vertical: Optional[List[str]] = typer.Option(
38
+ None,
39
+ "--vertical",
40
+ help="Repeatable registry vertical filter; omit to search all verticals",
41
+ ),
42
+ desired_at: Optional[List[str]] = typer.Option(None, "--desired-at"),
43
+ desired_from: Optional[List[str]] = typer.Option(None, "--desired-from"),
44
+ desired_range: Optional[List[str]] = typer.Option(None, "--desired-range"),
45
+ prefer_sooner_availability_slots: Optional[bool] = typer.Option(
46
+ None,
47
+ "--prefer-sooner-availability-slots/--no-prefer-sooner-availability-slots",
48
+ ),
49
+ ) -> None:
50
+ if ctx.invoked_subcommand is not None:
51
+ return
52
+ config_loader.load()
53
+ backend = resolve_backend()
54
+
55
+ async def _go():
56
+ from booking_agent.headless import (
57
+ build_search_intent,
58
+ build_desired_service_time_preferences,
59
+ discover_services,
60
+ parse_service_search_location,
61
+ parse_service_verticals,
62
+ )
63
+ from booking_agent.models.platform import ServiceSearchLocation
64
+
65
+ loc = None
66
+ if location and location.strip():
67
+ parsed = parse_service_search_location(location)
68
+ if radius_km is not None:
69
+ loc = ServiceSearchLocation(
70
+ coordinates=parsed.coordinates,
71
+ radius_km=radius_km,
72
+ )
73
+ else:
74
+ loc = parsed
75
+ time_preferences = build_desired_service_time_preferences(
76
+ desired_at=desired_at,
77
+ desired_from=desired_from,
78
+ desired_range=desired_range,
79
+ )
80
+ return await discover_services(
81
+ backend,
82
+ build_search_intent(
83
+ query=query,
84
+ location=loc,
85
+ verticals=parse_service_verticals(vertical),
86
+ desired_service_time_ranges=time_preferences or None,
87
+ prefer_sooner_availability_slots=prefer_sooner_availability_slots,
88
+ ),
89
+ )
90
+
91
+ try:
92
+ services = asyncio.run(_go())
93
+ except ValueError as exc:
94
+ typer.echo(json.dumps({"error": str(exc)}), err=True)
95
+ raise typer.Exit(code=1) from exc
96
+ from ..catalog import discover_summary_from_service, sort_discover_summaries_by_rank
97
+
98
+ summaries = []
99
+ for rank, service in enumerate(services):
100
+ row = discover_summary_from_service(service)
101
+ if row.get("search_rank") is None:
102
+ row["search_rank"] = rank
103
+ summaries.append(row)
104
+ typer.echo(json.dumps(sort_discover_summaries_by_rank(summaries), indent=2))
105
+
106
+
107
+ # profile --------------------------------------------------------------------
108
+
109
+ profile_app = typer.Typer(help="Low-level: profile resolution.")
110
+
111
+
112
+ @profile_app.command("resolve")
113
+ def profile_resolve(
114
+ profile_url: str = typer.Option(..., "--profile-url"),
115
+ ) -> None:
116
+ config_loader.load()
117
+ backend = resolve_backend()
118
+
119
+ async def _go():
120
+ return await backend.resolve_profile(profile_url)
121
+
122
+ profile = asyncio.run(_go())
123
+ typer.echo(json.dumps({
124
+ "version": profile.version,
125
+ "rest_endpoint": profile.rest_endpoint,
126
+ "business": profile.business.model_dump(by_alias=True),
127
+ "has_catalog": profile.has_catalog,
128
+ "has_availability": profile.has_availability,
129
+ "has_bookings": profile.has_bookings,
130
+ }, indent=2))
131
+
132
+
133
+ # availability ---------------------------------------------------------------
134
+
135
+ availability_app = typer.Typer(help="Low-level: query availability.")
136
+
137
+
138
+ @availability_app.command("query")
139
+ def availability_query(
140
+ profile_url: str = typer.Option(..., "--profile-url"),
141
+ service_id: str = typer.Option(..., "--service-id"),
142
+ start: Optional[str] = typer.Option(None, "--from"),
143
+ end: Optional[str] = typer.Option(None, "--to"),
144
+ ) -> None:
145
+ config_loader.load()
146
+ now = datetime.now(timezone.utc)
147
+ window_from = start or (now + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
148
+ window_to = end or (now + timedelta(days=14)).replace(hour=23, minute=59, second=59, microsecond=0).isoformat()
149
+ backend = resolve_backend()
150
+
151
+ async def _go():
152
+ from booking_agent.headless import query_availability_with_quirk
153
+ profile = await backend.resolve_profile(profile_url)
154
+ slots = await query_availability_with_quirk(
155
+ backend, profile, service_id, window_from, window_to
156
+ )
157
+ return slots
158
+
159
+ slots = asyncio.run(_go())
160
+ typer.echo(json.dumps(
161
+ [{"slot_id": s.id, "start": s.start, "end": s.end, "duration": s.duration} for s in slots],
162
+ indent=2,
163
+ ))
@@ -0,0 +1,46 @@
1
+ """``booking-agent runtime``: prepare the folders this runtime keeps its state in."""
2
+ from __future__ import annotations
3
+
4
+ from typing import List
5
+
6
+ import typer
7
+
8
+ from ..output import AgentOutput, emit
9
+ from ..runtime_prepare import PrepareError, prepare
10
+
11
+ app = typer.Typer(help="Runtime setup for agents whose filesystem resets between conversations.")
12
+
13
+
14
+ @app.command("prepare")
15
+ def runtime_prepare(
16
+ persistent_root: str = typer.Option(
17
+ ...,
18
+ "--persistent-root",
19
+ help="Absolute folder that survives between conversations in this runtime",
20
+ ),
21
+ link: List[str] = typer.Option(
22
+ [],
23
+ "--link",
24
+ help="Extra RUNTIME_PATH=PERSISTENT_PATH folder to keep (relative PERSISTENT_PATH is under the root)",
25
+ ),
26
+ ) -> None:
27
+ """Link Booking Agent state and the Link sign-in into a folder that outlives this runtime."""
28
+ try:
29
+ links = prepare(persistent_root, link)
30
+ except (PrepareError, OSError) as exc:
31
+ emit(AgentOutput(
32
+ phase="INIT",
33
+ prompt=(
34
+ "Persistence was not set up. Continue the booking without it; state from "
35
+ "this conversation may not survive a restart."
36
+ ),
37
+ next_command="booking-agent flow sessions list",
38
+ data={"error": {"code": "runtime_prepare_failed", "message": str(exc)}},
39
+ ))
40
+ raise typer.Exit(code=1)
41
+ emit(AgentOutput(
42
+ phase="INIT",
43
+ prompt="Booking Agent state and the Link sign-in now live in the persistent folder.",
44
+ next_command="booking-agent flow sessions list",
45
+ data={"links": links},
46
+ ))
@@ -0,0 +1,108 @@
1
+ """Load env files into the process for one command, in priority order.
2
+
3
+ Highest first: the process environment, ``~/.booking-agent/.env``, ``./.env``. Each
4
+ layer only fills names a higher layer left unset. The shipped ``defaults.env`` is the
5
+ lowest layer and is read through ``booking_agent.settings`` instead of being copied
6
+ into the environment.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Dict, List, Optional, Tuple
14
+
15
+ from dotenv import dotenv_values
16
+
17
+ from booking_agent import settings
18
+
19
+ SOURCE_PROCESS = "process environment"
20
+ SOURCE_USER = "user .env"
21
+ SOURCE_FOLDER = "folder .env"
22
+ SOURCE_SHIPPED = "shipped default"
23
+
24
+ # Names this module put into os.environ, with the value and the layer they came from.
25
+ _applied: Dict[str, Tuple[str, str]] = {}
26
+ _loaded_files: List[Tuple[str, Path]] = []
27
+ _ignored_runtime: List[Tuple[str, Path, str]] = []
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Layer:
32
+ source: str
33
+ path: Path
34
+
35
+
36
+ def user_env_path() -> Path:
37
+ return settings.booking_agent_home() / ".env"
38
+
39
+
40
+ def layers() -> List[Layer]:
41
+ return [
42
+ Layer(SOURCE_USER, user_env_path()),
43
+ Layer(SOURCE_FOLDER, Path.cwd() / ".env"),
44
+ ]
45
+
46
+
47
+ def reset() -> None:
48
+ """Remove every value this module put into the environment."""
49
+ for name, (value, _source) in _applied.items():
50
+ if os.environ.get(name) == value:
51
+ del os.environ[name]
52
+ _applied.clear()
53
+ _loaded_files.clear()
54
+ _ignored_runtime.clear()
55
+
56
+
57
+ def load() -> None:
58
+ """Re-apply the layers for this command. Safe to call more than once per process."""
59
+ reset()
60
+ for layer in layers():
61
+ if not layer.path.is_file():
62
+ continue
63
+ _loaded_files.append((layer.source, layer.path))
64
+ for name, value in dotenv_values(layer.path).items():
65
+ if name in settings.RUNTIME_ONLY:
66
+ _ignored_runtime.append((layer.source, layer.path, name))
67
+ continue
68
+ if value is None or name in os.environ:
69
+ continue
70
+ os.environ[name] = value
71
+ _applied[name] = (value, layer.source)
72
+
73
+
74
+ def source_of(name: str) -> str:
75
+ if name in _applied and os.environ.get(name) == _applied[name][0]:
76
+ return _applied[name][1]
77
+ if name in os.environ:
78
+ return SOURCE_PROCESS
79
+ if settings.shipped_default(name) is None:
80
+ return "unset"
81
+ return SOURCE_SHIPPED
82
+
83
+
84
+ def process_value(name: str) -> Optional[str]:
85
+ """The value the process started with; a value an env file supplied is never returned."""
86
+ if name in _applied and os.environ.get(name) == _applied[name][0]:
87
+ return None
88
+ return os.environ.get(name)
89
+
90
+
91
+ def loaded_files() -> List[Tuple[str, Path]]:
92
+ return list(_loaded_files)
93
+
94
+
95
+ def unknown_keys() -> List[Tuple[str, Path, str]]:
96
+ """Names in the loaded env files that nothing reads (for example renamed variables)."""
97
+ known = settings.known_names()
98
+ out = []
99
+ for source, path in _loaded_files:
100
+ for name in dotenv_values(path):
101
+ if name not in known:
102
+ out.append((source, path, name))
103
+ return out
104
+
105
+
106
+ def ignored_runtime_keys() -> List[Tuple[str, Path, str]]:
107
+ """Per-process names found in an env file. They are never read from one."""
108
+ return list(_ignored_runtime)
@@ -0,0 +1,168 @@
1
+ """Flow state machine (Section 6 of PLAN).
2
+
3
+ The engine reads + writes ``.usp-session.json`` via session.py. Gate
4
+ helpers raise ``GateError`` so command handlers can emit a uniform
5
+ agent-facing JSON error and exit 1.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+
11
+ from .session import BUYER_SOURCE_LINKCLI, Buyer, Session
12
+
13
+
14
+ class GateError(Exception):
15
+ """Raised when a hard gate has not been satisfied."""
16
+
17
+ def __init__(self, code: str, message: str, *, next_command: str | None = None):
18
+ super().__init__(message)
19
+ self.code = code
20
+ self.message = message
21
+ self.next_command = next_command
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class TransitionRule:
26
+ """Allowed phase progression check (advisory; used by validate)."""
27
+
28
+ name: str
29
+
30
+
31
+ def require_phase(session: Session, *allowed: str, code: str, next_command: str | None = None) -> None:
32
+ if session.flow_phase not in allowed:
33
+ raise GateError(
34
+ code,
35
+ f"Wrong phase '{session.flow_phase}'. Expected one of: {', '.join(allowed)}.",
36
+ next_command=next_command,
37
+ )
38
+
39
+
40
+ def require_business(session: Session) -> None:
41
+ if not session.business.profile_url:
42
+ raise GateError(
43
+ "no_business",
44
+ "No business selected. Run `booking-agent flow business use --profile-url ...` "
45
+ "or `booking-agent flow discover`.",
46
+ next_command="booking-agent flow discover",
47
+ )
48
+
49
+
50
+ def require_calendar_gate(session: Session) -> None:
51
+ if not session.calendar_gate_completed:
52
+ raise GateError(
53
+ "calendar_gate_not_completed",
54
+ "Calendar gate not completed. Ask the user about calendar conflict "
55
+ "checking, then run `booking-agent flow calendar ask|skip|connect`.",
56
+ next_command="booking-agent flow calendar ask",
57
+ )
58
+
59
+
60
+ def require_link_auth_gate(session: Session) -> None:
61
+ if not session.link_auth_gate_completed:
62
+ raise GateError(
63
+ "link_auth_gate_not_completed",
64
+ "Link wallet authentication not completed. Run "
65
+ "`booking-agent flow link-auth connect`, post the sign-in link, and start "
66
+ "`booking-agent flow link-auth poll` in the background in the same turn.",
67
+ next_command="booking-agent flow link-auth connect",
68
+ )
69
+
70
+
71
+ def require_checkout_prereq(session: Session) -> None:
72
+ if session.checkout_prereq_completed:
73
+ return
74
+ if getattr(session, "payment_route", None) in ("free", "at_service"):
75
+ return
76
+ raise GateError(
77
+ "checkout_prereq_not_completed",
78
+ "Checkout prerequisites were not completed. Run the checkout-mode "
79
+ "orchestrator before the calendar gate.",
80
+ next_command="booking-agent flow use-ucp-checkout-mode",
81
+ )
82
+
83
+
84
+ def require_slot_selected(session: Session) -> None:
85
+ if not session.slot_id or not session.slot_user_confirmed:
86
+ raise GateError(
87
+ "slot_not_selected",
88
+ "No user-confirmed slot. Present `slots_presented` to the user, "
89
+ "wait for their pick, then call `booking-agent flow slots select`.",
90
+ next_command="booking-agent flow slots list",
91
+ )
92
+
93
+
94
+ def require_buyer_from_link_cli(session: Session) -> None:
95
+ if session.buyer_source != BUYER_SOURCE_LINKCLI:
96
+ raise GateError(
97
+ "buyer_not_from_link_cli",
98
+ "Buyer must come from `link-cli user-info retrieve`. Placeholders, "
99
+ "hand-entered values, and other sources are rejected.",
100
+ next_command="booking-agent flow buyer resolve",
101
+ )
102
+ email = session.buyer.email or ""
103
+ if email and not email.lower().endswith("@example.com"):
104
+ return
105
+ from .link_cli import (
106
+ LinkCliUserInfoError,
107
+ buyer_fields_from_user_info,
108
+ retrieve_user_info,
109
+ )
110
+
111
+ try:
112
+ payload = retrieve_user_info()
113
+ except LinkCliUserInfoError as e:
114
+ raise GateError(
115
+ e.code,
116
+ e.message,
117
+ next_command="booking-agent flow buyer resolve",
118
+ ) from e
119
+ name, email, phone = buyer_fields_from_user_info(payload)
120
+ if not email or email.lower().endswith("@example.com"):
121
+ raise GateError(
122
+ "buyer_email_invalid",
123
+ "Buyer email is missing or uses an @example.com placeholder.",
124
+ next_command="booking-agent flow buyer resolve",
125
+ )
126
+ session.buyer = Buyer(name=name, email=email, phone=phone)
127
+
128
+
129
+ def transition(session: Session, *, to: str) -> None:
130
+ """In-place phase update with the previous phase implicit."""
131
+ session.flow_phase = to
132
+
133
+
134
+ def recommend_next(session: Session) -> str | None:
135
+ """Best-effort suggestion for the agent's next command."""
136
+ phase = session.flow_phase
137
+ pending = session.pending_booking_attempt
138
+ if isinstance(pending, dict) and isinstance(pending.get("retry_command"), str):
139
+ return pending["retry_command"]
140
+ if phase == "CONFIRMED" and session.reschedule_original_slot_id is not None:
141
+ if session.reschedule_slot_id:
142
+ return "booking-agent flow reschedule apply --user-confirmed"
143
+ if session.reschedule_slots:
144
+ return "booking-agent flow reschedule select --slot-id <id> --user-confirmed"
145
+ return "booking-agent flow reschedule availability"
146
+ if phase == "BUSINESS_SELECTED":
147
+ return "booking-agent flow use-ucp-checkout-mode"
148
+ if phase == "BOOKED":
149
+ return (
150
+ "booking-agent flow pay request --payment-method-id <id>"
151
+ )
152
+ if phase == "PAYMENT_PENDING":
153
+ sr = session.payment.spend_request_id
154
+ if sr:
155
+ return f"booking-agent flow pay ucp-complete --spend-request-id {sr}"
156
+ return "booking-agent flow pay ucp-complete"
157
+ table = {
158
+ "INIT": "booking-agent flow prereq check",
159
+ "PREREQ_OK": "booking-agent flow discover",
160
+ "CALENDAR_GATE": "booking-agent flow availability",
161
+ "AVAILABILITY_QUERIED": "booking-agent flow slots list",
162
+ "SLOTS_PRESENTED": "booking-agent flow slots select --slot-id <id>",
163
+ "SLOT_SELECTED": "booking-agent flow link-auth connect",
164
+ "BUYER_RESOLVED": "booking-agent flow book",
165
+ "CONFIRMED": None,
166
+ "FAILED": None,
167
+ }
168
+ return table.get(phase)
@@ -0,0 +1,34 @@
1
+ """Persist per-step flow timings on the session and surface them in CLI output."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Dict
5
+
6
+ from booking_agent.step_timing import drain_pending_timings, StepTiming
7
+
8
+ from .session import Session
9
+
10
+
11
+ def apply_pending_timings(session: Session) -> None:
12
+ """Merge drained step timings into the session record."""
13
+ for timing in drain_pending_timings():
14
+ if timing.step.startswith("human_gate:"):
15
+ gate = timing.step.split(":", 1)[1]
16
+ session.human_gate_timings[gate] = round(timing.human_gate_ms, 1)
17
+ else:
18
+ session.flow_timings[timing.step] = timing.to_dict()
19
+
20
+
21
+ def timings_data(session: Session) -> Dict[str, Any]:
22
+ """Payload fragment for AgentOutput ``data``."""
23
+ return {
24
+ "flow_timings": dict(session.flow_timings),
25
+ "human_gate_timings": dict(session.human_gate_timings),
26
+ }
27
+
28
+
29
+ def record_timing_on_session(session: Session, timing: StepTiming) -> None:
30
+ if timing.step.startswith("human_gate:"):
31
+ gate = timing.step.split(":", 1)[1]
32
+ session.human_gate_timings[gate] = round(timing.human_gate_ms, 1)
33
+ else:
34
+ session.flow_timings[timing.step] = timing.to_dict()