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
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utility functions related to packages and versions.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from typing import NamedTuple, cast
|
|
9
|
+
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
import semver
|
|
13
|
+
|
|
14
|
+
from .environment import Environment
|
|
15
|
+
from .log import logger
|
|
16
|
+
from .models import AppMode, AppModes
|
|
17
|
+
|
|
18
|
+
ComparisonOperator = Literal[">=", "<=", ">", "<", "==", "!="]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class VersionInfo(NamedTuple):
|
|
22
|
+
operator: ComparisonOperator
|
|
23
|
+
version: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PackageInfo(NamedTuple):
|
|
27
|
+
name: str
|
|
28
|
+
specs: list[VersionInfo]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def compare_semvers(version1: str, version2: str) -> Literal[-1, 0, 1]:
|
|
32
|
+
"""
|
|
33
|
+
Compare semver-like version numbers. This should be used for comparing Connect
|
|
34
|
+
versions, but not Python package versions.
|
|
35
|
+
|
|
36
|
+
This is a wrapper for semver.VersionInfo.compare(), but it can handle some version
|
|
37
|
+
strings that are not strictly valid semver strings. Specifically, it can handle
|
|
38
|
+
strings like "2024.01.0", which have a leading '0' in a version part. The leading
|
|
39
|
+
zero in "01" makes it an invalid semver string.
|
|
40
|
+
|
|
41
|
+
Note that semvers always have three parts, so "0.12" is not a valid semver. This
|
|
42
|
+
function can be safely used for comparing Connect version numbers, but not for
|
|
43
|
+
comparing Python package numbers, because the latter may not have exactly three
|
|
44
|
+
parts.
|
|
45
|
+
|
|
46
|
+
This function is better for comparing Connect version numbers than
|
|
47
|
+
compare_package_versions(), because the latter doesn't handle the prerelease and
|
|
48
|
+
build components of the version string. For "2024.01.0-dev+20-abcd", the prerelease
|
|
49
|
+
and build components are "dev" and "20-abcd", respectively.
|
|
50
|
+
|
|
51
|
+
:return: -1 if version1 < version2, 0 if version1 == version2, or 1 if version1 >
|
|
52
|
+
version2.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
version1 = _remove_leading_zeros(version1)
|
|
56
|
+
version2 = _remove_leading_zeros(version2)
|
|
57
|
+
|
|
58
|
+
return semver.VersionInfo.parse(version1).compare(version2) # type: ignore
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _remove_leading_zeros(version: str) -> str:
|
|
62
|
+
"""
|
|
63
|
+
Given a version string like "2024.01.0-dev+20-abcd", remove any leading zeros in a
|
|
64
|
+
version part, so that the result is "2024.1.0-dev+20-abcd".
|
|
65
|
+
"""
|
|
66
|
+
parts = version.split(".")
|
|
67
|
+
parts = [re.sub(r"^0+(\d)", "\\1", x) for x in parts]
|
|
68
|
+
return ".".join(parts)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def compare_package_versions(version1: str, version2: str) -> Literal[-1, 0, 1]:
|
|
72
|
+
"""
|
|
73
|
+
Compare two package versions. This function is similar to compare_semvers(), but it
|
|
74
|
+
can accept version numbers with any number of parts (instead of exactly three). It
|
|
75
|
+
is used to compare package versions, which may not be valid semver strings.
|
|
76
|
+
"""
|
|
77
|
+
parts1 = [int(x) for x in version1.split(".")]
|
|
78
|
+
parts2 = [int(x) for x in version2.split(".")]
|
|
79
|
+
|
|
80
|
+
max_length = max(len(parts1), len(parts2))
|
|
81
|
+
parts1 += [0] * (max_length - len(parts1))
|
|
82
|
+
parts2 += [0] * (max_length - len(parts2))
|
|
83
|
+
|
|
84
|
+
for part1, part2 in zip(parts1, parts2):
|
|
85
|
+
if part1 > part2:
|
|
86
|
+
return 1
|
|
87
|
+
elif part1 < part2:
|
|
88
|
+
return -1
|
|
89
|
+
|
|
90
|
+
return 0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def parse_requirements_txt(requirements_txt: str) -> list[PackageInfo]:
|
|
94
|
+
"""
|
|
95
|
+
Given a string of requirements (like the contents of a requirements.txt file), find
|
|
96
|
+
the version of the package that is installed. This is done by finding the line that
|
|
97
|
+
starts with the package name, and then extracting the version from that line.
|
|
98
|
+
|
|
99
|
+
:param requirements_txt: The contents of a requirements.txt file.
|
|
100
|
+
:param package: The name of the package to find.
|
|
101
|
+
|
|
102
|
+
:return: A list of PackageInfo objects.
|
|
103
|
+
"""
|
|
104
|
+
# Note that this parser is not perfect. If we want to perfectly parse a
|
|
105
|
+
# requirements.txt file, we could use the requirements-parser package, but that
|
|
106
|
+
# would introduce another dependency.
|
|
107
|
+
|
|
108
|
+
package_info: list[PackageInfo] = []
|
|
109
|
+
|
|
110
|
+
for line in requirements_txt.split("\n"):
|
|
111
|
+
# Skip blank lines or comment lines
|
|
112
|
+
if re.match(r"^\s*$", line) or re.match(r"^\s*#", line):
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
pkg_match = re.match(r"^([A-Z0-9][A-Z0-9][A-Z0-9._-]*[A-Z0-9])(.*)", line, flags=re.IGNORECASE)
|
|
116
|
+
if pkg_match is None:
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
# If the line is "foo>=1.0", this is "foo".
|
|
120
|
+
pkg_name = cast(str, pkg_match.group(1))
|
|
121
|
+
|
|
122
|
+
# If the line is "foo>=1.0", this is ">=1.0".
|
|
123
|
+
pkg_version_string = cast(str, pkg_match.group(2)).strip()
|
|
124
|
+
# Remove comment from version string, if present.
|
|
125
|
+
pkg_version_string = re.sub(r"#.*$", "", pkg_version_string).strip()
|
|
126
|
+
pkg_version_specs = _parse_version_specs(pkg_version_string)
|
|
127
|
+
|
|
128
|
+
package_info.append(PackageInfo(name=pkg_name, specs=pkg_version_specs))
|
|
129
|
+
|
|
130
|
+
return package_info
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _parse_version_specs(version_specs: str) -> list[VersionInfo]:
|
|
134
|
+
"""
|
|
135
|
+
Parse a version spec string like ">=1.0,<=2.0" into a list of tuples like [(">=",
|
|
136
|
+
"1.0"), ("<=", "2.0")]. If there is a semicolon, as in "==1.0;
|
|
137
|
+
python_version<'3.8'", then the semicolon and everything after it is ignored.
|
|
138
|
+
If it finds a spec string that it is unable to parse, it will ignore that string.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
version_specs = version_specs.split(";")[0].strip()
|
|
142
|
+
if version_specs.strip() == "":
|
|
143
|
+
return []
|
|
144
|
+
version_spec_strings = [x.strip() for x in version_specs.split(",")]
|
|
145
|
+
|
|
146
|
+
version_infos: list[VersionInfo] = []
|
|
147
|
+
for version_spec_string in version_spec_strings:
|
|
148
|
+
spec = _parse_version_spec(version_spec_string)
|
|
149
|
+
if spec is not None:
|
|
150
|
+
version_infos.append(spec)
|
|
151
|
+
|
|
152
|
+
return version_infos
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _parse_version_spec(version_spec: str) -> VersionInfo | None:
|
|
156
|
+
"""
|
|
157
|
+
Parse a version spec string like ">=1.0" into a tuple like (">=", "1.0"). If it is
|
|
158
|
+
unable to parse the string, return None.
|
|
159
|
+
|
|
160
|
+
For the purposes for which this function is used, it makes sense to return None if
|
|
161
|
+
the spec can't be parsed, instead of raising an exception. For our use cases, we do
|
|
162
|
+
not want to interrupt the flow of the program if we encounter malformed input.
|
|
163
|
+
"""
|
|
164
|
+
for op in (">=", "<=", ">", "<", "==", "!="):
|
|
165
|
+
if version_spec.startswith(op):
|
|
166
|
+
return VersionInfo(op, version_spec[len(op) :].strip()) # type: ignore
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def find_package_info(package: str, requirements: list[PackageInfo]) -> PackageInfo | None:
|
|
171
|
+
"""
|
|
172
|
+
Given a package name and a list of PackageInfo objects, find the PackageInfo object
|
|
173
|
+
that corresponds to the package name. If the package is not found, return None.
|
|
174
|
+
"""
|
|
175
|
+
for pkg_info in requirements:
|
|
176
|
+
if pkg_info.name == package:
|
|
177
|
+
return pkg_info
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def replace_requirement(package_name: str, replacement: str, requirements_txt: str) -> str:
|
|
182
|
+
"""
|
|
183
|
+
Given a requirements.txt file and a package name, replace the line of
|
|
184
|
+
requirements.txt for that package, with the replacement string, and return the
|
|
185
|
+
modified requirements.txt.
|
|
186
|
+
|
|
187
|
+
Note that package_name is a regular expression, so if the target package name
|
|
188
|
+
contains a ".", it should be escaped, as in "foo\\.bar".
|
|
189
|
+
"""
|
|
190
|
+
lines = requirements_txt.split("\n")
|
|
191
|
+
new_lines: list[str] = []
|
|
192
|
+
|
|
193
|
+
for line in lines:
|
|
194
|
+
new_line = re.sub(f"^{package_name}([^a-zA-Z0-9._-].*?)?$", replacement, line)
|
|
195
|
+
new_lines.append(new_line)
|
|
196
|
+
|
|
197
|
+
return "\n".join(new_lines)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def fix_starlette_requirements(
|
|
201
|
+
environment: Environment,
|
|
202
|
+
app_mode: AppMode,
|
|
203
|
+
connect_version_string: str,
|
|
204
|
+
) -> Environment:
|
|
205
|
+
"""
|
|
206
|
+
Ensure that the starlette version in an Environment is compatible with the Connect
|
|
207
|
+
server.
|
|
208
|
+
|
|
209
|
+
If the app mode is PYTHON_SHINY and the Connect server version is less than
|
|
210
|
+
2024.01.0, then make sure the starlette version is less than 0.35.0, due to an
|
|
211
|
+
incompatibility with between older Connect<2024.01.0 and starlette>=0.35.0.
|
|
212
|
+
|
|
213
|
+
After all users are on Connect 2024.01.0 or later, this function can be removed.
|
|
214
|
+
|
|
215
|
+
For more information, see https://github.com/posit-dev/py-shiny/issues/1114
|
|
216
|
+
|
|
217
|
+
:return: Either a modified environment (if changes were needed), or the original
|
|
218
|
+
environment.
|
|
219
|
+
"""
|
|
220
|
+
if not (app_mode == AppModes.PYTHON_SHINY and compare_semvers(connect_version_string, "2024.01.0") == -1):
|
|
221
|
+
return environment
|
|
222
|
+
|
|
223
|
+
requirements_txt = environment.contents
|
|
224
|
+
|
|
225
|
+
reqs = parse_requirements_txt(requirements_txt)
|
|
226
|
+
starlette_req = find_package_info("starlette", reqs)
|
|
227
|
+
|
|
228
|
+
# If didn't ask for specific version of starlette, add starlette<0.35.0.
|
|
229
|
+
# If pinned version is <0.35.0, do nothing.
|
|
230
|
+
# If pinned version is >=0.35.0, change the version and emit warning
|
|
231
|
+
# If more complex version spec (that doesn't use ==), do nothing.
|
|
232
|
+
|
|
233
|
+
if starlette_req is None:
|
|
234
|
+
# starlette is not listed in requirements.
|
|
235
|
+
# Add starlette<0.35.0 to requirements.
|
|
236
|
+
requirements_txt = requirements_txt.rstrip("\n") + "\nstarlette<0.35.0\n"
|
|
237
|
+
environment = environment._replace(contents=requirements_txt)
|
|
238
|
+
|
|
239
|
+
elif len(starlette_req.specs) == 0:
|
|
240
|
+
# starlette is in requirements, but without a version specification.
|
|
241
|
+
# Replace it with starlette<0.35.0.
|
|
242
|
+
requirements_txt = replace_requirement("starlette", "starlette<0.35.0", requirements_txt)
|
|
243
|
+
environment = environment._replace(contents=requirements_txt)
|
|
244
|
+
|
|
245
|
+
elif len(starlette_req.specs) == 1 and starlette_req.specs[0].operator == "==":
|
|
246
|
+
if compare_semvers(starlette_req.specs[0].version, "0.35.0") >= 0:
|
|
247
|
+
# starlette is in requirements.txt, but with a version spec that is
|
|
248
|
+
# not compatible with this version of Connect.
|
|
249
|
+
logger.warning(
|
|
250
|
+
"Starlette version is 0.35.0 or greater, but this version of Connect "
|
|
251
|
+
"requires starlette<0.35.0. Setting to <0.35.0."
|
|
252
|
+
)
|
|
253
|
+
requirements_txt = replace_requirement("starlette", "starlette<0.35.0", requirements_txt)
|
|
254
|
+
environment = environment._replace(contents=requirements_txt)
|
|
255
|
+
else:
|
|
256
|
+
# If more complex version spec (e.g., it uses something other than == or
|
|
257
|
+
# has multiple specs), do nothing, because this is an advanced user that
|
|
258
|
+
# is doing something complicated.
|
|
259
|
+
pass
|
|
260
|
+
|
|
261
|
+
return environment
|
rsconnect/validation.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from rsconnect.exception import RSConnectException
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_parameter_source_name_from_ctx(
|
|
11
|
+
var_or_param_name: str,
|
|
12
|
+
ctx: Optional[click.Context],
|
|
13
|
+
) -> str:
|
|
14
|
+
if ctx:
|
|
15
|
+
varName = var_or_param_name.replace("-", "_")
|
|
16
|
+
source = ctx.get_parameter_source(varName)
|
|
17
|
+
if source and source.name:
|
|
18
|
+
return source.name
|
|
19
|
+
return "<source unknown>"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _get_present_options(
|
|
23
|
+
options: dict[str, Optional[Any]],
|
|
24
|
+
ctx: Optional[click.Context],
|
|
25
|
+
) -> list[str]:
|
|
26
|
+
result: list[str] = []
|
|
27
|
+
for k, v in options.items():
|
|
28
|
+
if v:
|
|
29
|
+
parts = k.split("--")
|
|
30
|
+
if ctx and len(parts) == 2:
|
|
31
|
+
sourceName = get_parameter_source_name_from_ctx(parts[1], ctx)
|
|
32
|
+
result.append(f"{k} (from {sourceName})")
|
|
33
|
+
else:
|
|
34
|
+
result.append(f"{k}")
|
|
35
|
+
return result
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def validate_connection_options(
|
|
39
|
+
ctx: Optional[click.Context],
|
|
40
|
+
url: Optional[str],
|
|
41
|
+
api_key: Optional[str],
|
|
42
|
+
insecure: bool,
|
|
43
|
+
cacert: Optional[str],
|
|
44
|
+
account_name: Optional[str],
|
|
45
|
+
token: Optional[str],
|
|
46
|
+
secret: Optional[str],
|
|
47
|
+
name: Optional[str] = None,
|
|
48
|
+
snowflake_connection_name: Optional[str] = None,
|
|
49
|
+
has_default_server: bool = False,
|
|
50
|
+
):
|
|
51
|
+
"""
|
|
52
|
+
Validates provided Connect or shinyapps.io connection options and returns which target to use given the provided
|
|
53
|
+
options.
|
|
54
|
+
|
|
55
|
+
rsconnect deploy api --name localhost ./python-bottle-py3
|
|
56
|
+
should fail w/
|
|
57
|
+
-s/--server or CONNECT_SERVER
|
|
58
|
+
-T/--token or SHINYAPPS_TOKEN or RSCLOUD_TOKEN
|
|
59
|
+
-S/--secret or SHINYAPPS_SECRET or RSCLOUD_SECRET
|
|
60
|
+
-A/--account or SHINYAPPS_ACCOUNT
|
|
61
|
+
|
|
62
|
+
FAILURE if not any of:
|
|
63
|
+
-n/--name
|
|
64
|
+
-s/--server or CONNECT_SERVER
|
|
65
|
+
-T/--token or SHINYAPPS_TOKEN or RSCLOUD_TOKEN
|
|
66
|
+
-S/--secret or SHINYAPPS_SECRET or RSCLOUD_SECRET
|
|
67
|
+
-A/--account or SHINYAPPS_ACCOUNT
|
|
68
|
+
--snowflake-connection-name
|
|
69
|
+
|
|
70
|
+
FAILURE if any of:
|
|
71
|
+
-k/--api-key or CONNECT_API_KEY
|
|
72
|
+
-i/--insecure or CONNECT_INSECURE
|
|
73
|
+
-c/--cacert or CONNECT_CA_CERTIFICATE
|
|
74
|
+
AND any of:
|
|
75
|
+
-T/--token or SHINYAPPS_TOKEN
|
|
76
|
+
-S/--secret or SHINYAPPS_SECRET
|
|
77
|
+
-A/--account or SHINYAPPS_ACCOUNT
|
|
78
|
+
|
|
79
|
+
FAILURE if any of following are specified, without the rest:
|
|
80
|
+
-T/--token or SHINYAPPS_TOKEN
|
|
81
|
+
-S/--secret or SHINYAPPS_SECRET
|
|
82
|
+
-A/--account or SHINYAPPS_ACCOUNT
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
FAILURE if -s/--server or CONNECT_SERVER include "snowflakecomputing.app"
|
|
86
|
+
and not
|
|
87
|
+
--snowflake-connection-name
|
|
88
|
+
"""
|
|
89
|
+
connect_options = {"-k/--api-key": api_key, "-i/--insecure": insecure, "-c/--cacert": cacert}
|
|
90
|
+
shinyapps_options = {"-T/--token": token, "-S/--secret": secret, "-A/--account": account_name}
|
|
91
|
+
spcs_options = {"--snowflake-connection-name": snowflake_connection_name}
|
|
92
|
+
options_mutually_exclusive_with_name = {"-s/--server": url, **shinyapps_options}
|
|
93
|
+
present_options_mutually_exclusive_with_name = _get_present_options(options_mutually_exclusive_with_name, ctx)
|
|
94
|
+
|
|
95
|
+
if name and present_options_mutually_exclusive_with_name:
|
|
96
|
+
name_source = get_parameter_source_name_from_ctx("name", ctx)
|
|
97
|
+
raise RSConnectException(
|
|
98
|
+
f"-n/--name (from {name_source}) cannot be specified in conjunction with options \
|
|
99
|
+
{', '.join(present_options_mutually_exclusive_with_name)}. See command help for further details."
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if not name and not url and not any(shinyapps_options.values()) and not has_default_server:
|
|
103
|
+
raise RSConnectException(
|
|
104
|
+
"You must specify one of -n/--name OR -s/--server OR -T/--token, -S/--secret, \
|
|
105
|
+
either via command options or environment variables. See command help for further details."
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
present_connect_options = _get_present_options(connect_options, ctx)
|
|
109
|
+
present_shinyapps_options = _get_present_options(shinyapps_options, ctx)
|
|
110
|
+
present_spcs_options = _get_present_options(spcs_options, ctx)
|
|
111
|
+
|
|
112
|
+
if present_connect_options and present_shinyapps_options:
|
|
113
|
+
raise RSConnectException(
|
|
114
|
+
f"Connect options ({', '.join(present_connect_options)}) may not be passed \
|
|
115
|
+
alongside shinyapps.io options ({', '.join(present_shinyapps_options)}). \
|
|
116
|
+
See command help for further details."
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
if snowflake_connection_name and not url:
|
|
120
|
+
raise RSConnectException(
|
|
121
|
+
"--snowflake-connection-name requires -s/--server to be specified. \
|
|
122
|
+
See command help for further details."
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
if present_shinyapps_options and present_spcs_options:
|
|
126
|
+
raise RSConnectException(
|
|
127
|
+
f"Shinyapps.io options ({', '.join(present_shinyapps_options)}) may not be passed \
|
|
128
|
+
alongside SPCS options ({', '.join(present_spcs_options)}). \
|
|
129
|
+
See command help for further details."
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
if present_shinyapps_options:
|
|
133
|
+
if len(present_shinyapps_options) != len(shinyapps_options):
|
|
134
|
+
raise RSConnectException(
|
|
135
|
+
"-A/--account, -T/--token, and -S/--secret must all be provided \
|
|
136
|
+
for shinyapps.io. See command help for further details."
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class PythonVersionParamType(click.ParamType):
|
|
141
|
+
name = "python-version"
|
|
142
|
+
|
|
143
|
+
def convert(self, value: str, param: Optional[click.Parameter], ctx: Optional[click.Context]):
|
|
144
|
+
try:
|
|
145
|
+
parts = list(map(int, value.split(".")))
|
|
146
|
+
if len(parts) == 3:
|
|
147
|
+
return value
|
|
148
|
+
elif len(parts) == 2:
|
|
149
|
+
return value + ".0"
|
|
150
|
+
else:
|
|
151
|
+
raise ValueError
|
|
152
|
+
except (AttributeError, ValueError):
|
|
153
|
+
self.fail(f"{value!r} is not a valid python version; expected 3.x or 3.x.y", param, ctx)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
PYTHON_VERSION = PythonVersionParamType()
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Background version update check against PyPI.
|
|
2
|
+
|
|
3
|
+
Checked only on deploy commands. The latest known version is cached on disk and
|
|
4
|
+
refreshed at most once per :data:`_CACHE_TTL_SECONDS` in a background thread, so a
|
|
5
|
+
warm cache adds no network traffic and no latency and prints on every exit path
|
|
6
|
+
(including fast failures). Only a cold cache -- the first run, or an ephemeral
|
|
7
|
+
environment where the cache never persists -- waits briefly on the fetch so it can
|
|
8
|
+
still warn.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from http.client import HTTPSConnection
|
|
18
|
+
from os.path import join
|
|
19
|
+
from typing import Optional, Tuple
|
|
20
|
+
|
|
21
|
+
from packaging.version import Version
|
|
22
|
+
|
|
23
|
+
from . import VERSION
|
|
24
|
+
from .metadata import config_dirname, makedirs
|
|
25
|
+
|
|
26
|
+
RSCONNECT_DISABLE_VERSION_CHECK = "RSCONNECT_DISABLE_VERSION_CHECK"
|
|
27
|
+
_PYPI_HOST = "pypi.org"
|
|
28
|
+
_PYPI_PATH = "/pypi/rsconnect-python/json"
|
|
29
|
+
_PYPI_TIMEOUT_SECONDS = 2
|
|
30
|
+
_CACHE_TTL_SECONDS = 24 * 60 * 60 # Re-check PyPI at most once a day.
|
|
31
|
+
_CACHE_FILENAME = "version_check.json"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _is_check_disabled() -> bool:
|
|
35
|
+
value = os.environ.get(RSCONNECT_DISABLE_VERSION_CHECK, "").strip().lower()
|
|
36
|
+
return value in ("1", "true", "yes")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _is_dev_version(version_str: str) -> bool:
|
|
40
|
+
try:
|
|
41
|
+
return Version(version_str).is_devrelease
|
|
42
|
+
except Exception:
|
|
43
|
+
return True
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _cache_path() -> str:
|
|
47
|
+
return join(config_dirname(), _CACHE_FILENAME)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _read_cache() -> Tuple[bool, Optional[str]]:
|
|
51
|
+
"""Return ``(is_fresh, latest)``.
|
|
52
|
+
|
|
53
|
+
``latest`` is the last known PyPI version recorded in the cache (or None when
|
|
54
|
+
the cache is missing/unreadable or the last fetch failed). It is returned
|
|
55
|
+
regardless of freshness so even a stale value can drive the upgrade hint while
|
|
56
|
+
a refresh runs in the background. ``is_fresh`` is True only when the entry is
|
|
57
|
+
within the TTL, signalling that no background refresh is needed.
|
|
58
|
+
"""
|
|
59
|
+
try:
|
|
60
|
+
with open(_cache_path()) as f:
|
|
61
|
+
data = json.load(f)
|
|
62
|
+
latest = data.get("latest")
|
|
63
|
+
latest = latest if isinstance(latest, str) else None
|
|
64
|
+
is_fresh = time.time() - float(data["checked_at"]) <= _CACHE_TTL_SECONDS
|
|
65
|
+
return (is_fresh, latest)
|
|
66
|
+
except Exception:
|
|
67
|
+
return (False, None)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _write_cache(latest: Optional[str]) -> None:
|
|
71
|
+
"""Persist the latest known version (or None) with the current timestamp.
|
|
72
|
+
|
|
73
|
+
A None value is still cached so repeated failures don't re-hit PyPI on every
|
|
74
|
+
deploy until the TTL expires.
|
|
75
|
+
"""
|
|
76
|
+
try:
|
|
77
|
+
path = _cache_path()
|
|
78
|
+
makedirs(path)
|
|
79
|
+
with open(path, "w") as f:
|
|
80
|
+
json.dump({"checked_at": time.time(), "latest": latest}, f)
|
|
81
|
+
except Exception:
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _fetch_latest_version() -> Optional[str]:
|
|
86
|
+
conn = None
|
|
87
|
+
try:
|
|
88
|
+
conn = HTTPSConnection(_PYPI_HOST, timeout=_PYPI_TIMEOUT_SECONDS)
|
|
89
|
+
conn.request("GET", _PYPI_PATH, headers={"Accept": "application/json"})
|
|
90
|
+
response = conn.getresponse()
|
|
91
|
+
if response.status != 200:
|
|
92
|
+
return None
|
|
93
|
+
data = json.loads(response.read())
|
|
94
|
+
return data.get("info", {}).get("version")
|
|
95
|
+
except Exception:
|
|
96
|
+
return None
|
|
97
|
+
finally:
|
|
98
|
+
if conn is not None:
|
|
99
|
+
conn.close()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _update_message(latest: Optional[str]) -> Optional[str]:
|
|
103
|
+
"""Return an upgrade warning if ``latest`` is newer than the running version."""
|
|
104
|
+
try:
|
|
105
|
+
if latest is not None and Version(latest) > Version(VERSION):
|
|
106
|
+
return (
|
|
107
|
+
f"A new version of rsconnect-python is available: {latest} "
|
|
108
|
+
f"(you have {VERSION}).\n"
|
|
109
|
+
f"Upgrade with: pip install --upgrade rsconnect-python"
|
|
110
|
+
)
|
|
111
|
+
except Exception:
|
|
112
|
+
pass
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class BackgroundVersionCheck:
|
|
117
|
+
"""Resolves the latest available version, using a cache and a background fetch."""
|
|
118
|
+
|
|
119
|
+
def __init__(self) -> None:
|
|
120
|
+
self._thread: Optional[threading.Thread] = None
|
|
121
|
+
self._latest: Optional[str] = None
|
|
122
|
+
|
|
123
|
+
def start(self) -> None:
|
|
124
|
+
if _is_check_disabled() or _is_dev_version(VERSION):
|
|
125
|
+
return
|
|
126
|
+
is_fresh, latest = _read_cache()
|
|
127
|
+
# Seed the hint from the cached value (even a stale one is good enough to
|
|
128
|
+
# suggest an upgrade), so a warm cache prints instantly on every exit path
|
|
129
|
+
# -- including commands that fail fast.
|
|
130
|
+
self._latest = latest
|
|
131
|
+
if not is_fresh:
|
|
132
|
+
# Refresh in the background so the fetch overlaps the command's own
|
|
133
|
+
# work. A warm or stale cache never waits on this; only a cold cache
|
|
134
|
+
# waits briefly at exit -- see get_warning_message.
|
|
135
|
+
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
136
|
+
self._thread.start()
|
|
137
|
+
|
|
138
|
+
def _run(self) -> None:
|
|
139
|
+
latest = _fetch_latest_version()
|
|
140
|
+
_write_cache(latest)
|
|
141
|
+
# Adopt a successful result so a cold-cache run can warn this time, but
|
|
142
|
+
# never overwrite a usable cached value with a failed (None) refresh.
|
|
143
|
+
if latest is not None:
|
|
144
|
+
self._latest = latest
|
|
145
|
+
|
|
146
|
+
def get_warning_message(self) -> Optional[str]:
|
|
147
|
+
# With no cached value yet (a cold cache: first run, or an ephemeral
|
|
148
|
+
# environment where the cache never persists), give the in-flight fetch a
|
|
149
|
+
# bounded chance to finish so this run can still warn. The bound is the
|
|
150
|
+
# fetch's own time budget -- a slower network just means no hint this run.
|
|
151
|
+
# A warm or stale cache already has a value here and never waits.
|
|
152
|
+
if self._latest is None and self._thread is not None:
|
|
153
|
+
self._thread.join(timeout=_PYPI_TIMEOUT_SECONDS)
|
|
154
|
+
return _update_message(self._latest)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: rsconnect_python
|
|
3
|
+
Version: 1.30.0
|
|
4
|
+
Summary: The Posit Connect command-line interface.
|
|
5
|
+
Author: Posit, PBC
|
|
6
|
+
Author-email: Posit, PBC <rsconnect@posit.co>
|
|
7
|
+
License: # MIT License
|
|
8
|
+
|
|
9
|
+
Copyright (c) 2025 Posit Software, PBC
|
|
10
|
+
|
|
11
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
12
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
13
|
+
in the Software without restriction, including without limitation the rights
|
|
14
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
15
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
16
|
+
furnished to do so, subject to the following conditions:
|
|
17
|
+
|
|
18
|
+
The above copyright notice and this permission notice shall be included in all
|
|
19
|
+
copies or substantial portions of the Software.
|
|
20
|
+
|
|
21
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
22
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
23
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
24
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
25
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
26
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
27
|
+
SOFTWARE.
|
|
28
|
+
Requires-Dist: typing-extensions>=4.8.0
|
|
29
|
+
Requires-Dist: pip>=10.0.0
|
|
30
|
+
Requires-Dist: uv>=0.9.0
|
|
31
|
+
Requires-Dist: semver>=2.0.0,<4.0.0
|
|
32
|
+
Requires-Dist: pyjwt>=2.4.0
|
|
33
|
+
Requires-Dist: click>=8.0.0
|
|
34
|
+
Requires-Dist: packaging>=20.0
|
|
35
|
+
Requires-Dist: toml>=0.10 ; python_full_version < '3.11'
|
|
36
|
+
Requires-Dist: keyring>=23.0.0 ; extra == 'keyring'
|
|
37
|
+
Requires-Dist: snowflake-cli ; extra == 'snowflake'
|
|
38
|
+
Requires-Python: >=3.8
|
|
39
|
+
Project-URL: Repository, http://github.com/posit-dev/rsconnect-python
|
|
40
|
+
Project-URL: Documentation, https://docs.posit.co/rsconnect-python
|
|
41
|
+
Provides-Extra: keyring
|
|
42
|
+
Provides-Extra: snowflake
|
|
43
|
+
Description-Content-Type: text/markdown
|
|
44
|
+
|
|
45
|
+
# [rsconnect-python](https://docs.posit.co/rsconnect-python)
|
|
46
|
+
|
|
47
|
+
The [Posit Connect](https://docs.posit.co/connect/) command-line interface.
|
|
48
|
+
|
|
49
|
+
## Installation
|
|
50
|
+
|
|
51
|
+
### uv
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
uv tool install rsconnect-python
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### pipx
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pipx install rsconnect-python
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### into your project
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
python -m pip install rsconnect-python
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Usage
|
|
70
|
+
|
|
71
|
+
[Get an API key from your Posit Connect server](https://docs.posit.co/connect/user/api-keys/) with at least publisher privileges:
|
|
72
|
+
|
|
73
|
+
Store your credentials:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
rsconnect add --server https://connect.example.com --api-key <YOUR-CONNECT-API-KEY> --name production
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Deploy your application:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
rsconnect deploy shiny app.py --title "my shiny app"
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
[Read more about publisher and admin capabilities on the docs site.](https://docs.posit.co/rsconnect-python)
|
|
86
|
+
|
|
87
|
+
## Contributing
|
|
88
|
+
|
|
89
|
+
[Contributing docs](./CONTRIBUTING.md)
|