posit-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- posit_cli/__init__.py +8 -0
- posit_cli/__main__.py +19 -0
- posit_cli/connect/__init__.py +31 -0
- posit_cli/connect/api.py +584 -0
- posit_cli-0.1.0.dist-info/METADATA +142 -0
- posit_cli-0.1.0.dist-info/RECORD +9 -0
- posit_cli-0.1.0.dist-info/WHEEL +4 -0
- posit_cli-0.1.0.dist-info/entry_points.txt +2 -0
- posit_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
posit_cli/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""posit-cli: a single command-line interface for Posit Connect."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("posit-cli")
|
|
7
|
+
except PackageNotFoundError: # pragma: no cover - source checkout without install
|
|
8
|
+
__version__ = "0.0.0+unknown"
|
posit_cli/__main__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Top-level ``posit`` command group."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from . import __version__
|
|
6
|
+
from .connect import connect
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@click.group(no_args_is_help=True)
|
|
10
|
+
@click.version_option(version=__version__)
|
|
11
|
+
def cli() -> None:
|
|
12
|
+
"""Posit command-line interface."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
cli.add_command(connect)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
if __name__ == "__main__": # pragma: no cover
|
|
19
|
+
cli()
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""The ``posit connect`` command group.
|
|
2
|
+
|
|
3
|
+
Mounts the entire ``rsconnect`` CLI (login, deploy, add, list, ...) under
|
|
4
|
+
``posit connect`` so those commands come for free and track rsconnect-python
|
|
5
|
+
upstream, then layers on a ``gh api``-style ``posit connect api`` command.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from rsconnect.main import cli as rsconnect_cli
|
|
10
|
+
|
|
11
|
+
from .api import api as api_cmd
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_epilog = (
|
|
15
|
+
"Tip: prefer 'posit connect login' (OAuth, tokens stored in your OS "
|
|
16
|
+
"keyring) over 'posit connect server add' (stores a plaintext API key)."
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@click.group(no_args_is_help=True, epilog=_epilog)
|
|
21
|
+
def connect() -> None:
|
|
22
|
+
"""Work with Posit Connect."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Re-expose every rsconnect subcommand under `posit connect`. rsconnect's own
|
|
26
|
+
# `api` lives under its `deploy` group, so the top-level `api` name is free for
|
|
27
|
+
# our gh-style command added below.
|
|
28
|
+
for _name, _cmd in rsconnect_cli.commands.items():
|
|
29
|
+
connect.add_command(_cmd, name=_name)
|
|
30
|
+
|
|
31
|
+
connect.add_command(api_cmd, name="api")
|
posit_cli/connect/api.py
ADDED
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
"""``posit connect api`` -- a ``gh api``-style raw REST client for Connect.
|
|
2
|
+
|
|
3
|
+
Backed by rsconnect-python's authenticated client (``RSConnectExecutor`` ->
|
|
4
|
+
``RSConnectClient``), so it reuses the same credential store, transparently
|
|
5
|
+
picks OAuth ``Bearer`` vs API-``Key`` auth, and auto-refreshes OAuth tokens on
|
|
6
|
+
401 -- no separate auth glue needed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
12
|
+
from urllib.parse import parse_qsl, urlparse
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
import jq
|
|
16
|
+
from rsconnect.api import RSConnectExecutor, RSConnectException
|
|
17
|
+
from rsconnect.http_support import HTTPResponse
|
|
18
|
+
|
|
19
|
+
# Safety cap so a malformed/cyclic paging.next can't loop forever.
|
|
20
|
+
_MAX_PAGES = 10_000
|
|
21
|
+
# Connect's maximum page size; request it for follow-up pages to minimize
|
|
22
|
+
# round-trips (matches posit-sdk-py).
|
|
23
|
+
_MAX_PAGE_SIZE = 500
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _read_at_value(raw: str) -> str:
|
|
27
|
+
"""Resolve a ``@file`` / ``@-`` (stdin) field value, gh-style."""
|
|
28
|
+
source = raw[1:]
|
|
29
|
+
if source == "-":
|
|
30
|
+
return sys.stdin.read()
|
|
31
|
+
with open(source, encoding="utf-8") as f:
|
|
32
|
+
return f.read()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _parse_typed(value: str) -> Any:
|
|
36
|
+
"""Parse a ``-F`` typed field value (true/false/null/number/@file/string)."""
|
|
37
|
+
if value.startswith("@"):
|
|
38
|
+
return _read_at_value(value)
|
|
39
|
+
if value == "true":
|
|
40
|
+
return True
|
|
41
|
+
if value == "false":
|
|
42
|
+
return False
|
|
43
|
+
if value == "null":
|
|
44
|
+
return None
|
|
45
|
+
try:
|
|
46
|
+
return int(value)
|
|
47
|
+
except ValueError:
|
|
48
|
+
pass
|
|
49
|
+
try:
|
|
50
|
+
return float(value)
|
|
51
|
+
except ValueError:
|
|
52
|
+
pass
|
|
53
|
+
return value
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _split_pairs(pairs: Tuple[str, ...], *, typed: bool) -> Dict[str, Any]:
|
|
57
|
+
out: Dict[str, Any] = {}
|
|
58
|
+
for pair in pairs:
|
|
59
|
+
if "=" not in pair:
|
|
60
|
+
raise click.BadParameter(
|
|
61
|
+
f"expected key=value, got {pair!r}",
|
|
62
|
+
param_hint="--field/--raw-field",
|
|
63
|
+
)
|
|
64
|
+
key, value = pair.split("=", 1)
|
|
65
|
+
out[key] = _parse_typed(value) if typed else value
|
|
66
|
+
return out
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _split_headers(headers: Tuple[str, ...]) -> Dict[str, str]:
|
|
70
|
+
out: Dict[str, str] = {}
|
|
71
|
+
for header in headers:
|
|
72
|
+
if ":" not in header:
|
|
73
|
+
raise click.BadParameter(f"expected key:value, got {header!r}", param_hint="--header")
|
|
74
|
+
key, value = header.split(":", 1)
|
|
75
|
+
out[key.strip()] = value.strip()
|
|
76
|
+
return out
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@click.command(
|
|
80
|
+
"api",
|
|
81
|
+
short_help="Make an authenticated request to the Connect API.",
|
|
82
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
83
|
+
)
|
|
84
|
+
@click.argument("path")
|
|
85
|
+
@click.option(
|
|
86
|
+
"--method",
|
|
87
|
+
"-X",
|
|
88
|
+
default=None,
|
|
89
|
+
help="HTTP method. Defaults to GET, or POST when fields/input are given.",
|
|
90
|
+
)
|
|
91
|
+
@click.option(
|
|
92
|
+
"--field",
|
|
93
|
+
"-F",
|
|
94
|
+
"typed_fields",
|
|
95
|
+
multiple=True,
|
|
96
|
+
metavar="key=value",
|
|
97
|
+
help="Add a typed parameter (true/false/null/numbers; @file reads a file, @- reads stdin).",
|
|
98
|
+
)
|
|
99
|
+
@click.option(
|
|
100
|
+
"--raw-field",
|
|
101
|
+
"-f",
|
|
102
|
+
"raw_fields",
|
|
103
|
+
multiple=True,
|
|
104
|
+
metavar="key=value",
|
|
105
|
+
help="Add a string parameter.",
|
|
106
|
+
)
|
|
107
|
+
@click.option(
|
|
108
|
+
"--header",
|
|
109
|
+
"-H",
|
|
110
|
+
"headers",
|
|
111
|
+
multiple=True,
|
|
112
|
+
metavar="key:value",
|
|
113
|
+
help="Add a request header.",
|
|
114
|
+
)
|
|
115
|
+
@click.option(
|
|
116
|
+
"--input",
|
|
117
|
+
"input_body",
|
|
118
|
+
metavar="FILE",
|
|
119
|
+
help="Read the raw request body from a file ('-' for stdin).",
|
|
120
|
+
)
|
|
121
|
+
@click.option(
|
|
122
|
+
"--jq",
|
|
123
|
+
"-q",
|
|
124
|
+
"jq_filter",
|
|
125
|
+
default=None,
|
|
126
|
+
metavar="EXPR",
|
|
127
|
+
help="Filter the JSON response through a jq expression (like 'gh api --jq').",
|
|
128
|
+
)
|
|
129
|
+
@click.option(
|
|
130
|
+
"--include",
|
|
131
|
+
"-i",
|
|
132
|
+
is_flag=True,
|
|
133
|
+
default=False,
|
|
134
|
+
help="Include the HTTP response status line and headers in the output.",
|
|
135
|
+
)
|
|
136
|
+
@click.option(
|
|
137
|
+
"--paginate",
|
|
138
|
+
is_flag=True,
|
|
139
|
+
default=False,
|
|
140
|
+
help="Follow Connect's pagination and output all pages' results combined (GET only).",
|
|
141
|
+
)
|
|
142
|
+
# Credential selection -- mirrors rsconnect's own options/precedence.
|
|
143
|
+
@click.option("--name", "-n", default=None, help="Nickname of a saved server.")
|
|
144
|
+
@click.option(
|
|
145
|
+
"--server",
|
|
146
|
+
"-s",
|
|
147
|
+
default=None,
|
|
148
|
+
envvar="CONNECT_SERVER",
|
|
149
|
+
help="Connect server URL [env: CONNECT_SERVER].",
|
|
150
|
+
)
|
|
151
|
+
@click.option(
|
|
152
|
+
"--api-key",
|
|
153
|
+
"-k",
|
|
154
|
+
default=None,
|
|
155
|
+
envvar="CONNECT_API_KEY",
|
|
156
|
+
help="Connect API key [env: CONNECT_API_KEY].",
|
|
157
|
+
)
|
|
158
|
+
@click.option(
|
|
159
|
+
"--no-tls-verify",
|
|
160
|
+
"insecure",
|
|
161
|
+
is_flag=True,
|
|
162
|
+
default=False,
|
|
163
|
+
envvar="CONNECT_INSECURE",
|
|
164
|
+
help="Skip TLS certificate verification (still uses TLS) [env: CONNECT_INSECURE].",
|
|
165
|
+
)
|
|
166
|
+
@click.option(
|
|
167
|
+
"--cacert",
|
|
168
|
+
"-c",
|
|
169
|
+
type=click.Path(exists=True, dir_okay=False),
|
|
170
|
+
default=None,
|
|
171
|
+
envvar="CONNECT_CA_CERTIFICATE",
|
|
172
|
+
help="Path to trusted TLS CA certificate.",
|
|
173
|
+
)
|
|
174
|
+
def api(
|
|
175
|
+
path: str,
|
|
176
|
+
method: Optional[str],
|
|
177
|
+
typed_fields: Tuple[str, ...],
|
|
178
|
+
raw_fields: Tuple[str, ...],
|
|
179
|
+
headers: Tuple[str, ...],
|
|
180
|
+
input_body: Optional[str],
|
|
181
|
+
jq_filter: Optional[str],
|
|
182
|
+
include: bool,
|
|
183
|
+
paginate: bool,
|
|
184
|
+
name: Optional[str],
|
|
185
|
+
server: Optional[str],
|
|
186
|
+
api_key: Optional[str],
|
|
187
|
+
insecure: bool,
|
|
188
|
+
cacert: Optional[str],
|
|
189
|
+
) -> None:
|
|
190
|
+
"""Make an authenticated request to a Connect API endpoint and print the JSON response.
|
|
191
|
+
|
|
192
|
+
PATH is relative to the Connect API root, e.g. 'v1/content' or 'v1/user'
|
|
193
|
+
(the '/__api__' prefix is added for you). Behaves like 'gh api'.
|
|
194
|
+
|
|
195
|
+
Examples:
|
|
196
|
+
|
|
197
|
+
posit connect api v1/user
|
|
198
|
+
|
|
199
|
+
posit connect api v1/user -q .username
|
|
200
|
+
|
|
201
|
+
posit connect api v1/content -X POST -f name=my-app
|
|
202
|
+
|
|
203
|
+
posit connect api v1/content -F count=10
|
|
204
|
+
"""
|
|
205
|
+
fields: Dict[str, Any] = {}
|
|
206
|
+
fields.update(_split_pairs(raw_fields, typed=False))
|
|
207
|
+
fields.update(_split_pairs(typed_fields, typed=True))
|
|
208
|
+
|
|
209
|
+
raw_body = _read_at_value("@" + input_body) if input_body else None
|
|
210
|
+
if raw_body is not None and fields:
|
|
211
|
+
raise click.UsageError("--input cannot be combined with -f/-F fields.")
|
|
212
|
+
|
|
213
|
+
# Compile the jq filter up front so a typo fails before we hit the network.
|
|
214
|
+
jq_program = None
|
|
215
|
+
if jq_filter is not None:
|
|
216
|
+
try:
|
|
217
|
+
jq_program = jq.compile(jq_filter)
|
|
218
|
+
except ValueError as exc:
|
|
219
|
+
raise click.BadParameter(str(exc), param_hint="--jq") from exc
|
|
220
|
+
|
|
221
|
+
has_payload = bool(fields) or raw_body is not None
|
|
222
|
+
resolved_method = (method or ("POST" if has_payload else "GET")).upper()
|
|
223
|
+
|
|
224
|
+
if paginate:
|
|
225
|
+
# Pagination walks read endpoints and combines bodies; showing the
|
|
226
|
+
# headers of just one of N responses would be misleading, and following
|
|
227
|
+
# pages of a write makes no sense.
|
|
228
|
+
if include:
|
|
229
|
+
raise click.UsageError("--paginate cannot be combined with --include.")
|
|
230
|
+
if resolved_method != "GET":
|
|
231
|
+
raise click.UsageError("--paginate only supports GET requests.")
|
|
232
|
+
|
|
233
|
+
request_headers = _split_headers(headers)
|
|
234
|
+
|
|
235
|
+
query_params: Optional[Dict[str, Any]] = None
|
|
236
|
+
body: Any = None
|
|
237
|
+
if raw_body is not None:
|
|
238
|
+
body = raw_body
|
|
239
|
+
elif fields:
|
|
240
|
+
if resolved_method == "GET":
|
|
241
|
+
query_params = fields
|
|
242
|
+
else:
|
|
243
|
+
# Encode the JSON body ourselves rather than handing rsconnect a dict:
|
|
244
|
+
# when given a Mapping/list, RSConnectClient.request *replaces* the
|
|
245
|
+
# headers with just Content-Type, dropping any user -H values. Passing
|
|
246
|
+
# an already-encoded str keeps our headers intact.
|
|
247
|
+
body = json.dumps(fields)
|
|
248
|
+
# Only default Content-Type if the user didn't set it under any casing;
|
|
249
|
+
# a case-sensitive setdefault would emit a duplicate header.
|
|
250
|
+
if not any(k.lower() == "content-type" for k in request_headers):
|
|
251
|
+
request_headers["Content-Type"] = "application/json"
|
|
252
|
+
|
|
253
|
+
try:
|
|
254
|
+
ce = RSConnectExecutor(
|
|
255
|
+
ctx=None,
|
|
256
|
+
name=name,
|
|
257
|
+
url=server,
|
|
258
|
+
api_key=api_key,
|
|
259
|
+
insecure=insecure,
|
|
260
|
+
cacert=cacert,
|
|
261
|
+
)
|
|
262
|
+
ce.setup_client()
|
|
263
|
+
request_path = _resolve_path(path, getattr(ce.remote_server, "url", None))
|
|
264
|
+
if include:
|
|
265
|
+
# RSConnectClient._tweak_response unwraps a 2xx JSON body to a
|
|
266
|
+
# dict/list, discarding the status line and headers. To show them we
|
|
267
|
+
# need the raw HTTPResponse, so neutralize the unwrapping for this
|
|
268
|
+
# request (the identity is exactly the HTTPServer base behavior).
|
|
269
|
+
ce.client._tweak_response = lambda response: response
|
|
270
|
+
if paginate:
|
|
271
|
+
result = _request_all_pages(
|
|
272
|
+
ce.client, resolved_method, request_path, query_params, body, request_headers
|
|
273
|
+
)
|
|
274
|
+
else:
|
|
275
|
+
result = ce.client.request(
|
|
276
|
+
resolved_method,
|
|
277
|
+
request_path,
|
|
278
|
+
query_params=query_params,
|
|
279
|
+
body=body,
|
|
280
|
+
headers=request_headers,
|
|
281
|
+
)
|
|
282
|
+
except RSConnectException as exc:
|
|
283
|
+
raise click.ClickException(str(exc)) from exc
|
|
284
|
+
|
|
285
|
+
# Like gh, bare -f/-F fields imply POST and are sent as a JSON body. A common
|
|
286
|
+
# mistake is reaching for them to filter a GET list, which Connect rejects as
|
|
287
|
+
# an unknown body field. Only when the POST was *implicit* (no -X) does a 4xx
|
|
288
|
+
# warrant nudging the user toward query params.
|
|
289
|
+
fields_implied_post = method is None and bool(fields)
|
|
290
|
+
_emit(result, jq_program, query_param_hint=fields_implied_post, include=include)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _emit(
|
|
294
|
+
result: Any,
|
|
295
|
+
jq_program: Any = None,
|
|
296
|
+
query_param_hint: bool = False,
|
|
297
|
+
include: bool = False,
|
|
298
|
+
) -> None:
|
|
299
|
+
"""Print the response body, or surface an error and exit non-zero.
|
|
300
|
+
|
|
301
|
+
RSConnectClient unwraps a 2xx JSON body to a dict/list/scalar, but returns a
|
|
302
|
+
raw ``HTTPResponse`` for any other case -- including *successful* responses
|
|
303
|
+
with no JSON body (e.g. 204 No Content) as well as actual errors. So a bare
|
|
304
|
+
``HTTPResponse`` must be classified by status, not treated as failure.
|
|
305
|
+
|
|
306
|
+
``jq_program`` (when given) filters a *successful* JSON body before printing;
|
|
307
|
+
error responses are never filtered. ``query_param_hint`` adds a nudge toward
|
|
308
|
+
query params when an implicit-POST request fails with a client error.
|
|
309
|
+
``include`` prepends the HTTP status line and response headers (requires the
|
|
310
|
+
raw HTTPResponse, which the caller arranges by bypassing _tweak_response).
|
|
311
|
+
"""
|
|
312
|
+
if isinstance(result, HTTPResponse):
|
|
313
|
+
# status/reason/headers are only present when an actual response arrived;
|
|
314
|
+
# on a transport exception they're absent entirely.
|
|
315
|
+
status = getattr(result, "status", None)
|
|
316
|
+
payload = result.json_data if result.json_data is not None else result.response_body
|
|
317
|
+
else:
|
|
318
|
+
# Already-decoded 2xx JSON body (the unwrapped, non-include path).
|
|
319
|
+
status = 200
|
|
320
|
+
payload = result
|
|
321
|
+
|
|
322
|
+
success = status is not None and 200 <= status < 300
|
|
323
|
+
|
|
324
|
+
if success:
|
|
325
|
+
# Render the body (running jq) *before* emitting anything: a jq runtime
|
|
326
|
+
# failure must abort with nothing on stdout, not leak the headers first.
|
|
327
|
+
rendered = _render_success(payload, jq_program)
|
|
328
|
+
if include:
|
|
329
|
+
for line in _header_lines(result, status):
|
|
330
|
+
click.echo(line)
|
|
331
|
+
click.echo("") # blank line between headers and body
|
|
332
|
+
if rendered is not None:
|
|
333
|
+
click.echo(rendered)
|
|
334
|
+
return
|
|
335
|
+
|
|
336
|
+
# Failure: in include mode the status line + headers go to stderr too,
|
|
337
|
+
# matching our errors-to-stderr rule.
|
|
338
|
+
if include and status is not None:
|
|
339
|
+
for line in _header_lines(result, status):
|
|
340
|
+
click.echo(line, err=True)
|
|
341
|
+
click.echo("", err=True)
|
|
342
|
+
|
|
343
|
+
if status is None:
|
|
344
|
+
# Transport failure: no response or headers exist (include is moot here).
|
|
345
|
+
message = (
|
|
346
|
+
f"request failed: {result.exception}"
|
|
347
|
+
if result.exception is not None
|
|
348
|
+
else "request failed"
|
|
349
|
+
)
|
|
350
|
+
elif include:
|
|
351
|
+
# The status line and headers were already shown; just the body here.
|
|
352
|
+
message = _dumps(payload) if payload not in (None, "") else ""
|
|
353
|
+
else:
|
|
354
|
+
header = f"HTTP {status} {getattr(result, 'reason', '') or ''}".rstrip()
|
|
355
|
+
body = _dumps(payload) if payload not in (None, "") else ""
|
|
356
|
+
message = f"{header}\n{body}".rstrip()
|
|
357
|
+
|
|
358
|
+
if query_param_hint and status is not None and 400 <= status < 500:
|
|
359
|
+
message += (
|
|
360
|
+
"\n\nNote: -f/-F fields were sent as a JSON body (they imply POST). "
|
|
361
|
+
"For query parameters on a read, use '-X GET' or put them in the "
|
|
362
|
+
'path: api "<path>?key=value".'
|
|
363
|
+
)
|
|
364
|
+
if message:
|
|
365
|
+
click.echo(message, err=True)
|
|
366
|
+
raise SystemExit(1)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _request_all_pages(
|
|
370
|
+
client: Any,
|
|
371
|
+
method: str,
|
|
372
|
+
path: str,
|
|
373
|
+
query_params: Optional[Dict[str, Any]],
|
|
374
|
+
body: Any,
|
|
375
|
+
headers: Dict[str, str],
|
|
376
|
+
) -> Any:
|
|
377
|
+
"""Fetch every page of a Connect list endpoint and combine the results.
|
|
378
|
+
|
|
379
|
+
Connect paginates two ways (mirrored from posit-sdk-py's Paginator and
|
|
380
|
+
CursorPaginator):
|
|
381
|
+
|
|
382
|
+
* cursor style -- ``{paging: {cursors: {next}}, results}`` (e.g. audit logs):
|
|
383
|
+
follow the ``paging.cursors.next`` token until it's empty.
|
|
384
|
+
* page-number style -- ``{current_page, total, results}`` (e.g. users,
|
|
385
|
+
groups): request ``page_number`` 1..N until results are empty or the
|
|
386
|
+
accumulated count reaches ``total``.
|
|
387
|
+
|
|
388
|
+
Anything else (a bare array, an unrecognized shape) is treated as a single
|
|
389
|
+
page. Returns the combined result, or the first ``HTTPResponse`` (an error
|
|
390
|
+
or non-JSON body) so the caller's error path handles it.
|
|
391
|
+
"""
|
|
392
|
+
base_path, params = _split_query(path, query_params)
|
|
393
|
+
pages: List[Any] = []
|
|
394
|
+
count = 0
|
|
395
|
+
while True:
|
|
396
|
+
result = client.request(
|
|
397
|
+
method, base_path, query_params=(params or None), body=body, headers=headers
|
|
398
|
+
)
|
|
399
|
+
if isinstance(result, HTTPResponse):
|
|
400
|
+
# Non-2xx or non-JSON: surface it directly (errors aren't paged).
|
|
401
|
+
return result
|
|
402
|
+
pages.append(result)
|
|
403
|
+
|
|
404
|
+
paging = result.get("paging") if isinstance(result, dict) else None
|
|
405
|
+
results = result.get("results") if isinstance(result, dict) else None
|
|
406
|
+
|
|
407
|
+
# Work out the next request, or None to stop.
|
|
408
|
+
next_target: Optional[Tuple[str, Dict[str, Any]]] = None
|
|
409
|
+
if isinstance(paging, dict):
|
|
410
|
+
# Cursor style: prefer the cursor token; fall back to the full
|
|
411
|
+
# paging.next URL if an endpoint only provides that form. A cursor is
|
|
412
|
+
# a position token, so raising the page size between pages is safe.
|
|
413
|
+
cursors = paging.get("cursors") or {}
|
|
414
|
+
token = cursors.get("next")
|
|
415
|
+
if token:
|
|
416
|
+
next_params = {**params, "next": token}
|
|
417
|
+
next_params.setdefault("limit", _MAX_PAGE_SIZE)
|
|
418
|
+
next_target = (base_path, next_params)
|
|
419
|
+
else:
|
|
420
|
+
next_url = paging.get("next")
|
|
421
|
+
if isinstance(next_url, str) and next_url:
|
|
422
|
+
next_target = _split_query(_next_page_path(next_url), None)
|
|
423
|
+
elif isinstance(results, list) and isinstance(result.get("current_page"), int):
|
|
424
|
+
# Page-number style: stop on an empty page or once we've seen `total`.
|
|
425
|
+
# Page size must stay *constant* across requests -- it defines the
|
|
426
|
+
# offset window, so changing it mid-stream would skip/duplicate rows.
|
|
427
|
+
count += len(results)
|
|
428
|
+
total = result.get("total")
|
|
429
|
+
if results and not (isinstance(total, int) and count >= total):
|
|
430
|
+
next_target = (base_path, {**params, "page_number": result["current_page"] + 1})
|
|
431
|
+
# else: bare array or unrecognized shape -> single page.
|
|
432
|
+
|
|
433
|
+
if next_target is None:
|
|
434
|
+
break
|
|
435
|
+
if len(pages) >= _MAX_PAGES:
|
|
436
|
+
# There's still a next page but we've hit the cap: fail loudly rather
|
|
437
|
+
# than silently returning a truncated result with exit code 0.
|
|
438
|
+
raise click.ClickException(
|
|
439
|
+
f"--paginate stopped after {_MAX_PAGES} pages; "
|
|
440
|
+
"possible pagination loop or an unexpectedly large result set"
|
|
441
|
+
)
|
|
442
|
+
base_path, params = next_target
|
|
443
|
+
return _merge_pages(pages)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _split_query(path: str, query_params: Optional[Dict[str, Any]]) -> Tuple[str, Dict[str, Any]]:
|
|
447
|
+
"""Split any ``?query`` off ``path`` and merge it with ``query_params``.
|
|
448
|
+
|
|
449
|
+
Pagination needs the query as a mutable dict so it can advance the cursor or
|
|
450
|
+
page number while preserving the caller's other filters.
|
|
451
|
+
"""
|
|
452
|
+
base = path
|
|
453
|
+
params: Dict[str, Any] = {}
|
|
454
|
+
if "?" in path:
|
|
455
|
+
base, query = path.split("?", 1)
|
|
456
|
+
params.update(parse_qsl(query))
|
|
457
|
+
if query_params:
|
|
458
|
+
params.update(query_params)
|
|
459
|
+
return base, params
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _resolve_path(path: str, server_url: Optional[str]) -> str:
|
|
463
|
+
"""Normalize the PATH argument to something the client can request.
|
|
464
|
+
|
|
465
|
+
A relative path keeps its existing behavior (leading slash stripped; the
|
|
466
|
+
client adds ``/__api__``). A full URL -- handy for pasting a ``paging.next``
|
|
467
|
+
link -- is reduced to its API-relative path. Because the client is bound to
|
|
468
|
+
a single server, a URL for a different host is rejected rather than silently
|
|
469
|
+
sent to the configured server.
|
|
470
|
+
"""
|
|
471
|
+
if not path.startswith(("http://", "https://")):
|
|
472
|
+
return path.lstrip("/") # client prepends /__api__ itself
|
|
473
|
+
parsed = urlparse(path)
|
|
474
|
+
server = urlparse(server_url or "")
|
|
475
|
+
if server.hostname and not _same_host(parsed, server):
|
|
476
|
+
raise click.ClickException(
|
|
477
|
+
f"URL host '{parsed.netloc}' does not match the target server "
|
|
478
|
+
f"'{server.netloc}'; 'posit connect api' only talks to the configured server."
|
|
479
|
+
)
|
|
480
|
+
return _next_page_path(path)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _same_host(a: Any, b: Any) -> bool:
|
|
484
|
+
"""Whether two parsed URLs point at the same host:port.
|
|
485
|
+
|
|
486
|
+
Compares hostnames case-insensitively and effective ports (filling in the
|
|
487
|
+
scheme default), so e.g. ``c.example`` and ``C.EXAMPLE:443`` over https match.
|
|
488
|
+
"""
|
|
489
|
+
if (a.hostname or "").lower() != (b.hostname or "").lower():
|
|
490
|
+
return False
|
|
491
|
+
return _effective_port(a) == _effective_port(b)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _effective_port(parsed: Any) -> Optional[int]:
|
|
495
|
+
"""The URL's port, defaulting by scheme. Malformed ports become a CLI error.
|
|
496
|
+
|
|
497
|
+
``urlparse(...).port`` raises ``ValueError`` on a non-numeric port (e.g.
|
|
498
|
+
``:abc``); surface that as a clean error rather than a traceback.
|
|
499
|
+
"""
|
|
500
|
+
defaults = {"http": 80, "https": 443}
|
|
501
|
+
try:
|
|
502
|
+
return parsed.port or defaults.get(parsed.scheme)
|
|
503
|
+
except ValueError as exc:
|
|
504
|
+
raise click.ClickException(f"invalid port in URL: {exc}") from exc
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _next_page_path(next_url: str) -> str:
|
|
508
|
+
"""Turn an absolute ``paging.next`` URL into a path the client can request.
|
|
509
|
+
|
|
510
|
+
The client prepends ``/__api__``, so strip everything up to and including
|
|
511
|
+
that marker and keep the remainder (with any query string).
|
|
512
|
+
"""
|
|
513
|
+
parsed = urlparse(next_url)
|
|
514
|
+
marker = "/__api__/"
|
|
515
|
+
idx = parsed.path.find(marker)
|
|
516
|
+
rel = parsed.path[idx + len(marker) :] if idx != -1 else parsed.path.lstrip("/")
|
|
517
|
+
return f"{rel}?{parsed.query}" if parsed.query else rel
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _merge_pages(pages: List[Any]) -> Any:
|
|
521
|
+
"""Combine paginated pages into a single result.
|
|
522
|
+
|
|
523
|
+
A single page is returned unchanged (so unpaged endpoints behave exactly as
|
|
524
|
+
without ``--paginate``). Connect's paged endpoints wrap rows in a ``results``
|
|
525
|
+
array; across multiple pages those arrays are concatenated. If any page
|
|
526
|
+
lacks a ``results`` list, the raw pages are returned rather than dropping
|
|
527
|
+
data silently.
|
|
528
|
+
"""
|
|
529
|
+
if len(pages) == 1:
|
|
530
|
+
return pages[0]
|
|
531
|
+
merged: List[Any] = []
|
|
532
|
+
for page in pages:
|
|
533
|
+
rows = page.get("results") if isinstance(page, dict) else None
|
|
534
|
+
if not isinstance(rows, list):
|
|
535
|
+
return pages
|
|
536
|
+
merged.extend(rows)
|
|
537
|
+
return merged
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _header_lines(result: Any, status: int) -> "list[str]":
|
|
541
|
+
"""Yield the HTTP status line and response header lines, gh/curl-style."""
|
|
542
|
+
raw = getattr(result, "_response", None)
|
|
543
|
+
version = getattr(raw, "version", 11) if raw is not None else 11
|
|
544
|
+
reason = getattr(result, "reason", "") or ""
|
|
545
|
+
lines = [f"HTTP/{version // 10}.{version % 10} {status} {reason}".rstrip()]
|
|
546
|
+
if raw is not None:
|
|
547
|
+
lines.extend(f"{name}: {value}" for name, value in raw.getheaders())
|
|
548
|
+
return lines
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _render_success(value: Any, jq_program: Any = None) -> Optional[str]:
|
|
552
|
+
"""Render a successful response body, optionally filtered through jq.
|
|
553
|
+
|
|
554
|
+
Returns the text to print, or None for an empty body (e.g. 204). Mirrors
|
|
555
|
+
``gh api``: with a jq filter, each result is on its own line -- strings raw
|
|
556
|
+
(unquoted), everything else compact JSON; without a filter, pretty-printed.
|
|
557
|
+
|
|
558
|
+
This *renders* rather than prints so the caller can emit headers and body
|
|
559
|
+
together only after rendering succeeds: a jq runtime failure must abort
|
|
560
|
+
before any output, not leak partial output (e.g. headers) to stdout.
|
|
561
|
+
"""
|
|
562
|
+
if value in (None, ""):
|
|
563
|
+
return None
|
|
564
|
+
if jq_program is None:
|
|
565
|
+
return _dumps(value)
|
|
566
|
+
# A filter can compile cleanly yet fail at runtime (e.g. error(...) or a
|
|
567
|
+
# type mismatch against the actual payload). .all() materializes every
|
|
568
|
+
# result first, so a failure raises here before anything is printed --
|
|
569
|
+
# surface it as a clean CLI error, not a traceback.
|
|
570
|
+
try:
|
|
571
|
+
results = jq_program.input_value(value).all()
|
|
572
|
+
except ValueError as exc:
|
|
573
|
+
raise click.ClickException(f"jq: {exc}") from exc
|
|
574
|
+
if not results:
|
|
575
|
+
# Zero matches -> no body (a blank line would falsely signal output).
|
|
576
|
+
# This is distinct from a single empty-string result, which prints "".
|
|
577
|
+
return None
|
|
578
|
+
return "\n".join(item if isinstance(item, str) else json.dumps(item) for item in results)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _dumps(value: Any) -> str:
|
|
582
|
+
if isinstance(value, (dict, list)):
|
|
583
|
+
return json.dumps(value, indent=2)
|
|
584
|
+
return str(value)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: posit-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A single, friendly command-line interface for Posit Connect, in the spirit of gh.
|
|
5
|
+
Author: Posit Software, PBC
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.8
|
|
9
|
+
Requires-Dist: click>=8.0
|
|
10
|
+
Requires-Dist: jq>=1.4
|
|
11
|
+
Requires-Dist: keyring>=23.0
|
|
12
|
+
Requires-Dist: rsconnect-python<2,>=1.30
|
|
13
|
+
Provides-Extra: lint
|
|
14
|
+
Requires-Dist: ruff>=0.6; extra == 'lint'
|
|
15
|
+
Provides-Extra: test
|
|
16
|
+
Requires-Dist: pytest>=7; extra == 'test'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# posit-cli
|
|
20
|
+
|
|
21
|
+
A friendly command-line interface for Posit products, in the spirit of [`gh`](https://cli.github.com/).
|
|
22
|
+
|
|
23
|
+
```console
|
|
24
|
+
$ posit connect login https://connect.example.com # OAuth, tokens in your OS keyring
|
|
25
|
+
$ posit connect api v1/user -q .username # gh-api-style raw request
|
|
26
|
+
$ posit connect deploy streamlit ./my-app # everything rsconnect can do
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
This project is in early-stage development and so far only supports Posit Connect's APIs.
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
`posit-cli` isn't on PyPI yet. Install the latest version straight from GitHub
|
|
34
|
+
with [`uv`](https://docs.astral.sh/uv/):
|
|
35
|
+
|
|
36
|
+
```console
|
|
37
|
+
uv tool install git+https://github.com/posit-dev/posit-cli.git
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
If you authenticate to GitHub over SSH, use the `git+ssh://` form instead (uv
|
|
41
|
+
requires the `git@` username):
|
|
42
|
+
|
|
43
|
+
```console
|
|
44
|
+
uv tool install git+ssh://git@github.com/posit-dev/posit-cli.git
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Either way this puts the `posit` executable on your `PATH`. To upgrade later, run
|
|
48
|
+
`uv tool upgrade posit-cli`. See uv's
|
|
49
|
+
[Git authentication docs](https://docs.astral.sh/uv/concepts/authentication/git/)
|
|
50
|
+
for tokens and other hosts.
|
|
51
|
+
|
|
52
|
+
## Getting started
|
|
53
|
+
|
|
54
|
+
**1. Log in.** Point `posit` at your Connect server and authenticate. This runs
|
|
55
|
+
an OAuth 2.1 flow in your browser and saves the tokens in your OS keyring, so
|
|
56
|
+
you only do it once per server:
|
|
57
|
+
|
|
58
|
+
```console
|
|
59
|
+
$ posit connect login https://connect.example.com
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
**2. Try a request.** Confirm you're connected by asking Connect who you are:
|
|
63
|
+
|
|
64
|
+
```console
|
|
65
|
+
$ posit connect api v1/user -q .username
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
**3. Deploy something.** Anything `rsconnect` can deploy, `posit` can too:
|
|
69
|
+
|
|
70
|
+
```console
|
|
71
|
+
$ posit connect deploy streamlit ./my-app
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
That's it — from here, explore `posit connect --help` for the full command set.
|
|
75
|
+
|
|
76
|
+
## Authentication
|
|
77
|
+
|
|
78
|
+
`posit connect login` runs an OAuth 2.1 flow and stores tokens in your OS
|
|
79
|
+
keyring; `posit connect api` and the deploy commands reuse those credentials
|
|
80
|
+
automatically (including token refresh). You can also point at a server ad hoc
|
|
81
|
+
with `--server`/`CONNECT_SERVER` and `--api-key`/`CONNECT_API_KEY`.
|
|
82
|
+
|
|
83
|
+
## `posit connect api`
|
|
84
|
+
|
|
85
|
+
A `gh api`-style raw REST client, authenticated with your saved credentials.
|
|
86
|
+
`PATH` is relative to the Connect API root (the `/__api__` prefix is added for
|
|
87
|
+
you):
|
|
88
|
+
|
|
89
|
+
```console
|
|
90
|
+
$ posit connect api v1/user # GET, pretty-printed JSON
|
|
91
|
+
$ posit connect api v1/user -q .username # filter with a jq expression
|
|
92
|
+
$ posit connect api "v1/content?limit=3" -q '.[].name'
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`-q`/`--jq` runs the response through [jq](https://jqlang.github.io/jq/), like
|
|
96
|
+
`gh api --jq`: string results print unquoted, one per line.
|
|
97
|
+
|
|
98
|
+
`-i`/`--include` prepends the HTTP status line and response headers to the
|
|
99
|
+
output (handy for inspecting rate limits, request IDs, or the server version):
|
|
100
|
+
|
|
101
|
+
```console
|
|
102
|
+
$ posit connect api v1/user -i
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`--paginate` (GET only) follows Connect's pagination and prints all pages'
|
|
106
|
+
results combined. It handles both of Connect's pagination styles — the
|
|
107
|
+
cursor style (e.g. `v1/audit_logs`) and the page-number style (e.g.
|
|
108
|
+
`v1/users`, `v1/groups`) — and leaves unpaginated endpoints unchanged:
|
|
109
|
+
|
|
110
|
+
```console
|
|
111
|
+
$ posit connect api v1/users --paginate -q 'length' # count every user
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`PATH` may also be a full URL on the configured server — convenient for pasting
|
|
115
|
+
a `paging.next` link straight back in. A URL for a different host is rejected.
|
|
116
|
+
|
|
117
|
+
### Query parameters vs. request body
|
|
118
|
+
|
|
119
|
+
Like `gh api`, `-f`/`-F` fields default to a **POST request body** (adding any
|
|
120
|
+
field flips the method to POST unless you pass `-X`). To send **query
|
|
121
|
+
parameters** on a read, put them in the path or force `GET`:
|
|
122
|
+
|
|
123
|
+
```console
|
|
124
|
+
$ posit connect api "v1/users?page_size=5" # query params in the path
|
|
125
|
+
$ posit connect api v1/users -X GET -f page_size=5 # or force GET
|
|
126
|
+
$ posit connect api v1/content -f name=my-app # POST body (creates content)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## `posit connect *`
|
|
130
|
+
|
|
131
|
+
`posit` wraps [`rsconnect-python`](https://github.com/posit-dev/rsconnect-python):
|
|
132
|
+
it re-exposes the full `rsconnect` command set under `posit connect`, in addition to the `api` utility.
|
|
133
|
+
|
|
134
|
+
## Using `posit` with Claude Code
|
|
135
|
+
|
|
136
|
+
This repo ships a skill (at [`skills/posit-cli/`](skills/posit-cli/)) that teaches
|
|
137
|
+
coding agents to drive `posit` — auth, `posit connect api`, and deploys. Install
|
|
138
|
+
it with the [`skills`](https://www.skills.sh/) CLI:
|
|
139
|
+
|
|
140
|
+
```console
|
|
141
|
+
npx skills add posit-dev/posit-cli
|
|
142
|
+
```
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
posit_cli/__init__.py,sha256=k7UhlrmiZtFUzxY8Z0owopjiPWi6i6sjgqVVzmbAOew,292
|
|
2
|
+
posit_cli/__main__.py,sha256=mMl5ElnnT8Z9-sJhT_ExfrLUWRuNzW_k9Zx9xuSEPrk,336
|
|
3
|
+
posit_cli/connect/__init__.py,sha256=99ITeAaAgC2faLHUxCZvj2QbXi7YKd8w1rkRO9Gertw,975
|
|
4
|
+
posit_cli/connect/api.py,sha256=5lgXcXT7SyXrGg3qboIveEZrg_IFl0DomJNghEfW9dE,21823
|
|
5
|
+
posit_cli-0.1.0.dist-info/METADATA,sha256=JTvrzs_pur7faoVIqx6Pm3ZmLAabIcs0ftzmxPSsdx0,4937
|
|
6
|
+
posit_cli-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
7
|
+
posit_cli-0.1.0.dist-info/entry_points.txt,sha256=Z6JDyEoPDuTU3cbea8YSdjoShcNIlI0NRqkkBadyn_0,49
|
|
8
|
+
posit_cli-0.1.0.dist-info/licenses/LICENSE,sha256=_ncZcgqErlIxg3Y5Sulf751gDrrncCUl2vAihj6ibzQ,1076
|
|
9
|
+
posit_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Posit Software, PBC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|