rsconnect-python 1.31.0__tar.gz → 1.31.1__tar.gz

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 (65) hide show
  1. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/PKG-INFO +1 -1
  2. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/pyproject.toml +1 -1
  3. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/pyproject.toml.orig +1 -1
  4. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/api.py +60 -20
  5. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/connect_cloud.py +209 -0
  6. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/LICENSE.md +0 -0
  7. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/README.md +0 -0
  8. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/__init__.py +0 -0
  9. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/actions.py +0 -0
  10. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/actions_content.py +0 -0
  11. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/actions_environment.py +0 -0
  12. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/actions_integration.py +0 -0
  13. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/bundle.py +0 -0
  14. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/certificates.py +0 -0
  15. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/environment.py +0 -0
  16. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/environment_node.py +0 -0
  17. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/environment_r.py +0 -0
  18. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/exception.py +0 -0
  19. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/git_metadata.py +0 -0
  20. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/http_support.py +0 -0
  21. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/json_web_token.py +0 -0
  22. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/log.py +0 -0
  23. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/main.py +0 -0
  24. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/metadata.py +0 -0
  25. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/models.py +0 -0
  26. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/oauth.py +0 -0
  27. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/py.typed +0 -0
  28. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/pyproject.py +0 -0
  29. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/quickstart/__init__.py +0 -0
  30. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/quickstart/quickstart.py +0 -0
  31. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/quickstart/templates/__init__.py +0 -0
  32. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/quickstart/templates/api/README.md.tmpl +0 -0
  33. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/quickstart/templates/api/__connect__.py.tmpl +0 -0
  34. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/quickstart/templates/api/__init__.py.tmpl +0 -0
  35. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/quickstart/templates/api/__main__.py.tmpl +0 -0
  36. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/quickstart/templates/api/app.py.tmpl +0 -0
  37. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/quickstart/templates/api/pyproject.toml.tmpl +0 -0
  38. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/quickstart/templates/fastapi/README.md.tmpl +0 -0
  39. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/quickstart/templates/fastapi/__connect__.py.tmpl +0 -0
  40. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/quickstart/templates/fastapi/__init__.py.tmpl +0 -0
  41. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/quickstart/templates/fastapi/__main__.py.tmpl +0 -0
  42. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/quickstart/templates/fastapi/app.py.tmpl +0 -0
  43. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/quickstart/templates/fastapi/pyproject.toml.tmpl +0 -0
  44. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/quickstart/templates/notebook/README.md.tmpl +0 -0
  45. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/quickstart/templates/notebook/notebook.ipynb.tmpl +0 -0
  46. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/quickstart/templates/notebook/pyproject.toml.tmpl +0 -0
  47. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/quickstart/templates/quarto/README.md.tmpl +0 -0
  48. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/quickstart/templates/quarto/pyproject.toml.tmpl +0 -0
  49. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/quickstart/templates/quarto/report.qmd.tmpl +0 -0
  50. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/quickstart/templates/shiny/README.md.tmpl +0 -0
  51. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/quickstart/templates/shiny/app.py.tmpl +0 -0
  52. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/quickstart/templates/shiny/pyproject.toml.tmpl +0 -0
  53. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/quickstart/templates/streamlit/README.md.tmpl +0 -0
  54. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/quickstart/templates/streamlit/app.py.tmpl +0 -0
  55. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/quickstart/templates/streamlit/pyproject.toml.tmpl +0 -0
  56. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/quickstart/templates/voila/README.md.tmpl +0 -0
  57. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/quickstart/templates/voila/pyproject.toml.tmpl +0 -0
  58. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/shiny_express.py +0 -0
  59. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/snowflake.py +0 -0
  60. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/subprocesses/__init__.py +0 -0
  61. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/subprocesses/inspect_environment.py +0 -0
  62. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/timeouts.py +0 -0
  63. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/utils_package.py +0 -0
  64. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/validation.py +0 -0
  65. {rsconnect_python-1.31.0 → rsconnect_python-1.31.1}/rsconnect/version_check.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: rsconnect_python
3
- Version: 1.31.0
3
+ Version: 1.31.1
4
4
  Summary: The Posit Connect command-line interface.
