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
@@ -0,0 +1,178 @@
1
+ """
2
+ Json Web Token (JWT) utilities
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import base64
8
+ import binascii
9
+ import os
10
+ from datetime import datetime, timedelta, timezone
11
+ from typing import Any, Optional
12
+
13
+ import jwt
14
+
15
+ from .exception import RSConnectException
16
+ from .http_support import HTTPResponse, JsonData
17
+ from .models import BootstrapOutputDTO
18
+
19
+ DEFAULT_ISSUER = "rsconnect-python"
20
+ DEFAULT_AUDIENCE = "rsconnect"
21
+
22
+ BOOTSTRAP_SCOPE = "bootstrap"
23
+ BOOTSTRAP_EXP = timedelta(minutes=15)
24
+
25
+ SECRET_KEY_ENV = "CONNECT_BOOTSTRAP_SECRETKEY"
26
+
27
+
28
+ def read_secret_key(keypath: Optional[str]) -> bytes:
29
+ """
30
+ Reads a secret key as bytes given a path to a file containing a base64-encoded key.
31
+
32
+ The secret key can optionally be set with an environment variable.
33
+ """
34
+
35
+ env_raw_data = os.getenv(SECRET_KEY_ENV)
36
+
37
+ if keypath is not None and env_raw_data is not None:
38
+ raise RSConnectException("Cannot specify secret key using both a keyfile and environment variable.")
39
+
40
+ if keypath is None and env_raw_data is None:
41
+ raise RSConnectException("Must specify secret key using either a keyfile or environment variable.")
42
+
43
+ # check if secret key was specified using an env variable first
44
+ if env_raw_data is not None:
45
+ try:
46
+ return base64.b64decode(env_raw_data.encode("utf-8"))
47
+ except binascii.Error:
48
+ raise RSConnectException("Unable to decode base64 data from environment variable: " + SECRET_KEY_ENV)
49
+
50
+ if keypath is None:
51
+ raise RSConnectException("Keypath must not be None.")
52
+
53
+ if not os.path.exists(keypath):
54
+ raise RSConnectException("Keypath does not exist.")
55
+
56
+ with open(keypath, "r") as f:
57
+ raw_data = f.read()
58
+ if raw_data is None:
59
+ raise RSConnectException("Secret key cannot be 'None'")
60
+ try:
61
+ return base64.b64decode(raw_data)
62
+ except binascii.Error:
63
+ raise RSConnectException("Unable to decode base64 data from keyfile: " + keypath)
64
+
65
+
66
+ # https://www.ibm.com/docs/vi/sva/9.0.6?topic=jwt-support
67
+ def validate_hs256_secret_key(key: bytes):
68
+ if len(key) < 32:
69
+ raise RSConnectException("Secret key expected to be at least 32 bytes in length")
70
+
71
+
72
+ def parse_client_response(response: BootstrapOutputDTO | HTTPResponse) -> tuple[int, BootstrapOutputDTO | JsonData]:
73
+ """
74
+ Helper to handle the response type from RSConnectClient, because
75
+ it can have different types depending on the response
76
+ """
77
+
78
+ if isinstance(response, dict):
79
+ return 200, response
80
+ elif isinstance(response, HTTPResponse):
81
+ # fail fast if a non-http exception occurred
82
+ if hasattr(response, "exception") and response.exception is not None:
83
+ raise RSConnectException(str(response.exception))
84
+
85
+ status = 500
86
+ if hasattr(response, "status"):
87
+ status = response.status
88
+
89
+ json_data: JsonData = {}
90
+ if hasattr(response, "json_data"):
91
+ json_data = response.json_data
92
+
93
+ return status, json_data
94
+
95
+ raise RSConnectException("Unrecognized response type: " + str(type(response)))
96
+
97
+
98
+ def produce_bootstrap_output(status: int, json_data: BootstrapOutputDTO | JsonData) -> dict[str, int | str]:
99
+ """
100
+ Produces the expected programmatic output format from a request to the initial_admin endpoint
101
+ """
102
+
103
+ # Parse the returned API key if one is provided
104
+ api_key = ""
105
+ if isinstance(json_data, dict) and "api_key" in json_data:
106
+ api_key = json_data["api_key"]
107
+ if not isinstance(api_key, str):
108
+ raise RSConnectException("Connect returned a non-string value for api_key.")
109
+
110
+ # Catch unexpected response states and error early
111
+ if status == 200 and api_key == "":
112
+ raise RSConnectException("Connect returned a successful HTTP response but no API key.")
113
+
114
+ if status != 200 and api_key != "":
115
+ raise RSConnectException("Connect returned a non-successful HTTP response and an API key. ")
116
+
117
+ output = {"status": status, "api_key": api_key}
118
+
119
+ # Create a helpful error message
120
+ message = "Unexpected response status."
121
+ if status == 200:
122
+ message = "Success."
123
+ elif status == 401:
124
+ message = "JWT authorization failed."
125
+ elif status == 403:
126
+ message = "Unable to provision initial admin. Please check status of Connect database."
127
+ elif status == 404:
128
+ message = (
129
+ "Unable to find provisioning endpoint. Please check your 'rsconnect bootstrap --server' "
130
+ "parameter and your Connect configuration."
131
+ )
132
+
133
+ output["message"] = message
134
+
135
+ return output
136
+
137
+
138
+ class JWTEncoder:
139
+ def __init__(self, issuer: str, audience: str, secret: str | bytes):
140
+ self.issuer = issuer
141
+ self.audience = audience
142
+ self.secret = secret
143
+
144
+ def generate_standard_claims(self, current_datetime: datetime, exp: timedelta):
145
+
146
+ if exp < timedelta(0):
147
+ raise RSConnectException("Unable to generate a token with a negative exp claim.")
148
+
149
+ return {
150
+ "exp": int((current_datetime + exp).timestamp()),
151
+ "iss": self.issuer,
152
+ "aud": self.audience,
153
+ "iat": int(current_datetime.timestamp()),
154
+ }
155
+
156
+ def new_token(self, custom_claims: dict[str, Any], exp: timedelta) -> str:
157
+
158
+ standard_claims = self.generate_standard_claims(datetime.now(tz=timezone.utc), exp)
159
+
160
+ claims: dict[str, Any] = {}
161
+ for c in [standard_claims, custom_claims]:
162
+ claims.update(c)
163
+
164
+ return jwt.encode(claims, self.secret, algorithm="HS256") # pyright: ignore[reportUnknownMemberType]
165
+
166
+
167
+ # Uses a generic encoder to create JWTs with specific custom scopes / expiration times
168
+ class TokenGenerator:
169
+ """
170
+ Generates 'typed' JWTs with specific custom scopes / expiration times to serve a specific purpose.
171
+ """
172
+
173
+ def __init__(self, secret: str | bytes):
174
+ self.encoder = JWTEncoder(DEFAULT_ISSUER, DEFAULT_AUDIENCE, secret)
175
+
176
+ def bootstrap(self):
177
+ custom_claims = {"scope": BOOTSTRAP_SCOPE}
178
+ return self.encoder.new_token(custom_claims, BOOTSTRAP_EXP)
rsconnect/log.py ADDED
@@ -0,0 +1,253 @@
1
+ """
2
+ Logging wrapper and shared instance
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import logging
9
+ import sys
10
+ from functools import partial, wraps
11
+ from typing import TYPE_CHECKING, Any, Callable, Literal, Optional, Protocol, TypeVar
12
+
13
+ if sys.version_info >= (3, 10):
14
+ from typing import Concatenate, ParamSpec
15
+ else:
16
+ from typing_extensions import Concatenate, ParamSpec
17
+
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import MutableMapping
21
+
22
+ import click
23
+
24
+ T = TypeVar("T")
25
+ P = ParamSpec("P")
26
+
27
+
28
+ _DATE_FORMAT = "%Y-%m-%dT%H:%M:%S%z"
29
+
30
+ VERBOSE = int((logging.INFO + logging.DEBUG) / 2)
31
+ logging.addLevelName(VERBOSE, "VERBOSE")
32
+
33
+
34
+ class LogOutputFormat(object):
35
+ TEXT = "text"
36
+ JSON = "json"
37
+ DEFAULT = TEXT
38
+ _all = [TEXT, JSON]
39
+ All = Literal["text", "json"]
40
+
41
+
42
+ class JsonLogFormatter(logging.Formatter):
43
+ """
44
+ https://stackoverflow.com/a/70223539
45
+ Formatter that outputs JSON strings after parsing the LogRecord.
46
+
47
+ @param dict fmt_dict: Key: logging format attribute pairs.
48
+ @param str datefmt: Key: strftime format string
49
+ """
50
+
51
+ def __init__(self, fmt_dict: Optional[dict[str, str]] = None, datefmt: str = _DATE_FORMAT):
52
+ self.fmt_dict = (
53
+ fmt_dict if fmt_dict is not None else {"timestamp": "asctime", "level": "levelname", "message": "message"}
54
+ )
55
+ self.datefmt = datefmt
56
+
57
+ def usesTime(self):
58
+ """
59
+ Overwritten to look for the attribute in the format dict values instead of the fmt string.
60
+ """
61
+ return "asctime" in self.fmt_dict.values()
62
+
63
+ def formatMessage(self, record: logging.LogRecord): # pyright: ignore[reportIncompatibleMethodOverride]
64
+ """
65
+ Overwritten to return a dictionary of the relevant LogRecord attributes instead of a string.
66
+ KeyError is raised if an unknown attribute is provided in the fmt_dict.
67
+ """
68
+ return {fmt_key: record.__dict__[fmt_val] for fmt_key, fmt_val in self.fmt_dict.items()}
69
+
70
+ def format(self, record: logging.LogRecord):
71
+ """
72
+ Mostly the same as the parent's class method, the difference being that a dict is manipulated and dumped as JSON
73
+ instead of a string.
74
+ """
75
+ record.message = record.getMessage()
76
+
77
+ if self.usesTime():
78
+ record.asctime = self.formatTime(record, self.datefmt)
79
+
80
+ message_dict = self.formatMessage(record)
81
+
82
+ if record.exc_info:
83
+ # Cache the traceback text to avoid converting it multiple times
84
+ # (it's constant anyway)
85
+ if not record.exc_text:
86
+ record.exc_text = self.formatException(record.exc_info)
87
+
88
+ if record.exc_text:
89
+ message_dict["exc_info"] = record.exc_text
90
+
91
+ if record.stack_info:
92
+ message_dict["stack_info"] = self.formatStack(record.stack_info)
93
+
94
+ return json.dumps(message_dict, default=str)
95
+
96
+
97
+ # This is a workaround for LoggerAdapter not being generic in Python<=3.10.
98
+ # See also:
99
+ # https://github.com/python/typeshed/issues/7855#issuecomment-1128857842
100
+ if sys.version_info >= (3, 11):
101
+ _LoggerAdapter = logging.LoggerAdapter[logging.Logger]
102
+ else:
103
+ _LoggerAdapter = logging.LoggerAdapter
104
+
105
+
106
+ class RSLogger(_LoggerAdapter):
107
+ def __init__(self):
108
+ super(RSLogger, self).__init__(logging.getLogger("rsconnect"), {})
109
+ self._in_feedback = False
110
+ self._have_feedback_output = False
111
+ self._log_format = LogOutputFormat.DEFAULT
112
+
113
+ def addHandler(self, handler: logging.Handler):
114
+ self.logger.addHandler(handler)
115
+
116
+ def set_in_feedback(self, value: bool):
117
+ self._in_feedback = value
118
+ self._have_feedback_output = False
119
+
120
+ def set_log_output_format(self, value: LogOutputFormat.All):
121
+ self._log_format = value
122
+ if self._log_format == LogOutputFormat.JSON:
123
+ for h in self.logger.handlers:
124
+ h.setFormatter(JsonLogFormatter())
125
+ else:
126
+ for h in self.logger.handlers:
127
+ h.setFormatter(logging.Formatter("[%(levelname)s] %(asctime)s %(message)s", datefmt=_DATE_FORMAT))
128
+
129
+ def process(self, msg: str, kwargs: MutableMapping[str, Any]):
130
+ msg, kwargs = super(RSLogger, self).process(msg, kwargs)
131
+ if self._in_feedback and self.is_debugging():
132
+ if not self._have_feedback_output:
133
+ print()
134
+ self._have_feedback_output = True
135
+ msg = click.style(" %s" % msg, fg="green")
136
+ return msg, kwargs
137
+
138
+ def is_debugging(self):
139
+ return self.isEnabledFor(logging.DEBUG)
140
+
141
+ def setLevel(self, level: int | str):
142
+ """
143
+ Set the specified level on the underlying logger.
144
+
145
+ **Note:** This is present in newer Python versions but since it's missing
146
+ from 2.7, we replicate it here.
147
+ """
148
+ self.logger.setLevel(level)
149
+
150
+
151
+ logger = RSLogger()
152
+ logger.addHandler(logging.StreamHandler())
153
+ logger.set_log_output_format(LogOutputFormat.DEFAULT)
154
+
155
+
156
+ class ConsoleFormatter(logging.Formatter):
157
+ green = "\x1b[32;20m"
158
+ yellow = "\x1b[33;20m"
159
+ red = "\x1b[31;20m"
160
+ msg_format = "%(message)s"
161
+ reset = "\x1b[0m"
162
+
163
+ FORMATS = {
164
+ logging.DEBUG: green + msg_format + reset,
165
+ logging.INFO: reset + msg_format + reset,
166
+ logging.WARNING: yellow + msg_format + reset,
167
+ logging.ERROR: red + msg_format + reset,
168
+ logging.CRITICAL: red + msg_format + reset,
169
+ }
170
+
171
+ def format(self, record: logging.LogRecord):
172
+ log_fmt = self.FORMATS.get(record.levelno)
173
+ formatter = logging.Formatter(log_fmt)
174
+ return formatter.format(record)
175
+
176
+
177
+ console_logger = logging.getLogger("console")
178
+ console_logger.setLevel(logging.DEBUG)
179
+
180
+ # create console handler
181
+ console_handler = logging.StreamHandler()
182
+ console_handler.terminator = ""
183
+ console_handler.setLevel(logging.DEBUG)
184
+ console_handler.setFormatter(ConsoleFormatter())
185
+ console_logger.addHandler(console_handler)
186
+
187
+
188
+ def logged(logger: logging.Logger, label: str):
189
+ def decorator(f: Callable[P, T]) -> Callable[P, T]:
190
+ @wraps(f)
191
+ def wrapper(*args: P.args, **kw: P.kwargs):
192
+ logger.info(label)
193
+ result = None
194
+ try:
195
+ result = f(*args, **kw)
196
+ except Exception as exc:
197
+ logger.error(" \t[ERROR]: {}\n".format(str(exc)))
198
+ raise
199
+ logger.debug(" \t[OK]\n")
200
+ return result
201
+
202
+ return wrapper
203
+
204
+ return decorator
205
+
206
+
207
+ class HasLoggerMethod(Protocol):
208
+ logger: logging.Logger | None
209
+
210
+
211
+ HasLoggerMethodT = TypeVar("HasLoggerMethodT", bound=HasLoggerMethod)
212
+
213
+
214
+ def cls_logged(label: str): # uses logger provided by a class' self.logger
215
+ def decorator(
216
+ method: Callable[Concatenate[HasLoggerMethodT, P], T],
217
+ ) -> Callable[Concatenate[HasLoggerMethodT, P], T]:
218
+
219
+ @wraps(method)
220
+ def wrapper(self: HasLoggerMethodT, *args: P.args, **kw: P.kwargs):
221
+ logger = self.logger
222
+ if logger:
223
+ logger.info(label)
224
+ result = None
225
+ try:
226
+ result = method(self, *args, **kw)
227
+ except Exception as exc:
228
+ msg = " \t[ERROR]: {}\n"
229
+ if logger:
230
+ logger.error(msg.format(str(exc)))
231
+ else:
232
+ print(msg)
233
+ raise
234
+ if logger:
235
+ logger.debug(" \t[OK]\n")
236
+ return result
237
+
238
+ return wrapper
239
+
240
+ return decorator
241
+
242
+
243
+ console_logged = partial(logged, console_logger)
244
+
245
+
246
+ # generic logger
247
+ connect_logger = logging.getLogger("connect_logger")
248
+ connect_logger.setLevel(logging.DEBUG)
249
+ connect_handler = logging.StreamHandler()
250
+ connect_handler.terminator = "\n"
251
+ connect_handler.setLevel(logging.DEBUG)
252
+ connect_handler.setFormatter(ConsoleFormatter())
253
+ connect_logger.addHandler(connect_handler)