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,362 @@
1
+ #!/usr/bin/env python
2
+ """
3
+ Environment data class abstraction that is usable as an executable module
4
+
5
+ ```bash
6
+ python -m rsconnect.subprocesses.inspect_environment
7
+ ```
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import datetime
14
+ import json
15
+ import locale
16
+ import os
17
+ import tempfile
18
+ import re
19
+ import subprocess
20
+ import sys
21
+ from dataclasses import asdict, dataclass, replace
22
+ from typing import Callable, Optional
23
+
24
+ try:
25
+ import tomllib
26
+ except ImportError:
27
+ # Python <3.11 doesn't have tomllib in the standard library
28
+ import toml as tomllib # type: ignore[no-redef]
29
+
30
+ version_re = re.compile(r"\d+\.\d+(\.\d+)?")
31
+ exec_dir = os.path.dirname(sys.executable)
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class EnvironmentData:
36
+ contents: str
37
+ filename: str
38
+ locale: str
39
+ package_manager: str
40
+ pip: str
41
+ python: str
42
+ source: str
43
+ python_requires: Optional[str]
44
+ error: Optional[str]
45
+
46
+ def _asdict(self):
47
+ return asdict(self)
48
+
49
+ def _replace(self, **kwargs: object):
50
+ return replace(self, **kwargs)
51
+
52
+
53
+ def MakeEnvironmentData(
54
+ contents: str,
55
+ filename: str,
56
+ locale: str,
57
+ package_manager: str,
58
+ pip: str,
59
+ python: str,
60
+ source: str,
61
+ python_requires: Optional[str] = None,
62
+ error: Optional[str] = None,
63
+ **kwargs: object, # provides compatibility where we no longer support some older properties
64
+ ) -> EnvironmentData:
65
+ return EnvironmentData(contents, filename, locale, package_manager, pip, python, source, python_requires, error)
66
+
67
+
68
+ class EnvironmentException(Exception):
69
+ pass
70
+
71
+
72
+ def detect_environment(dirname: str, requirements_file: Optional[str] = "requirements.txt") -> EnvironmentData:
73
+ """Determine the python dependencies in the environment.
74
+
75
+ `pip freeze` will be used to introspect the environment.
76
+
77
+ :param: dirname Directory name
78
+ :param: requirements_file The requirements file to read. If None, generate using pip freeze.
79
+ :return: a dictionary containing the package spec filename and contents if successful,
80
+ or a dictionary containing `error` on failure.
81
+ """
82
+
83
+ if requirements_file is None:
84
+ # --force-generate sets requirements_file to None
85
+ result = pip_freeze()
86
+ elif os.path.basename(requirements_file) == "uv.lock":
87
+ result = uv_export(dirname, requirements_file)
88
+ elif os.path.basename(requirements_file) == "pyproject.toml":
89
+ result = pyproject_dependencies(dirname, requirements_file)
90
+ else:
91
+ result = output_file(dirname, requirements_file, "pip")
92
+ if result is None:
93
+ raise EnvironmentException(
94
+ "The requirements file '%s' was not found in '%s'. "
95
+ "Please create it or use --force-generate to use pip freeze." % (requirements_file, dirname)
96
+ )
97
+
98
+ if result is not None:
99
+ result["python"] = get_python_version()
100
+ result["pip"] = get_version("pip")
101
+ result["locale"] = get_default_locale()
102
+
103
+ return MakeEnvironmentData(**result)
104
+
105
+
106
+ def get_python_version() -> str:
107
+ v = sys.version_info
108
+ return "%d.%d.%d" % (v[0], v[1], v[2])
109
+
110
+
111
+ def get_default_locale(locale_source: Callable[..., tuple[str | None, str | None]] = locale.getlocale):
112
+ result = ".".join([item or "" for item in locale_source()])
113
+ return "" if result == "." else result
114
+
115
+
116
+ def get_version(module: str):
117
+ try:
118
+ args = [sys.executable, "-m", module, "--version"]
119
+ proc = subprocess.Popen(
120
+ args,
121
+ stdout=subprocess.PIPE,
122
+ stderr=subprocess.PIPE,
123
+ universal_newlines=True,
124
+ )
125
+ stdout, _stderr = proc.communicate()
126
+ match = version_re.search(stdout)
127
+ if match:
128
+ return match.group()
129
+
130
+ msg = "Failed to get version of '%s' from the output of: %s" % (
131
+ module,
132
+ " ".join(args),
133
+ )
134
+ raise EnvironmentException(msg)
135
+ except Exception as exception:
136
+ raise EnvironmentException("Error getting '%s' version: %s" % (module, str(exception)))
137
+
138
+
139
+ def output_file(dirname: str, filename: str, package_manager: str):
140
+ """Read an existing package spec file.
141
+
142
+ Returns a dictionary containing the filename and contents
143
+ if successful, None if the file does not exist,
144
+ or a dictionary containing 'error' on failure.
145
+ """
146
+ try:
147
+ path = os.path.join(dirname, filename)
148
+ if not os.path.exists(path):
149
+ return None
150
+
151
+ with open(path, "r") as f:
152
+ data = f.read()
153
+
154
+ data = "\n".join([line for line in data.split("\n") if "rsconnect" not in line])
155
+
156
+ return {
157
+ "filename": filename,
158
+ "contents": data,
159
+ "source": "file",
160
+ "package_manager": package_manager,
161
+ }
162
+ except Exception as exception:
163
+ raise EnvironmentException("Error reading %s: %s" % (filename, str(exception)))
164
+
165
+
166
+ def pip_freeze():
167
+ """Inspect the environment using `pip freeze --disable-pip-version-check version`.
168
+
169
+ Returns a dictionary containing the filename
170
+ (always 'requirements.txt') and contents if successful,
171
+ or a dictionary containing 'error' on failure.
172
+ """
173
+ try:
174
+ proc = subprocess.Popen(
175
+ [sys.executable, "-m", "pip", "freeze", "--disable-pip-version-check"],
176
+ stdout=subprocess.PIPE,
177
+ stderr=subprocess.PIPE,
178
+ universal_newlines=True,
179
+ )
180
+
181
+ pip_stdout, pip_stderr = proc.communicate()
182
+ pip_status = proc.returncode
183
+ except Exception as exception:
184
+ raise EnvironmentException("Error during pip freeze: %s" % str(exception))
185
+
186
+ if pip_status != 0:
187
+ msg = pip_stderr or ("exited with code %d" % pip_status)
188
+ raise EnvironmentException("Error during pip freeze: %s" % msg)
189
+
190
+ pip_stdout = filter_pip_freeze_output(pip_stdout)
191
+
192
+ pip_stdout = (
193
+ "# requirements.txt generated by rsconnect-python on "
194
+ + str(datetime.datetime.now(datetime.timezone.utc))
195
+ + "\n"
196
+ + pip_stdout
197
+ )
198
+
199
+ return {
200
+ "filename": "requirements.txt",
201
+ "contents": pip_stdout,
202
+ "source": "pip_freeze",
203
+ "package_manager": "pip",
204
+ }
205
+
206
+
207
+ def uv_export(dirname: str, lock_filename: str):
208
+ """
209
+ Export requirements from a uv.lock file using `uv export`.
210
+ """
211
+ lock_path = lock_filename
212
+ if not os.path.isabs(lock_filename):
213
+ lock_path = os.path.join(dirname, lock_filename)
214
+
215
+ if not os.path.exists(lock_path):
216
+ raise EnvironmentException("uv.lock not found: %s" % lock_filename)
217
+
218
+ with tempfile.TemporaryDirectory() as tmpdir:
219
+ output_path = os.path.join(tmpdir, "requirements.txt.lock")
220
+ try:
221
+ result = subprocess.run(
222
+ [
223
+ "uv",
224
+ "export",
225
+ "--format",
226
+ "requirements-txt",
227
+ "--frozen",
228
+ "--no-hashes",
229
+ "--no-annotate",
230
+ "--offline",
231
+ "--no-header",
232
+ "--no-emit-project",
233
+ "--output-file",
234
+ output_path,
235
+ ],
236
+ cwd=os.path.dirname(lock_path),
237
+ stdout=sys.stderr,
238
+ stderr=sys.stderr,
239
+ check=False,
240
+ )
241
+ except Exception as exception:
242
+ raise EnvironmentException("Error during uv export: %s" % str(exception))
243
+
244
+ if result.returncode != 0:
245
+ raise EnvironmentException("Error during uv export: exited with code %d" % result.returncode)
246
+
247
+ with open(output_path, mode="r", encoding="utf-8") as output_file:
248
+ exported = output_file.read()
249
+
250
+ requirements = filter_pip_freeze_output(exported)
251
+ requirements = (
252
+ "# requirements.txt.lock generated from uv.lock by rsconnect-python on "
253
+ + str(datetime.datetime.now(datetime.timezone.utc))
254
+ + "\n"
255
+ + requirements
256
+ )
257
+
258
+ return {
259
+ "filename": "requirements.txt.lock",
260
+ "contents": requirements,
261
+ "source": "uv_lock",
262
+ "package_manager": "uv",
263
+ "pip": None,
264
+ }
265
+
266
+
267
+ def pyproject_dependencies(dirname: str, pyproject_filename: str):
268
+ """Read project.dependencies from a pyproject.toml file as a requirements spec.
269
+
270
+ Emits a requirements.txt file listing the top-level dependencies declared
271
+ in ``[project].dependencies``. Unlike ``uv.lock``, the result is not a
272
+ fully resolved lock and Connect will perform dependency resolution at
273
+ deploy time.
274
+ """
275
+ pyproject_path = pyproject_filename
276
+ if not os.path.isabs(pyproject_filename):
277
+ pyproject_path = os.path.join(dirname, pyproject_filename)
278
+
279
+ if not os.path.exists(pyproject_path):
280
+ raise EnvironmentException(f"pyproject.toml not found: {pyproject_filename}")
281
+
282
+ try:
283
+ with open(pyproject_path, "r", encoding="utf-8") as f:
284
+ pyproject = tomllib.loads(f.read())
285
+ except Exception as exception:
286
+ raise EnvironmentException(f"Error reading {pyproject_filename}: {exception}")
287
+
288
+ project = pyproject.get("project", {})
289
+ dependencies: list[object] = project.get("dependencies", [])
290
+ if not isinstance(dependencies, list):
291
+ raise EnvironmentException(f"Invalid project.dependencies in {pyproject_filename}: expected a list of strings.")
292
+
293
+ requirements = filter_pip_freeze_output("\n".join(str(dep) for dep in dependencies))
294
+ requirements = (
295
+ f"# requirements.txt generated from pyproject.toml by rsconnect-python on "
296
+ f"{datetime.datetime.now(datetime.timezone.utc)}\n"
297
+ f"{requirements}"
298
+ )
299
+
300
+ return {
301
+ "filename": "requirements.txt",
302
+ "contents": requirements,
303
+ "source": "pyproject_toml",
304
+ "package_manager": "pip",
305
+ }
306
+
307
+
308
+ def filter_pip_freeze_output(pip_stdout: str):
309
+ # Filter out dependency on `rsconnect` and ignore output lines from pip which start with `[notice]`
310
+ return "\n".join(
311
+ [line for line in pip_stdout.split("\n") if (("rsconnect" not in line) and (line.find("[notice]") != 0))]
312
+ )
313
+
314
+
315
+ def strip_ref(line: str):
316
+ # remove erroneous conda build paths that will break pip install
317
+ return line.split(" @ file:", 1)[0].strip()
318
+
319
+
320
+ def exclude(line: str):
321
+ return line and line.startswith("setuptools") and "post" in line
322
+
323
+
324
+ def main():
325
+ """
326
+ Run `detect_environment` and dump the result as JSON.
327
+ """
328
+ try:
329
+ parser = argparse.ArgumentParser(
330
+ description="Inspect python environment and return dependency metadata.", add_help=True
331
+ )
332
+ parser.add_argument(
333
+ "-r",
334
+ "--requirements-file",
335
+ dest="requirements_file",
336
+ default="requirements.txt",
337
+ help="Requirements file name (relative to the directory). Use 'none' to capture via pip freeze.",
338
+ )
339
+ parser.add_argument("directory", help="Directory to inspect.")
340
+ args = parser.parse_args()
341
+
342
+ requirements_file = args.requirements_file
343
+ if requirements_file.lower() == "none":
344
+ requirements_file = None
345
+
346
+ envinfo = detect_environment(args.directory, requirements_file=requirements_file)._asdict()
347
+ if "contents" in envinfo:
348
+ keepers = list(map(strip_ref, envinfo["contents"].split("\n")))
349
+ keepers = [line for line in keepers if not exclude(line)]
350
+ envinfo["contents"] = "\n".join(keepers)
351
+
352
+ json.dump(
353
+ envinfo,
354
+ sys.stdout,
355
+ indent=4,
356
+ )
357
+ except EnvironmentException as exception:
358
+ json.dump(dict(error=str(exception)), sys.stdout, indent=4)
359
+
360
+
361
+ if __name__ == "__main__":
362
+ main()
rsconnect/timeouts.py ADDED
@@ -0,0 +1,89 @@
1
+ import os
2
+ import textwrap
3
+
4
+ from typing import Union
5
+
6
+ from rsconnect.exception import RSConnectException
7
+
8
+ _CONNECT_REQUEST_TIMEOUT_KEY: str = "CONNECT_REQUEST_TIMEOUT"
9
+ _CONNECT_REQUEST_TIMEOUT_DEFAULT_VALUE: str = "300"
10
+
11
+ _CONNECT_TASK_TIMEOUT_KEY: str = "CONNECT_TASK_TIMEOUT"
12
+ _CONNECT_TASK_TIMEOUT_DEFAULT_VALUE: str = "86400"
13
+
14
+
15
+ def get_request_timeout() -> int:
16
+ """Gets the timeout from the CONNECT_REQUEST_TIMEOUT env variable.
17
+
18
+ The timeout value is intended to be interpreted in seconds. A value of 60 is equal to sixty seconds, or one minute.
19
+
20
+ If CONNECT_REQUEST_TIMEOUT is unset, a default value of 300 is used.
21
+
22
+ If CONNECT_REQUEST_TIMEOUT is set to a value less than 0, an `RSConnectException` is raised.
23
+
24
+ A CONNECT_REQUEST_TIMEOUT set to 0 is logically equivalent to no timeout.
25
+
26
+ The primary intent for this method is for usage with the `http` module. Specifically, for setting the timeout
27
+ parameter with an `http.client.HTTPConnection` or `http.client.HTTPSConnection`.
28
+
29
+ :raises: `RSConnectException` if CONNECT_REQUEST_TIMEOUT is not a natural number.
30
+ :return: the timeout value
31
+ """
32
+ timeout: Union[int, str] = os.environ.get(_CONNECT_REQUEST_TIMEOUT_KEY, _CONNECT_REQUEST_TIMEOUT_DEFAULT_VALUE)
33
+
34
+ try:
35
+ timeout = int(timeout)
36
+ except ValueError:
37
+ raise RSConnectException(
38
+ f"'CONNECT_REQUEST_TIMEOUT' is set to '{timeout}'. The value must be a non-negative integer."
39
+ )
40
+
41
+ if timeout < 0:
42
+ raise RSConnectException(
43
+ f"'CONNECT_REQUEST_TIMEOUT' is set to '{timeout}'. The value must be a non-negative integer."
44
+ )
45
+
46
+ return timeout
47
+
48
+
49
+ def get_task_timeout() -> int:
50
+ """Gets the timeout from the CONNECT_TASK_TIMEOUT env variable.
51
+
52
+ The timeout value is intended to be interpreted in seconds. A value of 60 is equal to sixty seconds, or one minute.
53
+
54
+ If CONNECT_TASK_TIMEOUT is unset, a default value of 86,400 (1 day) is used.
55
+
56
+ If CONNECT_TASK_TIMEOUT is set to a value less or equal to 0, an `RSConnectException` is raised.
57
+
58
+ The primary intent for this method is for usage with the `api` module. Specifically, for setting the timeout
59
+ parameter in the method `wait_for_task`.
60
+
61
+ :raises: `RSConnectException` if CONNECT_TASK_TIMEOUT is not a positive integer.
62
+ :return: the timeout value
63
+ """
64
+ timeout: Union[int, str] = os.environ.get(_CONNECT_TASK_TIMEOUT_KEY, _CONNECT_TASK_TIMEOUT_DEFAULT_VALUE)
65
+
66
+ try:
67
+ timeout = int(timeout)
68
+ except ValueError:
69
+ raise RSConnectException(f"'CONNECT_TASK_TIMEOUT' is set to '{timeout}'. The value must be a positive integer.")
70
+
71
+ if timeout <= 0:
72
+ raise RSConnectException(f"'CONNECT_TASK_TIMEOUT' is set to '{timeout}'. The value must be a positive integer.")
73
+
74
+ return timeout
75
+
76
+
77
+ def get_task_timeout_help_message(timeout: int = get_task_timeout()) -> str:
78
+ """Gets a human friendly help message for adjusting the task timeout value."""
79
+
80
+ return f"The task timed out after {timeout} seconds." + textwrap.dedent(
81
+ f"""
82
+
83
+ You may try increasing the task timeout value using the {_CONNECT_TASK_TIMEOUT_KEY} environment variable. The default value is {_CONNECT_TASK_TIMEOUT_DEFAULT_VALUE} seconds. The current value is {get_task_timeout()} seconds.
84
+
85
+ Example:
86
+
87
+ CONNECT_TASK_TIMEOUT={_CONNECT_TASK_TIMEOUT_DEFAULT_VALUE} rsconnect deploy api --server <your-server> --api-key <your-api-key> ./
88
+ """ # noqa: E501
89
+ )