5
5
  Author: Posit, PBC
6
6
  Author-email: Posit, PBC <rsconnect@posit.co>
@@ -2,7 +2,7 @@
2
2
  name = "rsconnect_python"
3
3
  description = "The Posit Connect command-line interface."
4
4
  requires-python = ">=3.8"
5
- version = "1.31.0"
5
+ version = "1.31.1"
6
6
  dependencies = [
7
7
  "typing-extensions>=4.8.0",
8
8
  "pip>=10.0.0",
@@ -2,7 +2,7 @@
2
2
  name = "rsconnect_python"
3
3
  description = "The Posit Connect command-line interface."
4
4
  requires-python = ">=3.8"
5
- version = "1.31.0"
5
+ version = "1.31.1"
6
6
  dependencies = [ "typing-extensions>=4.8.0", "pip>=10.0.0", "uv>=0.9.0", "semver>=2.0.0,<4.0.0", "pyjwt>=2.4.0", "click>=8.0.0", "packaging>=20.0", "toml>=0.10; python_version < '3.11'",]
7
7
  [[project.authors]]
8
8
  name = "Posit, PBC"
@@ -2165,16 +2165,15 @@ for shinyapps.io. See command help for further details."
2165
2165
  )
2166
2166
  upload_result = S3Server(upload_url).handle_bad_response(upload_result, is_httpresponse=True)
2167
2167
 
2168
- def primary_file_for_connect_cloud(self) -> str:
2169
- """The entrypoint to report to Connect Cloud, read from the built bundle.
2168
+ def bundle_manifest(self) -> dict[str, Any]:
2169
+ """The manifest of the built bundle.
2170
2170
 
2171
- Connect Cloud needs a `primary_file` and uses it to decide, for example,
2172
- whether Shiny content is R or Python. Every deploy path writes the
2173
- entrypoint into the bundle's manifest, so reading it back from there
2174
- avoids having to thread it separately through each command.
2171
+ Every deploy path writes what Connect Cloud needs into the bundle's
2172
+ manifest, so reading it back from there avoids having to thread the same
2173
+ values separately through each command.
2175
2174
  """
2176
2175
  if self.bundle is None:
2177
- raise RSConnectException("A bundle must be created before determining the primary file.")
2176
+ raise RSConnectException("A bundle must be created before reading its manifest.")
2178
2177
 
2179
2178
  position = self.bundle.tell()
2180
2179
  try:
@@ -2186,12 +2185,34 @@ for shinyapps.io. See command help for further details."
2186
2185
  extracted = tar.extractfile(member) if member is not None else None
2187
2186
  if extracted is None:
2188
2187
  raise RSConnectException("The bundle does not contain a manifest.json.")
2189
- manifest = json.loads(extracted.read().decode("utf-8"))
2188
+ return cast("dict[str, Any]", json.loads(extracted.read().decode("utf-8")))
2190
2189
  except (tarfile.TarError, KeyError, ValueError) as exc:
2191
2190
  raise RSConnectException("Could not read the bundle manifest: %s" % exc) from exc
2192
2191
  finally:
2193
2192
  self.bundle.seek(position)
2194
2193
 
2194
+ def python_version_for_connect_cloud(self) -> Optional[str]:
2195
+ """The MAJOR.MINOR Python version to ask Connect Cloud for, or None.
2196
+
2197
+ None means the request omits the field: a redeploy keeps the version already
2198
+ set on the content, and a first deploy takes Connect Cloud's default. Content
2199
+ with no Python at all lands here too.
2200
+ """
2201
+ manifest = self.bundle_manifest()
2202
+ environment: dict[str, Any] = manifest.get("environment") or {}
2203
+ requirement: dict[str, Any] = environment.get("python") or {}
2204
+ interpreter: dict[str, Any] = manifest.get("python") or {}
2205
+ requires: Optional[str] = requirement.get("requires")
2206
+ local_version: Optional[str] = interpreter.get("version")
2207
+ return connect_cloud.resolve_python_version(requires, local_version)
2208
+
2209
+ def primary_file_for_connect_cloud(self) -> str:
2210
+ """The entrypoint to report to Connect Cloud, read from the built bundle.
2211
+
2212
+ Connect Cloud needs a `primary_file` and uses it to decide, for example,
2213
+ whether Shiny content is R or Python.
2214
+ """
2215
+ manifest = self.bundle_manifest()
2195
2216
  metadata = manifest.get("metadata") or {}
2196
2217
  primary_file = metadata.get("entrypoint") or metadata.get("primary_rmd") or metadata.get("primary_html")
2197
2218
  files = manifest.get("files") or {}
@@ -2302,6 +2323,7 @@ for shinyapps.io. See command help for further details."
2302
2323
  title=self.title,
2303
2324
  app_mode=self.app_mode,
2304
2325
  primary_file=self.primary_file_for_connect_cloud(),
2326
+ python_version=self.python_version_for_connect_cloud(),
2305
2327
  env_vars=self.env_vars,
2306
2328
  update_title=not self.title_is_default,
2307
2329
  app_id_is_explicit=self.app_id_is_explicit,
@@ -3293,6 +3315,7 @@ class ConnectCloudAccountSearchResults(TypedDict):
3293
3315
  class ConnectCloudRevision(TypedDict):
3294
3316
  id: str
3295
3317
  content_id: NotRequired[str]
3318
+ python_version: NotRequired[Optional[str]]
3296
3319
  status: NotRequired[str]
3297
3320
  source_bundle_upload_url: NotRequired[str]
3298
3321
  publish_result: NotRequired[Optional[str]]
@@ -3648,21 +3671,29 @@ class ConnectCloudClient(BearerTokenHTTPServer):
3648
3671
  primary_file: str,
3649
3672
  secrets: Optional[list[dict[str, str]]] = None,
3650
3673
  access: Optional[str] = None,
3674
+ python_version: Optional[str] = None,
3651
3675
  ) -> ConnectCloudContent:
3652
3676
  """Create content to upload a bundle into.
