rsconnect-python 1.30.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.
- rsconnect/__init__.py +13 -0
- rsconnect/actions.py +565 -0
- rsconnect/actions_content.py +508 -0
- rsconnect/actions_environment.py +160 -0
- rsconnect/actions_integration.py +118 -0
- rsconnect/api.py +2582 -0
- rsconnect/bundle.py +2481 -0
- rsconnect/certificates.py +39 -0
- rsconnect/environment.py +390 -0
- rsconnect/environment_node.py +115 -0
- rsconnect/environment_r.py +300 -0
- rsconnect/exception.py +15 -0
- rsconnect/git_metadata.py +180 -0
- rsconnect/http_support.py +595 -0
- rsconnect/json_web_token.py +178 -0
- rsconnect/log.py +253 -0
- rsconnect/main.py +5889 -0
- rsconnect/metadata.py +879 -0
- rsconnect/models.py +835 -0
- rsconnect/oauth.py +623 -0
- rsconnect/py.typed +0 -0
- rsconnect/pyproject.py +283 -0
- rsconnect/quickstart/__init__.py +16 -0
- rsconnect/quickstart/quickstart.py +486 -0
- rsconnect/quickstart/templates/__init__.py +16 -0
- rsconnect/quickstart/templates/api/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/api/__connect__.py.tmpl +3 -0
- rsconnect/quickstart/templates/api/__init__.py.tmpl +1 -0
- rsconnect/quickstart/templates/api/__main__.py.tmpl +14 -0
- rsconnect/quickstart/templates/api/app.py.tmpl +11 -0
- rsconnect/quickstart/templates/api/pyproject.toml.tmpl +13 -0
- rsconnect/quickstart/templates/fastapi/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/fastapi/__connect__.py.tmpl +3 -0
- rsconnect/quickstart/templates/fastapi/__init__.py.tmpl +1 -0
- rsconnect/quickstart/templates/fastapi/__main__.py.tmpl +16 -0
- rsconnect/quickstart/templates/fastapi/app.py.tmpl +11 -0
- rsconnect/quickstart/templates/fastapi/pyproject.toml.tmpl +14 -0
- rsconnect/quickstart/templates/notebook/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/notebook/notebook.ipynb.tmpl +34 -0
- rsconnect/quickstart/templates/notebook/pyproject.toml.tmpl +13 -0
- rsconnect/quickstart/templates/quarto/README.md.tmpl +19 -0
- rsconnect/quickstart/templates/quarto/pyproject.toml.tmpl +11 -0
- rsconnect/quickstart/templates/quarto/report.qmd.tmpl +8 -0
- rsconnect/quickstart/templates/shiny/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/shiny/app.py.tmpl +3 -0
- rsconnect/quickstart/templates/shiny/pyproject.toml.tmpl +13 -0
- rsconnect/quickstart/templates/streamlit/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/streamlit/app.py.tmpl +3 -0
- rsconnect/quickstart/templates/streamlit/pyproject.toml.tmpl +13 -0
- rsconnect/quickstart/templates/voila/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/voila/pyproject.toml.tmpl +14 -0
- rsconnect/shiny_express.py +136 -0
- rsconnect/snowflake.py +93 -0
- rsconnect/subprocesses/__init__.py +0 -0
- rsconnect/subprocesses/inspect_environment.py +362 -0
- rsconnect/timeouts.py +89 -0
- rsconnect/utils_package.py +261 -0
- rsconnect/validation.py +156 -0
- rsconnect/version_check.py +154 -0
- rsconnect_python-1.30.0.dist-info/METADATA +89 -0
- rsconnect_python-1.30.0.dist-info/RECORD +63 -0
- rsconnect_python-1.30.0.dist-info/WHEEL +4 -0
- rsconnect_python-1.30.0.dist-info/entry_points.txt +3 -0
rsconnect/metadata.py
ADDED
|
@@ -0,0 +1,879 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Metadata management objects and utility functions
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import base64
|
|
8
|
+
import glob
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import sys
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from io import BufferedWriter
|
|
16
|
+
from os.path import abspath, basename, dirname, exists, join
|
|
17
|
+
from threading import Lock
|
|
18
|
+
from typing import (
|
|
19
|
+
TYPE_CHECKING,
|
|
20
|
+
Callable,
|
|
21
|
+
Dict,
|
|
22
|
+
Generic,
|
|
23
|
+
Mapping,
|
|
24
|
+
Optional,
|
|
25
|
+
TypeVar,
|
|
26
|
+
Union,
|
|
27
|
+
)
|
|
28
|
+
from urllib.parse import urlparse
|
|
29
|
+
|
|
30
|
+
# Even though TypedDict is available in Python 3.8, because it's used with NotRequired,
|
|
31
|
+
# they should both come from the same typing module.
|
|
32
|
+
# https://peps.python.org/pep-0655/#usage-in-python-3-11
|
|
33
|
+
if sys.version_info >= (3, 11):
|
|
34
|
+
from typing import NotRequired, TypedDict
|
|
35
|
+
else:
|
|
36
|
+
from typing_extensions import NotRequired, TypedDict
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
from .api import RSConnectServer, SPCSConnectServer
|
|
41
|
+
|
|
42
|
+
from .exception import RSConnectException
|
|
43
|
+
from .log import logger
|
|
44
|
+
from .models import AppMode, AppModes, ContentItemV1, TaskStatusResult, TaskStatusV1
|
|
45
|
+
|
|
46
|
+
T = TypeVar("T", bound=Mapping[str, object])
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def config_dirname(platform: str = sys.platform, env: Mapping[str, str] = os.environ):
|
|
50
|
+
"""Get the user's configuration directory path for this platform."""
|
|
51
|
+
home = env.get("HOME", "~")
|
|
52
|
+
base_dir = home
|
|
53
|
+
|
|
54
|
+
if platform.startswith("linux"):
|
|
55
|
+
base_dir = env.get("XDG_CONFIG_HOME", home)
|
|
56
|
+
elif platform == "darwin":
|
|
57
|
+
base_dir = join(home, "Library", "Application Support")
|
|
58
|
+
elif platform == "win32":
|
|
59
|
+
# noinspection SpellCheckingInspection
|
|
60
|
+
base_dir = env.get("APPDATA", home)
|
|
61
|
+
|
|
62
|
+
if base_dir == home:
|
|
63
|
+
return join(base_dir, ".rsconnect-python")
|
|
64
|
+
else:
|
|
65
|
+
return join(base_dir, "rsconnect-python")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# noinspection SpellCheckingInspection
|
|
69
|
+
def makedirs(filepath: str):
|
|
70
|
+
"""Create the parent directories of filepath.
|
|
71
|
+
|
|
72
|
+
`filepath` itself is not created.
|
|
73
|
+
It is not an error if the directories already exist.
|
|
74
|
+
"""
|
|
75
|
+
try:
|
|
76
|
+
os.makedirs(dirname(filepath))
|
|
77
|
+
except OSError:
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _normalize_server_url(server_url: str):
|
|
82
|
+
url = urlparse(server_url)
|
|
83
|
+
return url.netloc.replace(".", "_").replace(":", "_")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class DataStore(Generic[T]):
|
|
87
|
+
"""
|
|
88
|
+
Defines a base class for a persistent store. The store supports a primary location and
|
|
89
|
+
an optional secondary one.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(self, primary_path: str, secondary_path: Optional[str] = None, chmod: bool = False):
|
|
93
|
+
self._primary_path = primary_path
|
|
94
|
+
self._secondary_path = secondary_path
|
|
95
|
+
self._chmod = chmod
|
|
96
|
+
self._data: dict[str, T] = {}
|
|
97
|
+
self._real_path: str | None = None
|
|
98
|
+
self._lock = Lock()
|
|
99
|
+
|
|
100
|
+
self.load()
|
|
101
|
+
|
|
102
|
+
def count(self):
|
|
103
|
+
"""
|
|
104
|
+
Return the number of items in the data store.
|
|
105
|
+
|
|
106
|
+
:return: the number of items currently in the data store.
|
|
107
|
+
"""
|
|
108
|
+
return len(self._data)
|
|
109
|
+
|
|
110
|
+
def _load_from(self, path: str):
|
|
111
|
+
"""
|
|
112
|
+
Load the data for this store from the specified path, if it exists.
|
|
113
|
+
|
|
114
|
+
Returns True if the data was successfully loaded.
|
|
115
|
+
"""
|
|
116
|
+
if exists(path):
|
|
117
|
+
with open(path, "rb") as f:
|
|
118
|
+
self._data = json.loads(f.read().decode("utf-8"))
|
|
119
|
+
self._real_path = path
|
|
120
|
+
return True
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
def load(self):
|
|
124
|
+
"""
|
|
125
|
+
Load the data from a file. If the primary file doesn't exist, load it
|
|
126
|
+
from the secondary one (if there is one.
|
|
127
|
+
"""
|
|
128
|
+
if not self._load_from(self._primary_path) and self._secondary_path:
|
|
129
|
+
self._load_from(self._secondary_path)
|
|
130
|
+
|
|
131
|
+
def _get_by_key(self, key: str, default: T | None = None) -> T | None:
|
|
132
|
+
"""
|
|
133
|
+
Return a stored value by its key.
|
|
134
|
+
|
|
135
|
+
:param key: the key for the value to return.
|
|
136
|
+
:return: the associated value or None if there isn't one.
|
|
137
|
+
"""
|
|
138
|
+
return self._data.get(key, default)
|
|
139
|
+
|
|
140
|
+
def _get_by_value_attr(self, attr: str, value: T) -> T | None:
|
|
141
|
+
"""
|
|
142
|
+
Return a stored value by an attribute of its value.
|
|
143
|
+
|
|
144
|
+
:param attr: the value attribute to search for.
|
|
145
|
+
:param value: the value of the attribute to search for.
|
|
146
|
+
:return: the value that carries the named attribute's value or None if
|
|
147
|
+
there isn't one.
|
|
148
|
+
"""
|
|
149
|
+
for item in self._data.values():
|
|
150
|
+
if item[attr] == value:
|
|
151
|
+
return item
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
def _get_first_value(self) -> T:
|
|
155
|
+
"""
|
|
156
|
+
A convenience function that returns the (arbitrary) first value in the
|
|
157
|
+
store. This is most useful when the store contains one, and only one,
|
|
158
|
+
value
|
|
159
|
+
|
|
160
|
+
:return: the first value in the store.
|
|
161
|
+
"""
|
|
162
|
+
return list(self._data.values())[0]
|
|
163
|
+
|
|
164
|
+
def _get_sorted_values(self, sort_by: Callable[[T], str]):
|
|
165
|
+
"""
|
|
166
|
+
Return all the values in the store sorted by the given lambda expression.
|
|
167
|
+
|
|
168
|
+
:param sort_by: a lambda expression to use to sort the values.
|
|
169
|
+
:return: the sorted values.
|
|
170
|
+
"""
|
|
171
|
+
return sorted(self._data.values(), key=sort_by)
|
|
172
|
+
|
|
173
|
+
def _set(self, key: str, value: T):
|
|
174
|
+
"""
|
|
175
|
+
Store a new (or updated) value in the store. This will automatically rewrite
|
|
176
|
+
the backing file.
|
|
177
|
+
|
|
178
|
+
:param key: the key to store the data under.
|
|
179
|
+
:param value: the data to store.
|
|
180
|
+
"""
|
|
181
|
+
self._data[key] = value
|
|
182
|
+
self.save()
|
|
183
|
+
|
|
184
|
+
def _remove_by_key(self, key: str):
|
|
185
|
+
"""
|
|
186
|
+
Remove the given key from our data store.
|
|
187
|
+
|
|
188
|
+
:param key: the key of the value to remove.
|
|
189
|
+
:return: True if the associated value was removed.
|
|
190
|
+
"""
|
|
191
|
+
if self._get_by_key(key):
|
|
192
|
+
del self._data[key]
|
|
193
|
+
self.save()
|
|
194
|
+
return True
|
|
195
|
+
return False
|
|
196
|
+
|
|
197
|
+
def _remove_by_value_attr(self, key_attr: str, attr: str, value: T) -> bool:
|
|
198
|
+
"""
|
|
199
|
+
Remove a stored value by an attribute of its value.
|
|
200
|
+
|
|
201
|
+
:param key_attr: the name of the attribute which is on the value and kee
|
|
202
|
+
to the store.
|
|
203
|
+
:param attr: the value attribute to search for.
|
|
204
|
+
:param value: the value of the attribute to search for.
|
|
205
|
+
:return: True if the associated value was removed.
|
|
206
|
+
"""
|
|
207
|
+
val = self._get_by_value_attr(attr, value)
|
|
208
|
+
if val:
|
|
209
|
+
del self._data[val[key_attr]]
|
|
210
|
+
self.save()
|
|
211
|
+
return True
|
|
212
|
+
return False
|
|
213
|
+
|
|
214
|
+
def get_path(self):
|
|
215
|
+
return self._real_path or self._primary_path
|
|
216
|
+
|
|
217
|
+
# noinspection PyShadowingBuiltins
|
|
218
|
+
def save_to(self, path: str, data: bytes, open: Callable[..., BufferedWriter] = open):
|
|
219
|
+
"""
|
|
220
|
+
Save our data to the specified file.
|
|
221
|
+
"""
|
|
222
|
+
with open(path, "wb") as f:
|
|
223
|
+
f.write(data)
|
|
224
|
+
self._real_path = path
|
|
225
|
+
|
|
226
|
+
# noinspection PyShadowingBuiltins
|
|
227
|
+
def save(self, open: Callable[..., BufferedWriter] = open):
|
|
228
|
+
"""
|
|
229
|
+
Save our data to a file.
|
|
230
|
+
|
|
231
|
+
The app directory is tried first. If that fails,
|
|
232
|
+
then we write to the global config location.
|
|
233
|
+
"""
|
|
234
|
+
data = json.dumps(self._data, indent=4).encode("utf-8")
|
|
235
|
+
try:
|
|
236
|
+
makedirs(self._primary_path)
|
|
237
|
+
self.save_to(self._primary_path, data, open)
|
|
238
|
+
except OSError:
|
|
239
|
+
if not self._secondary_path:
|
|
240
|
+
raise
|
|
241
|
+
makedirs(self._secondary_path)
|
|
242
|
+
self.save_to(self._secondary_path, data, open)
|
|
243
|
+
|
|
244
|
+
if self._chmod and self._real_path is not None:
|
|
245
|
+
os.chmod(self._real_path, 0o600)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class ServerDataDict(TypedDict):
|
|
249
|
+
"""
|
|
250
|
+
Server data representation used internally in the ServerStore class.
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
name: str
|
|
254
|
+
url: str
|
|
255
|
+
api_key: NotRequired[str]
|
|
256
|
+
snowflake_connection_name: NotRequired[str]
|
|
257
|
+
insecure: NotRequired[bool]
|
|
258
|
+
ca_cert: NotRequired[str]
|
|
259
|
+
account_name: NotRequired[str]
|
|
260
|
+
token: NotRequired[str]
|
|
261
|
+
secret: NotRequired[str]
|
|
262
|
+
oauth_client_id: NotRequired[str]
|
|
263
|
+
oauth_access_token: NotRequired[str]
|
|
264
|
+
oauth_refresh_token: NotRequired[str]
|
|
265
|
+
oauth_token_expiry: NotRequired[float]
|
|
266
|
+
default: NotRequired[bool]
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class ServerData:
|
|
270
|
+
"""
|
|
271
|
+
Server data representation which the ServerStore class provides to external
|
|
272
|
+
consumers.
|
|
273
|
+
"""
|
|
274
|
+
|
|
275
|
+
def __init__(
|
|
276
|
+
self,
|
|
277
|
+
name: str,
|
|
278
|
+
url: str,
|
|
279
|
+
from_store: bool,
|
|
280
|
+
api_key: Optional[str] = None,
|
|
281
|
+
snowflake_connection_name: Optional[str] = None,
|
|
282
|
+
insecure: Optional[bool] = None,
|
|
283
|
+
ca_data: Optional[str] = None,
|
|
284
|
+
account_name: Optional[str] = None,
|
|
285
|
+
token: Optional[str] = None,
|
|
286
|
+
secret: Optional[str] = None,
|
|
287
|
+
oauth_client_id: Optional[str] = None,
|
|
288
|
+
oauth_access_token: Optional[str] = None,
|
|
289
|
+
oauth_refresh_token: Optional[str] = None,
|
|
290
|
+
oauth_token_expiry: Optional[float] = None,
|
|
291
|
+
):
|
|
292
|
+
self.name = name
|
|
293
|
+
self.url = url
|
|
294
|
+
self.from_store = from_store
|
|
295
|
+
self.api_key = api_key
|
|
296
|
+
self.snowflake_connection_name = snowflake_connection_name
|
|
297
|
+
self.insecure = insecure
|
|
298
|
+
self.ca_data = ca_data
|
|
299
|
+
self.account_name = account_name
|
|
300
|
+
self.token = token
|
|
301
|
+
self.secret = secret
|
|
302
|
+
self.oauth_client_id = oauth_client_id
|
|
303
|
+
self.oauth_access_token = oauth_access_token
|
|
304
|
+
self.oauth_refresh_token = oauth_refresh_token
|
|
305
|
+
self.oauth_token_expiry = oauth_token_expiry
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class ServerStore(DataStore[ServerDataDict]):
|
|
309
|
+
"""Defines a metadata store for server information.
|
|
310
|
+
|
|
311
|
+
Servers consist of a user-supplied name, URL, and API key.
|
|
312
|
+
Data is stored in the customary platform-specific location
|
|
313
|
+
(typically a subdirectory of the user's home directory).
|
|
314
|
+
"""
|
|
315
|
+
|
|
316
|
+
def __init__(self, base_dir: str = config_dirname()):
|
|
317
|
+
super(ServerStore, self).__init__(join(base_dir, "servers.json"), chmod=True)
|
|
318
|
+
|
|
319
|
+
def get_by_name(self, name: str):
|
|
320
|
+
"""
|
|
321
|
+
Get the server information for the given nickname..
|
|
322
|
+
|
|
323
|
+
:param name: the nickname of the server to get information for.
|
|
324
|
+
"""
|
|
325
|
+
return self._get_by_key(name)
|
|
326
|
+
|
|
327
|
+
def get_by_url(self, url: str):
|
|
328
|
+
"""
|
|
329
|
+
Get the server information for the given URL..
|
|
330
|
+
|
|
331
|
+
:param url: the Connect URL of the server to get information for.
|
|
332
|
+
"""
|
|
333
|
+
return self._get_by_value_attr("url", url)
|
|
334
|
+
|
|
335
|
+
def get_all_servers(self):
|
|
336
|
+
"""
|
|
337
|
+
Returns a list of all known servers sorted by nickname.
|
|
338
|
+
|
|
339
|
+
:return: the sorted list of known servers.
|
|
340
|
+
"""
|
|
341
|
+
return self._get_sorted_values(lambda s: s.get("name") or "")
|
|
342
|
+
|
|
343
|
+
def get_default(self) -> Optional[ServerDataDict]:
|
|
344
|
+
"""Return the entry marked as default, or None."""
|
|
345
|
+
for entry in self._data.values():
|
|
346
|
+
if entry.get("default"):
|
|
347
|
+
return entry
|
|
348
|
+
return None
|
|
349
|
+
|
|
350
|
+
def clear_default(self) -> None:
|
|
351
|
+
"""Remove the default flag from all entries. Does not save."""
|
|
352
|
+
for entry in self._data.values():
|
|
353
|
+
entry.pop("default", None) # type: ignore[misc]
|
|
354
|
+
|
|
355
|
+
def set_default(self, name: str) -> None:
|
|
356
|
+
"""Mark the named server as the default, clearing any prior default."""
|
|
357
|
+
entry = self._get_by_key(name)
|
|
358
|
+
if entry is None:
|
|
359
|
+
raise RSConnectException('The nickname, "%s", does not exist.' % name)
|
|
360
|
+
self.clear_default()
|
|
361
|
+
entry["default"] = True # type: ignore[typeddict-unknown-key]
|
|
362
|
+
self.save()
|
|
363
|
+
|
|
364
|
+
def set(
|
|
365
|
+
self,
|
|
366
|
+
name: str,
|
|
367
|
+
url: str,
|
|
368
|
+
api_key: Optional[str] = None,
|
|
369
|
+
snowflake_connection_name: Optional[str] = None,
|
|
370
|
+
insecure: Optional[bool] = False,
|
|
371
|
+
ca_data: Optional[str] = None,
|
|
372
|
+
account_name: Optional[str] = None,
|
|
373
|
+
token: Optional[str] = None,
|
|
374
|
+
secret: Optional[str] = None,
|
|
375
|
+
oauth_client_id: Optional[str] = None,
|
|
376
|
+
oauth_access_token: Optional[str] = None,
|
|
377
|
+
oauth_refresh_token: Optional[str] = None,
|
|
378
|
+
oauth_token_expiry: Optional[float] = None,
|
|
379
|
+
set_as_default: bool = False,
|
|
380
|
+
):
|
|
381
|
+
"""
|
|
382
|
+
Add (or update) information about a Connect server
|
|
383
|
+
|
|
384
|
+
:param name: the nickname for the Connect server.
|
|
385
|
+
:param url: the full URL for the Connect server.
|
|
386
|
+
:param api_key: the API key to use to authenticate with the Connect server.
|
|
387
|
+
:param snowflake_connection_name: the snowflake connection name
|
|
388
|
+
:param insecure: a flag to disable TLS verification.
|
|
389
|
+
:param ca_data: client side certificate data to use for TLS.
|
|
390
|
+
:param account_name: shinyapps.io account name.
|
|
391
|
+
:param token: shinyapps.io token.
|
|
392
|
+
:param secret: shinyapps.io secret.
|
|
393
|
+
:param oauth_client_id: OAuth client ID.
|
|
394
|
+
:param oauth_access_token: OAuth access token (fallback when keyring unavailable).
|
|
395
|
+
:param oauth_refresh_token: OAuth refresh token (fallback when keyring unavailable).
|
|
396
|
+
:param oauth_token_expiry: OAuth token expiry as unix timestamp.
|
|
397
|
+
:param set_as_default: mark this server as the default.
|
|
398
|
+
"""
|
|
399
|
+
existing = self._get_by_key(name)
|
|
400
|
+
was_default = bool(existing.get("default")) if existing else False
|
|
401
|
+
|
|
402
|
+
if set_as_default:
|
|
403
|
+
self.clear_default()
|
|
404
|
+
|
|
405
|
+
common_data: ServerDataDict = {
|
|
406
|
+
"name": name,
|
|
407
|
+
"url": url,
|
|
408
|
+
}
|
|
409
|
+
if snowflake_connection_name:
|
|
410
|
+
target_data = dict(snowflake_connection_name=snowflake_connection_name, api_key=api_key)
|
|
411
|
+
elif api_key:
|
|
412
|
+
target_data = dict(api_key=api_key, insecure=insecure, ca_cert=ca_data)
|
|
413
|
+
elif oauth_client_id:
|
|
414
|
+
target_data: dict[str, object] = dict(oauth_client_id=oauth_client_id, insecure=insecure, ca_cert=ca_data)
|
|
415
|
+
if oauth_access_token:
|
|
416
|
+
target_data["oauth_access_token"] = oauth_access_token
|
|
417
|
+
if oauth_refresh_token:
|
|
418
|
+
target_data["oauth_refresh_token"] = oauth_refresh_token
|
|
419
|
+
if oauth_token_expiry is not None:
|
|
420
|
+
target_data["oauth_token_expiry"] = oauth_token_expiry
|
|
421
|
+
elif account_name:
|
|
422
|
+
target_data = dict(account_name=account_name, token=token, secret=secret)
|
|
423
|
+
else:
|
|
424
|
+
target_data = dict(token=token, secret=secret)
|
|
425
|
+
|
|
426
|
+
entry = {**common_data, **target_data}
|
|
427
|
+
if set_as_default or was_default:
|
|
428
|
+
entry["default"] = True
|
|
429
|
+
self._set(name, entry) # type: ignore
|
|
430
|
+
|
|
431
|
+
def remove_by_name(self, name: str):
|
|
432
|
+
"""
|
|
433
|
+
Remove the server information for the given nickname.
|
|
434
|
+
|
|
435
|
+
:param name: the nickname of the server to remove.
|
|
436
|
+
"""
|
|
437
|
+
return self._remove_by_key(name)
|
|
438
|
+
|
|
439
|
+
def remove_by_url(self, url: str):
|
|
440
|
+
"""
|
|
441
|
+
Remove the server information for the given URL..
|
|
442
|
+
|
|
443
|
+
:param url: the Connect URL of the server to remove.
|
|
444
|
+
"""
|
|
445
|
+
return self._remove_by_value_attr("name", "url", url)
|
|
446
|
+
|
|
447
|
+
def update_oauth_tokens(
|
|
448
|
+
self,
|
|
449
|
+
name: str,
|
|
450
|
+
access_token: Optional[str],
|
|
451
|
+
refresh_token: Optional[str],
|
|
452
|
+
expiry: Optional[float],
|
|
453
|
+
) -> None:
|
|
454
|
+
"""Update (or clear) stored OAuth token fields for an existing server entry."""
|
|
455
|
+
entry = self._get_by_key(name)
|
|
456
|
+
if entry is None:
|
|
457
|
+
return
|
|
458
|
+
updated: ServerDataDict = {**entry} # type: ignore[misc]
|
|
459
|
+
if access_token:
|
|
460
|
+
updated["oauth_access_token"] = access_token # type: ignore[typeddict-unknown-key]
|
|
461
|
+
updated["oauth_refresh_token"] = refresh_token # type: ignore[typeddict-unknown-key]
|
|
462
|
+
updated["oauth_token_expiry"] = expiry # type: ignore[typeddict-unknown-key]
|
|
463
|
+
else:
|
|
464
|
+
updated.pop("oauth_access_token", None) # type: ignore[misc]
|
|
465
|
+
updated.pop("oauth_refresh_token", None) # type: ignore[misc]
|
|
466
|
+
updated.pop("oauth_token_expiry", None) # type: ignore[misc]
|
|
467
|
+
self._set(name, updated)
|
|
468
|
+
|
|
469
|
+
def resolve(self, name: Optional[str], url: Optional[str]) -> ServerData:
|
|
470
|
+
"""
|
|
471
|
+
This function will resolve the given inputs into a set of server information.
|
|
472
|
+
It assumes that either `name` or `url` is provided.
|
|
473
|
+
|
|
474
|
+
If `name` is provided, the server information is looked up by its nickname
|
|
475
|
+
and an error is produced if the nickname is not known.
|
|
476
|
+
|
|
477
|
+
If `url` is provided, the server information is looked up by its URL. If
|
|
478
|
+
that is found, the stored information is returned. Otherwise the corresponding
|
|
479
|
+
arguments are returned as-is.
|
|
480
|
+
|
|
481
|
+
If neither 'name' nor 'url' is provided and there is only one stored server,
|
|
482
|
+
that information is returned. In this case, the last value in the tuple returned
|
|
483
|
+
notes this situation. It is `False` in all other cases.
|
|
484
|
+
|
|
485
|
+
:param name: the nickname to look for.
|
|
486
|
+
:param url: the Connect server URL to look for.
|
|
487
|
+
:return: the information needed to interact with the resolved server and whether
|
|
488
|
+
it came from the store or the arguments.
|
|
489
|
+
"""
|
|
490
|
+
if name:
|
|
491
|
+
entry = self.get_by_name(name)
|
|
492
|
+
if not entry:
|
|
493
|
+
raise RSConnectException('The nickname, "%s", does not exist.' % name)
|
|
494
|
+
elif url:
|
|
495
|
+
entry = self.get_by_url(url)
|
|
496
|
+
else:
|
|
497
|
+
entry = self.get_default()
|
|
498
|
+
if entry is None and self.count() == 1:
|
|
499
|
+
entry = self._get_first_value()
|
|
500
|
+
|
|
501
|
+
if entry:
|
|
502
|
+
return ServerData(
|
|
503
|
+
name or entry["name"],
|
|
504
|
+
entry["url"],
|
|
505
|
+
True,
|
|
506
|
+
insecure=entry.get("insecure"),
|
|
507
|
+
ca_data=entry.get("ca_cert"),
|
|
508
|
+
api_key=entry.get("api_key"),
|
|
509
|
+
snowflake_connection_name=entry.get("snowflake_connection_name"),
|
|
510
|
+
account_name=entry.get("account_name"),
|
|
511
|
+
token=entry.get("token"),
|
|
512
|
+
secret=entry.get("secret"),
|
|
513
|
+
oauth_client_id=entry.get("oauth_client_id"),
|
|
514
|
+
oauth_access_token=entry.get("oauth_access_token"),
|
|
515
|
+
oauth_refresh_token=entry.get("oauth_refresh_token"),
|
|
516
|
+
oauth_token_expiry=entry.get("oauth_token_expiry"),
|
|
517
|
+
)
|
|
518
|
+
else:
|
|
519
|
+
return ServerData(
|
|
520
|
+
name,
|
|
521
|
+
url,
|
|
522
|
+
False,
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def sha1(s: str):
|
|
527
|
+
m = hashlib.sha1()
|
|
528
|
+
b = s.encode("utf-8")
|
|
529
|
+
m.update(b)
|
|
530
|
+
return base64.urlsafe_b64encode(m.digest()).decode("utf-8").rstrip("=")
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
class AppMetadata(TypedDict):
|
|
534
|
+
server_url: str
|
|
535
|
+
filename: str
|
|
536
|
+
app_url: str
|
|
537
|
+
app_id: str
|
|
538
|
+
app_guid: str
|
|
539
|
+
title: str
|
|
540
|
+
app_mode: str
|
|
541
|
+
app_store_version: int
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
class AppStore(DataStore[AppMetadata]):
|
|
545
|
+
"""
|
|
546
|
+
Defines a metadata store for information about where the app has been
|
|
547
|
+
deployed. Each instance of this store represents one application as
|
|
548
|
+
represented by its entry point file.
|
|
549
|
+
|
|
550
|
+
Metadata for an app consists of one entry for each server where it was
|
|
551
|
+
deployed, containing:
|
|
552
|
+
|
|
553
|
+
* Server URL
|
|
554
|
+
* Entry point file name
|
|
555
|
+
* App URL
|
|
556
|
+
* App ID
|
|
557
|
+
* App GUID
|
|
558
|
+
* Title
|
|
559
|
+
* App mode
|
|
560
|
+
* App store file version
|
|
561
|
+
|
|
562
|
+
The metadata file for an app is written in the same directory as the app's
|
|
563
|
+
entry point file, if that directory is writable. Otherwise, it is stored
|
|
564
|
+
in the user's config directory under `applications/{hash}.json` where the
|
|
565
|
+
hash is derived from the entry point file name. The file contains a version
|
|
566
|
+
field, which is incremented when backwards-incompatible file format changes
|
|
567
|
+
are made.
|
|
568
|
+
"""
|
|
569
|
+
|
|
570
|
+
def __init__(self, app_file: str, version: int = 1):
|
|
571
|
+
base_name = str(basename(app_file).rsplit(".", 1)[0]) + ".json"
|
|
572
|
+
super(AppStore, self).__init__(
|
|
573
|
+
join(dirname(app_file), "rsconnect-python", base_name),
|
|
574
|
+
join(config_dirname(), "applications", sha1(abspath(app_file)) + ".json"),
|
|
575
|
+
)
|
|
576
|
+
self.version = version
|
|
577
|
+
|
|
578
|
+
def get(self, server_url: str):
|
|
579
|
+
"""
|
|
580
|
+
Get the metadata for the last app deployed to the given server.
|
|
581
|
+
|
|
582
|
+
:param server_url: the Connect URL to get the metadata for.
|
|
583
|
+
"""
|
|
584
|
+
return self._get_by_key(server_url)
|
|
585
|
+
|
|
586
|
+
def get_all(self):
|
|
587
|
+
"""
|
|
588
|
+
Get all metadata for this app.
|
|
589
|
+
"""
|
|
590
|
+
return self._get_sorted_values(lambda entry: entry.get("server_url"))
|
|
591
|
+
|
|
592
|
+
def set(
|
|
593
|
+
self,
|
|
594
|
+
server_url: str,
|
|
595
|
+
filename: str,
|
|
596
|
+
app_url: str,
|
|
597
|
+
app_id: str,
|
|
598
|
+
app_guid: str,
|
|
599
|
+
title: str,
|
|
600
|
+
app_mode: AppMode | str,
|
|
601
|
+
):
|
|
602
|
+
"""
|
|
603
|
+
Remember the metadata for the app last deployed to the specified server.
|
|
604
|
+
|
|
605
|
+
:param server_url: the URL of the server the app was deployed to.
|
|
606
|
+
:param filename: the name of the deployed manifest file.
|
|
607
|
+
:param app_url: the URL of the application itself.
|
|
608
|
+
:param app_id: the ID of the application.
|
|
609
|
+
:param app_guid: the UUID of the application.
|
|
610
|
+
:param title: the title of the application.
|
|
611
|
+
:param app_mode: the mode of the application.
|
|
612
|
+
."""
|
|
613
|
+
self._set(
|
|
614
|
+
server_url,
|
|
615
|
+
{
|
|
616
|
+
"server_url": server_url,
|
|
617
|
+
"filename": filename,
|
|
618
|
+
"app_url": app_url,
|
|
619
|
+
"app_id": app_id,
|
|
620
|
+
"app_guid": app_guid,
|
|
621
|
+
"title": title,
|
|
622
|
+
"app_mode": app_mode.name() if isinstance(app_mode, AppMode) else app_mode,
|
|
623
|
+
"app_store_version": self.version,
|
|
624
|
+
},
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
def resolve(self, server: str, app_id: Optional[str], app_mode: Optional[AppMode]):
|
|
628
|
+
metadata = self.get(server)
|
|
629
|
+
if metadata is None:
|
|
630
|
+
logger.debug("No previous deployment to this server was found; this will be a new deployment.")
|
|
631
|
+
return app_id, app_mode, self.version
|
|
632
|
+
|
|
633
|
+
logger.debug("Found previous deployment data in %s" % self.get_path())
|
|
634
|
+
|
|
635
|
+
if app_id is None:
|
|
636
|
+
app_id = metadata.get("app_guid") or metadata.get("app_id")
|
|
637
|
+
logger.debug("Using saved app ID: %s" % app_id)
|
|
638
|
+
|
|
639
|
+
# app mode cannot be changed on redeployment
|
|
640
|
+
app_mode = AppModes.get_by_name(metadata.get("app_mode"))
|
|
641
|
+
|
|
642
|
+
app_store_version = metadata.get("app_store_version")
|
|
643
|
+
return app_id, app_mode, app_store_version
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
DEFAULT_BUILD_DIR = join(os.getcwd(), "rsconnect-build")
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
# A trimmed version of TaskStatusV1 which doesn't contain `output` and `last` fields.
|
|
650
|
+
class TaskStatusV1Trimmed(TypedDict):
|
|
651
|
+
id: str
|
|
652
|
+
finished: bool
|
|
653
|
+
code: int
|
|
654
|
+
error: str
|
|
655
|
+
result: TaskStatusResult | None
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
class ContentItemWithBuildState(ContentItemV1, TypedDict):
|
|
659
|
+
rsconnect_build_status: str
|
|
660
|
+
rsconnect_last_build_time: NotRequired[str]
|
|
661
|
+
rsconnect_last_build_log: NotRequired[str | None]
|
|
662
|
+
rsconnect_build_task_result: NotRequired[TaskStatusV1Trimmed]
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
class ContentBuildStoreData(TypedDict):
|
|
666
|
+
rsconnect_build_running: bool
|
|
667
|
+
rsconnect_content: dict[str, ContentItemWithBuildState]
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
# Python<=3.8 needs `Dict`. After dropping 3.8 support it can be changed to `dict`.
|
|
671
|
+
class ContentBuildStore(DataStore[Dict[str, object]]):
|
|
672
|
+
"""
|
|
673
|
+
Defines a metadata store for information about content builds.
|
|
674
|
+
|
|
675
|
+
The metadata directory for a content build is written in the directory specified by
|
|
676
|
+
CONNECT_CONTENT_BUILD_DIR or the current working directory is none is supplied.
|
|
677
|
+
|
|
678
|
+
A build-state file contains "tracked" content for a single connect server.
|
|
679
|
+
The file is named using the normalized server URL for the target server.
|
|
680
|
+
The structure is as follows:
|
|
681
|
+
{
|
|
682
|
+
"rsconnect_build_running": <bool>,
|
|
683
|
+
"rsconnect_content": {
|
|
684
|
+
"<content guid 1>": {
|
|
685
|
+
"rsconnect_build_status": <models.BuildStatus>,
|
|
686
|
+
..., // various content metadata fields returned by the v1/content api
|
|
687
|
+
},
|
|
688
|
+
"<content guid 2>": {
|
|
689
|
+
...,
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
"""
|
|
694
|
+
|
|
695
|
+
_BUILD_ABORTED: bool = False
|
|
696
|
+
|
|
697
|
+
def __init__(
|
|
698
|
+
self,
|
|
699
|
+
server: Union[RSConnectServer, SPCSConnectServer],
|
|
700
|
+
base_dir: str = os.getenv("CONNECT_CONTENT_BUILD_DIR", DEFAULT_BUILD_DIR),
|
|
701
|
+
):
|
|
702
|
+
# This type declaration is a bit of a hack. It is needed because data model used
|
|
703
|
+
# in this class doesn't quite match the one used in the superclass.
|
|
704
|
+
self._data: ContentBuildStoreData
|
|
705
|
+
self._server = server
|
|
706
|
+
self._base_dir = os.path.abspath(base_dir)
|
|
707
|
+
self._build_logs_dir = join(self._base_dir, "logs", _normalize_server_url(server.url))
|
|
708
|
+
self._build_state_file = join(self._base_dir, "%s.json" % _normalize_server_url(server.url))
|
|
709
|
+
super(ContentBuildStore, self).__init__(self._build_state_file, chmod=True)
|
|
710
|
+
|
|
711
|
+
def aborted(self) -> bool:
|
|
712
|
+
return ContentBuildStore._BUILD_ABORTED
|
|
713
|
+
|
|
714
|
+
def get_build_logs_dir(self, guid: str) -> str:
|
|
715
|
+
return join(self._build_logs_dir, guid)
|
|
716
|
+
|
|
717
|
+
def ensure_logs_dir(self, guid: str) -> None:
|
|
718
|
+
log_dir = self.get_build_logs_dir(guid)
|
|
719
|
+
os.makedirs(log_dir, exist_ok=True)
|
|
720
|
+
if self._chmod:
|
|
721
|
+
os.chmod(log_dir, 0o700)
|
|
722
|
+
|
|
723
|
+
def get_build_log(self, guid: str, task_id: Optional[str] = None) -> str | None:
|
|
724
|
+
"""
|
|
725
|
+
Returns the path to the build log file. This method does not check
|
|
726
|
+
whether the file exists if a task_id is provided.
|
|
727
|
+
If task_id is not provided, we will return the latest log,
|
|
728
|
+
specified by rsconnect_last_build_log.
|
|
729
|
+
If no log file is found, returns None
|
|
730
|
+
"""
|
|
731
|
+
log_dir = self.get_build_logs_dir(guid)
|
|
732
|
+
if task_id:
|
|
733
|
+
return join(log_dir, "%s.log" % task_id)
|
|
734
|
+
else:
|
|
735
|
+
content = self.get_content_item(guid)
|
|
736
|
+
return content.get("rsconnect_last_build_log")
|
|
737
|
+
|
|
738
|
+
def get_build_history(self, guid: str) -> list[dict[str, str]]:
|
|
739
|
+
"""
|
|
740
|
+
Returns the build history for a given content guid.
|
|
741
|
+
"""
|
|
742
|
+
log_dir = self.get_build_logs_dir(guid)
|
|
743
|
+
log_files = glob.glob(join(log_dir, "*.log"))
|
|
744
|
+
history: list[dict[str, str]] = []
|
|
745
|
+
for f in log_files:
|
|
746
|
+
task_id = basename(f).split(".log")[0]
|
|
747
|
+
t = datetime.fromtimestamp(os.path.getctime(f), tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S%z")
|
|
748
|
+
history.append({"time": t, "task_id": task_id})
|
|
749
|
+
history.sort(key=lambda x: x["time"])
|
|
750
|
+
return history
|
|
751
|
+
|
|
752
|
+
def get_build_running(self) -> bool:
|
|
753
|
+
return self._data.get("rsconnect_build_running")
|
|
754
|
+
|
|
755
|
+
def set_build_running(self, is_running: bool, defer_save: bool = False) -> None:
|
|
756
|
+
with self._lock:
|
|
757
|
+
self._data["rsconnect_build_running"] = is_running
|
|
758
|
+
if not defer_save:
|
|
759
|
+
self.save()
|
|
760
|
+
|
|
761
|
+
def add_content_item(self, content: ContentItemV1, defer_save: bool = False) -> None:
|
|
762
|
+
"""
|
|
763
|
+
Add an item to the tracked content store
|
|
764
|
+
"""
|
|
765
|
+
with self._lock:
|
|
766
|
+
if "rsconnect_content" not in self._data:
|
|
767
|
+
self._data["rsconnect_content"] = {}
|
|
768
|
+
|
|
769
|
+
self._data["rsconnect_content"][content["guid"]] = dict(
|
|
770
|
+
guid=content["guid"],
|
|
771
|
+
bundle_id=content["bundle_id"],
|
|
772
|
+
title=content["title"],
|
|
773
|
+
name=content["name"],
|
|
774
|
+
app_mode=content["app_mode"],
|
|
775
|
+
content_url=content["content_url"],
|
|
776
|
+
dashboard_url=content["dashboard_url"],
|
|
777
|
+
created_time=content["created_time"],
|
|
778
|
+
last_deployed_time=content["last_deployed_time"],
|
|
779
|
+
owner_guid=content["owner_guid"],
|
|
780
|
+
)
|
|
781
|
+
if not defer_save:
|
|
782
|
+
self.save()
|
|
783
|
+
|
|
784
|
+
def get_content_item(self, guid: str) -> ContentItemWithBuildState:
|
|
785
|
+
"""
|
|
786
|
+
Get a content item from the tracked content store by guid
|
|
787
|
+
"""
|
|
788
|
+
item = self._data.get("rsconnect_content", {}).get(guid)
|
|
789
|
+
if item is None:
|
|
790
|
+
raise RSConnectException(f"Content item with guid {guid} not found.")
|
|
791
|
+
return item
|
|
792
|
+
|
|
793
|
+
def _cleanup_content_log_dir(self, guid: str) -> None:
|
|
794
|
+
"""
|
|
795
|
+
Delete the local logs directory for a given content item.
|
|
796
|
+
"""
|
|
797
|
+
logs_dir = self.get_build_logs_dir(guid)
|
|
798
|
+
try:
|
|
799
|
+
shutil.rmtree(logs_dir)
|
|
800
|
+
except FileNotFoundError:
|
|
801
|
+
pass
|
|
802
|
+
|
|
803
|
+
def remove_content_item(self, guid: str, purge: bool = False, defer_save: bool = False) -> None:
|
|
804
|
+
"""
|
|
805
|
+
Remove a content item from the tracked content from the state-file.
|
|
806
|
+
If purge is True, cleanup the log files on the local filesystem.
|
|
807
|
+
"""
|
|
808
|
+
if purge:
|
|
809
|
+
self._cleanup_content_log_dir(guid)
|
|
810
|
+
|
|
811
|
+
with self._lock:
|
|
812
|
+
try:
|
|
813
|
+
self._data.get("rsconnect_content", {}).pop(guid)
|
|
814
|
+
except KeyError:
|
|
815
|
+
pass
|
|
816
|
+
if not defer_save:
|
|
817
|
+
self.save()
|
|
818
|
+
|
|
819
|
+
def set_content_item_build_status(self, guid: str, status: str, defer_save: bool = False) -> None:
|
|
820
|
+
"""
|
|
821
|
+
Set the latest status for a content build
|
|
822
|
+
"""
|
|
823
|
+
with self._lock:
|
|
824
|
+
content = self.get_content_item(guid)
|
|
825
|
+
content["rsconnect_build_status"] = str(status)
|
|
826
|
+
if not defer_save:
|
|
827
|
+
self.save()
|
|
828
|
+
|
|
829
|
+
def update_content_item_last_build_time(self, guid: str, defer_save: bool = False) -> None:
|
|
830
|
+
"""
|
|
831
|
+
Set the last_build_time for a content build
|
|
832
|
+
"""
|
|
833
|
+
with self._lock:
|
|
834
|
+
content = self.get_content_item(guid)
|
|
835
|
+
content["rsconnect_last_build_time"] = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
836
|
+
if not defer_save:
|
|
837
|
+
self.save()
|
|
838
|
+
|
|
839
|
+
def update_content_item_last_build_log(self, guid: str, log_file: str | None, defer_save: bool = False) -> None:
|
|
840
|
+
"""
|
|
841
|
+
Set the last_build_log filepath for a content build
|
|
842
|
+
"""
|
|
843
|
+
with self._lock:
|
|
844
|
+
content = self.get_content_item(guid)
|
|
845
|
+
content["rsconnect_last_build_log"] = log_file
|
|
846
|
+
if not defer_save:
|
|
847
|
+
self.save()
|
|
848
|
+
|
|
849
|
+
def set_content_item_last_build_task_result(self, guid: str, task: TaskStatusV1, defer_save: bool = False) -> None:
|
|
850
|
+
"""
|
|
851
|
+
Set the latest task_result for a content build
|
|
852
|
+
"""
|
|
853
|
+
with self._lock:
|
|
854
|
+
content = self.get_content_item(guid)
|
|
855
|
+
# status contains the log lines for the build. We have already recorded these in the
|
|
856
|
+
# log file on disk so we can remove them from the task result before storing it
|
|
857
|
+
# to reduce the data stored in our state-file.
|
|
858
|
+
task_copy: TaskStatusV1Trimmed = {
|
|
859
|
+
"id": task["id"],
|
|
860
|
+
"finished": task["finished"],
|
|
861
|
+
"code": task["code"],
|
|
862
|
+
"error": task["error"],
|
|
863
|
+
"result": task["result"],
|
|
864
|
+
}
|
|
865
|
+
content["rsconnect_build_task_result"] = task_copy
|
|
866
|
+
if not defer_save:
|
|
867
|
+
self.save()
|
|
868
|
+
|
|
869
|
+
def get_content_items(self, status: Optional[str] = None) -> list[ContentItemWithBuildState]:
|
|
870
|
+
"""
|
|
871
|
+
Get all the content items that are tracked for build in the state-file.
|
|
872
|
+
:param status: Filter results by build status
|
|
873
|
+
:return: A list of content items
|
|
874
|
+
"""
|
|
875
|
+
all_content = list(self._data.get("rsconnect_content", {}).values())
|
|
876
|
+
if status:
|
|
877
|
+
return [item for item in all_content if item["rsconnect_build_status"] == status]
|
|
878
|
+
else:
|
|
879
|
+
return all_content
|