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.
Files changed (63) hide show
  1. rsconnect/__init__.py +13 -0
  2. rsconnect/actions.py +565 -0
  3. rsconnect/actions_content.py +508 -0
  4. rsconnect/actions_environment.py +160 -0
  5. rsconnect/actions_integration.py +118 -0
  6. rsconnect/api.py +2582 -0
  7. rsconnect/bundle.py +2481 -0
  8. rsconnect/certificates.py +39 -0
  9. rsconnect/environment.py +390 -0
  10. rsconnect/environment_node.py +115 -0
  11. rsconnect/environment_r.py +300 -0
  12. rsconnect/exception.py +15 -0
  13. rsconnect/git_metadata.py +180 -0
  14. rsconnect/http_support.py +595 -0
  15. rsconnect/json_web_token.py +178 -0
  16. rsconnect/log.py +253 -0
  17. rsconnect/main.py +5889 -0
  18. rsconnect/metadata.py +879 -0
  19. rsconnect/models.py +835 -0
  20. rsconnect/oauth.py +623 -0
  21. rsconnect/py.typed +0 -0
  22. rsconnect/pyproject.py +283 -0
  23. rsconnect/quickstart/__init__.py +16 -0
  24. rsconnect/quickstart/quickstart.py +486 -0
  25. rsconnect/quickstart/templates/__init__.py +16 -0
  26. rsconnect/quickstart/templates/api/README.md.tmpl +15 -0
  27. rsconnect/quickstart/templates/api/__connect__.py.tmpl +3 -0
  28. rsconnect/quickstart/templates/api/__init__.py.tmpl +1 -0
  29. rsconnect/quickstart/templates/api/__main__.py.tmpl +14 -0
  30. rsconnect/quickstart/templates/api/app.py.tmpl +11 -0
  31. rsconnect/quickstart/templates/api/pyproject.toml.tmpl +13 -0
  32. rsconnect/quickstart/templates/fastapi/README.md.tmpl +15 -0
  33. rsconnect/quickstart/templates/fastapi/__connect__.py.tmpl +3 -0
  34. rsconnect/quickstart/templates/fastapi/__init__.py.tmpl +1 -0
  35. rsconnect/quickstart/templates/fastapi/__main__.py.tmpl +16 -0
  36. rsconnect/quickstart/templates/fastapi/app.py.tmpl +11 -0
  37. rsconnect/quickstart/templates/fastapi/pyproject.toml.tmpl +14 -0
  38. rsconnect/quickstart/templates/notebook/README.md.tmpl +15 -0
  39. rsconnect/quickstart/templates/notebook/notebook.ipynb.tmpl +34 -0
  40. rsconnect/quickstart/templates/notebook/pyproject.toml.tmpl +13 -0
  41. rsconnect/quickstart/templates/quarto/README.md.tmpl +19 -0
  42. rsconnect/quickstart/templates/quarto/pyproject.toml.tmpl +11 -0
  43. rsconnect/quickstart/templates/quarto/report.qmd.tmpl +8 -0
  44. rsconnect/quickstart/templates/shiny/README.md.tmpl +15 -0
  45. rsconnect/quickstart/templates/shiny/app.py.tmpl +3 -0
  46. rsconnect/quickstart/templates/shiny/pyproject.toml.tmpl +13 -0
  47. rsconnect/quickstart/templates/streamlit/README.md.tmpl +15 -0
  48. rsconnect/quickstart/templates/streamlit/app.py.tmpl +3 -0
  49. rsconnect/quickstart/templates/streamlit/pyproject.toml.tmpl +13 -0
  50. rsconnect/quickstart/templates/voila/README.md.tmpl +15 -0
  51. rsconnect/quickstart/templates/voila/pyproject.toml.tmpl +14 -0
  52. rsconnect/shiny_express.py +136 -0
  53. rsconnect/snowflake.py +93 -0
  54. rsconnect/subprocesses/__init__.py +0 -0
  55. rsconnect/subprocesses/inspect_environment.py +362 -0
  56. rsconnect/timeouts.py +89 -0
  57. rsconnect/utils_package.py +261 -0
  58. rsconnect/validation.py +156 -0
  59. rsconnect/version_check.py +154 -0
  60. rsconnect_python-1.30.0.dist-info/METADATA +89 -0
  61. rsconnect_python-1.30.0.dist-info/RECORD +63 -0
  62. rsconnect_python-1.30.0.dist-info/WHEEL +4 -0
  63. 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