3653
3677
 
3654
3678
  `access` is the content's visibility ("public" or "private"). Omitted from
3655
3679
  the request when None so the server picks its own default.
3680
+
3681
+ `python_version` is MAJOR.MINOR. Omitted when None, which is how content
3682
+ with no Python is created; Connect Cloud then falls back to its own
3683
+ default rather than reading the version out of the bundle's manifest.
3656
3684
  """
3685
+ next_revision: dict[str, Any] = {
3686
+ "source_type": "bundle",
3687
+ "content_type": content_type,
3688
+ "app_mode": app_mode,
3689
+ "primary_file": primary_file,
3690
+ }
3691
+ if python_version is not None:
3692
+ next_revision["python_version"] = python_version
3657
3693
  body: dict[str, Any] = {
3658
3694
  "account_id": account_id,
3659
3695
  "title": title,
3660
- "next_revision": {
3661
- "source_type": "bundle",
3662
- "content_type": content_type,
3663
- "app_mode": app_mode,
3664
- "primary_file": primary_file,
3665
- },
3696
+ "next_revision": next_revision,
3666
3697
  "secrets": secrets or [],
3667
3698
  }
3668
3699
  if access is not None:
@@ -3680,6 +3711,7 @@ class ConnectCloudClient(BearerTokenHTTPServer):
3680
3711
  new_bundle: bool = True,
3681
3712
  title: Optional[str] = None,
3682
3713
  access: Optional[str] = None,
3714
+ python_version: Optional[str] = None,
3683
3715
  ) -> ConnectCloudContent:
3684
3716
  """Update content, optionally minting a fresh revision to upload into.
3685
3717
 
