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,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
@@ -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)