openmagpie 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.
- openmagpie/__init__.py +1 -0
- openmagpie/action_template.yaml +18 -0
- openmagpie/api/__init__.py +56 -0
- openmagpie/api/_params.py +20 -0
- openmagpie/api/activity.py +39 -0
- openmagpie/api/auth.py +167 -0
- openmagpie/api/delivery.py +36 -0
- openmagpie/api/engine.py +34 -0
- openmagpie/api/feed.py +151 -0
- openmagpie/api/watch.py +100 -0
- openmagpie/cli.py +60 -0
- openmagpie/commands/__init__.py +0 -0
- openmagpie/commands/_shared/__init__.py +74 -0
- openmagpie/commands/_shared/authoring.py +80 -0
- openmagpie/commands/_shared/choices.py +31 -0
- openmagpie/commands/_shared/columns/__init__.py +37 -0
- openmagpie/commands/_shared/columns/extract.py +145 -0
- openmagpie/commands/_shared/columns/options.py +54 -0
- openmagpie/commands/_shared/columns/render.py +180 -0
- openmagpie/commands/_shared/errors.py +85 -0
- openmagpie/commands/_shared/files.py +93 -0
- openmagpie/commands/_shared/output.py +125 -0
- openmagpie/commands/activity.py +297 -0
- openmagpie/commands/auth.py +304 -0
- openmagpie/commands/auth_token.py +96 -0
- openmagpie/commands/delivery.py +111 -0
- openmagpie/commands/feed/__init__.py +11 -0
- openmagpie/commands/feed/_apps.py +31 -0
- openmagpie/commands/feed/_crud.py +299 -0
- openmagpie/commands/feed/_items.py +112 -0
- openmagpie/commands/feed/_sources.py +342 -0
- openmagpie/commands/watch/__init__.py +11 -0
- openmagpie/commands/watch/_actions.py +210 -0
- openmagpie/commands/watch/_apps.py +22 -0
- openmagpie/commands/watch/_crud.py +286 -0
- openmagpie/config.py +205 -0
- openmagpie/console.py +151 -0
- openmagpie/constants.py +42 -0
- openmagpie/context.py +166 -0
- openmagpie/feed_template.yaml +29 -0
- openmagpie/http.py +311 -0
- openmagpie/routes.py +149 -0
- openmagpie/sources_template.yaml +45 -0
- openmagpie/watch_template.yaml +29 -0
- openmagpie-0.1.0.dist-info/METADATA +9 -0
- openmagpie-0.1.0.dist-info/RECORD +64 -0
- openmagpie-0.1.0.dist-info/WHEEL +4 -0
- openmagpie-0.1.0.dist-info/entry_points.txt +2 -0
- openmagpie_schema/__init__.py +7 -0
- openmagpie_schema/configs.py +60 -0
- openmagpie_schema/email_enums.py +24 -0
- openmagpie_schema/engine.py +46 -0
- openmagpie_schema/feed.py +320 -0
- openmagpie_schema/waitlist_enums.py +79 -0
- openmagpie_schema/watch.py +323 -0
- openmagpie_schema/watch_actions/__init__.py +55 -0
- openmagpie_schema/watch_actions/_delivery.py +42 -0
- openmagpie_schema/watch_actions/_secrets.py +48 -0
- openmagpie_schema/watch_actions/base.py +62 -0
- openmagpie_schema/watch_actions/log.py +49 -0
- openmagpie_schema/watch_actions/semantic_filter.py +84 -0
- openmagpie_schema/watch_actions/webhook.py +166 -0
- openmagpie_schema/watch_enums.py +151 -0
- openmagpie_schema/wire.py +13 -0
openmagpie/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# A single watch action: `{kind, config}`. Author it for:
|
|
2
|
+
# magpie watch action add --watch <watch_id> -f action.yaml # new action
|
|
3
|
+
# magpie watch action edit <action_id> -f action.yaml # replace one
|
|
4
|
+
# Or run either with no -f to fill in this template (add) / edit the
|
|
5
|
+
# action's current config (edit) in $EDITOR.
|
|
6
|
+
#
|
|
7
|
+
# `kind` selects the action type; `config` is its kind-specific settings.
|
|
8
|
+
# A chain's first action is typically a semantic_filter (an LLM relevance
|
|
9
|
+
# gate that STOPS the chain when an item scores below `threshold`); delivery
|
|
10
|
+
# actions (log, webhook) follow. Each kind has its own `config` shape; the
|
|
11
|
+
# semantic_filter below is the common starting point (see `magpie watch
|
|
12
|
+
# template` for a full chain, or `magpie watch action get <id>` for a live one).
|
|
13
|
+
|
|
14
|
+
kind: semantic_filter
|
|
15
|
+
config:
|
|
16
|
+
instructions: posts about an interesting new open-source LLM release
|
|
17
|
+
threshold: 0.7 # pass when relevance >= this (0.0 < threshold <= 1.0)
|
|
18
|
+
# engine: {kind: "", model: ""} # optional; empty = server default
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Top-level API coordinator.
|
|
2
|
+
|
|
3
|
+
`Api` owns the underlying MagpieClient and exposes resource sub-clients
|
|
4
|
+
lazily via `@cached_property`. Call sites read like an SDK:
|
|
5
|
+
|
|
6
|
+
ac.api.auth.me()
|
|
7
|
+
ac.api.auth.create_device_session()
|
|
8
|
+
ac.api.feed.list()
|
|
9
|
+
ac.api.feed.create({...})
|
|
10
|
+
|
|
11
|
+
Adding a resource = one new file in `api/`, one cached_property here.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from functools import cached_property
|
|
17
|
+
|
|
18
|
+
from ..http import MagpieClient
|
|
19
|
+
from .activity import ActivityApi
|
|
20
|
+
from .auth import AuthApi
|
|
21
|
+
from .delivery import DeliveryApi
|
|
22
|
+
from .engine import EngineApi
|
|
23
|
+
from .feed import FeedApi
|
|
24
|
+
from .watch import WatchApi
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Api:
|
|
28
|
+
def __init__(self, http: MagpieClient) -> None:
|
|
29
|
+
self._http = http
|
|
30
|
+
|
|
31
|
+
@cached_property
|
|
32
|
+
def auth(self) -> AuthApi:
|
|
33
|
+
return AuthApi(self._http)
|
|
34
|
+
|
|
35
|
+
@cached_property
|
|
36
|
+
def feed(self) -> FeedApi:
|
|
37
|
+
return FeedApi(self._http)
|
|
38
|
+
|
|
39
|
+
@cached_property
|
|
40
|
+
def watch(self) -> WatchApi:
|
|
41
|
+
return WatchApi(self._http)
|
|
42
|
+
|
|
43
|
+
@cached_property
|
|
44
|
+
def activity(self) -> ActivityApi:
|
|
45
|
+
return ActivityApi(self._http)
|
|
46
|
+
|
|
47
|
+
@cached_property
|
|
48
|
+
def delivery(self) -> DeliveryApi:
|
|
49
|
+
return DeliveryApi(self._http)
|
|
50
|
+
|
|
51
|
+
@cached_property
|
|
52
|
+
def engine(self) -> EngineApi:
|
|
53
|
+
return EngineApi(self._http)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
__all__ = ["ActivityApi", "Api", "AuthApi", "DeliveryApi", "EngineApi", "FeedApi", "WatchApi"]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Shared query-param helper for the cursor-paginated observability lists."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def list_params(
|
|
7
|
+
*, state: str | None = None, after: str | None = None, limit: int | None = None, window: str | None = None
|
|
8
|
+
) -> dict[str, str] | None:
|
|
9
|
+
"""The cursor-list query params shared by the activity / delivery list
|
|
10
|
+
endpoints, dropping the unset ones. None when empty so httpx sends none."""
|
|
11
|
+
params: dict[str, str] = {}
|
|
12
|
+
if state:
|
|
13
|
+
params["state"] = state
|
|
14
|
+
if after:
|
|
15
|
+
params["after"] = after
|
|
16
|
+
if limit is not None:
|
|
17
|
+
params["limit"] = str(limit)
|
|
18
|
+
if window:
|
|
19
|
+
params["window"] = window
|
|
20
|
+
return params or None
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Activity (run audit) API resource client.
|
|
2
|
+
|
|
3
|
+
The flat observability noun over one action's `WatchActionRun`s: the list
|
|
4
|
+
(`/v1/actions/<id>/activity`, whose first page also carries the summary
|
|
5
|
+
rollup) and one run's detail (`/v1/action-activity/<id>`). Shapes live once in
|
|
6
|
+
`openmagpie_schema.watch`; the server is the authority.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from openmagpie_schema.watch import WatchActionRunListResponse, WatchActionRunView
|
|
12
|
+
|
|
13
|
+
from .. import routes
|
|
14
|
+
from ..http import MagpieClient
|
|
15
|
+
from ._params import list_params
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ActivityApi:
|
|
19
|
+
def __init__(self, http: MagpieClient) -> None:
|
|
20
|
+
self._http = http
|
|
21
|
+
|
|
22
|
+
def list(
|
|
23
|
+
self,
|
|
24
|
+
action_id: str,
|
|
25
|
+
*,
|
|
26
|
+
state: str | None = None,
|
|
27
|
+
after: str | None = None,
|
|
28
|
+
limit: int | None = None,
|
|
29
|
+
window: str | None = None,
|
|
30
|
+
) -> WatchActionRunListResponse:
|
|
31
|
+
# `window` is the summary preset (server resolves it to bounds); the
|
|
32
|
+
# first page carries the summary rollup, paged calls don't.
|
|
33
|
+
params = list_params(state=state, after=after, limit=limit, window=window)
|
|
34
|
+
raw = self._http.get(routes.actions.runs(action_id), params=params)
|
|
35
|
+
return WatchActionRunListResponse.model_validate(raw)
|
|
36
|
+
|
|
37
|
+
def get(self, activity_id: str) -> WatchActionRunView:
|
|
38
|
+
raw = self._http.get(routes.action_activity.detail(activity_id))
|
|
39
|
+
return WatchActionRunView.model_validate(raw)
|
openmagpie/api/auth.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Auth API resource client + response models.
|
|
2
|
+
|
|
3
|
+
`AuthApi` wraps the transport client with typed entrypoints for every
|
|
4
|
+
`/v1/auth/*` endpoint. Accessed via `Api.auth` (see `api/__init__.py`),
|
|
5
|
+
so call sites read `ac.api.auth.me()` rather than threading the raw
|
|
6
|
+
http client through each handler.
|
|
7
|
+
|
|
8
|
+
Models match the Django response shapes (see `core/auth_api/`); update
|
|
9
|
+
both sides together when the contract changes.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from typing import Literal
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel
|
|
18
|
+
|
|
19
|
+
from .. import routes
|
|
20
|
+
from ..constants import DeviceSessionStatus
|
|
21
|
+
from ..http import MagpieClient, client_info
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AuthUser(BaseModel):
|
|
25
|
+
id: str
|
|
26
|
+
email: str
|
|
27
|
+
account_id: str | None = None
|
|
28
|
+
created_at: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TokenPair(BaseModel):
|
|
32
|
+
access_token: str
|
|
33
|
+
refresh_token: str
|
|
34
|
+
expires_in: int
|
|
35
|
+
token_type: Literal["Bearer"] # OAuth2 token_type per RFC 6750
|
|
36
|
+
user: AuthUser
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class DeviceSessionCreated(BaseModel):
|
|
40
|
+
session_id: str
|
|
41
|
+
authorize_url: str
|
|
42
|
+
user_code: str
|
|
43
|
+
# Bearer credential the CLI presents on each poll. Returned by the
|
|
44
|
+
# server ONCE at create time; never persisted to disk. Identifies
|
|
45
|
+
# *this CLI process* as the legitimate poller for this session_id.
|
|
46
|
+
device_secret: str
|
|
47
|
+
expires_in: int
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class DeviceSessionPending(BaseModel):
|
|
51
|
+
status: Literal[DeviceSessionStatus.PENDING]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class DeviceSessionExpired(BaseModel):
|
|
55
|
+
status: Literal[DeviceSessionStatus.EXPIRED]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class DeviceSessionCompleted(TokenPair):
|
|
59
|
+
"""Completed bag is a TokenPair plus the status tag for the union
|
|
60
|
+
discriminator. Inheriting keeps `AppContext.sign_in(bundle)` polymorphic
|
|
61
|
+
across the device-flow completion path and any future direct-login path.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
status: Literal[DeviceSessionStatus.COMPLETED]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
DeviceSessionPoll = DeviceSessionPending | DeviceSessionCompleted | DeviceSessionExpired
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class CliToken(BaseModel):
|
|
71
|
+
"""Metadata for one personal access token. Never carries the raw
|
|
72
|
+
secret; only the mint response (`CliTokenCreated`) does, once."""
|
|
73
|
+
|
|
74
|
+
id: str
|
|
75
|
+
name: str
|
|
76
|
+
last_four: str
|
|
77
|
+
created_at: datetime
|
|
78
|
+
last_used_at: datetime | None = None
|
|
79
|
+
expires_at: datetime | None = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class CliTokenCreated(CliToken):
|
|
83
|
+
"""`POST` response: metadata plus the raw `token`, shown once."""
|
|
84
|
+
|
|
85
|
+
token: str
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class TokensApi:
|
|
89
|
+
"""Resource client for `/v1/auth/tokens/*`, bearer lifecycle."""
|
|
90
|
+
|
|
91
|
+
def __init__(self, http: MagpieClient) -> None:
|
|
92
|
+
self._http = http
|
|
93
|
+
|
|
94
|
+
def revoke(self) -> None:
|
|
95
|
+
"""Invalidate the current bearer token server-side. Best-effort:
|
|
96
|
+
the server returns 200 even if the token is already revoked, so
|
|
97
|
+
this won't raise except on transport errors.
|
|
98
|
+
"""
|
|
99
|
+
self._http.post(routes.auth.tokens.revoke)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class CliTokensApi:
|
|
103
|
+
"""Resource client for `/v1/auth/cli-tokens`, personal access tokens."""
|
|
104
|
+
|
|
105
|
+
def __init__(self, http: MagpieClient) -> None:
|
|
106
|
+
self._http = http
|
|
107
|
+
|
|
108
|
+
def create(self, *, name: str, expires_in_days: int | None = None) -> CliTokenCreated:
|
|
109
|
+
body: dict[str, object] = {"name": name}
|
|
110
|
+
if expires_in_days is not None:
|
|
111
|
+
body["expires_in_days"] = expires_in_days
|
|
112
|
+
raw = self._http.post(routes.auth.cli_tokens, json_body=body)
|
|
113
|
+
return CliTokenCreated.model_validate(raw)
|
|
114
|
+
|
|
115
|
+
def list(self) -> list[CliToken]:
|
|
116
|
+
raw = self._http.get(routes.auth.cli_tokens)
|
|
117
|
+
return [CliToken.model_validate(t) for t in raw]
|
|
118
|
+
|
|
119
|
+
def revoke(self, token_id: str) -> None:
|
|
120
|
+
self._http.delete(routes.auth.cli_token(token_id))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class AuthApi:
|
|
124
|
+
"""Resource client for `/v1/auth/*`."""
|
|
125
|
+
|
|
126
|
+
def __init__(self, http: MagpieClient) -> None:
|
|
127
|
+
self._http = http
|
|
128
|
+
self.tokens = TokensApi(http)
|
|
129
|
+
self.cli_tokens = CliTokensApi(http)
|
|
130
|
+
|
|
131
|
+
def create_device_session(self) -> DeviceSessionCreated:
|
|
132
|
+
raw = self._http.post(
|
|
133
|
+
routes.auth.device_sessions,
|
|
134
|
+
with_auth=False,
|
|
135
|
+
# Structured CLI identity. Server stores it under `initiator`
|
|
136
|
+
# in the cache bag; the authorize page renders the fields
|
|
137
|
+
# directly with no User-Agent parsing.
|
|
138
|
+
json_body={"client": client_info()},
|
|
139
|
+
)
|
|
140
|
+
return DeviceSessionCreated.model_validate(raw)
|
|
141
|
+
|
|
142
|
+
def poll_device_session(self, session_id: str, *, device_secret: str) -> DeviceSessionPoll:
|
|
143
|
+
raw = self._http.get(
|
|
144
|
+
routes.auth.device_session(session_id),
|
|
145
|
+
# (Re-)login: authenticated by the device secret, NOT the stored
|
|
146
|
+
# bearer. Sending a stale/revoked credential here would get the
|
|
147
|
+
# poll rejected with 401 before the device secret is checked.
|
|
148
|
+
with_auth=False,
|
|
149
|
+
headers={"X-Device-Secret": device_secret},
|
|
150
|
+
)
|
|
151
|
+
status = raw.get("status")
|
|
152
|
+
match status:
|
|
153
|
+
case DeviceSessionStatus.COMPLETED:
|
|
154
|
+
return DeviceSessionCompleted.model_validate(raw)
|
|
155
|
+
case DeviceSessionStatus.EXPIRED:
|
|
156
|
+
return DeviceSessionExpired.model_validate(raw)
|
|
157
|
+
case DeviceSessionStatus.PENDING:
|
|
158
|
+
return DeviceSessionPending.model_validate(raw)
|
|
159
|
+
case _:
|
|
160
|
+
# An unknown status means the server contract changed
|
|
161
|
+
# out from under us. Failing loud beats quietly treating
|
|
162
|
+
# it as pending and looping forever.
|
|
163
|
+
raise RuntimeError(f"unknown device-session status from server: {status!r}")
|
|
164
|
+
|
|
165
|
+
def me(self) -> AuthUser:
|
|
166
|
+
raw = self._http.get(routes.auth.me)
|
|
167
|
+
return AuthUser.model_validate(raw)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Delivery (outbound HTTP call audit) API resource client.
|
|
2
|
+
|
|
3
|
+
The flat observability noun over one action's `WatchActionDelivery`s: the list
|
|
4
|
+
(`/v1/actions/<id>/deliveries`, lean rows) and one delivery's detail
|
|
5
|
+
(`/v1/action-deliveries/<id>`, including the sent request payload). Shapes live
|
|
6
|
+
once in `openmagpie_schema.watch`; the server is the authority.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from openmagpie_schema.watch import WatchActionDeliveryListResponse, WatchActionDeliveryView
|
|
12
|
+
|
|
13
|
+
from .. import routes
|
|
14
|
+
from ..http import MagpieClient
|
|
15
|
+
from ._params import list_params
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DeliveryApi:
|
|
19
|
+
def __init__(self, http: MagpieClient) -> None:
|
|
20
|
+
self._http = http
|
|
21
|
+
|
|
22
|
+
def list(
|
|
23
|
+
self,
|
|
24
|
+
action_id: str,
|
|
25
|
+
*,
|
|
26
|
+
state: str | None = None,
|
|
27
|
+
after: str | None = None,
|
|
28
|
+
limit: int | None = None,
|
|
29
|
+
) -> WatchActionDeliveryListResponse:
|
|
30
|
+
params = list_params(state=state, after=after, limit=limit)
|
|
31
|
+
raw = self._http.get(routes.actions.deliveries(action_id), params=params)
|
|
32
|
+
return WatchActionDeliveryListResponse.model_validate(raw)
|
|
33
|
+
|
|
34
|
+
def get(self, delivery_id: str) -> WatchActionDeliveryView:
|
|
35
|
+
raw = self._http.get(routes.deliveries.detail(delivery_id))
|
|
36
|
+
return WatchActionDeliveryView.model_validate(raw)
|
openmagpie/api/engine.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Engines API resource client.
|
|
2
|
+
|
|
3
|
+
Wraps `/v1/engines`. Response models live in the shared
|
|
4
|
+
`openmagpie_schema.engine` module and are imported verbatim.
|
|
5
|
+
|
|
6
|
+
Intended to back a future pre-flight check (surfacing "LLM unreachable"
|
|
7
|
+
at config time beats discovering it via 500-per-judge-cycle once polling
|
|
8
|
+
starts); no `magpie` command consumes it yet.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from openmagpie_schema.engine import EngineListResponse, EngineStatus
|
|
14
|
+
|
|
15
|
+
from .. import routes
|
|
16
|
+
from ..http import MagpieClient
|
|
17
|
+
|
|
18
|
+
__all__ = ["EngineApi", "EngineListResponse", "EngineStatus"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class EngineApi:
|
|
22
|
+
"""Resource client for `/v1/engines`."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, http: MagpieClient) -> None:
|
|
25
|
+
self._http = http
|
|
26
|
+
|
|
27
|
+
def list(self) -> list[EngineStatus]:
|
|
28
|
+
"""GET /v1/engines — registered engines + reachability snapshot.
|
|
29
|
+
|
|
30
|
+
Returns the unwrapped items list; the envelope is internal and
|
|
31
|
+
callers always want the per-engine entries.
|
|
32
|
+
"""
|
|
33
|
+
raw = self._http.get(routes.engines.collection)
|
|
34
|
+
return EngineListResponse.model_validate(raw).items
|
openmagpie/api/feed.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Feeds API resource client.
|
|
2
|
+
|
|
3
|
+
Wraps the `/v1/feeds` endpoints. Response
|
|
4
|
+
models live ONCE in the shared `openmagpie_schema.feed` package
|
|
5
|
+
(populated by the server, imported verbatim here). Only `FeedEnvelope`
|
|
6
|
+
(the request envelope the CLI *constructs*) is CLI-owned.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
from openmagpie_schema.feed import (
|
|
16
|
+
FeedItemListResponse,
|
|
17
|
+
FeedItemWire,
|
|
18
|
+
FeedListResponse,
|
|
19
|
+
FeedMutationResponse,
|
|
20
|
+
FeedView,
|
|
21
|
+
FeedWire,
|
|
22
|
+
SourceInput,
|
|
23
|
+
SourceSetResult,
|
|
24
|
+
SourceWire,
|
|
25
|
+
)
|
|
26
|
+
from openmagpie_schema.wire import ConfigBlob
|
|
27
|
+
|
|
28
|
+
from .. import routes
|
|
29
|
+
from ..http import MagpieClient
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"FeedApi",
|
|
33
|
+
"FeedEnvelope",
|
|
34
|
+
"FeedItemListResponse",
|
|
35
|
+
"FeedItemWire",
|
|
36
|
+
"FeedListResponse",
|
|
37
|
+
"FeedMutationResponse",
|
|
38
|
+
"FeedView",
|
|
39
|
+
"FeedWire",
|
|
40
|
+
"SourceSetResult",
|
|
41
|
+
"SourceWire",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class FeedEnvelope(BaseModel):
|
|
46
|
+
"""The envelope the CLI constructs for a feed write (request side).
|
|
47
|
+
CLI-owned, distinct from the server-emitted models. `data` carries
|
|
48
|
+
the kind-specific config (retention + default_field_map), opaque
|
|
49
|
+
here; the server validates it. `sources` is the optional starter
|
|
50
|
+
source list for curated feeds (server creates Source rows
|
|
51
|
+
atomically with the Feed). Extra keys ignored (so the edit seed's
|
|
52
|
+
read-only fields drop on round-trip)."""
|
|
53
|
+
|
|
54
|
+
name: str
|
|
55
|
+
kind: str = "curated"
|
|
56
|
+
poll_interval_seconds: int = 300
|
|
57
|
+
data: ConfigBlob = {}
|
|
58
|
+
sources: list[SourceInput] = []
|
|
59
|
+
|
|
60
|
+
model_config = {"extra": "ignore"}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class FeedApi:
|
|
64
|
+
"""Resource client for `/v1/feeds`."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, http: MagpieClient) -> None:
|
|
67
|
+
self._http = http
|
|
68
|
+
|
|
69
|
+
def create(self, body: dict[str, Any], *, dry_run: bool = False) -> FeedMutationResponse:
|
|
70
|
+
params = {"dry_run": "true"} if dry_run else None
|
|
71
|
+
raw = self._http.post(routes.feeds.collection, json_body=body, params=params)
|
|
72
|
+
return FeedMutationResponse.model_validate(raw)
|
|
73
|
+
|
|
74
|
+
def list(self, *, after: str | None = None, limit: int | None = None) -> FeedListResponse:
|
|
75
|
+
"""One page of feeds (cursor-paginated, newest-first by ULID pk).
|
|
76
|
+
`after` = id of the last feed from the previous page; omit on first
|
|
77
|
+
call. The returned `next_cursor` is None when there are no more rows.
|
|
78
|
+
"""
|
|
79
|
+
params: dict[str, Any] = {}
|
|
80
|
+
if after:
|
|
81
|
+
params["after"] = after
|
|
82
|
+
if limit is not None:
|
|
83
|
+
params["limit"] = limit
|
|
84
|
+
raw = self._http.get(routes.feeds.collection, params=params or None)
|
|
85
|
+
return FeedListResponse.model_validate(raw)
|
|
86
|
+
|
|
87
|
+
def get(self, feed_id: str) -> FeedView:
|
|
88
|
+
"""GET one feed's CONFIG detail (account-scoped): the kind-independent
|
|
89
|
+
envelope + display `summary` + its current source set. The item log is a
|
|
90
|
+
separate read (`feed item list` / GET /v1/feeds/<id>/items); this view is
|
|
91
|
+
the feed's configuration, not its items."""
|
|
92
|
+
raw = self._http.get(routes.feeds.detail(feed_id))
|
|
93
|
+
return FeedView.model_validate(raw)
|
|
94
|
+
|
|
95
|
+
def update(self, feed_id: str, body: dict[str, Any], *, dry_run: bool = False) -> FeedMutationResponse:
|
|
96
|
+
params = {"dry_run": "true"} if dry_run else None
|
|
97
|
+
raw = self._http.put(routes.feeds.detail(feed_id), json_body=body, params=params)
|
|
98
|
+
return FeedMutationResponse.model_validate(raw)
|
|
99
|
+
|
|
100
|
+
def delete(self, feed_id: str) -> None:
|
|
101
|
+
self._http.delete(routes.feeds.detail(feed_id))
|
|
102
|
+
|
|
103
|
+
# ── Items sub-resource (read-only) ─────────────────────────────────
|
|
104
|
+
|
|
105
|
+
def list_items(self, feed_id: str, *, after: str | None = None, limit: int | None = None) -> FeedItemListResponse:
|
|
106
|
+
"""One page of the feed's items (cursor-paginated, newest-first by ULID
|
|
107
|
+
pk). `after` = id of the last item from the previous page; the returned
|
|
108
|
+
`next_cursor` is None when there are no more rows."""
|
|
109
|
+
params: dict[str, Any] = {}
|
|
110
|
+
if after:
|
|
111
|
+
params["after"] = after
|
|
112
|
+
if limit is not None:
|
|
113
|
+
params["limit"] = limit
|
|
114
|
+
raw = self._http.get(routes.feeds.items(feed_id), params=params or None)
|
|
115
|
+
return FeedItemListResponse.model_validate(raw)
|
|
116
|
+
|
|
117
|
+
def get_item(self, item_id: str) -> FeedItemWire:
|
|
118
|
+
"""GET one feed item by its own (globally unique) ULID, account-scoped."""
|
|
119
|
+
raw = self._http.get(routes.feed_items.detail(item_id))
|
|
120
|
+
return FeedItemWire.model_validate(raw)
|
|
121
|
+
|
|
122
|
+
# ── Sources sub-resource ───────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
def list_sources(self, feed_id: str) -> list[SourceWire]:
|
|
125
|
+
raw = self._http.get(routes.feeds.sources(feed_id))
|
|
126
|
+
items = (raw or {}).get("items") or []
|
|
127
|
+
return [SourceWire.model_validate(it) for it in items]
|
|
128
|
+
|
|
129
|
+
def get_source(self, source_id: str) -> SourceWire:
|
|
130
|
+
"""GET one source by its own (globally unique) ULID; the server resolves
|
|
131
|
+
its feed (sources address by own id, not feed-scoped)."""
|
|
132
|
+
raw = self._http.get(routes.feed_sources.detail(source_id))
|
|
133
|
+
return SourceWire.model_validate(raw)
|
|
134
|
+
|
|
135
|
+
def set_sources(
|
|
136
|
+
self,
|
|
137
|
+
feed_id: str,
|
|
138
|
+
sources: list[dict[str, Any]],
|
|
139
|
+
*,
|
|
140
|
+
dry_run: bool = False,
|
|
141
|
+
) -> SourceSetResult:
|
|
142
|
+
raw = self._http.put(
|
|
143
|
+
routes.feeds.sources(feed_id),
|
|
144
|
+
json_body={"sources": sources, "dry_run": dry_run},
|
|
145
|
+
)
|
|
146
|
+
return SourceSetResult.model_validate(raw)
|
|
147
|
+
|
|
148
|
+
def delete_source(self, source_id: str) -> None:
|
|
149
|
+
# By the source's own id; the server resolves its feed (sources address
|
|
150
|
+
# by own id now, not feed-scoped).
|
|
151
|
+
self._http.delete(routes.feed_sources.detail(source_id))
|
openmagpie/api/watch.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Watches API resource client.
|
|
2
|
+
|
|
3
|
+
Wraps the `/v1/watches` endpoints. ALL shapes live ONCE in the shared
|
|
4
|
+
`openmagpie_schema.watch` package (the server is the authority) and are
|
|
5
|
+
imported verbatim here — including the write envelope `WatchInput`, which
|
|
6
|
+
the CLI constructs. No CLI-side copy. Mirrors `api/feed.py`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import builtins
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from openmagpie_schema.watch import (
|
|
15
|
+
WatchActionWire,
|
|
16
|
+
WatchInput,
|
|
17
|
+
WatchListResponse,
|
|
18
|
+
WatchMutationResponse,
|
|
19
|
+
WatchView,
|
|
20
|
+
WatchWire,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
from .. import routes
|
|
24
|
+
from ..http import MagpieClient
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"WatchApi",
|
|
28
|
+
"WatchInput",
|
|
29
|
+
"WatchListResponse",
|
|
30
|
+
"WatchMutationResponse",
|
|
31
|
+
"WatchView",
|
|
32
|
+
"WatchWire",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class WatchApi:
|
|
37
|
+
"""Resource client for `/v1/watches`."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, http: MagpieClient) -> None:
|
|
40
|
+
self._http = http
|
|
41
|
+
|
|
42
|
+
def create(self, body: dict[str, Any], *, dry_run: bool = False) -> WatchMutationResponse:
|
|
43
|
+
params = {"dry_run": "true"} if dry_run else None
|
|
44
|
+
raw = self._http.post(routes.watches.collection, json_body=body, params=params)
|
|
45
|
+
return WatchMutationResponse.model_validate(raw)
|
|
46
|
+
|
|
47
|
+
def list(self, *, after: str | None = None, limit: int | None = None) -> WatchListResponse:
|
|
48
|
+
"""One page of watches (cursor-paginated, newest-first by ULID pk).
|
|
49
|
+
`after` = id of the last watch from the previous page; omit on the
|
|
50
|
+
first call. `next_cursor` is None when there are no more rows."""
|
|
51
|
+
params: dict[str, Any] = {}
|
|
52
|
+
if after:
|
|
53
|
+
params["after"] = after
|
|
54
|
+
if limit is not None:
|
|
55
|
+
params["limit"] = limit
|
|
56
|
+
raw = self._http.get(routes.watches.collection, params=params or None)
|
|
57
|
+
return WatchListResponse.model_validate(raw)
|
|
58
|
+
|
|
59
|
+
def get(self, watch_id: str) -> WatchView:
|
|
60
|
+
"""GET one watch (account-scoped). Carries the initial path's
|
|
61
|
+
ordered action chain."""
|
|
62
|
+
raw = self._http.get(routes.watches.detail(watch_id))
|
|
63
|
+
return WatchView.model_validate(raw)
|
|
64
|
+
|
|
65
|
+
def update(self, watch_id: str, body: dict[str, Any], *, dry_run: bool = False) -> WatchMutationResponse:
|
|
66
|
+
params = {"dry_run": "true"} if dry_run else None
|
|
67
|
+
raw = self._http.put(routes.watches.detail(watch_id), json_body=body, params=params)
|
|
68
|
+
return WatchMutationResponse.model_validate(raw)
|
|
69
|
+
|
|
70
|
+
def delete(self, watch_id: str) -> None:
|
|
71
|
+
self._http.delete(routes.watches.detail(watch_id))
|
|
72
|
+
|
|
73
|
+
# ── Actions sub-resource (the chain) ────────────────────────────────
|
|
74
|
+
|
|
75
|
+
def list_actions(self, watch_id: str) -> builtins.list[WatchActionWire]:
|
|
76
|
+
raw = self._http.get(routes.watches.actions(watch_id))
|
|
77
|
+
items = (raw or {}).get("items") or []
|
|
78
|
+
return [WatchActionWire.model_validate(it) for it in items]
|
|
79
|
+
|
|
80
|
+
def get_action(self, action_id: str) -> WatchActionWire:
|
|
81
|
+
"""GET one action's definition (kind + redacted config + summary) by its
|
|
82
|
+
own id; the watch/chain is resolved server-side, not in the path."""
|
|
83
|
+
raw = self._http.get(routes.actions.detail(action_id))
|
|
84
|
+
return WatchActionWire.model_validate(raw)
|
|
85
|
+
|
|
86
|
+
def add_action(
|
|
87
|
+
self, watch_id: str, kind: str, config: dict[str, Any], *, rank: int | None = None
|
|
88
|
+
) -> WatchActionWire:
|
|
89
|
+
body: dict[str, Any] = {"kind": kind, "config": config}
|
|
90
|
+
if rank is not None:
|
|
91
|
+
body["rank"] = rank
|
|
92
|
+
raw = self._http.post(routes.watches.actions(watch_id), json_body=body)
|
|
93
|
+
return WatchActionWire.model_validate(raw)
|
|
94
|
+
|
|
95
|
+
def edit_action(self, action_id: str, kind: str, config: dict[str, Any]) -> WatchActionWire:
|
|
96
|
+
raw = self._http.put(routes.actions.detail(action_id), json_body={"kind": kind, "config": config})
|
|
97
|
+
return WatchActionWire.model_validate(raw)
|
|
98
|
+
|
|
99
|
+
def delete_action(self, action_id: str) -> None:
|
|
100
|
+
self._http.delete(routes.actions.detail(action_id))
|
openmagpie/cli.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Root Typer app + subcommand registration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from .commands.activity import activity_app
|
|
8
|
+
from .commands.auth import auth_app
|
|
9
|
+
from .commands.delivery import delivery_app
|
|
10
|
+
from .commands.feed import feed_app
|
|
11
|
+
from .commands.watch import watch_app
|
|
12
|
+
from .context import AppContext, bind_app_ctx, unbind_app_ctx
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(
|
|
15
|
+
name="magpie",
|
|
16
|
+
help="The magpie CLI. Talk to an OpenMagpie server.",
|
|
17
|
+
no_args_is_help=True,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.callback()
|
|
22
|
+
def main(
|
|
23
|
+
ctx: typer.Context,
|
|
24
|
+
server: str | None = typer.Option(
|
|
25
|
+
None,
|
|
26
|
+
"--server",
|
|
27
|
+
"-s",
|
|
28
|
+
help="OpenMagpie server URL override for this invocation.",
|
|
29
|
+
),
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Build the shared AppContext (config + resource API) and bind it
|
|
32
|
+
into the ContextVar so every subcommand can pull it via `app_ctx()`.
|
|
33
|
+
"""
|
|
34
|
+
obj = AppContext(server_url=server)
|
|
35
|
+
token = bind_app_ctx(obj)
|
|
36
|
+
ctx.call_on_close(obj.close)
|
|
37
|
+
ctx.call_on_close(lambda: unbind_app_ctx(token))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
app.add_typer(auth_app, name="auth", help="Sign in / out and inspect identity.")
|
|
41
|
+
app.add_typer(
|
|
42
|
+
feed_app,
|
|
43
|
+
name="feed",
|
|
44
|
+
help="Curate + read feeds (the source set watches subscribe to).",
|
|
45
|
+
)
|
|
46
|
+
app.add_typer(
|
|
47
|
+
watch_app,
|
|
48
|
+
name="watch",
|
|
49
|
+
help="Build + manage watches (a feed subscription + action chain).",
|
|
50
|
+
)
|
|
51
|
+
app.add_typer(
|
|
52
|
+
activity_app,
|
|
53
|
+
name="activity",
|
|
54
|
+
help="Audit an action's runs: summary / list / one in full.",
|
|
55
|
+
)
|
|
56
|
+
app.add_typer(
|
|
57
|
+
delivery_app,
|
|
58
|
+
name="delivery",
|
|
59
|
+
help="Audit an action's outbound webhook calls.",
|
|
60
|
+
)
|