@@ -3693,14 +3725,19 @@ class ConnectCloudClient(BearerTokenHTTPServer):
3693
3725
  deploy without -E/-t must leave the existing values alone. `access` (the
3694
3726
  content's visibility) is omitted the same way, so a redeploy without
3695
3727
  -V keeps whatever visibility the content already has.
3728
+
3729
+ `python_version` is omitted when None for the same reason: a redeploy of
3730
+ content whose bundle carries no Python version keeps the version already
3731
+ set on the content, including one chosen in the Connect Cloud UI.
3696
3732
  """
3697
- body: dict[str, Any] = {
3698
- "revision_overrides": {
3699
- "primary_file": primary_file,
3700
- "app_mode": app_mode,
3701
- "content_type": content_type,
3702
- },
3733
+ revision_overrides: dict[str, Any] = {
3734
+ "primary_file": primary_file,
3735
+ "app_mode": app_mode,
3736
+ "content_type": content_type,
3703
3737
  }
3738
+ if python_version is not None:
3739
+ revision_overrides["python_version"] = python_version
3740
+ body: dict[str, Any] = {"revision_overrides": revision_overrides}
3704
3741
  if secrets is not None:
3705
3742
  body["secrets"] = secrets
3706
3743
  if title is not None:
@@ -3895,6 +3932,7 @@ class ConnectCloudService:
3895
3932
  update_title: bool = False,
3896
3933
  app_id_is_explicit: bool = False,
3897
3934
  visibility: Optional[str] = None,
3935
+ python_version: Optional[str] = None,
3898
3936
  ) -> ConnectCloudDeployResult:
3899
3937
  """Create or fetch the content item and get a revision to upload into.
3900
3938
 
@@ -3961,6 +3999,7 @@ class ConnectCloudService:
3961
3999
  content_type=content_type,
3962
4000
  app_mode=app_mode.name(),
3963
4001
  primary_file=primary_file,
4002
+ python_version=python_version,
3964
4003
  secrets=secrets,
3965
4004
  access=visibility,
3966
4005
  )
@@ -3975,6 +4014,7 @@ class ConnectCloudService:
3975
4014
  primary_file=primary_file,
3976
4015
  app_mode=app_mode.name(),
3977
4016
  content_type=content_type,
4017
+ python_version=python_version,
3978
4018
  secrets=secrets,
3979
4019
  new_bundle=upload,
3980
4020
  title=title if update_title and title else None,
@@ -12,7 +12,11 @@ import os
12
12
  from typing import Any, NamedTuple, Optional
13
13
  from urllib.parse import urlparse
14
14
 
15
+ from packaging.specifiers import InvalidSpecifier, Specifier, SpecifierSet
16
+ from packaging.version import InvalidVersion, Version
17
+
15
18
  from .exception import RSConnectException
