pipefy-cli 0.3.0a1__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.
- pipefy_cli/__init__.py +7 -0
- pipefy_cli/_docs.py +3 -0
- pipefy_cli/auth.py +225 -0
- pipefy_cli/commands/__init__.py +5 -0
- pipefy_cli/commands/_common.py +359 -0
- pipefy_cli/commands/agent.py +358 -0
- pipefy_cli/commands/ai_automation.py +318 -0
- pipefy_cli/commands/attachment.py +122 -0
- pipefy_cli/commands/audit.py +63 -0
- pipefy_cli/commands/auth.py +519 -0
- pipefy_cli/commands/automation.py +529 -0
- pipefy_cli/commands/card.py +439 -0
- pipefy_cli/commands/email.py +169 -0
- pipefy_cli/commands/export.py +61 -0
- pipefy_cli/commands/field.py +113 -0
- pipefy_cli/commands/field_condition.py +201 -0
- pipefy_cli/commands/graphql.py +110 -0
- pipefy_cli/commands/introspect.py +110 -0
- pipefy_cli/commands/label.py +109 -0
- pipefy_cli/commands/member.py +126 -0
- pipefy_cli/commands/org.py +60 -0
- pipefy_cli/commands/phase.py +264 -0
- pipefy_cli/commands/pipe.py +167 -0
- pipefy_cli/commands/portal.py +871 -0
- pipefy_cli/commands/record.py +219 -0
- pipefy_cli/commands/relation.py +173 -0
- pipefy_cli/commands/report_org.py +241 -0
- pipefy_cli/commands/report_pipe.py +290 -0
- pipefy_cli/commands/table.py +151 -0
- pipefy_cli/commands/usage.py +100 -0
- pipefy_cli/commands/webhook.py +141 -0
- pipefy_cli/main.py +132 -0
- pipefy_cli/output/__init__.py +8 -0
- pipefy_cli/output/json_renderer.py +24 -0
- pipefy_cli/output/rich_renderer.py +81 -0
- pipefy_cli/settings.py +66 -0
- pipefy_cli-0.3.0a1.dist-info/METADATA +104 -0
- pipefy_cli-0.3.0a1.dist-info/RECORD +40 -0
- pipefy_cli-0.3.0a1.dist-info/WHEEL +4 -0
- pipefy_cli-0.3.0a1.dist-info/entry_points.txt +2 -0
pipefy_cli/__init__.py
ADDED
pipefy_cli/_docs.py
ADDED
pipefy_cli/auth.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Build an authenticated :class:`PipefyClient` from CLI configuration.
|
|
2
|
+
|
|
3
|
+
The precedence chain lives in :func:`pipefy_auth.resolve_pipefy_auth`; this
|
|
4
|
+
module collapses the CLI's two static-token surfaces (``--token`` flag and
|
|
5
|
+
``PIPEFY_TOKEN`` env var) into a single value before calling the resolver, and
|
|
6
|
+
translates resolver failures into Typer exits. The flag-vs-env distinction
|
|
7
|
+
survives only as a diagnostic label for ``pipefy auth status``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
from dataclasses import asdict, dataclass, field
|
|
15
|
+
from typing import Any, Final, Literal, assert_never
|
|
16
|
+
|
|
17
|
+
import typer
|
|
18
|
+
from pipefy_auth import (
|
|
19
|
+
OidcClient,
|
|
20
|
+
RefreshError,
|
|
21
|
+
ResolvedAuth,
|
|
22
|
+
ServiceAccount,
|
|
23
|
+
ServiceAccountAuth,
|
|
24
|
+
StaticTokenAuth,
|
|
25
|
+
StoredSessionAuth,
|
|
26
|
+
build_httpx_auth,
|
|
27
|
+
detect_pipefy_auth_methods,
|
|
28
|
+
ensure_fresh_session,
|
|
29
|
+
missing_auth_message,
|
|
30
|
+
resolve_pipefy_auth,
|
|
31
|
+
)
|
|
32
|
+
from pipefy_sdk import (
|
|
33
|
+
PipefyClient,
|
|
34
|
+
PipefySettings,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
from pipefy_cli._docs import DOCS_CLI_AUTH_REF
|
|
38
|
+
|
|
39
|
+
# Display labels for ``pipefy auth status``. The resolver knows the
|
|
40
|
+
# static-token method; the CLI restores the flag-vs-env distinction here.
|
|
41
|
+
FLAG_TOKEN_SOURCE: Final = "flag-token"
|
|
42
|
+
ENV_TOKEN_SOURCE: Final = "env-token"
|
|
43
|
+
|
|
44
|
+
# Locked JSON wire schema for ``pipefy auth status``: the auth-method names,
|
|
45
|
+
# with the static-token method split into the CLI's flag-vs-env surfaces, plus
|
|
46
|
+
# an explicit ``"none"`` sentinel for when no method resolved. Produced by
|
|
47
|
+
# :func:`detect_cli_auth_methods` / :func:`to_display_source`; ``commands.auth``
|
|
48
|
+
# renders it.
|
|
49
|
+
DisplaySource = Literal[
|
|
50
|
+
"flag-token",
|
|
51
|
+
"env-token",
|
|
52
|
+
"service-account",
|
|
53
|
+
"stored-session",
|
|
54
|
+
"none",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class BearerToken:
|
|
60
|
+
"""Static bearer token plus the surface that produced it (``--token`` or env)."""
|
|
61
|
+
|
|
62
|
+
value: str = field(repr=False)
|
|
63
|
+
source: Literal["flag", "env"]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class AuthContext:
|
|
68
|
+
"""Auth inputs for a single CLI invocation.
|
|
69
|
+
|
|
70
|
+
Each field maps to one auth method (bearer-token, service-account,
|
|
71
|
+
stored-session). Built once at startup from the loaded
|
|
72
|
+
:class:`pipefy_auth.AuthSettings` plus the per-invocation ``--token`` /
|
|
73
|
+
``PIPEFY_TOKEN`` resolution.
|
|
74
|
+
|
|
75
|
+
``oidc_client`` is ``None`` only when ``AuthSettings.disable_stored_session``
|
|
76
|
+
is set (env: PIPEFY_DISABLE_STORED_SESSION); the stored-session method is
|
|
77
|
+
then skipped end-to-end. Otherwise ``auth_url`` defaults to the prod IdP
|
|
78
|
+
and the client is always present.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
bearer_token: BearerToken | None
|
|
82
|
+
service_account: ServiceAccount | None
|
|
83
|
+
oidc_client: OidcClient | None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
_cached_signature: str | None = None
|
|
87
|
+
# One-shot CLIs reuse this; long-lived programmatic use should call
|
|
88
|
+
# ``clear_authenticated_client_cache`` between logical sessions (tests reset via fixture).
|
|
89
|
+
_cached_client: PipefyClient | None = None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def clear_authenticated_client_cache() -> None:
|
|
93
|
+
"""Drop the in-process client cache (tests and rare reload scenarios)."""
|
|
94
|
+
global _cached_signature, _cached_client
|
|
95
|
+
_cached_signature = None
|
|
96
|
+
_cached_client = None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _resolver_kwargs(auth: AuthContext) -> dict[str, Any]:
|
|
100
|
+
"""Map an :class:`AuthContext` onto the keyword inputs the resolver takes."""
|
|
101
|
+
return {
|
|
102
|
+
"static_token": auth.bearer_token.value if auth.bearer_token else None,
|
|
103
|
+
"service_account": auth.service_account,
|
|
104
|
+
"oidc_client": auth.oidc_client,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _resolve(auth: AuthContext) -> ResolvedAuth | None:
|
|
109
|
+
return resolve_pipefy_auth(**_resolver_kwargs(auth))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def to_display_source(
|
|
113
|
+
resolved: ResolvedAuth, bearer: BearerToken | None
|
|
114
|
+
) -> DisplaySource:
|
|
115
|
+
"""Map a resolved auth method to its locked ``auth status`` wire value.
|
|
116
|
+
|
|
117
|
+
The static-token method splits into the flag-vs-env distinction the CLI
|
|
118
|
+
surfaces; the other methods map to their resolver wire name unchanged.
|
|
119
|
+
"""
|
|
120
|
+
match resolved:
|
|
121
|
+
case StaticTokenAuth():
|
|
122
|
+
return (
|
|
123
|
+
FLAG_TOKEN_SOURCE
|
|
124
|
+
if bearer and bearer.source == "flag"
|
|
125
|
+
else ENV_TOKEN_SOURCE
|
|
126
|
+
)
|
|
127
|
+
case ServiceAccountAuth():
|
|
128
|
+
return "service-account"
|
|
129
|
+
case StoredSessionAuth():
|
|
130
|
+
return "stored-session"
|
|
131
|
+
case _:
|
|
132
|
+
assert_never(resolved)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def detect_cli_auth_methods(auth: AuthContext) -> list[ResolvedAuth]:
|
|
136
|
+
"""Return the detected auth methods for a CLI invocation, precedence-first.
|
|
137
|
+
|
|
138
|
+
The non-short-circuiting view of the chain: every configured method, not
|
|
139
|
+
just the winner. ``pipefy auth status`` renders each via
|
|
140
|
+
:func:`to_display_source` and treats the first as the active source.
|
|
141
|
+
"""
|
|
142
|
+
return detect_pipefy_auth_methods(**_resolver_kwargs(auth))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _cache_key(
|
|
146
|
+
pipefy_settings: PipefySettings,
|
|
147
|
+
auth: AuthContext,
|
|
148
|
+
) -> str:
|
|
149
|
+
"""SHA-256 digest of every input that could change the cached client.
|
|
150
|
+
|
|
151
|
+
Hashed (not stored as plaintext) so the dump's secrets — the bearer
|
|
152
|
+
token, the service-account ``client_secret`` — don't linger in module
|
|
153
|
+
state for the process lifetime. Adding a new field to
|
|
154
|
+
:class:`PipefySettings` or :class:`AuthContext` automatically participates
|
|
155
|
+
in the key without touching this function. The resolved method is omitted:
|
|
156
|
+
it is a pure function of the auth fields above, so it adds no distinction.
|
|
157
|
+
"""
|
|
158
|
+
payload = json.dumps(
|
|
159
|
+
{
|
|
160
|
+
"settings": pipefy_settings.model_dump(mode="json"),
|
|
161
|
+
"auth": asdict(auth),
|
|
162
|
+
},
|
|
163
|
+
sort_keys=True,
|
|
164
|
+
default=str,
|
|
165
|
+
)
|
|
166
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def get_authenticated_client(
|
|
170
|
+
pipefy_settings: PipefySettings,
|
|
171
|
+
auth: AuthContext,
|
|
172
|
+
) -> PipefyClient:
|
|
173
|
+
"""Return a facade client using the highest-precedence available auth source.
|
|
174
|
+
|
|
175
|
+
Raises:
|
|
176
|
+
typer.Exit: Code 2 when no auth source resolves, or when refreshing a
|
|
177
|
+
stored session fails.
|
|
178
|
+
"""
|
|
179
|
+
global _cached_signature, _cached_client
|
|
180
|
+
|
|
181
|
+
resolved = _resolve(auth)
|
|
182
|
+
if resolved is None:
|
|
183
|
+
typer.echo(f"{missing_auth_message()} See {DOCS_CLI_AUTH_REF}.", err=True)
|
|
184
|
+
raise typer.Exit(2)
|
|
185
|
+
|
|
186
|
+
# Stored-session: warm up eagerly so refresh failures surface as a clean
|
|
187
|
+
# exit(2) with a "run `pipefy auth login` again" hint instead of leaking
|
|
188
|
+
# out as a transport error on the first GraphQL call.
|
|
189
|
+
if isinstance(resolved, StoredSessionAuth):
|
|
190
|
+
try:
|
|
191
|
+
ensure_fresh_session(
|
|
192
|
+
issuer=resolved.oidc_client.issuer_url,
|
|
193
|
+
client_id=resolved.oidc_client.client_id,
|
|
194
|
+
)
|
|
195
|
+
except RefreshError as exc:
|
|
196
|
+
typer.echo(
|
|
197
|
+
f"Stored Pipefy session could not be refreshed: {exc}. "
|
|
198
|
+
"Run `pipefy auth login` to sign in again.",
|
|
199
|
+
err=True,
|
|
200
|
+
)
|
|
201
|
+
raise typer.Exit(2) from exc
|
|
202
|
+
|
|
203
|
+
key = _cache_key(pipefy_settings, auth)
|
|
204
|
+
if _cached_client is not None and _cached_signature == key:
|
|
205
|
+
return _cached_client
|
|
206
|
+
|
|
207
|
+
client = PipefyClient(
|
|
208
|
+
pipefy_settings, auth=build_httpx_auth(resolved), surface="cli"
|
|
209
|
+
)
|
|
210
|
+
_cached_signature = key
|
|
211
|
+
_cached_client = client
|
|
212
|
+
return client
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
__all__ = [
|
|
216
|
+
"AuthContext",
|
|
217
|
+
"BearerToken",
|
|
218
|
+
"DisplaySource",
|
|
219
|
+
"ENV_TOKEN_SOURCE",
|
|
220
|
+
"FLAG_TOKEN_SOURCE",
|
|
221
|
+
"clear_authenticated_client_cache",
|
|
222
|
+
"detect_cli_auth_methods",
|
|
223
|
+
"get_authenticated_client",
|
|
224
|
+
"to_display_source",
|
|
225
|
+
]
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""Shared Typer helpers for domain command modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import sys
|
|
9
|
+
from collections.abc import Awaitable, Callable
|
|
10
|
+
from typing import Any, TypeVar
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
from gql.transport.exceptions import TransportError, TransportQueryError
|
|
14
|
+
from pipefy_sdk import PipefyClient, PipefySettings, stream_bytes
|
|
15
|
+
from pipefy_sdk.exceptions import PipefyError
|
|
16
|
+
from pipefy_sdk.label_color import normalize_label_color
|
|
17
|
+
from pipefy_sdk.report_filter_preflight import prepare_report_cards_filter
|
|
18
|
+
|
|
19
|
+
from pipefy_cli.auth import (
|
|
20
|
+
AuthContext,
|
|
21
|
+
BearerToken,
|
|
22
|
+
get_authenticated_client,
|
|
23
|
+
)
|
|
24
|
+
from pipefy_cli.output import render_json, render_rich
|
|
25
|
+
|
|
26
|
+
_T = TypeVar("_T")
|
|
27
|
+
_R = TypeVar("_R")
|
|
28
|
+
|
|
29
|
+
DEFAULT_EXPORT_MAX_BYTES = 50 * 1024 * 1024
|
|
30
|
+
|
|
31
|
+
# Pipefy occasionally encodes ids with a leading hyphen (e.g. table id "-ZocGcM0").
|
|
32
|
+
# Click parses tokens starting with "-" as short options by default, which breaks
|
|
33
|
+
# every `<sub> get/update/delete <ID>` command. Setting ignore_unknown_options on
|
|
34
|
+
# the command relaxes the parser so the dashed token is consumed as the positional
|
|
35
|
+
# id. Tokens starting with ``--`` must still be rejected (they look like long
|
|
36
|
+
# options); use :func:`validate_positional_id` on those arguments.
|
|
37
|
+
ID_POSITIONAL_CONTEXT_SETTINGS = {"ignore_unknown_options": True}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def validate_positional_id(value: str) -> str:
|
|
41
|
+
"""Reject typoed long-option tokens mistakenly captured as a resource id."""
|
|
42
|
+
if value.startswith("--"):
|
|
43
|
+
raise typer.BadParameter(
|
|
44
|
+
f"unknown option-like value {value!r}; if an id starts with '-', pass it after '--'"
|
|
45
|
+
)
|
|
46
|
+
return value
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def resource_id_argument(*, help: str) -> Any:
|
|
50
|
+
"""Typer ``Argument`` for resource ids when ``ignore_unknown_options`` is enabled."""
|
|
51
|
+
return typer.Argument(..., help=help, callback=validate_positional_id)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def validate_optional_resource_id(value: str | None, label: str) -> str | None:
|
|
55
|
+
if value is None:
|
|
56
|
+
return None
|
|
57
|
+
cleaned = value.strip()
|
|
58
|
+
if not cleaned:
|
|
59
|
+
raise typer.BadParameter(
|
|
60
|
+
f"Invalid '{label}': provide a non-empty string or positive integer."
|
|
61
|
+
)
|
|
62
|
+
if cleaned.startswith("-") and cleaned[1:].isdigit():
|
|
63
|
+
raise typer.BadParameter(f"Invalid '{label}': provide a positive integer.")
|
|
64
|
+
if cleaned.isdigit() and int(cleaned) <= 0:
|
|
65
|
+
raise typer.BadParameter(f"Invalid '{label}': provide a positive integer.")
|
|
66
|
+
return cleaned
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
_CARDS_PAGE_SIZE_MIN = 1
|
|
70
|
+
_CARDS_PAGE_SIZE_MAX = 500
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def validate_cards_page_size(first: int | None) -> int | None:
|
|
74
|
+
if first is None:
|
|
75
|
+
return None
|
|
76
|
+
if first < _CARDS_PAGE_SIZE_MIN or first > _CARDS_PAGE_SIZE_MAX:
|
|
77
|
+
raise typer.BadParameter(
|
|
78
|
+
f"--first must be between {_CARDS_PAGE_SIZE_MIN} and "
|
|
79
|
+
f"{_CARDS_PAGE_SIZE_MAX} (inclusive)."
|
|
80
|
+
)
|
|
81
|
+
return first
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def validate_label_name_cli(name: str) -> str:
|
|
85
|
+
nm = name.strip()
|
|
86
|
+
if not nm:
|
|
87
|
+
raise typer.BadParameter("--name must be non-empty.")
|
|
88
|
+
return nm
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def prepare_report_cards_filter_cli(
|
|
92
|
+
filter_obj: dict[str, Any] | None,
|
|
93
|
+
) -> dict[str, Any] | None:
|
|
94
|
+
"""Normalize and validate ``ReportCardsFilter`` before auth or network I/O."""
|
|
95
|
+
try:
|
|
96
|
+
return prepare_report_cards_filter(filter_obj)
|
|
97
|
+
except ValueError as exc:
|
|
98
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def normalize_label_color_cli(color: str) -> str:
|
|
102
|
+
"""Normalize label ``color`` before auth or network I/O."""
|
|
103
|
+
try:
|
|
104
|
+
return normalize_label_color(color)
|
|
105
|
+
except ValueError as exc:
|
|
106
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def export_poll_max_rounds(
|
|
110
|
+
poll_timeout_seconds: float, *, delay_seconds: float = 2.0
|
|
111
|
+
) -> int:
|
|
112
|
+
"""Map a wall-clock export poll budget to loop iterations (``delay_seconds`` per round).
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
poll_timeout_seconds: Maximum time to wait for export state ``done``.
|
|
116
|
+
delay_seconds: Sleep between status polls.
|
|
117
|
+
"""
|
|
118
|
+
if poll_timeout_seconds <= 0:
|
|
119
|
+
raise ValueError("poll_timeout_seconds must be positive")
|
|
120
|
+
return max(1, math.ceil(poll_timeout_seconds / delay_seconds))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
async def poll_export_until_done(
|
|
124
|
+
fetch: Callable[[str], Awaitable[dict[str, Any] | None]],
|
|
125
|
+
export_id: str,
|
|
126
|
+
unwrap: Callable[[dict[str, Any]], dict[str, Any]],
|
|
127
|
+
*,
|
|
128
|
+
max_rounds: int,
|
|
129
|
+
delay_seconds: float = 2.0,
|
|
130
|
+
) -> str:
|
|
131
|
+
"""Poll an export job until ``state == done`` and return ``fileURL``.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
fetch: Async callable taking export id and returning the raw GraphQL payload dict.
|
|
135
|
+
export_id: Export job id from the start mutation.
|
|
136
|
+
unwrap: Extract the export status node dict from ``fetch``'s return value.
|
|
137
|
+
max_rounds: Maximum poll iterations before timing out.
|
|
138
|
+
delay_seconds: Delay between polls.
|
|
139
|
+
"""
|
|
140
|
+
eid = str(export_id)
|
|
141
|
+
for _ in range(max_rounds):
|
|
142
|
+
raw = await fetch(eid)
|
|
143
|
+
node = unwrap(raw or {}) or {}
|
|
144
|
+
state = str(node.get("state") or "")
|
|
145
|
+
if state in ("failed", "error"):
|
|
146
|
+
raise ValueError(f"Export failed (state={state!r}).")
|
|
147
|
+
if state == "done":
|
|
148
|
+
url = node.get("fileURL") or node.get("fileUrl")
|
|
149
|
+
if isinstance(url, str) and url.strip():
|
|
150
|
+
return url.strip()
|
|
151
|
+
raise ValueError("Export is done but fileURL is missing.")
|
|
152
|
+
await asyncio.sleep(delay_seconds)
|
|
153
|
+
raise ValueError(
|
|
154
|
+
f"Timed out waiting for export {eid} after {max_rounds * delay_seconds:.0f}s."
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
async def write_export_csv_to_stdout(
|
|
159
|
+
*,
|
|
160
|
+
export_id: str,
|
|
161
|
+
poll_fetch: Callable[[str], Awaitable[dict[str, Any] | None]],
|
|
162
|
+
unwrap_status: Callable[[dict[str, Any]], dict[str, Any]],
|
|
163
|
+
poll_timeout_seconds: float,
|
|
164
|
+
max_bytes: int = DEFAULT_EXPORT_MAX_BYTES,
|
|
165
|
+
) -> None:
|
|
166
|
+
"""Poll an export job until ``done`` and stream the CSV body to stdout.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
export_id: Export job id (from the start mutation).
|
|
170
|
+
poll_fetch: Async callable that fetches the export status payload by id.
|
|
171
|
+
unwrap_status: Extract the export status node dict from the raw payload.
|
|
172
|
+
poll_timeout_seconds: Wall-clock budget for polling before raising ``ValueError``.
|
|
173
|
+
max_bytes: Hard cap on the download size enforced by :func:`stream_bytes`.
|
|
174
|
+
"""
|
|
175
|
+
max_rounds = export_poll_max_rounds(poll_timeout_seconds)
|
|
176
|
+
url = await poll_export_until_done(
|
|
177
|
+
poll_fetch,
|
|
178
|
+
export_id,
|
|
179
|
+
unwrap_status,
|
|
180
|
+
max_rounds=max_rounds,
|
|
181
|
+
)
|
|
182
|
+
async for chunk in stream_bytes(url, max_bytes=max_bytes):
|
|
183
|
+
sys.stdout.buffer.write(chunk)
|
|
184
|
+
sys.stdout.buffer.flush()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def run_pipefy_client_coroutine(
|
|
188
|
+
ctx: typer.Context,
|
|
189
|
+
coro_factory: Callable[[PipefyClient], Awaitable[_T]],
|
|
190
|
+
*,
|
|
191
|
+
value_error_exit_code: int | None = None,
|
|
192
|
+
) -> _T:
|
|
193
|
+
"""Run ``asyncio.run`` on a coroutine built with a configured :class:`PipefyClient`.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
ctx: Typer context (resolves settings/token from the root app).
|
|
197
|
+
coro_factory: Async callable receiving an authenticated client.
|
|
198
|
+
value_error_exit_code: When set, map ``ValueError`` from the factory to this exit code (stderr message).
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
The coroutine result.
|
|
202
|
+
|
|
203
|
+
Raises:
|
|
204
|
+
typer.Exit: On :class:`PipefyError` (exit code 1), optional ``ValueError`` mapping, or ``BrokenPipeError`` (exit 0).
|
|
205
|
+
"""
|
|
206
|
+
pipefy_settings, auth = settings_and_auth_from_ctx(ctx)
|
|
207
|
+
|
|
208
|
+
async def _run() -> _T:
|
|
209
|
+
client = get_authenticated_client(pipefy_settings, auth)
|
|
210
|
+
return await coro_factory(client)
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
return asyncio.run(_run())
|
|
214
|
+
except PipefyError as exc:
|
|
215
|
+
typer.echo(str(exc), err=True)
|
|
216
|
+
raise typer.Exit(1) from exc
|
|
217
|
+
except ValueError as exc:
|
|
218
|
+
if value_error_exit_code is None:
|
|
219
|
+
raise
|
|
220
|
+
typer.echo(str(exc), err=True)
|
|
221
|
+
raise typer.Exit(value_error_exit_code) from exc
|
|
222
|
+
except BrokenPipeError:
|
|
223
|
+
raise typer.Exit(0) from None
|
|
224
|
+
except TransportQueryError as exc:
|
|
225
|
+
typer.echo(_format_transport_query_error(exc), err=True)
|
|
226
|
+
raise typer.Exit(1) from exc
|
|
227
|
+
except TransportError as exc:
|
|
228
|
+
typer.echo(f"Pipefy transport error: {exc}", err=True)
|
|
229
|
+
raise typer.Exit(1) from exc
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _format_transport_query_error(exc: TransportQueryError) -> str:
|
|
233
|
+
"""Render a GraphQL transport error as a clean single-line message for the CLI.
|
|
234
|
+
|
|
235
|
+
Falls back to ``str(exc)`` when the structured ``errors`` payload is missing or empty.
|
|
236
|
+
"""
|
|
237
|
+
errors = getattr(exc, "errors", None) or []
|
|
238
|
+
if not errors:
|
|
239
|
+
return str(exc)
|
|
240
|
+
first = errors[0] if isinstance(errors[0], dict) else {"message": str(errors[0])}
|
|
241
|
+
message = first.get("message") or str(exc)
|
|
242
|
+
code = (first.get("extensions") or {}).get("code")
|
|
243
|
+
return f"{message} ({code})" if code else message
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def format_card_get_transport_query_error(exc: TransportQueryError) -> str:
|
|
247
|
+
"""Like :func:`_format_transport_query_error` with a hint for missing/deleted cards."""
|
|
248
|
+
base = _format_transport_query_error(exc)
|
|
249
|
+
errors = getattr(exc, "errors", None) or []
|
|
250
|
+
if errors and isinstance(errors[0], dict):
|
|
251
|
+
code = (errors[0].get("extensions") or {}).get("code")
|
|
252
|
+
if code == "PERMISSION_DENIED":
|
|
253
|
+
return f"{base} The card may have been deleted or is not visible to this token."
|
|
254
|
+
return base
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def settings_and_token(
|
|
258
|
+
ctx: typer.Context,
|
|
259
|
+
) -> tuple[PipefySettings, BearerToken | None]:
|
|
260
|
+
"""Resolve root CLI context object into settings and optional bearer token."""
|
|
261
|
+
root = ctx.find_root()
|
|
262
|
+
obj = root.obj
|
|
263
|
+
return obj["pipefy_settings"], obj.get("token")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def settings_and_auth_from_ctx(
|
|
267
|
+
ctx: typer.Context,
|
|
268
|
+
) -> tuple[PipefySettings, AuthContext]:
|
|
269
|
+
"""Resolve root ``ctx.obj`` into the (settings, auth) pair the client boundary needs."""
|
|
270
|
+
obj = ctx.find_root().obj
|
|
271
|
+
auth_settings = obj["auth_settings"]
|
|
272
|
+
auth = AuthContext(
|
|
273
|
+
bearer_token=obj.get("token"),
|
|
274
|
+
service_account=auth_settings.to_service_account(),
|
|
275
|
+
oidc_client=auth_settings.to_oidc_client(),
|
|
276
|
+
)
|
|
277
|
+
return obj["pipefy_settings"], auth
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def authenticated_client_from_ctx(ctx: typer.Context) -> PipefyClient:
|
|
281
|
+
"""Build a :class:`PipefyClient` using the same auth path as ``run_cli_command``."""
|
|
282
|
+
pipefy_settings, auth = settings_and_auth_from_ctx(ctx)
|
|
283
|
+
return get_authenticated_client(pipefy_settings, auth)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def parse_json_value(raw: str | None, option_name: str) -> Any:
|
|
287
|
+
"""Parse a JSON value from a CLI string option (empty input returns ``None``)."""
|
|
288
|
+
if raw is None or raw.strip() == "":
|
|
289
|
+
return None
|
|
290
|
+
try:
|
|
291
|
+
return json.loads(raw)
|
|
292
|
+
except json.JSONDecodeError as exc:
|
|
293
|
+
raise typer.BadParameter(f"Invalid JSON for {option_name}: {exc}") from exc
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def parse_json_object(raw: str | None, option_name: str) -> dict[str, Any] | None:
|
|
297
|
+
"""Parse a JSON object from a CLI string option (empty input returns ``None``)."""
|
|
298
|
+
if raw is None or raw.strip() == "":
|
|
299
|
+
return None
|
|
300
|
+
parsed = parse_json_value(raw, option_name)
|
|
301
|
+
if not isinstance(parsed, dict):
|
|
302
|
+
raise typer.BadParameter(f"{option_name} must be a JSON object")
|
|
303
|
+
return parsed
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def confirm_destructive(*, yes: bool, description: str, verb: str = "delete") -> None:
|
|
307
|
+
"""Prompt before a destructive action unless ``yes`` is True."""
|
|
308
|
+
if yes:
|
|
309
|
+
return
|
|
310
|
+
if not typer.confirm(f"Permanently {verb} {description}?"):
|
|
311
|
+
raise typer.Abort()
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def run_cli_command(
|
|
315
|
+
ctx: typer.Context,
|
|
316
|
+
json_out: bool,
|
|
317
|
+
coro_factory: Callable[[PipefyClient], Awaitable[_R]],
|
|
318
|
+
*,
|
|
319
|
+
exit_code_2_on_value_error: bool = True,
|
|
320
|
+
format_transport_query_error: Callable[[TransportQueryError], str] | None = None,
|
|
321
|
+
) -> None:
|
|
322
|
+
"""Run an async coroutine factory with a configured client and render the result.
|
|
323
|
+
|
|
324
|
+
Args:
|
|
325
|
+
ctx: Typer context (resolves settings/token from the root app).
|
|
326
|
+
json_out: When True, print JSON; otherwise Rich rendering.
|
|
327
|
+
coro_factory: Async callable receiving ``PipefyClient`` and returning renderable data.
|
|
328
|
+
exit_code_2_on_value_error: Map ``ValueError`` to process exit code 2 (stderr).
|
|
329
|
+
format_transport_query_error: Optional override for GraphQL transport errors
|
|
330
|
+
(defaults to a single-line formatter).
|
|
331
|
+
"""
|
|
332
|
+
pipefy_settings, auth = settings_and_auth_from_ctx(ctx)
|
|
333
|
+
transport_fmt = format_transport_query_error or _format_transport_query_error
|
|
334
|
+
|
|
335
|
+
async def _run() -> _R:
|
|
336
|
+
client = get_authenticated_client(pipefy_settings, auth)
|
|
337
|
+
return await coro_factory(client)
|
|
338
|
+
|
|
339
|
+
try:
|
|
340
|
+
data = asyncio.run(_run())
|
|
341
|
+
except PipefyError as exc:
|
|
342
|
+
typer.echo(str(exc), err=True)
|
|
343
|
+
raise typer.Exit(1) from exc
|
|
344
|
+
except TransportQueryError as exc:
|
|
345
|
+
typer.echo(transport_fmt(exc), err=True)
|
|
346
|
+
raise typer.Exit(1) from exc
|
|
347
|
+
except TransportError as exc:
|
|
348
|
+
typer.echo(f"Pipefy transport error: {exc}", err=True)
|
|
349
|
+
raise typer.Exit(1) from exc
|
|
350
|
+
except ValueError as exc:
|
|
351
|
+
if exit_code_2_on_value_error:
|
|
352
|
+
typer.echo(str(exc), err=True)
|
|
353
|
+
raise typer.Exit(2) from exc
|
|
354
|
+
raise
|
|
355
|
+
|
|
356
|
+
if json_out:
|
|
357
|
+
render_json(data)
|
|
358
|
+
else:
|
|
359
|
+
render_rich(data)
|