world-model-optimizer 0.2.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.
- llm_waterfall/LICENSE +21 -0
- llm_waterfall/__init__.py +53 -0
- llm_waterfall/adapters/__init__.py +36 -0
- llm_waterfall/adapters/anthropic.py +105 -0
- llm_waterfall/adapters/aws_mantle.py +47 -0
- llm_waterfall/adapters/azure_openai.py +71 -0
- llm_waterfall/adapters/base.py +51 -0
- llm_waterfall/adapters/bedrock.py +309 -0
- llm_waterfall/adapters/openai.py +130 -0
- llm_waterfall/classify.py +184 -0
- llm_waterfall/pricing.py +110 -0
- llm_waterfall/py.typed +0 -0
- llm_waterfall/types.py +295 -0
- llm_waterfall/waterfall.py +255 -0
- wmo/__init__.py +38 -0
- wmo/agents/__init__.py +7 -0
- wmo/agents/default.py +29 -0
- wmo/agents/meta.py +55 -0
- wmo/agents/optimizer.py +55 -0
- wmo/agents/project.py +928 -0
- wmo/cli/__init__.py +5 -0
- wmo/cli/agent_session.py +1123 -0
- wmo/cli/app.py +2489 -0
- wmo/cli/e2b_cmds.py +212 -0
- wmo/cli/eval_closed_loop.py +207 -0
- wmo/cli/harness_app.py +1147 -0
- wmo/cli/harness_distill.py +659 -0
- wmo/cli/hosted_session.py +880 -0
- wmo/cli/ingest_cmd.py +165 -0
- wmo/cli/model_roles.py +82 -0
- wmo/cli/platform_cmds.py +372 -0
- wmo/cli/route_app.py +274 -0
- wmo/cli/session_state.py +243 -0
- wmo/cli/ui.py +1107 -0
- wmo/cli/workspace_sync.py +504 -0
- wmo/config/__init__.py +60 -0
- wmo/config/card.py +129 -0
- wmo/config/config.py +367 -0
- wmo/config/dotenv.py +67 -0
- wmo/config/settings.py +128 -0
- wmo/config/store.py +177 -0
- wmo/conftest.py +19 -0
- wmo/connect/__init__.py +88 -0
- wmo/connect/apps.py +78 -0
- wmo/connect/brave.py +284 -0
- wmo/connect/connector.py +79 -0
- wmo/connect/credentials.py +164 -0
- wmo/connect/github.py +321 -0
- wmo/connect/google.py +627 -0
- wmo/connect/notion.py +790 -0
- wmo/connect/oauth.py +461 -0
- wmo/connect/slack.py +555 -0
- wmo/connect/store.py +199 -0
- wmo/connect/types.py +156 -0
- wmo/core/__init__.py +21 -0
- wmo/core/parsing.py +281 -0
- wmo/core/render.py +271 -0
- wmo/core/text.py +40 -0
- wmo/core/types.py +116 -0
- wmo/distill/__init__.py +14 -0
- wmo/distill/agents.py +140 -0
- wmo/distill/config.py +1006 -0
- wmo/distill/cost.py +437 -0
- wmo/distill/data.py +921 -0
- wmo/distill/deadlines.py +254 -0
- wmo/distill/fake_tinker.py +734 -0
- wmo/distill/gate.py +122 -0
- wmo/distill/loop.py +3499 -0
- wmo/distill/renderers.py +399 -0
- wmo/distill/rendering.py +620 -0
- wmo/distill/rollouts.py +726 -0
- wmo/distill/samples.py +195 -0
- wmo/distill/store.py +829 -0
- wmo/distill/teacher.py +714 -0
- wmo/distill/tokens.py +535 -0
- wmo/distill/tracking.py +552 -0
- wmo/distill/tripwire.py +411 -0
- wmo/distill/xtoken/byte_offsets.py +152 -0
- wmo/distill/xtoken/chunks.py +457 -0
- wmo/distill/xtoken/prompt_logprobs.py +475 -0
- wmo/distill/xtoken/teacher_render.py +346 -0
- wmo/engine/__init__.py +28 -0
- wmo/engine/autoconfig.py +367 -0
- wmo/engine/build.py +346 -0
- wmo/engine/demo.py +77 -0
- wmo/engine/eval_suites.py +245 -0
- wmo/engine/grounding.py +491 -0
- wmo/engine/knowledge.py +291 -0
- wmo/engine/loader.py +36 -0
- wmo/engine/play.py +92 -0
- wmo/engine/prompts.py +99 -0
- wmo/engine/replay.py +443 -0
- wmo/engine/reporting.py +58 -0
- wmo/engine/workspace.py +468 -0
- wmo/engine/world_model.py +568 -0
- wmo/env/__init__.py +22 -0
- wmo/env/base.py +121 -0
- wmo/env/closed_loop.py +229 -0
- wmo/env/episode.py +107 -0
- wmo/env/llm_agent.py +93 -0
- wmo/env/scenarios.py +73 -0
- wmo/evals/__init__.py +52 -0
- wmo/evals/agreement.py +110 -0
- wmo/evals/base.py +45 -0
- wmo/evals/closed_loop.py +480 -0
- wmo/evals/failover.py +96 -0
- wmo/evals/gold.py +127 -0
- wmo/evals/grid.py +394 -0
- wmo/evals/grid_plot.py +205 -0
- wmo/evals/harbor/__init__.py +27 -0
- wmo/evals/harbor/agent.py +573 -0
- wmo/evals/harbor/ctrf.py +171 -0
- wmo/evals/harbor/e2b_environment.py +587 -0
- wmo/evals/harbor/e2b_template_policy.py +144 -0
- wmo/evals/harbor/scorer.py +875 -0
- wmo/evals/harbor/tasks.py +140 -0
- wmo/evals/open_loop.py +194 -0
- wmo/evals/tasks.py +53 -0
- wmo/harness/__init__.py +51 -0
- wmo/harness/code_runtime.py +288 -0
- wmo/harness/create.py +1191 -0
- wmo/harness/delta.py +220 -0
- wmo/harness/doc.py +556 -0
- wmo/harness/e2b_ledger.py +342 -0
- wmo/harness/e2b_reap.py +476 -0
- wmo/harness/e2b_sandbox.py +350 -0
- wmo/harness/environment.py +35 -0
- wmo/harness/live_session.py +543 -0
- wmo/harness/mutate.py +343 -0
- wmo/harness/pi_e2b.py +1710 -0
- wmo/harness/pi_entry/entry.ts +268 -0
- wmo/harness/pi_entry/runner_frames.ts +92 -0
- wmo/harness/pi_entry/runner_live.ts +587 -0
- wmo/harness/pi_entry/runner_service.ts +270 -0
- wmo/harness/pi_entry/runner_stdio.ts +374 -0
- wmo/harness/pi_entry/runner_termination.ts +142 -0
- wmo/harness/pi_local.py +262 -0
- wmo/harness/pi_runtime.py +495 -0
- wmo/harness/pi_vendor.py +65 -0
- wmo/harness/population.py +509 -0
- wmo/harness/project_proposer.py +569 -0
- wmo/harness/proposer.py +977 -0
- wmo/harness/runner_link.py +619 -0
- wmo/harness/runtime.py +389 -0
- wmo/harness/scoring.py +247 -0
- wmo/harness/skills.py +116 -0
- wmo/harness/source_tree.py +319 -0
- wmo/harness/store.py +176 -0
- wmo/harness/tools.py +105 -0
- wmo/harness/vendor/manifest.sha256 +58 -0
- wmo/harness/vendor/pi-agent/CHANGELOG.md +556 -0
- wmo/harness/vendor/pi-agent/LICENSE +21 -0
- wmo/harness/vendor/pi-agent/README.md +488 -0
- wmo/harness/vendor/pi-agent/VENDOR.md +39 -0
- wmo/harness/vendor/pi-agent/docs/agent-harness.md +486 -0
- wmo/harness/vendor/pi-agent/docs/durable-harness.md +212 -0
- wmo/harness/vendor/pi-agent/docs/hooks.md +445 -0
- wmo/harness/vendor/pi-agent/docs/models.md +966 -0
- wmo/harness/vendor/pi-agent/docs/observability.md +376 -0
- wmo/harness/vendor/pi-agent/package.json +60 -0
- wmo/harness/vendor/pi-agent/src/agent-loop.ts +748 -0
- wmo/harness/vendor/pi-agent/src/agent.ts +575 -0
- wmo/harness/vendor/pi-agent/src/harness/agent-harness.ts +1029 -0
- wmo/harness/vendor/pi-agent/src/harness/compaction/branch-summarization.ts +261 -0
- wmo/harness/vendor/pi-agent/src/harness/compaction/compaction.ts +747 -0
- wmo/harness/vendor/pi-agent/src/harness/compaction/utils.ts +144 -0
- wmo/harness/vendor/pi-agent/src/harness/env/nodejs.ts +550 -0
- wmo/harness/vendor/pi-agent/src/harness/messages.ts +164 -0
- wmo/harness/vendor/pi-agent/src/harness/prompt-templates.ts +267 -0
- wmo/harness/vendor/pi-agent/src/harness/session/jsonl-repo.ts +177 -0
- wmo/harness/vendor/pi-agent/src/harness/session/jsonl-storage.ts +293 -0
- wmo/harness/vendor/pi-agent/src/harness/session/memory-repo.ts +50 -0
- wmo/harness/vendor/pi-agent/src/harness/session/memory-storage.ts +131 -0
- wmo/harness/vendor/pi-agent/src/harness/session/repo-utils.ts +51 -0
- wmo/harness/vendor/pi-agent/src/harness/session/session.ts +267 -0
- wmo/harness/vendor/pi-agent/src/harness/session/uuid.ts +54 -0
- wmo/harness/vendor/pi-agent/src/harness/skills.ts +375 -0
- wmo/harness/vendor/pi-agent/src/harness/system-prompt.ts +34 -0
- wmo/harness/vendor/pi-agent/src/harness/types.ts +836 -0
- wmo/harness/vendor/pi-agent/src/harness/utils/shell-output.ts +135 -0
- wmo/harness/vendor/pi-agent/src/harness/utils/truncate.ts +344 -0
- wmo/harness/vendor/pi-agent/src/index.ts +44 -0
- wmo/harness/vendor/pi-agent/src/node.ts +2 -0
- wmo/harness/vendor/pi-agent/src/proxy.ts +367 -0
- wmo/harness/vendor/pi-agent/src/types.ts +428 -0
- wmo/harness/vendor/pi-agent/test/agent-loop.test.ts +1351 -0
- wmo/harness/vendor/pi-agent/test/agent.test.ts +699 -0
- wmo/harness/vendor/pi-agent/test/e2e.test.ts +404 -0
- wmo/harness/vendor/pi-agent/test/harness/agent-harness-stream.test.ts +213 -0
- wmo/harness/vendor/pi-agent/test/harness/agent-harness.test.ts +608 -0
- wmo/harness/vendor/pi-agent/test/harness/compaction.test.ts +655 -0
- wmo/harness/vendor/pi-agent/test/harness/nodejs-env.test.ts +321 -0
- wmo/harness/vendor/pi-agent/test/harness/prompt-templates.test.ts +90 -0
- wmo/harness/vendor/pi-agent/test/harness/repo.test.ts +68 -0
- wmo/harness/vendor/pi-agent/test/harness/resource-formatting.test.ts +24 -0
- wmo/harness/vendor/pi-agent/test/harness/session-test-utils.ts +55 -0
- wmo/harness/vendor/pi-agent/test/harness/session-uuid.test.ts +50 -0
- wmo/harness/vendor/pi-agent/test/harness/session.test.ts +156 -0
- wmo/harness/vendor/pi-agent/test/harness/skills.test.ts +116 -0
- wmo/harness/vendor/pi-agent/test/harness/storage.test.ts +299 -0
- wmo/harness/vendor/pi-agent/test/harness/system-prompt.test.ts +66 -0
- wmo/harness/vendor/pi-agent/test/harness/truncate.test.ts +169 -0
- wmo/harness/vendor/pi-agent/test/scratch/simple.ts +72 -0
- wmo/harness/vendor/pi-agent/test/utils/calculate.ts +32 -0
- wmo/harness/vendor/pi-agent/test/utils/get-current-time.ts +46 -0
- wmo/harness/vendor/pi-agent/tsconfig.build.json +13 -0
- wmo/harness/vendor/pi-agent/vitest.config.ts +19 -0
- wmo/harness/vendor/pi-agent/vitest.harness.config.ts +28 -0
- wmo/harness/vendor/vendor_pi.sh +59 -0
- wmo/harness/workspace_patch.py +270 -0
- wmo/ingest/__init__.py +47 -0
- wmo/ingest/adapter.py +72 -0
- wmo/ingest/base.py +114 -0
- wmo/ingest/braintrust.py +339 -0
- wmo/ingest/detect.py +126 -0
- wmo/ingest/langfuse.py +291 -0
- wmo/ingest/langsmith.py +444 -0
- wmo/ingest/mastra.py +330 -0
- wmo/ingest/messages.py +170 -0
- wmo/ingest/normalize.py +679 -0
- wmo/ingest/otel_genai.py +69 -0
- wmo/ingest/otel_writer.py +100 -0
- wmo/ingest/phoenix.py +150 -0
- wmo/ingest/postgres.py +246 -0
- wmo/ingest/posthog.py +320 -0
- wmo/ingest/quality.py +28 -0
- wmo/ingest/stream.py +209 -0
- wmo/ingest/testdata/sample_otlp.json +60 -0
- wmo/ingest/testdata/sample_spans.jsonl +3 -0
- wmo/optimize/__init__.py +25 -0
- wmo/optimize/base.py +143 -0
- wmo/optimize/gepa.py +806 -0
- wmo/optimize/judge.py +262 -0
- wmo/optimize/judge_quality.py +359 -0
- wmo/optimize/knn.py +468 -0
- wmo/optimize/numeric.py +152 -0
- wmo/optimize/outcomes.py +103 -0
- wmo/optimize/policy.py +669 -0
- wmo/optimize/report.py +231 -0
- wmo/optimize/reward.py +129 -0
- wmo/optimize/routing.py +373 -0
- wmo/platform/__init__.py +6 -0
- wmo/platform/auth.py +115 -0
- wmo/platform/client.py +551 -0
- wmo/platform/credentials.py +126 -0
- wmo/platform/transfer.py +158 -0
- wmo/providers/__init__.py +40 -0
- wmo/providers/_bedrock_chat.py +155 -0
- wmo/providers/_openai_common.py +182 -0
- wmo/providers/_responses_common.py +472 -0
- wmo/providers/anthropic.py +134 -0
- wmo/providers/azure_openai.py +296 -0
- wmo/providers/base.py +300 -0
- wmo/providers/bedrock.py +312 -0
- wmo/providers/models.py +205 -0
- wmo/providers/openai.py +143 -0
- wmo/providers/openai_responses.py +240 -0
- wmo/providers/pool.py +170 -0
- wmo/providers/registry.py +73 -0
- wmo/providers/retry.py +151 -0
- wmo/providers/tinker.py +936 -0
- wmo/providers/waterfall.py +336 -0
- wmo/research/__init__.py +81 -0
- wmo/research/ablation.py +133 -0
- wmo/research/concurrency_plot.py +523 -0
- wmo/research/concurrency_run.py +240 -0
- wmo/research/concurrency_scaling.py +270 -0
- wmo/research/gepa_scaling.py +274 -0
- wmo/research/pipeline.py +198 -0
- wmo/research/scaling_split.py +82 -0
- wmo/research/scenario_fidelity.py +198 -0
- wmo/research/scenario_recovery.py +92 -0
- wmo/research/seed_stability.py +90 -0
- wmo/research/trace_scaling.py +348 -0
- wmo/retrieval/__init__.py +6 -0
- wmo/retrieval/embedders.py +105 -0
- wmo/retrieval/leakfree.py +52 -0
- wmo/retrieval/retriever.py +173 -0
- wmo/scenarios/__init__.py +58 -0
- wmo/scenarios/builder.py +152 -0
- wmo/scenarios/mining/__init__.py +27 -0
- wmo/scenarios/mining/clustering.py +171 -0
- wmo/scenarios/mining/facets.py +226 -0
- wmo/scenarios/mining/selection.py +220 -0
- wmo/scenarios/synthesis/__init__.py +6 -0
- wmo/scenarios/synthesis/scenario_set.py +63 -0
- wmo/scenarios/synthesis/synthesizer.py +85 -0
- wmo/scenarios/verification/__init__.py +17 -0
- wmo/scenarios/verification/judge.py +97 -0
- wmo/scenarios/verification/verify.py +135 -0
- wmo/serving/__init__.py +5 -0
- wmo/serving/builds.py +451 -0
- wmo/serving/chat.py +878 -0
- wmo/serving/endpoint_config.py +64 -0
- wmo/serving/savings.py +250 -0
- wmo/serving/server.py +553 -0
- wmo/serving/traces_source.py +206 -0
- wmo/telemetry.py +213 -0
- wmo/tracking/__init__.py +36 -0
- wmo/tracking/clock.py +24 -0
- wmo/tracking/metered.py +125 -0
- wmo/tracking/pricing.py +99 -0
- wmo/tracking/store.py +31 -0
- wmo/tracking/tracker.py +149 -0
- world_model_optimizer-0.2.0.dist-info/METADATA +203 -0
- world_model_optimizer-0.2.0.dist-info/RECORD +308 -0
- world_model_optimizer-0.2.0.dist-info/WHEEL +4 -0
- world_model_optimizer-0.2.0.dist-info/entry_points.txt +2 -0
wmo/connect/google.py
ADDED
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
"""Google context connectors: Calendar, Drive, and Gmail read-only pulls.
|
|
2
|
+
|
|
3
|
+
All three connectors share one Google OAuth app (`get_app("google")`) and the browser loopback
|
|
4
|
+
flow, differing only in the scope they request and the account label they report. Each keeps its
|
|
5
|
+
own stored credential (one consent per service in v1) and refreshes it through `ensure_fresh`
|
|
6
|
+
before every pull so refresh tokens keep working unattended.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import logging
|
|
13
|
+
from datetime import UTC, datetime, timedelta
|
|
14
|
+
from typing import cast
|
|
15
|
+
from urllib.parse import quote
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from wmo.connect.apps import get_app
|
|
20
|
+
from wmo.connect.connector import ConnectUI, register_connector
|
|
21
|
+
from wmo.connect.oauth import ensure_fresh, run_loopback_flow
|
|
22
|
+
from wmo.connect.types import (
|
|
23
|
+
ConnectError,
|
|
24
|
+
ConnectorAuth,
|
|
25
|
+
ContextItem,
|
|
26
|
+
ItemKind,
|
|
27
|
+
PullQuery,
|
|
28
|
+
capped,
|
|
29
|
+
opt_str,
|
|
30
|
+
strip_html,
|
|
31
|
+
transport_errors,
|
|
32
|
+
)
|
|
33
|
+
from wmo.core.types import JsonObject, JsonValue
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
_APP_NAME = "google"
|
|
38
|
+
_TIMEOUT_SECONDS = 30.0
|
|
39
|
+
|
|
40
|
+
_CALENDAR_BASE = "https://www.googleapis.com/calendar/v3"
|
|
41
|
+
_DRIVE_BASE = "https://www.googleapis.com/drive/v3"
|
|
42
|
+
_GMAIL_BASE = "https://gmail.googleapis.com/gmail/v1"
|
|
43
|
+
|
|
44
|
+
CALENDAR_SCOPE = "https://www.googleapis.com/auth/calendar.readonly"
|
|
45
|
+
DRIVE_SCOPE = "https://www.googleapis.com/auth/drive.readonly"
|
|
46
|
+
GMAIL_SCOPE = "https://www.googleapis.com/auth/gmail.readonly"
|
|
47
|
+
|
|
48
|
+
# Per-request page sizes, bounded well within each API's documented maximum.
|
|
49
|
+
_CALENDAR_PAGE_MAX = 250
|
|
50
|
+
_DRIVE_PAGE_MAX = 100
|
|
51
|
+
_GMAIL_PAGE_MAX = 100
|
|
52
|
+
|
|
53
|
+
# Calendar pulls default to a window around now when since/until are not given.
|
|
54
|
+
_DEFAULT_LOOKBACK = timedelta(days=30)
|
|
55
|
+
_DEFAULT_LOOKAHEAD = timedelta(days=60)
|
|
56
|
+
|
|
57
|
+
_DRIVE_LIST_FIELDS = (
|
|
58
|
+
"nextPageToken,files(id,name,mimeType,modifiedTime,createdTime,webViewLink,size)"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Google-native types Drive can export as text, mapped to the export mime each one supports.
|
|
62
|
+
# Spreadsheets have no text/plain export; text/csv (the first sheet only) is the text form.
|
|
63
|
+
_EXPORT_MIME_BY_TYPE: dict[str, str] = {
|
|
64
|
+
"application/vnd.google-apps.document": "text/plain",
|
|
65
|
+
"application/vnd.google-apps.presentation": "text/plain",
|
|
66
|
+
"application/vnd.google-apps.spreadsheet": "text/csv",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class _GoogleConnector:
|
|
71
|
+
"""Shared auth, refresh, and HTTP plumbing for the three Google service connectors.
|
|
72
|
+
|
|
73
|
+
Subclasses set `name`/`label`/`scope` and implement `verify` and `pull`; everything that
|
|
74
|
+
talks OAuth or carries the bearer token lives here.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
name: str
|
|
78
|
+
label: str
|
|
79
|
+
scope: str
|
|
80
|
+
|
|
81
|
+
def __init__(self, transport: httpx.BaseTransport | None = None) -> None:
|
|
82
|
+
"""`transport` is injected into every httpx call; None means the real network."""
|
|
83
|
+
self._transport = transport
|
|
84
|
+
|
|
85
|
+
def connect(self, ui: ConnectUI) -> ConnectorAuth:
|
|
86
|
+
"""Run the browser loopback OAuth flow for this service's single read-only scope.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
ui: Presentation callbacks; only `open_url` and `info` are used.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
The oauth-kind credential with `account` set to the verified identity.
|
|
93
|
+
"""
|
|
94
|
+
app = get_app(_APP_NAME)
|
|
95
|
+
ui.info(f"Requesting read-only Google access for {self.label} ({self.scope}).")
|
|
96
|
+
auth = run_loopback_flow(
|
|
97
|
+
app, scopes=[self.scope], open_url=ui.open_url, transport=self._transport
|
|
98
|
+
)
|
|
99
|
+
account = self.verify(auth)
|
|
100
|
+
ui.info(f"Connected {self.label} as {account}.")
|
|
101
|
+
return auth.model_copy(update={"account": account})
|
|
102
|
+
|
|
103
|
+
def verify(self, auth: ConnectorAuth) -> str:
|
|
104
|
+
"""Cheap identity check; implemented per service."""
|
|
105
|
+
raise NotImplementedError
|
|
106
|
+
|
|
107
|
+
def pull(self, auth: ConnectorAuth, query: PullQuery) -> list[ContextItem]:
|
|
108
|
+
"""Fetch normalized items; implemented per service."""
|
|
109
|
+
raise NotImplementedError
|
|
110
|
+
|
|
111
|
+
def _fresh(self, auth: ConnectorAuth) -> ConnectorAuth:
|
|
112
|
+
"""Refresh the credential when it is about to expire, persisting the result.
|
|
113
|
+
|
|
114
|
+
Consults the OAuth app config only when a refresh is actually possible, so plain
|
|
115
|
+
env-injected tokens work without any Google OAuth client configured.
|
|
116
|
+
|
|
117
|
+
Raises:
|
|
118
|
+
ConnectError: When the provider rejects the refresh (e.g. `invalid_grant` after
|
|
119
|
+
the user revoked access); the message says the connection must be reauthorized.
|
|
120
|
+
"""
|
|
121
|
+
if not auth.refresh_token or not auth.expires_at:
|
|
122
|
+
return auth
|
|
123
|
+
try:
|
|
124
|
+
return ensure_fresh(get_app(_APP_NAME), self.name, auth, transport=self._transport)
|
|
125
|
+
except ConnectError as exc:
|
|
126
|
+
raise ConnectError(
|
|
127
|
+
f"could not refresh the {self.label} credential ({exc}); "
|
|
128
|
+
"the token is invalid or expired and the connection must be reauthorized"
|
|
129
|
+
) from exc
|
|
130
|
+
|
|
131
|
+
def _client(self) -> httpx.Client:
|
|
132
|
+
"""A short-lived HTTP client carrying the injected transport."""
|
|
133
|
+
return httpx.Client(timeout=_TIMEOUT_SECONDS, transport=self._transport)
|
|
134
|
+
|
|
135
|
+
def _get(
|
|
136
|
+
self,
|
|
137
|
+
client: httpx.Client,
|
|
138
|
+
auth: ConnectorAuth,
|
|
139
|
+
url: str,
|
|
140
|
+
*,
|
|
141
|
+
params: dict[str, str] | None = None,
|
|
142
|
+
) -> httpx.Response:
|
|
143
|
+
"""One authorized GET; 401s and transport failures become actionable ConnectErrors."""
|
|
144
|
+
with transport_errors(httpx.URL(url).host):
|
|
145
|
+
response = client.get(
|
|
146
|
+
url, params=params, headers={"Authorization": f"Bearer {auth.access_token}"}
|
|
147
|
+
)
|
|
148
|
+
if response.status_code == 401:
|
|
149
|
+
raise ConnectError(
|
|
150
|
+
f"{self.label} rejected the stored credential (HTTP 401); "
|
|
151
|
+
"the token is invalid or expired and the connection must be reauthorized"
|
|
152
|
+
)
|
|
153
|
+
return response
|
|
154
|
+
|
|
155
|
+
def _get_json(
|
|
156
|
+
self,
|
|
157
|
+
client: httpx.Client,
|
|
158
|
+
auth: ConnectorAuth,
|
|
159
|
+
url: str,
|
|
160
|
+
*,
|
|
161
|
+
params: dict[str, str] | None = None,
|
|
162
|
+
) -> JsonObject:
|
|
163
|
+
"""An authorized GET that must return a JSON object, else raises ConnectError."""
|
|
164
|
+
response = self._get(client, auth, url, params=params)
|
|
165
|
+
if response.status_code != 200:
|
|
166
|
+
raise ConnectError(
|
|
167
|
+
f"{self.label} request to {url} failed "
|
|
168
|
+
f"(HTTP {response.status_code}): {response.text[:200]}; "
|
|
169
|
+
"check the target/query and retry"
|
|
170
|
+
)
|
|
171
|
+
try:
|
|
172
|
+
raw = response.json()
|
|
173
|
+
except ValueError:
|
|
174
|
+
raw = None
|
|
175
|
+
if not isinstance(raw, dict):
|
|
176
|
+
raise ConnectError(
|
|
177
|
+
f"{self.label} returned an unexpected non-object response from {url}; retry later"
|
|
178
|
+
)
|
|
179
|
+
return cast(JsonObject, raw)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class GoogleCalendarConnector(_GoogleConnector):
|
|
183
|
+
"""Pulls calendar events, normalized as EVENT items with a human-readable detail block."""
|
|
184
|
+
|
|
185
|
+
name = "google-calendar"
|
|
186
|
+
label = "Google Calendar"
|
|
187
|
+
scope = CALENDAR_SCOPE
|
|
188
|
+
|
|
189
|
+
def verify(self, auth: ConnectorAuth) -> str:
|
|
190
|
+
"""The primary calendar's summary (usually the account's email address)."""
|
|
191
|
+
with self._client() as client:
|
|
192
|
+
payload = self._get_json(client, auth, f"{_CALENDAR_BASE}/calendars/primary")
|
|
193
|
+
return opt_str(payload.get("summary")) or "primary calendar"
|
|
194
|
+
|
|
195
|
+
def pull(self, auth: ConnectorAuth, query: PullQuery) -> list[ContextItem]:
|
|
196
|
+
"""List events from `query.target` (default "primary") within the query window.
|
|
197
|
+
|
|
198
|
+
Defaults to now-30d .. now+60d when `since`/`until` are unset; expands recurring
|
|
199
|
+
events (`singleEvents=true`) and paginates `nextPageToken` up to `query.limit`.
|
|
200
|
+
"""
|
|
201
|
+
auth = self._fresh(auth)
|
|
202
|
+
calendar_id = query.target or "primary"
|
|
203
|
+
now = datetime.now(UTC)
|
|
204
|
+
time_min = (
|
|
205
|
+
_rfc3339(query.since, field="since") if query.since else _iso(now - _DEFAULT_LOOKBACK)
|
|
206
|
+
)
|
|
207
|
+
time_max = (
|
|
208
|
+
_rfc3339(query.until, field="until") if query.until else _iso(now + _DEFAULT_LOOKAHEAD)
|
|
209
|
+
)
|
|
210
|
+
url = f"{_CALENDAR_BASE}/calendars/{quote(calendar_id, safe='@.')}/events"
|
|
211
|
+
items: list[ContextItem] = []
|
|
212
|
+
page_token: str | None = None
|
|
213
|
+
with self._client() as client:
|
|
214
|
+
while len(items) < query.limit:
|
|
215
|
+
params: dict[str, str] = {
|
|
216
|
+
"singleEvents": "true",
|
|
217
|
+
"orderBy": "startTime",
|
|
218
|
+
"timeMin": time_min,
|
|
219
|
+
"timeMax": time_max,
|
|
220
|
+
"maxResults": str(min(query.limit - len(items), _CALENDAR_PAGE_MAX)),
|
|
221
|
+
}
|
|
222
|
+
if query.query:
|
|
223
|
+
params["q"] = query.query
|
|
224
|
+
if page_token:
|
|
225
|
+
params["pageToken"] = page_token
|
|
226
|
+
payload = self._get_json(client, auth, url, params=params)
|
|
227
|
+
for event in _dicts(payload.get("items")):
|
|
228
|
+
item = self._event_item(event, calendar_id)
|
|
229
|
+
if item is not None:
|
|
230
|
+
items.append(item)
|
|
231
|
+
if len(items) >= query.limit:
|
|
232
|
+
break
|
|
233
|
+
page_token = opt_str(payload.get("nextPageToken"))
|
|
234
|
+
if not page_token:
|
|
235
|
+
break
|
|
236
|
+
return items
|
|
237
|
+
|
|
238
|
+
def _event_item(self, event: JsonObject, calendar_id: str) -> ContextItem | None:
|
|
239
|
+
"""Normalize one API event; events without an id are skipped."""
|
|
240
|
+
event_id = opt_str(event.get("id"))
|
|
241
|
+
if not event_id:
|
|
242
|
+
logger.debug("skipping calendar event without an id: %r", event)
|
|
243
|
+
return None
|
|
244
|
+
metadata: JsonObject = {"calendar": calendar_id}
|
|
245
|
+
status = opt_str(event.get("status"))
|
|
246
|
+
if status:
|
|
247
|
+
metadata["status"] = status
|
|
248
|
+
return ContextItem(
|
|
249
|
+
id=event_id,
|
|
250
|
+
source=self.name,
|
|
251
|
+
kind=ItemKind.EVENT,
|
|
252
|
+
title=opt_str(event.get("summary")) or "(no title)",
|
|
253
|
+
body=_event_body(event),
|
|
254
|
+
url=opt_str(event.get("htmlLink")),
|
|
255
|
+
created_at=opt_str(event.get("created")),
|
|
256
|
+
updated_at=opt_str(event.get("updated")),
|
|
257
|
+
metadata=metadata,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class GoogleDriveConnector(_GoogleConnector):
|
|
262
|
+
"""Pulls Drive files: Google docs exported as text, text blobs downloaded, binaries listed."""
|
|
263
|
+
|
|
264
|
+
name = "google-drive"
|
|
265
|
+
label = "Google Drive"
|
|
266
|
+
scope = DRIVE_SCOPE
|
|
267
|
+
|
|
268
|
+
def verify(self, auth: ConnectorAuth) -> str:
|
|
269
|
+
"""The Drive user's display name and email address."""
|
|
270
|
+
with self._client() as client:
|
|
271
|
+
payload = self._get_json(
|
|
272
|
+
client, auth, f"{_DRIVE_BASE}/about", params={"fields": "user"}
|
|
273
|
+
)
|
|
274
|
+
user = payload.get("user")
|
|
275
|
+
user_obj = cast(JsonObject, user) if isinstance(user, dict) else {}
|
|
276
|
+
name = opt_str(user_obj.get("displayName"))
|
|
277
|
+
email = opt_str(user_obj.get("emailAddress"))
|
|
278
|
+
if name and email:
|
|
279
|
+
return f"{name} ({email})"
|
|
280
|
+
return name or email or "Google Drive user"
|
|
281
|
+
|
|
282
|
+
def pull(self, auth: ConnectorAuth, query: PullQuery) -> list[ContextItem]:
|
|
283
|
+
"""Search files (newest modified first) and fetch readable content per file.
|
|
284
|
+
|
|
285
|
+
The Drive `q` is derived from the query: `trashed=false` always, `fullText contains`
|
|
286
|
+
for free text, `'<target>' in parents` for a folder id, and `modifiedTime` bounds for
|
|
287
|
+
since/until. Google-native files are exported as text (Docs/Slides as plain text,
|
|
288
|
+
Sheets as CSV); `text/*` and JSON blobs are downloaded (all capped at 200000
|
|
289
|
+
characters); other binaries become body-less FILE items.
|
|
290
|
+
"""
|
|
291
|
+
auth = self._fresh(auth)
|
|
292
|
+
q = _drive_search_terms(query)
|
|
293
|
+
items: list[ContextItem] = []
|
|
294
|
+
page_token: str | None = None
|
|
295
|
+
with self._client() as client:
|
|
296
|
+
while len(items) < query.limit:
|
|
297
|
+
params: dict[str, str] = {
|
|
298
|
+
"q": q,
|
|
299
|
+
"orderBy": "modifiedTime desc",
|
|
300
|
+
"pageSize": str(min(query.limit - len(items), _DRIVE_PAGE_MAX)),
|
|
301
|
+
"fields": _DRIVE_LIST_FIELDS,
|
|
302
|
+
}
|
|
303
|
+
if page_token:
|
|
304
|
+
params["pageToken"] = page_token
|
|
305
|
+
payload = self._get_json(client, auth, f"{_DRIVE_BASE}/files", params=params)
|
|
306
|
+
for file in _dicts(payload.get("files")):
|
|
307
|
+
item = self._file_item(client, auth, file)
|
|
308
|
+
if item is not None:
|
|
309
|
+
items.append(item)
|
|
310
|
+
if len(items) >= query.limit:
|
|
311
|
+
break
|
|
312
|
+
page_token = opt_str(payload.get("nextPageToken"))
|
|
313
|
+
if not page_token:
|
|
314
|
+
break
|
|
315
|
+
return items
|
|
316
|
+
|
|
317
|
+
def _file_item(
|
|
318
|
+
self, client: httpx.Client, auth: ConnectorAuth, file: JsonObject
|
|
319
|
+
) -> ContextItem | None:
|
|
320
|
+
"""Normalize one Drive file, fetching its content when it has a readable form."""
|
|
321
|
+
file_id = opt_str(file.get("id"))
|
|
322
|
+
if not file_id:
|
|
323
|
+
logger.debug("skipping drive file without an id: %r", file)
|
|
324
|
+
return None
|
|
325
|
+
mime = opt_str(file.get("mimeType")) or ""
|
|
326
|
+
body, kind, fetch_error = self._file_body(client, auth, file_id, mime)
|
|
327
|
+
metadata: JsonObject = {"mimeType": mime}
|
|
328
|
+
size = opt_str(file.get("size"))
|
|
329
|
+
if size:
|
|
330
|
+
metadata["size"] = size
|
|
331
|
+
if fetch_error:
|
|
332
|
+
metadata["fetch_error"] = fetch_error
|
|
333
|
+
return ContextItem(
|
|
334
|
+
id=file_id,
|
|
335
|
+
source=self.name,
|
|
336
|
+
kind=kind,
|
|
337
|
+
title=opt_str(file.get("name")) or file_id,
|
|
338
|
+
body=body,
|
|
339
|
+
url=opt_str(file.get("webViewLink")),
|
|
340
|
+
created_at=opt_str(file.get("createdTime")),
|
|
341
|
+
updated_at=opt_str(file.get("modifiedTime")),
|
|
342
|
+
metadata=metadata,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
def _file_body(
|
|
346
|
+
self, client: httpx.Client, auth: ConnectorAuth, file_id: str, mime: str
|
|
347
|
+
) -> tuple[str, ItemKind, str | None]:
|
|
348
|
+
"""(body, kind, fetch_error) for one file; unreadable binaries stay body-less FILEs.
|
|
349
|
+
|
|
350
|
+
A failed per-file content fetch (except 401) degrades to an empty body with the
|
|
351
|
+
failure recorded in metadata instead of aborting the whole pull.
|
|
352
|
+
"""
|
|
353
|
+
export_mime = _EXPORT_MIME_BY_TYPE.get(mime)
|
|
354
|
+
if export_mime is not None:
|
|
355
|
+
response = self._get(
|
|
356
|
+
client,
|
|
357
|
+
auth,
|
|
358
|
+
f"{_DRIVE_BASE}/files/{quote(file_id, safe='')}/export",
|
|
359
|
+
params={"mimeType": export_mime},
|
|
360
|
+
)
|
|
361
|
+
elif mime.startswith("text/") or mime == "application/json":
|
|
362
|
+
response = self._get(
|
|
363
|
+
client,
|
|
364
|
+
auth,
|
|
365
|
+
f"{_DRIVE_BASE}/files/{quote(file_id, safe='')}",
|
|
366
|
+
params={"alt": "media"},
|
|
367
|
+
)
|
|
368
|
+
else:
|
|
369
|
+
return "", ItemKind.FILE, None
|
|
370
|
+
if response.status_code != 200:
|
|
371
|
+
logger.warning(
|
|
372
|
+
"could not fetch drive file %s content (HTTP %s); leaving its body empty",
|
|
373
|
+
file_id,
|
|
374
|
+
response.status_code,
|
|
375
|
+
)
|
|
376
|
+
return "", ItemKind.DOCUMENT, f"HTTP {response.status_code}"
|
|
377
|
+
return capped(response.text), ItemKind.DOCUMENT, None
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
class GmailConnector(_GoogleConnector):
|
|
381
|
+
"""Pulls Gmail messages as EMAIL items: header block plus the decoded text body."""
|
|
382
|
+
|
|
383
|
+
name = "gmail"
|
|
384
|
+
label = "Gmail"
|
|
385
|
+
scope = GMAIL_SCOPE
|
|
386
|
+
|
|
387
|
+
def verify(self, auth: ConnectorAuth) -> str:
|
|
388
|
+
"""The mailbox's email address."""
|
|
389
|
+
with self._client() as client:
|
|
390
|
+
payload = self._get_json(client, auth, f"{_GMAIL_BASE}/users/me/profile")
|
|
391
|
+
return opt_str(payload.get("emailAddress")) or "Gmail user"
|
|
392
|
+
|
|
393
|
+
def pull(self, auth: ConnectorAuth, query: PullQuery) -> list[ContextItem]:
|
|
394
|
+
"""Search messages, then fetch each match in full and normalize it.
|
|
395
|
+
|
|
396
|
+
`query.query` passes through as Gmail search syntax; `since`/`until` become
|
|
397
|
+
`after:`/`before:` date operators. Message ids are collected across `nextPageToken`
|
|
398
|
+
pages up to `query.limit`, then each id costs one `format=full` fetch.
|
|
399
|
+
"""
|
|
400
|
+
auth = self._fresh(auth)
|
|
401
|
+
q = _gmail_search_terms(query)
|
|
402
|
+
message_ids: list[str] = []
|
|
403
|
+
items: list[ContextItem] = []
|
|
404
|
+
with self._client() as client:
|
|
405
|
+
page_token: str | None = None
|
|
406
|
+
while len(message_ids) < query.limit:
|
|
407
|
+
params: dict[str, str] = {
|
|
408
|
+
"maxResults": str(min(query.limit - len(message_ids), _GMAIL_PAGE_MAX))
|
|
409
|
+
}
|
|
410
|
+
if q:
|
|
411
|
+
params["q"] = q
|
|
412
|
+
if page_token:
|
|
413
|
+
params["pageToken"] = page_token
|
|
414
|
+
payload = self._get_json(
|
|
415
|
+
client, auth, f"{_GMAIL_BASE}/users/me/messages", params=params
|
|
416
|
+
)
|
|
417
|
+
for ref in _dicts(payload.get("messages")):
|
|
418
|
+
ref_id = opt_str(ref.get("id"))
|
|
419
|
+
if ref_id:
|
|
420
|
+
message_ids.append(ref_id)
|
|
421
|
+
if len(message_ids) >= query.limit:
|
|
422
|
+
break
|
|
423
|
+
page_token = opt_str(payload.get("nextPageToken"))
|
|
424
|
+
if not page_token:
|
|
425
|
+
break
|
|
426
|
+
for message_id in message_ids:
|
|
427
|
+
message = self._get_json(
|
|
428
|
+
client,
|
|
429
|
+
auth,
|
|
430
|
+
f"{_GMAIL_BASE}/users/me/messages/{quote(message_id, safe='')}",
|
|
431
|
+
params={"format": "full"},
|
|
432
|
+
)
|
|
433
|
+
items.append(self._message_item(message, message_id))
|
|
434
|
+
return items
|
|
435
|
+
|
|
436
|
+
def _message_item(self, message: JsonObject, message_id: str) -> ContextItem:
|
|
437
|
+
"""Normalize one full-format message into an EMAIL item."""
|
|
438
|
+
payload = message.get("payload")
|
|
439
|
+
payload_obj = cast(JsonObject, payload) if isinstance(payload, dict) else {}
|
|
440
|
+
headers = payload_obj.get("headers")
|
|
441
|
+
sender = _gmail_header(headers, "From")
|
|
442
|
+
to = _gmail_header(headers, "To")
|
|
443
|
+
date = _gmail_header(headers, "Date")
|
|
444
|
+
text = _find_part_text(payload_obj, "text/plain")
|
|
445
|
+
if text is None:
|
|
446
|
+
markup = _find_part_text(payload_obj, "text/html")
|
|
447
|
+
text = strip_html(markup) if markup else ""
|
|
448
|
+
body = f"From: {sender}\nTo: {to}\nDate: {date}"
|
|
449
|
+
if text.strip():
|
|
450
|
+
body = f"{body}\n\n{text.strip()}"
|
|
451
|
+
metadata: JsonObject = {"from": sender, "to": to}
|
|
452
|
+
label_ids = message.get("labelIds")
|
|
453
|
+
if isinstance(label_ids, list):
|
|
454
|
+
metadata["labelIds"] = [label for label in label_ids if isinstance(label, str)]
|
|
455
|
+
thread_id = opt_str(message.get("threadId"))
|
|
456
|
+
if thread_id:
|
|
457
|
+
metadata["threadId"] = thread_id
|
|
458
|
+
return ContextItem(
|
|
459
|
+
id=message_id,
|
|
460
|
+
source=self.name,
|
|
461
|
+
kind=ItemKind.EMAIL,
|
|
462
|
+
title=_gmail_header(headers, "Subject") or "(no subject)",
|
|
463
|
+
body=body,
|
|
464
|
+
url=f"https://mail.google.com/mail/u/0/#all/{message_id}",
|
|
465
|
+
created_at=_from_epoch_ms(message.get("internalDate")),
|
|
466
|
+
metadata=metadata,
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
# -- shared normalization helpers ---------------------------------------------------------------
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _dicts(value: JsonValue | None) -> list[JsonObject]:
|
|
474
|
+
"""The dict entries of a JSON list (non-lists and non-dict entries are dropped)."""
|
|
475
|
+
if not isinstance(value, list):
|
|
476
|
+
return []
|
|
477
|
+
return [cast(JsonObject, entry) for entry in value if isinstance(entry, dict)]
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _parse_iso(value: str, *, field: str) -> datetime:
|
|
481
|
+
"""Parse an ISO-8601 date or datetime; naive values are taken as UTC.
|
|
482
|
+
|
|
483
|
+
Raises:
|
|
484
|
+
ConnectError: When the value is not ISO-8601.
|
|
485
|
+
"""
|
|
486
|
+
try:
|
|
487
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
488
|
+
except ValueError:
|
|
489
|
+
raise ConnectError(
|
|
490
|
+
f"could not parse {field}={value!r} as ISO-8601; use YYYY-MM-DD or a full timestamp"
|
|
491
|
+
) from None
|
|
492
|
+
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _rfc3339(value: str, *, field: str) -> str:
|
|
496
|
+
"""An ISO-8601 query bound as the RFC 3339 timestamp Google APIs expect."""
|
|
497
|
+
return _parse_iso(value, field=field).isoformat()
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _iso(moment: datetime) -> str:
|
|
501
|
+
"""A datetime as a seconds-precision ISO-8601 string."""
|
|
502
|
+
return moment.isoformat(timespec="seconds")
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
# -- calendar helpers ---------------------------------------------------------------------------
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _event_body(event: JsonObject) -> str:
|
|
509
|
+
"""A human-readable block: when, where, who, then the event description."""
|
|
510
|
+
lines: list[str] = []
|
|
511
|
+
start = _event_moment(event.get("start"))
|
|
512
|
+
end = _event_moment(event.get("end"))
|
|
513
|
+
if start or end:
|
|
514
|
+
lines.append(f"When: {start or 'unknown'} to {end or 'unknown'}")
|
|
515
|
+
location = opt_str(event.get("location"))
|
|
516
|
+
if location:
|
|
517
|
+
lines.append(f"Location: {location}")
|
|
518
|
+
attendees = [label for a in _dicts(event.get("attendees")) if (label := _attendee_label(a))]
|
|
519
|
+
if attendees:
|
|
520
|
+
lines.append("Attendees: " + ", ".join(attendees))
|
|
521
|
+
description = opt_str(event.get("description"))
|
|
522
|
+
if description:
|
|
523
|
+
lines.append("")
|
|
524
|
+
lines.append(description)
|
|
525
|
+
return "\n".join(lines)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _event_moment(value: JsonValue | None) -> str | None:
|
|
529
|
+
"""An event boundary's dateTime (timed) or date (all-day), whichever is present."""
|
|
530
|
+
if not isinstance(value, dict):
|
|
531
|
+
return None
|
|
532
|
+
return opt_str(value.get("dateTime")) or opt_str(value.get("date"))
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _attendee_label(attendee: JsonObject) -> str:
|
|
536
|
+
""" "Name <email>" when both are known, else whichever one is."""
|
|
537
|
+
name = opt_str(attendee.get("displayName"))
|
|
538
|
+
email = opt_str(attendee.get("email"))
|
|
539
|
+
if name and email:
|
|
540
|
+
return f"{name} <{email}>"
|
|
541
|
+
return name or email or ""
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
# -- drive helpers ------------------------------------------------------------------------------
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _drive_search_terms(query: PullQuery) -> str:
|
|
548
|
+
"""The Drive files.list `q` derived from a PullQuery (always excludes trashed files)."""
|
|
549
|
+
terms = ["trashed=false"]
|
|
550
|
+
if query.query:
|
|
551
|
+
terms.append(f"fullText contains '{_drive_escape(query.query)}'")
|
|
552
|
+
if query.target:
|
|
553
|
+
terms.append(f"'{_drive_escape(query.target)}' in parents")
|
|
554
|
+
if query.since:
|
|
555
|
+
terms.append(f"modifiedTime > '{_rfc3339(query.since, field='since')}'")
|
|
556
|
+
if query.until:
|
|
557
|
+
terms.append(f"modifiedTime <= '{_rfc3339(query.until, field='until')}'")
|
|
558
|
+
return " and ".join(terms)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _drive_escape(value: str) -> str:
|
|
562
|
+
"""Escape backslashes and single quotes for a Drive query string literal."""
|
|
563
|
+
return value.replace("\\", "\\\\").replace("'", "\\'")
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
# -- gmail helpers ------------------------------------------------------------------------------
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _gmail_search_terms(query: PullQuery) -> str:
|
|
570
|
+
"""The Gmail `q` string: the user's search syntax plus after:/before: date operators."""
|
|
571
|
+
terms: list[str] = []
|
|
572
|
+
if query.query:
|
|
573
|
+
terms.append(query.query)
|
|
574
|
+
if query.since:
|
|
575
|
+
terms.append(f"after:{_parse_iso(query.since, field='since').strftime('%Y/%m/%d')}")
|
|
576
|
+
if query.until:
|
|
577
|
+
terms.append(f"before:{_parse_iso(query.until, field='until').strftime('%Y/%m/%d')}")
|
|
578
|
+
return " ".join(terms)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _gmail_header(headers: JsonValue | None, name: str) -> str:
|
|
582
|
+
"""The first header value with the given case-insensitive name, or ""."""
|
|
583
|
+
for entry in _dicts(headers):
|
|
584
|
+
if (opt_str(entry.get("name")) or "").lower() == name.lower():
|
|
585
|
+
return opt_str(entry.get("value")) or ""
|
|
586
|
+
return ""
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _find_part_text(part: JsonObject, mime: str) -> str | None:
|
|
590
|
+
"""Depth-first search of a message payload tree for the first decodable `mime` part."""
|
|
591
|
+
if opt_str(part.get("mimeType")) == mime:
|
|
592
|
+
body = part.get("body")
|
|
593
|
+
if isinstance(body, dict):
|
|
594
|
+
decoded = _decode_base64url(body.get("data"))
|
|
595
|
+
if decoded:
|
|
596
|
+
return decoded
|
|
597
|
+
for child in _dicts(part.get("parts")):
|
|
598
|
+
found = _find_part_text(child, mime)
|
|
599
|
+
if found is not None:
|
|
600
|
+
return found
|
|
601
|
+
return None
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _decode_base64url(data: JsonValue | None) -> str | None:
|
|
605
|
+
"""Decode Gmail's unpadded base64url part data; undecodable data becomes None."""
|
|
606
|
+
if not isinstance(data, str) or not data:
|
|
607
|
+
return None
|
|
608
|
+
padded = data + "=" * (-len(data) % 4)
|
|
609
|
+
try:
|
|
610
|
+
return base64.urlsafe_b64decode(padded).decode("utf-8", errors="replace")
|
|
611
|
+
except ValueError:
|
|
612
|
+
logger.debug("undecodable base64url message part (%d chars)", len(data))
|
|
613
|
+
return None
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _from_epoch_ms(value: JsonValue | None) -> str | None:
|
|
617
|
+
"""A Gmail internalDate (milliseconds since the epoch) as ISO-8601, or None."""
|
|
618
|
+
try:
|
|
619
|
+
ms = int(str(value))
|
|
620
|
+
except (TypeError, ValueError):
|
|
621
|
+
return None
|
|
622
|
+
return datetime.fromtimestamp(ms / 1000, tz=UTC).isoformat(timespec="seconds")
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
register_connector(GoogleCalendarConnector())
|
|
626
|
+
register_connector(GoogleDriveConnector())
|
|
627
|
+
register_connector(GmailConnector())
|