19
+ from .log import logger
16
20
  from .oauth import (
17
21
  ACCESS_TOKEN_FIELD,
18
22
  CLIENT_SECRET_FIELD,
@@ -313,3 +317,208 @@ def credentials_from_keyring(url: str, nickname: str) -> tuple[Optional[str], Op
313
317
  def delete_credentials_from_keyring(url: str, nickname: str) -> None:
314
318
  """Delete a saved credential's secrets from the system keyring."""
315
319
  keyring_delete_values(keyring_key(url, nickname), _CREDENTIAL_FIELDS)
320
+
321
+
322
+ # The lowest MAJOR.MINOR Connect Cloud will accept for a revision's python_version
323
+ # (the RequestPythonVersion enum in https://api.connect.posit.cloud/openapi.json).
324
+ MINIMUM_PYTHON_VERSION = Version("3.9")
325
+
326
+ # Tail of both warnings below. resolve_python_version runs before the deploy knows
327
+ # whether the content already exists, so it has to name both outcomes.
328
+ _VERSION_CHOSEN_BY_CONNECT_CLOUD = (
329
+ "The content will run on the Python version already set on it in Posit Connect "
330
+ "Cloud, or Connect Cloud's default if this is a first deployment."
331
+ )
332
+
333
+ # Operators that put a floor under the versions a clause admits. "<" and "<=" bound
334
+ # only the top, and "!=" excludes a point, so neither says where the range starts.
335
+ _LOWER_BOUND_OPERATORS = (">=", ">", "==", "===", "~=")
336
+
337
+
338
+ def _clause_version(clause: Specifier) -> Optional[Version]:
339
+ """The version a single specifier clause names, or None if it cannot be parsed.
340
+
341
+ Strips the wildcard from "==3.11.*", which is not a version on its own.
342
+ """
343
+ try:
344
+ return Version(clause.version.rstrip("*").rstrip("."))
345
+ except InvalidVersion:
346
+ return None
347
+
348
+
349
+ def _effective_floor(specifier: SpecifierSet) -> Optional[Version]:
350
+ """The lowest version the constraint admits, or None if it has no lower bound.
351
+
352
+ Clauses are ANDed, so the highest of their floors is the one that binds:
353
+ ">=3.9,==3.12.*" starts at 3.12, not 3.9.
354
+ """
355
+ floor = None
356
+ for clause in specifier:
357
+ if clause.operator not in _LOWER_BOUND_OPERATORS:
358
+ continue
359
+ version = _clause_version(clause)
360
+ if version is not None and (floor is None or version > floor):
361
+ floor = version
362
+ return floor
363
+
364
+
365
+ # Connect Cloud accepts a MAJOR.MINOR line, such as "3.11", and chooses the patch
366
+ # release. The helpers below determine whether a constraint permits none, some,
367
+ # or all patch releases in that line.
368
+ #
369
+ # SpecifierSet can test only concrete versions, so we sample patch 0 and, for each
370
+ # clause referring to the line, its named patch and the following patch. Between
371
+ # these boundaries the result cannot change, making those samples sufficient.
372
+
373
+
374
+ def _line_boundary_patches(minor: str, specifier: SpecifierSet) -> list[int]:
375
+ """The sample patches for a line. For "3.11" and ">3.11.5", [0, 5, 6]."""
376
+ line = Version(minor).release[:2]
377
+ patches = {0}
378
+ for clause in specifier:
379
+ version = _clause_version(clause)
380
+ if version is None:
381
+ continue
382
+ release = version.release
383
+ # Padded so a clause naming only a major, as ">4" does, is read against that
384
+ # major's first line rather than matching nothing.
385
+ clause_line = (release + (0, 0))[:2]
386
+ if clause_line != line:
387
+ continue
388
+ patch = release[2] if len(release) > 2 else 0
389
+ patches.update((patch, patch + 1))
390
+ return sorted(patches)
391
+
392
+
393
+ def _line_permits_some_patch(minor: str, specifier: SpecifierSet) -> bool:
394
+ """Return whether `specifier` permits at least one patch release in `minor`.
395
+
396
+ `minor` is a MAJOR.MINOR line such as "3.11". For example, ">3.11.5,<3.12"
397
+ permits the 3.11 line, while "!=3.11.*" does not.
398
+ """
399
+ return any(Version("%s.%d" % (minor, patch)) in specifier for patch in _line_boundary_patches(minor, specifier))
400
+
401
+
402
+ # How many minor lines above the constraint's floor to consider when the floor's own
403
+ # line is excluded. Generous for any real requirement, and only there to stop a
404
+ # pathological constraint scanning forever. This is a bound on our own search, not on
405
+ # the versions Connect Cloud offers: it never rejects a version, it only gives up
406
+ # looking for one.
407
+ _LINE_SCAN_LIMIT = 20
408
+
409
+
410
+ def _lowest_admitted_line(floor: Version, specifier: SpecifierSet) -> Optional[str]:
411
+ """The lowest MAJOR.MINOR at or above `floor` that the constraint admits.
412
+
413
+ Usually the floor's own line. It is not when an exclusion empties that line, as
414
+ ">=3.9,!=3.9.*" does, which means 3.10 and is the reason this scans rather than
415
+ testing the floor alone.
416
+ """
417
+ release = floor.release
418
+ # A floor naming only a major version, as ">=4" does, starts at that major's
419
+ # first line.
420
+ major, minor = release[0], release[1] if len(release) > 1 else 0
421
+ for offset in range(_LINE_SCAN_LIMIT + 1):
422
+ candidate = "%d.%d" % (major, minor + offset)
423
+ if _line_permits_some_patch(candidate, specifier):
424
+ return candidate
425
+ return None
426
+
427
+
428
+ def _minor_version(version: Version) -> Optional[str]:
429
+ """A version as the MAJOR.MINOR string Connect Cloud names its interpreters by.
430
+
431
+ None when the version names no minor, as "==3.*" does: there is no single line
432
+ to ask for.
433
+ """
434
+ if len(version.release) < 2:
435
+ return None
436
+ return "%d.%d" % (version.release[0], version.release[1])
437
+
438
+
439
+ def _parsed(version: str) -> Optional[Version]:
440
+ try:
441
+ return Version(version)
442
+ except InvalidVersion:
443
+ return None
444
+
445
+
446
+ def _log_requested(minor: str) -> str:
447
+ """Log the version being asked for and return it.
448
+
449
+ Said on every deploy, and names the patch as Connect Cloud's choice. A patch the
450
+ user asked for is already gone by the time it reaches here -- adapt_python_requires
451
+ turns a .python-version of "3.11.5" into "~=3.11.0" -- so this line is the only
452
+ place they see that it was not honored.
453
+ """
454
+ logger.info("Requesting Python %s from Posit Connect Cloud, which chooses the patch release." % minor)
455
+ return minor
456
+
457
+
458
+ def resolve_python_version(requires: Optional[str], local_version: Optional[str]) -> Optional[str]:
459
+ """Pick the Connect Cloud Python version for a deploy.
460
+
461
+ `requires` is the PEP 440 constraint from the manifest's
462
+ ``environment.python.requires``; `local_version` is the interpreter the
463
+ bundle was built against, from ``python.version``. Returns None when there is
464
+ nothing usable to send, which leaves the field off the request: a redeploy then
465
+ keeps the version already set on the content, and a first deploy takes Connect
466
+ Cloud's default.
467
+
468
+ The interpreter the content was built against wins when the constraint allows
469
+ it. Otherwise the lowest version the constraint admits is used.
470
+
471
+ Availability is not checked here: an unrecognized version is sent and Connect
472
+ Cloud rules on it. A version below the lowest Connect Cloud offers is the
473
+ exception, and is not sent at all.
474
+
475
+ Connect Cloud picks the patch itself, so a constraint that names one ("==3.11.14")
476
+ can only be honored as far as its minor line.
477
+ """
478
+ specifier = None
479
+ if requires:
480
+ try:
481
+ specifier = SpecifierSet(requires)
482
+ except InvalidSpecifier:
483
+ logger.warning(
484
+ "Ignoring the manifest's Python version requirement, which is not a valid "
485
+ "PEP 440 constraint: %s" % requires
486
+ )
487
+
488
+ local = _parsed(local_version) if local_version else None
489
+ if local is not None and (specifier is None or local in specifier):
490
+ minor = _minor_version(local)
491
+ if minor is not None and Version(minor) >= MINIMUM_PYTHON_VERSION:
492
+ return _log_requested(minor)
493
+ # rsconnect runs on Pythons older than Connect Cloud offers. Without a
494
+ # requirement there is nothing else to go on, so let the platform choose;
495
+ # with one, the search below may still find a version it does offer.
496
+ if specifier is None:
497
+ if minor is None:
498
+ logger.warning(
499
+ 'The manifest\'s Python version "%s" does not name a minor version to ask Posit '
500
+ "Connect Cloud for. %s" % (local_version, _VERSION_CHOSEN_BY_CONNECT_CLOUD)
501
+ )
502
+ else:
503
+ logger.warning(
504
+ "Posit Connect Cloud does not offer Python %s. %s" % (minor, _VERSION_CHOSEN_BY_CONNECT_CLOUD)
505
+ )
506
+ return None
507
+
508
+ if specifier is None:
509
+ return None
510
+
511
+ # Search from the requirement's lowest version or Connect Cloud's, whichever is
512
+ # higher, so the result is one Connect Cloud can actually run: ">=3.8" is met by
513
+ # every version it offers and resolves to 3.9. A requirement with no lower bound,
514
+ # or one naming only a major version, starts at Connect Cloud's lowest.
515
+ floor = _effective_floor(specifier)
516
+ start = max(floor, MINIMUM_PYTHON_VERSION) if floor is not None else MINIMUM_PYTHON_VERSION
517
+ minor = _lowest_admitted_line(start, specifier)
518
+ if minor is None:
519
+ logger.warning(
520
+ "No Python version Posit Connect Cloud offers satisfies the requirement %s, so it "
521
+ "will not be met. %s" % (requires, _VERSION_CHOSEN_BY_CONNECT_CLOUD)
522
+ )
523
+ return None
524
+ return _log_requested(minor)