flowlit-integration 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.
- flowlit_integration/__init__.py +26 -0
- flowlit_integration/credentials/__init__.py +10 -0
- flowlit_integration/credentials/provider.py +419 -0
- flowlit_integration/executors/__init__.py +10 -0
- flowlit_integration/executors/gmail_executor.py +220 -0
- flowlit_integration/executors/google_sheets_executor.py +309 -0
- flowlit_integration/executors/harvest_executor.py +200 -0
- flowlit_integration/executors/slack_executor.py +150 -0
- flowlit_integration/executors/spreadsheet_executor.py +395 -0
- flowlit_integration/py.typed +0 -0
- flowlit_integration-0.1.0.dist-info/METADATA +107 -0
- flowlit_integration-0.1.0.dist-info/RECORD +14 -0
- flowlit_integration-0.1.0.dist-info/WHEEL +4 -0
- flowlit_integration-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""flowlit-integration: reusable flowlit executors for common external
|
|
2
|
+
systems (Harvest, Google Sheets, Gmail, Slack) and generic data output
|
|
3
|
+
(spreadsheets), plus the shared `CredentialsProvider` abstraction they
|
|
4
|
+
resolve their secrets through.
|
|
5
|
+
|
|
6
|
+
This package sits in the middle of a three-tier hierarchy:
|
|
7
|
+
|
|
8
|
+
flowlit -> flowlit-integration -> <client application>
|
|
9
|
+
|
|
10
|
+
`flowlit` is the execution engine (DAG plans, the `Executor` port, the
|
|
11
|
+
`EventBus`) and has no knowledge of any specific external system.
|
|
12
|
+
`flowlit-integration` knows how to talk to a handful of common ones, but
|
|
13
|
+
nothing about any particular business's use of them. A client
|
|
14
|
+
application (e.g. `ccq-automation`) imports the executors it needs from
|
|
15
|
+
here, registers them with `flowlit` itself
|
|
16
|
+
(`flowlit.register_executor(step_type, executor_instance)`), and wires
|
|
17
|
+
them into its own business-specific DAGs.
|
|
18
|
+
|
|
19
|
+
Registration is deliberately *not* automatic: importing this package, or
|
|
20
|
+
any module in it, performs no registration and no I/O. A client calls
|
|
21
|
+
`flowlit.register_executor(...)` explicitly for each executor it wants,
|
|
22
|
+
the same way it would for an executor it wrote itself -- see each
|
|
23
|
+
executor's own module docstring for its step-type name and spec shape.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""The `CredentialsProvider` abstraction shared by every executor in this
|
|
2
|
+
package: a small chain of sources (explicit value > environment variable
|
|
3
|
+
> credentials file > future secret-manager extension point) plus typed,
|
|
4
|
+
per-integration resolver functions.
|
|
5
|
+
|
|
6
|
+
Populated by the "Move Generic Executors & Credentials Provider into
|
|
7
|
+
flowlit-integration" task; currently empty scaffolding.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
"""Pluggable credential resolution, shared by every executor in this
|
|
2
|
+
package.
|
|
3
|
+
|
|
4
|
+
Why this exists
|
|
5
|
+
----------------
|
|
6
|
+
Originally written for a Google Sheets proof-of-concept that established
|
|
7
|
+
a precedent worth keeping: try more than one source, in a defined order,
|
|
8
|
+
and fall through silently to the next one rather than hard-failing on
|
|
9
|
+
the first miss. Its precedence was, informally, *inline value > file
|
|
10
|
+
path > environment variable*. Rather than re-implement that same
|
|
11
|
+
fallback chain by hand in each executor (Harvest, Google Sheets, Gmail,
|
|
12
|
+
Slack) — the kind of copy-paste that quietly drifts out of sync the
|
|
13
|
+
moment one executor needs a fifth source — this module gives every
|
|
14
|
+
executor one small, testable abstraction: :class:`CredentialsProvider`.
|
|
15
|
+
|
|
16
|
+
Supported sources, and precedence
|
|
17
|
+
----------------------------------
|
|
18
|
+
A caller-supplied :class:`CredentialsProvider` (typically the one
|
|
19
|
+
:func:`default_credentials_provider` builds) resolves a single named key
|
|
20
|
+
(e.g. ``"HARVEST_TOKEN"``) by trying, in order:
|
|
21
|
+
|
|
22
|
+
1. **Explicit / inline value** — not part of the provider chain at all:
|
|
23
|
+
any resolver function below (e.g. :func:`resolve_harvest_credentials`)
|
|
24
|
+
accepts the fully-resolved value as a keyword argument, and uses it
|
|
25
|
+
as-is, before ever consulting a provider. This is what lets a step
|
|
26
|
+
``spec`` (or a test) hand in a credential directly, same as the POC's
|
|
27
|
+
own ``credentials_json``/``credentials_path`` spec fields do today.
|
|
28
|
+
2. **Environment variable** — :class:`EnvCredentialsProvider`, reading
|
|
29
|
+
``os.environ``.
|
|
30
|
+
3. **Credentials file** — :class:`FileCredentialsProvider`, a flat JSON
|
|
31
|
+
object on disk (``{"HARVEST_TOKEN": "...", ...}``) — the standard
|
|
32
|
+
local-dev / container-mounted-secret pattern (a Docker/Kubernetes
|
|
33
|
+
secrets file, an Azure App Service "Configuration" file mount, etc.).
|
|
34
|
+
4. **(Documented, not yet implemented) a cloud secret manager** — e.g.
|
|
35
|
+
Azure Key Vault or AWS Secrets Manager, for a client that deploys
|
|
36
|
+
there (see ``docs/credentials.md``). Adding it later means writing
|
|
37
|
+
one more ``CredentialsProvider`` and adding it to the chain
|
|
38
|
+
:func:`default_credentials_provider` builds — no executor changes.
|
|
39
|
+
|
|
40
|
+
Sources 2–4 are what :class:`ChainedCredentialsProvider` implements:
|
|
41
|
+
try each configured provider in order, return the first non-``None``
|
|
42
|
+
hit. :func:`default_credentials_provider` is the single factory that
|
|
43
|
+
builds "the" chain callers actually use — keeping it the one place a
|
|
44
|
+
future source gets added.
|
|
45
|
+
|
|
46
|
+
Nothing in this module logs a secret's *value* — only the key being
|
|
47
|
+
looked up, and (for the file provider) the path being read. A missing
|
|
48
|
+
credentials file is not an error here: it just means that source
|
|
49
|
+
contributes nothing, so the chain falls through to the next one. A
|
|
50
|
+
malformed one (not valid JSON, or not a flat object) *is* treated as a
|
|
51
|
+
configuration bug and raises — silently ignoring a file that's actually
|
|
52
|
+
present but broken would hide a real mistake.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
from __future__ import annotations
|
|
56
|
+
|
|
57
|
+
import json
|
|
58
|
+
import logging
|
|
59
|
+
import os
|
|
60
|
+
from abc import ABC, abstractmethod
|
|
61
|
+
from collections.abc import Sequence
|
|
62
|
+
from dataclasses import dataclass
|
|
63
|
+
from pathlib import Path
|
|
64
|
+
from typing import Any
|
|
65
|
+
|
|
66
|
+
logger = logging.getLogger(__name__)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class MissingCredentialError(Exception):
|
|
70
|
+
"""Raised by :meth:`CredentialsProvider.require` when `key` isn't
|
|
71
|
+
available from any configured source.
|
|
72
|
+
|
|
73
|
+
Carries `key` (never a resolved value) so callers/tests can assert
|
|
74
|
+
on *which* credential was missing without parsing the message.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(self, key: str) -> None:
|
|
78
|
+
self.key = key
|
|
79
|
+
super().__init__(
|
|
80
|
+
f"missing credential '{key}': not found in the environment, the "
|
|
81
|
+
"credentials file (if configured via FLOWLIT_INTEGRATION_CREDENTIALS_FILE), or any "
|
|
82
|
+
"other configured source. See docs/credentials.md."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class CredentialsProvider(ABC):
|
|
87
|
+
"""A source of named credential values.
|
|
88
|
+
|
|
89
|
+
`get()` is the one method a concrete source implements. `require()`
|
|
90
|
+
is a free convenience built on top of it — resolvers below use it so
|
|
91
|
+
a missing credential fails with one clear, typed error instead of a
|
|
92
|
+
`KeyError`/`TypeError` surfacing wherever the `None` first gets used.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
@abstractmethod
|
|
96
|
+
def get(self, key: str) -> str | None:
|
|
97
|
+
"""Return the value for `key`, or `None` if this source has
|
|
98
|
+
nothing for it. Never raises for an ordinary "not found" — that
|
|
99
|
+
is exactly what `None` means. A source-level failure that isn't
|
|
100
|
+
an ordinary miss (e.g. a credentials file that exists but isn't
|
|
101
|
+
valid JSON) may still raise.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
def require(self, key: str) -> str:
|
|
105
|
+
"""Like `get()`, but raises `MissingCredentialError` instead of
|
|
106
|
+
returning `None`."""
|
|
107
|
+
value = self.get(key)
|
|
108
|
+
if value is None:
|
|
109
|
+
raise MissingCredentialError(key)
|
|
110
|
+
return value
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class EnvCredentialsProvider(CredentialsProvider):
|
|
114
|
+
"""Reads credentials from `os.environ`.
|
|
115
|
+
|
|
116
|
+
`prefix`, if given, is prepended to every key before the environment
|
|
117
|
+
lookup — e.g. `prefix="CCQ_"` makes `get("HARVEST_TOKEN")` read
|
|
118
|
+
`CCQ_HARVEST_TOKEN`. Defaults to no prefix, which is what every
|
|
119
|
+
resolver in this module actually uses (the documented env var names
|
|
120
|
+
in `docs/credentials.md` are read as-is).
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
def __init__(self, prefix: str = "") -> None:
|
|
124
|
+
self._prefix = prefix
|
|
125
|
+
|
|
126
|
+
def get(self, key: str) -> str | None:
|
|
127
|
+
return os.environ.get(f"{self._prefix}{key}")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class FileCredentialsProvider(CredentialsProvider):
|
|
131
|
+
"""Reads credentials from a flat JSON file: `{"KEY": "value", ...}`.
|
|
132
|
+
|
|
133
|
+
The file is read lazily, on first `get()`/`require()` call, and
|
|
134
|
+
cached for the lifetime of this instance — not at construction time,
|
|
135
|
+
so building one costs nothing and doesn't require the file to exist
|
|
136
|
+
yet (it may be mounted into a container after this provider is
|
|
137
|
+
constructed but before the first credential lookup, for example).
|
|
138
|
+
|
|
139
|
+
A missing file is treated as "no credentials from this source" —
|
|
140
|
+
`get()` returns `None` for every key, exactly as if the file were
|
|
141
|
+
present but empty — so a :class:`ChainedCredentialsProvider` falls
|
|
142
|
+
through to its next source rather than hard-failing. A file that
|
|
143
|
+
*exists* but isn't valid JSON, or isn't a flat `{str: str}` object,
|
|
144
|
+
raises `ValueError` — that's a real misconfiguration, not an absent
|
|
145
|
+
optional source.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
def __init__(self, path: str | Path) -> None:
|
|
149
|
+
self._path = Path(path)
|
|
150
|
+
self._values: dict[str, str] | None = None
|
|
151
|
+
|
|
152
|
+
def _load(self) -> dict[str, str]:
|
|
153
|
+
if self._values is not None:
|
|
154
|
+
return self._values
|
|
155
|
+
if not self._path.is_file():
|
|
156
|
+
logger.debug("credentials file %s not found; skipping", self._path)
|
|
157
|
+
self._values = {}
|
|
158
|
+
return self._values
|
|
159
|
+
try:
|
|
160
|
+
raw = json.loads(self._path.read_text(encoding="utf-8"))
|
|
161
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
162
|
+
raise ValueError(f"credentials file {self._path} is not valid JSON: {exc}") from exc
|
|
163
|
+
if not isinstance(raw, dict) or not all(isinstance(v, str) for v in raw.values()):
|
|
164
|
+
raise ValueError(
|
|
165
|
+
f"credentials file {self._path} must be a flat JSON object of "
|
|
166
|
+
"string keys to string values"
|
|
167
|
+
)
|
|
168
|
+
logger.debug("loaded %d credential key(s) from %s", len(raw), self._path)
|
|
169
|
+
self._values = raw
|
|
170
|
+
return self._values
|
|
171
|
+
|
|
172
|
+
def get(self, key: str) -> str | None:
|
|
173
|
+
return self._load().get(key)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class ChainedCredentialsProvider(CredentialsProvider):
|
|
177
|
+
"""Tries a sequence of providers in order, returning the first hit.
|
|
178
|
+
|
|
179
|
+
This is the "more than one supply method" contract itself: each
|
|
180
|
+
configured provider is asked in turn, and the first one to return a
|
|
181
|
+
non-`None` value for `key` wins. An empty/absent source (e.g.
|
|
182
|
+
`FileCredentialsProvider` over a file that doesn't exist) simply
|
|
183
|
+
contributes nothing and the chain moves on — see
|
|
184
|
+
`default_credentials_provider` for the concrete order this package
|
|
185
|
+
uses by default.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
def __init__(self, providers: Sequence[CredentialsProvider]) -> None:
|
|
189
|
+
self._providers = list(providers)
|
|
190
|
+
|
|
191
|
+
def get(self, key: str) -> str | None:
|
|
192
|
+
for provider in self._providers:
|
|
193
|
+
value = provider.get(key)
|
|
194
|
+
if value is not None:
|
|
195
|
+
return value
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
_CREDENTIALS_FILE_ENV_VAR = "FLOWLIT_INTEGRATION_CREDENTIALS_FILE"
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def default_credentials_provider() -> CredentialsProvider:
|
|
203
|
+
"""Build the composite provider every executor resolves credentials
|
|
204
|
+
through, by default.
|
|
205
|
+
|
|
206
|
+
Order: environment variables, then — only if `FLOWLIT_INTEGRATION_CREDENTIALS_FILE`
|
|
207
|
+
names an existing file — that file. This is the single place a
|
|
208
|
+
future source (e.g. a cloud secret manager, for a client that needs
|
|
209
|
+
one) gets added: one more provider appended to the list
|
|
210
|
+
here, with no executor-level changes needed.
|
|
211
|
+
|
|
212
|
+
Callers that want a different order, an extra source, or a fixed set
|
|
213
|
+
of values (tests, mainly) can construct their own
|
|
214
|
+
`CredentialsProvider` instead — every executor accepts one via its
|
|
215
|
+
constructor, defaulting to this factory.
|
|
216
|
+
"""
|
|
217
|
+
providers: list[CredentialsProvider] = [EnvCredentialsProvider()]
|
|
218
|
+
file_path = os.environ.get(_CREDENTIALS_FILE_ENV_VAR)
|
|
219
|
+
if file_path and os.path.isfile(file_path):
|
|
220
|
+
providers.append(FileCredentialsProvider(file_path))
|
|
221
|
+
return ChainedCredentialsProvider(providers)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# --------------------------------------------------------------------------
|
|
225
|
+
# Per-integration typed credentials + resolvers.
|
|
226
|
+
#
|
|
227
|
+
# Each dataclass documents exactly which keys it reads. The same names
|
|
228
|
+
# are used both as environment variable names and as keys in the
|
|
229
|
+
# credentials-file JSON, so the two sources are drop-in interchangeable
|
|
230
|
+
# — moving from one to the other never means renaming anything.
|
|
231
|
+
# --------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@dataclass(frozen=True)
|
|
235
|
+
class HarvestCredentials:
|
|
236
|
+
"""Harvest API v2 auth: a personal access token plus the account it
|
|
237
|
+
scopes requests to.
|
|
238
|
+
|
|
239
|
+
Reads (env var / credentials-file key):
|
|
240
|
+
HARVEST_TOKEN: the personal access token, sent as
|
|
241
|
+
``Authorization: Bearer <token>``.
|
|
242
|
+
HARVEST_ACCOUNT_ID: sent as the ``Harvest-Account-ID`` header.
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
token: str
|
|
246
|
+
account_id: str
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def resolve_harvest_credentials(
|
|
250
|
+
provider: CredentialsProvider | None = None,
|
|
251
|
+
*,
|
|
252
|
+
token: str | None = None,
|
|
253
|
+
account_id: str | None = None,
|
|
254
|
+
) -> HarvestCredentials:
|
|
255
|
+
"""Resolve :class:`HarvestCredentials`.
|
|
256
|
+
|
|
257
|
+
`token`/`account_id`, if given, are used as-is (the "explicit /
|
|
258
|
+
inline" precedence tier — see the module docstring) and skip
|
|
259
|
+
`provider` entirely for that field. Otherwise falls back to
|
|
260
|
+
`provider` (defaulting to :func:`default_credentials_provider`),
|
|
261
|
+
which raises `MissingCredentialError` if the key isn't available
|
|
262
|
+
from any of its configured sources.
|
|
263
|
+
"""
|
|
264
|
+
resolved_provider = provider if provider is not None else default_credentials_provider()
|
|
265
|
+
return HarvestCredentials(
|
|
266
|
+
token=token if token is not None else resolved_provider.require("HARVEST_TOKEN"),
|
|
267
|
+
account_id=(
|
|
268
|
+
account_id
|
|
269
|
+
if account_id is not None
|
|
270
|
+
else resolved_provider.require("HARVEST_ACCOUNT_ID")
|
|
271
|
+
),
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@dataclass(frozen=True)
|
|
276
|
+
class SlackCredentials:
|
|
277
|
+
"""Slack Web API auth: a bot token.
|
|
278
|
+
|
|
279
|
+
Reads (env var / credentials-file key):
|
|
280
|
+
SLACK_BOT_TOKEN: sent as ``Authorization: Bearer <token>`` on
|
|
281
|
+
calls to Slack's Web API (e.g. ``chat.postMessage``).
|
|
282
|
+
"""
|
|
283
|
+
|
|
284
|
+
bot_token: str
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def resolve_slack_credentials(
|
|
288
|
+
provider: CredentialsProvider | None = None,
|
|
289
|
+
*,
|
|
290
|
+
bot_token: str | None = None,
|
|
291
|
+
) -> SlackCredentials:
|
|
292
|
+
"""Resolve :class:`SlackCredentials`. See
|
|
293
|
+
:func:`resolve_harvest_credentials` for the explicit-value /
|
|
294
|
+
provider precedence this follows."""
|
|
295
|
+
resolved_provider = provider if provider is not None else default_credentials_provider()
|
|
296
|
+
return SlackCredentials(
|
|
297
|
+
bot_token=(
|
|
298
|
+
bot_token if bot_token is not None else resolved_provider.require("SLACK_BOT_TOKEN")
|
|
299
|
+
)
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# Deliberately not included here: a Slack *signing-secret* resolver (for
|
|
304
|
+
# verifying inbound webhook requests). None of this package's executors
|
|
305
|
+
# need it -- it's an inbound-transport concern, specific to whatever
|
|
306
|
+
# client exposes a Slack webhook endpoint. A client needing it builds a
|
|
307
|
+
# small resolver of its own on top of `CredentialsProvider`/
|
|
308
|
+
# `default_credentials_provider` above, the same way this module's own
|
|
309
|
+
# resolvers do -- see `docs/architecture.md`.
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@dataclass(frozen=True)
|
|
313
|
+
class GmailCredentials:
|
|
314
|
+
"""Gmail SMTP auth: a sending address plus an app password.
|
|
315
|
+
|
|
316
|
+
Reads (env var / credentials-file key):
|
|
317
|
+
GMAIL_SMTP_USERNAME: the full Gmail address to authenticate and
|
|
318
|
+
send as (e.g. ``reports@example.com``).
|
|
319
|
+
GMAIL_SMTP_APP_PASSWORD: a Google Account *app password* (not
|
|
320
|
+
the account's normal login password) — see
|
|
321
|
+
``docs/credentials.md`` for how to generate one.
|
|
322
|
+
"""
|
|
323
|
+
|
|
324
|
+
username: str
|
|
325
|
+
app_password: str
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def resolve_gmail_credentials(
|
|
329
|
+
provider: CredentialsProvider | None = None,
|
|
330
|
+
*,
|
|
331
|
+
username: str | None = None,
|
|
332
|
+
app_password: str | None = None,
|
|
333
|
+
) -> GmailCredentials:
|
|
334
|
+
"""Resolve :class:`GmailCredentials`. See
|
|
335
|
+
:func:`resolve_harvest_credentials` for the explicit-value /
|
|
336
|
+
provider precedence this follows."""
|
|
337
|
+
resolved_provider = provider if provider is not None else default_credentials_provider()
|
|
338
|
+
return GmailCredentials(
|
|
339
|
+
username=(
|
|
340
|
+
username
|
|
341
|
+
if username is not None
|
|
342
|
+
else resolved_provider.require("GMAIL_SMTP_USERNAME")
|
|
343
|
+
),
|
|
344
|
+
app_password=(
|
|
345
|
+
app_password
|
|
346
|
+
if app_password is not None
|
|
347
|
+
else resolved_provider.require("GMAIL_SMTP_APP_PASSWORD")
|
|
348
|
+
),
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@dataclass(frozen=True)
|
|
353
|
+
class GoogleSheetsCredentials:
|
|
354
|
+
"""Google service-account credentials for `gspread`.
|
|
355
|
+
|
|
356
|
+
Structurally different from the other three: a service-account
|
|
357
|
+
credential is a multi-field JSON document, not a single flat secret
|
|
358
|
+
string, so this holds *either* the inline JSON (as a string or an
|
|
359
|
+
already-parsed dict) *or* a file path to it — exactly the two forms
|
|
360
|
+
`google.oauth2.service_account.Credentials` already knows how to
|
|
361
|
+
load from (`from_service_account_info` / `from_service_account_file`)
|
|
362
|
+
— never both. `google_sheets_executor.py` does the actual parsing;
|
|
363
|
+
this dataclass only carries *which* form is available and where.
|
|
364
|
+
|
|
365
|
+
At most one of `credentials_json` / `credentials_path` is set. Both
|
|
366
|
+
`None` means: no credential was found from any source, including the
|
|
367
|
+
`GOOGLE_APPLICATION_CREDENTIALS` convention — callers fall back to
|
|
368
|
+
`gspread`'s own default discovery (`gspread.service_account()`),
|
|
369
|
+
matching the POC's behavior.
|
|
370
|
+
"""
|
|
371
|
+
|
|
372
|
+
credentials_json: str | dict[str, Any] | None
|
|
373
|
+
credentials_path: str | None
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def resolve_google_sheets_credentials(
|
|
377
|
+
provider: CredentialsProvider | None = None,
|
|
378
|
+
*,
|
|
379
|
+
credentials_json: str | dict[str, Any] | None = None,
|
|
380
|
+
credentials_path: str | None = None,
|
|
381
|
+
) -> GoogleSheetsCredentials:
|
|
382
|
+
"""Resolve :class:`GoogleSheetsCredentials`, preserving the POC's
|
|
383
|
+
(`flowlite-client/google_sheet_executer.py`) precedence:
|
|
384
|
+
|
|
385
|
+
1. `credentials_json` — explicit inline JSON (string or dict) passed
|
|
386
|
+
by the caller (e.g. a step `spec` field). Used as-is.
|
|
387
|
+
2. `credentials_path` — explicit file path passed by the caller.
|
|
388
|
+
Used as-is (existence is checked when it's actually loaded).
|
|
389
|
+
3. `GOOGLE_CREDENTIALS_JSON` — inline JSON, resolved via `provider`
|
|
390
|
+
(env var or credentials file — either works, since a
|
|
391
|
+
`CredentialsProvider` doesn't care which source produced it).
|
|
392
|
+
4. `GOOGLE_APPLICATION_CREDENTIALS` — a file path, resolved via
|
|
393
|
+
`provider`. This is the same env var name Google's own client
|
|
394
|
+
libraries conventionally use (Application Default Credentials),
|
|
395
|
+
kept for drop-in compatibility with the POC and with any Google
|
|
396
|
+
tooling already configured in the environment.
|
|
397
|
+
5. Neither found: both fields come back `None` — the executor falls
|
|
398
|
+
back to `gspread.service_account()` (its own default local
|
|
399
|
+
discovery), exactly like the POC.
|
|
400
|
+
|
|
401
|
+
Only the *env var lookup* leg (steps 3–4) actually goes through
|
|
402
|
+
`provider` — the POC's own file-path/inline-JSON precedence (steps
|
|
403
|
+
1–2) is preserved as-is rather than being routed through the
|
|
404
|
+
provider chain, since those are per-call spec values, not named
|
|
405
|
+
secrets a `CredentialsProvider` source would hold.
|
|
406
|
+
"""
|
|
407
|
+
if credentials_json is not None:
|
|
408
|
+
return GoogleSheetsCredentials(credentials_json=credentials_json, credentials_path=None)
|
|
409
|
+
if credentials_path is not None:
|
|
410
|
+
return GoogleSheetsCredentials(credentials_json=None, credentials_path=credentials_path)
|
|
411
|
+
|
|
412
|
+
resolved_provider = provider if provider is not None else default_credentials_provider()
|
|
413
|
+
env_json = resolved_provider.get("GOOGLE_CREDENTIALS_JSON")
|
|
414
|
+
if env_json is not None:
|
|
415
|
+
return GoogleSheetsCredentials(credentials_json=env_json, credentials_path=None)
|
|
416
|
+
env_path = resolved_provider.get("GOOGLE_APPLICATION_CREDENTIALS")
|
|
417
|
+
if env_path is not None:
|
|
418
|
+
return GoogleSheetsCredentials(credentials_json=None, credentials_path=env_path)
|
|
419
|
+
return GoogleSheetsCredentials(credentials_json=None, credentials_path=None)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Reusable flowlit executors, one per file, one per external system (or
|
|
2
|
+
generic capability). Nothing in this package imports the others'
|
|
3
|
+
optional third-party SDKs at module-import time beyond what that one
|
|
4
|
+
executor needs -- see `pyproject.toml`'s per-integration extras.
|
|
5
|
+
|
|
6
|
+
Populated by the "Move Generic Executors & Credentials Provider into
|
|
7
|
+
flowlit-integration" task; currently empty scaffolding.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Gmail step executor: outbound email dispatch, with attachment support.
|
|
2
|
+
|
|
3
|
+
Handles step type `"gmail"` once a client registers it (e.g.
|
|
4
|
+
`flowlit.register_executor("gmail", GmailExecutor())` — see this
|
|
5
|
+
package's own README/`docs/architecture.md`; registration is always the
|
|
6
|
+
client's own explicit job, never automatic).
|
|
7
|
+
|
|
8
|
+
Approach: SMTP via Gmail's SMTP endpoint (`smtp.gmail.com:587`,
|
|
9
|
+
STARTTLS), authenticated with a Google Account *app password* — not
|
|
10
|
+
`aiosmtplib` (a native-async SMTP library), and not full OAuth2. Chosen
|
|
11
|
+
for three reasons, in order of weight:
|
|
12
|
+
|
|
13
|
+
1. **Standard, well-documented practice for Gmail automation without the
|
|
14
|
+
OAuth consent-screen overhead.** An app password is a Google Account
|
|
15
|
+
feature purpose-built for exactly this (send mail as yourself from a
|
|
16
|
+
script/service account) — generate one under the account's Security
|
|
17
|
+
settings, no Google Cloud project, OAuth client, or consent screen
|
|
18
|
+
required. OAuth2 is the *better* answer for a multi-tenant product
|
|
19
|
+
sending on behalf of many different users' inboxes; this executor
|
|
20
|
+
sends from one fixed automation mailbox, which is exactly the case
|
|
21
|
+
app passwords are meant for.
|
|
22
|
+
2. **`smtplib` is stdlib.** No extra dependency for what is, at bottom,
|
|
23
|
+
"connect, STARTTLS, log in, send" — one well-worn code path with
|
|
24
|
+
decades of Python usage behind it, versus a smaller, newer async-native
|
|
25
|
+
library. `aiosmtplib` would be a reasonable alternative if this
|
|
26
|
+
executor needed to hold many concurrent SMTP connections open at once;
|
|
27
|
+
it doesn't — sending one report/alert email per step is not a
|
|
28
|
+
throughput-sensitive path.
|
|
29
|
+
3. **The blocking call is isolated, not ignored.** `smtplib.SMTP` is
|
|
30
|
+
synchronous, so every actual network call here runs inside
|
|
31
|
+
`asyncio.to_thread()` — same reasoning as `GoogleSheetsExecutor`
|
|
32
|
+
wrapping `gspread`: this process is a single-threaded, cooperative
|
|
33
|
+
event loop, and a blocking network call would freeze every other
|
|
34
|
+
concurrently-running step for as long as it takes.
|
|
35
|
+
|
|
36
|
+
Credentials: `GMAIL_SMTP_USERNAME` (the sending address) and
|
|
37
|
+
`GMAIL_SMTP_APP_PASSWORD` (the app password, *not* the account's normal
|
|
38
|
+
login password), resolved via `flowlit_integration.credentials.provider.
|
|
39
|
+
resolve_gmail_credentials` — see `config/credentials.py` for supported
|
|
40
|
+
sources.
|
|
41
|
+
|
|
42
|
+
Attachments are given as `{"filename": ..., "content_base64": ...}` —
|
|
43
|
+
inline base64-encoded bytes, not a filesystem path. This keeps the
|
|
44
|
+
executor's `spec` fully self-contained and serializable (a plan step's
|
|
45
|
+
`spec` is plain JSON-compatible data flowing through flowlit's own
|
|
46
|
+
output-placeholder substitution — e.g. a prior step's generated-report
|
|
47
|
+
bytes, base64-encoded, feeding straight into this one) rather than
|
|
48
|
+
depending on a shared filesystem between whatever step produced the
|
|
49
|
+
attachment and this one, which would break the moment either runs on a
|
|
50
|
+
different machine/container.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
from __future__ import annotations
|
|
54
|
+
|
|
55
|
+
import asyncio
|
|
56
|
+
import base64
|
|
57
|
+
import binascii
|
|
58
|
+
import smtplib
|
|
59
|
+
from collections.abc import Mapping
|
|
60
|
+
from email.mime.application import MIMEApplication
|
|
61
|
+
from email.mime.multipart import MIMEMultipart
|
|
62
|
+
from email.mime.text import MIMEText
|
|
63
|
+
from typing import Any
|
|
64
|
+
|
|
65
|
+
from flowlit.application.ports.executor import ExecutorResult
|
|
66
|
+
from flowlit.infrastructure.executors.base_executor import BaseExecutor
|
|
67
|
+
from pydantic import BaseModel, Field, field_validator
|
|
68
|
+
|
|
69
|
+
from flowlit_integration.credentials.provider import CredentialsProvider, resolve_gmail_credentials
|
|
70
|
+
|
|
71
|
+
_SMTP_HOST = "smtp.gmail.com"
|
|
72
|
+
_SMTP_PORT = 587
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class GmailAttachment(BaseModel):
|
|
76
|
+
"""One email attachment.
|
|
77
|
+
|
|
78
|
+
filename (string, required): attachment file name, as the recipient
|
|
79
|
+
will see it (e.g. `"timesheet_report.csv"`).
|
|
80
|
+
content_base64 (string, required): the attachment's bytes,
|
|
81
|
+
base64-encoded.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
filename: str
|
|
85
|
+
content_base64: str
|
|
86
|
+
|
|
87
|
+
@field_validator("filename")
|
|
88
|
+
@classmethod
|
|
89
|
+
def _filename_not_blank(cls, value: str) -> str:
|
|
90
|
+
if not value.strip():
|
|
91
|
+
raise ValueError("must be a non-empty string")
|
|
92
|
+
return value
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class GmailSpec(BaseModel):
|
|
96
|
+
"""Spec fields for a `"gmail"` step.
|
|
97
|
+
|
|
98
|
+
to (list of string, required): recipient email addresses.
|
|
99
|
+
subject (string, required): email subject line.
|
|
100
|
+
body (string, required): message body.
|
|
101
|
+
is_html (bool, optional): whether `body` is HTML rather than plain
|
|
102
|
+
text. Defaults to `False`.
|
|
103
|
+
attachments (list of `GmailAttachment`, optional): defaults to none.
|
|
104
|
+
cc (list of string, optional): CC recipients. Defaults to none.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
to: list[str] = Field(min_length=1)
|
|
108
|
+
subject: str
|
|
109
|
+
body: str
|
|
110
|
+
is_html: bool = False
|
|
111
|
+
attachments: list[GmailAttachment] = Field(default_factory=list)
|
|
112
|
+
cc: list[str] = Field(default_factory=list)
|
|
113
|
+
|
|
114
|
+
@field_validator("to")
|
|
115
|
+
@classmethod
|
|
116
|
+
def _to_non_blank(cls, value: list[str]) -> list[str]:
|
|
117
|
+
if not all(addr.strip() for addr in value):
|
|
118
|
+
raise ValueError("must not contain blank addresses")
|
|
119
|
+
return value
|
|
120
|
+
|
|
121
|
+
@field_validator("subject")
|
|
122
|
+
@classmethod
|
|
123
|
+
def _subject_not_blank(cls, value: str) -> str:
|
|
124
|
+
if not value.strip():
|
|
125
|
+
raise ValueError("must be a non-empty string")
|
|
126
|
+
return value
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class GmailExecutor(BaseExecutor):
|
|
130
|
+
"""Implements `flowlit.application.ports.executor.Executor` (via
|
|
131
|
+
`BaseExecutor`) for outbound Gmail dispatch. See module docstring
|
|
132
|
+
for the SMTP-via-app-password approach and why it was chosen.
|
|
133
|
+
|
|
134
|
+
Validated against `GmailSpec` (see `BaseExecutor.spec_model`).
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
spec_model = GmailSpec
|
|
138
|
+
|
|
139
|
+
def __init__(self, credentials_provider: CredentialsProvider | None = None) -> None:
|
|
140
|
+
super().__init__()
|
|
141
|
+
self._credentials_provider = credentials_provider
|
|
142
|
+
|
|
143
|
+
def _sync_send(self, spec: Mapping[str, Any], username: str, app_password: str) -> None:
|
|
144
|
+
"""Build and send the MIME message. Runs inside `asyncio.to_thread`."""
|
|
145
|
+
message = MIMEMultipart()
|
|
146
|
+
message["From"] = username
|
|
147
|
+
message["To"] = ", ".join(spec["to"])
|
|
148
|
+
message["Subject"] = spec["subject"]
|
|
149
|
+
if spec.get("cc"):
|
|
150
|
+
message["Cc"] = ", ".join(spec["cc"])
|
|
151
|
+
|
|
152
|
+
body_subtype = "html" if spec.get("is_html") else "plain"
|
|
153
|
+
message.attach(MIMEText(spec["body"], body_subtype))
|
|
154
|
+
|
|
155
|
+
for attachment in spec.get("attachments", []):
|
|
156
|
+
part = MIMEApplication(base64.b64decode(attachment["content_base64"]))
|
|
157
|
+
part.add_header(
|
|
158
|
+
"Content-Disposition", "attachment", filename=attachment["filename"]
|
|
159
|
+
)
|
|
160
|
+
message.attach(part)
|
|
161
|
+
|
|
162
|
+
all_recipients = [*spec["to"], *spec.get("cc", [])]
|
|
163
|
+
|
|
164
|
+
with smtplib.SMTP(_SMTP_HOST, _SMTP_PORT, timeout=30) as smtp:
|
|
165
|
+
smtp.starttls()
|
|
166
|
+
smtp.login(username, app_password)
|
|
167
|
+
smtp.sendmail(username, all_recipients, message.as_string())
|
|
168
|
+
|
|
169
|
+
async def _run(self, step_id: str, spec: Mapping[str, Any]) -> ExecutorResult:
|
|
170
|
+
try:
|
|
171
|
+
credentials = resolve_gmail_credentials(self._credentials_provider)
|
|
172
|
+
except Exception as exc: # MissingCredentialError, or a bad credentials file
|
|
173
|
+
self._logger.warning("step %s (gmail): credential resolution failed: %s", step_id, exc)
|
|
174
|
+
return ExecutorResult(success=False, error=f"Gmail credentials unavailable: {exc}")
|
|
175
|
+
|
|
176
|
+
for attachment in spec.get("attachments", []):
|
|
177
|
+
try:
|
|
178
|
+
base64.b64decode(attachment["content_base64"], validate=True)
|
|
179
|
+
except (binascii.Error, ValueError) as exc:
|
|
180
|
+
return ExecutorResult(
|
|
181
|
+
success=False,
|
|
182
|
+
error=(
|
|
183
|
+
f"attachment '{attachment['filename']}' has invalid "
|
|
184
|
+
f"base64 content: {exc}"
|
|
185
|
+
),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
self._logger.info(
|
|
189
|
+
"step %s (gmail): sending '%s' to %d recipient(s)",
|
|
190
|
+
step_id,
|
|
191
|
+
spec["subject"],
|
|
192
|
+
len(spec["to"]),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
await asyncio.to_thread(
|
|
197
|
+
self._sync_send, spec, credentials.username, credentials.app_password
|
|
198
|
+
)
|
|
199
|
+
except smtplib.SMTPAuthenticationError as exc:
|
|
200
|
+
err_msg = f"Gmail SMTP authentication failed: {exc}"
|
|
201
|
+
self._logger.warning("step %s (gmail): %s", step_id, err_msg)
|
|
202
|
+
return ExecutorResult(success=False, error=err_msg)
|
|
203
|
+
except smtplib.SMTPException as exc:
|
|
204
|
+
err_msg = f"Gmail SMTP error: {type(exc).__name__}: {exc}"
|
|
205
|
+
self._logger.warning("step %s (gmail): %s", step_id, err_msg)
|
|
206
|
+
return ExecutorResult(success=False, error=err_msg)
|
|
207
|
+
except OSError as exc:
|
|
208
|
+
err_msg = f"Could not connect to {_SMTP_HOST}:{_SMTP_PORT}: {exc}"
|
|
209
|
+
self._logger.warning("step %s (gmail): %s", step_id, err_msg)
|
|
210
|
+
return ExecutorResult(success=False, error=err_msg)
|
|
211
|
+
|
|
212
|
+
self._logger.info("step %s (gmail): sent '%s'", step_id, spec["subject"])
|
|
213
|
+
return ExecutorResult(
|
|
214
|
+
success=True,
|
|
215
|
+
output={
|
|
216
|
+
"to": spec["to"],
|
|
217
|
+
"subject": spec["subject"],
|
|
218
|
+
"attachment_count": len(spec.get("attachments", [])),
|
|
219
|
+
},
|
|
220
|
+
)
|