Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
69ab9ea
Add Posit Publisher .posit/publish TOML interoperability
mconflitti-pbc Jul 24, 2026
ebc4f09
Fix redeploy creating a duplicate .posit config
mconflitti-pbc Jul 24, 2026
a918349
redeploy: reuse a saved credential matched by server URL
mconflitti-pbc Jul 27, 2026
94cc1ef
Bundle files per .posit config; honor .gitignore otherwise
mconflitti-pbc Jul 29, 2026
120b8cb
Propagate config integration_requests into manifest.json
mconflitti-pbc Jul 29, 2026
da35758
Fix Python 3.8 collection error in test_redeploy.py
mconflitti-pbc Jul 29, 2026
eb4a736
Isolate ambient Connect env vars in redeploy tests
mconflitti-pbc Jul 29, 2026
857017a
Fix .posit config files list drifting from the bundle
mconflitti-pbc Jul 30, 2026
db10e65
Revert .gitignore-aware default file selection
mconflitti-pbc Jul 30, 2026
0a34551
Don't let a written config narrow the next deploy
mconflitti-pbc Jul 30, 2026
9fec48d
Pin the Publisher-curation round-trip with tests
mconflitti-pbc Jul 30, 2026
d9ee8a3
Fix Windows path separator in gitignore regression test
mconflitti-pbc Jul 30, 2026
cc8e0f4
Normalize server_url before writing a .posit deployment record
mconflitti-pbc Jul 30, 2026
8b32b46
Recover the literal entrypoint file for a written .posit config
mconflitti-pbc Jul 30, 2026
c3e77d6
Address PR review: widen redeploy coverage, fix record data loss
mconflitti-pbc Aug 6, 2026
c86b22d
List the Python package file literally so Publisher can redeploy it
mconflitti-pbc Aug 6, 2026
c6b63ee
Rewrite CHANGELOG Unreleased section in Simplified Technical English
mconflitti-pbc Aug 6, 2026
65cc12a
Stop calling the package-file listing entry a bug fix
mconflitti-pbc Aug 6, 2026
c92d8f4
Add reusable Publisher init and publish services
mconflitti-pbc Aug 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
# module imports rsconnect. (Previously injected by the Makefile's TEST_ENV.)
os.environ.setdefault("CONNECT_CONTENT_BUILD_DIR", "rsconnect-build-test")


# httpretty (1.1.4, released 2021) mocks TLS with
# `ssl.SSLContext.wrap_socket = functools.partial(fake_wrap_socket, ...)`.
# Python 3.14 made partial objects descriptors, so that class attribute now binds
Expand Down
29 changes: 21 additions & 8 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

- Added support for Python 3.14. The test suite now runs on Python 3.14 in CI.
- `rsconnect deploy` subcommands now accept `--quiet`, which suppresses the
step-by-step progress lines and the streamed server build log, printing only
the deployed content URL to stdout so it can be captured with
`URL=$(rsconnect deploy ... --quiet)`. Errors still go to stderr, and on a
failed deploy the server task log is emitted to stderr so failures remain
diagnosable. `--quiet` cannot be combined with `-v/--verbose`, and for
shinyapps.io deploys it also skips opening a browser.
### Added

- Python 3.14 support. The test suite now runs on Python 3.14 in CI.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(I know this isn't from your PR but it's in the diff) but is it true that we "added" support for Python 3.14? Or did we just add CI? If the latter, then we should delete this line.

- `--quiet` flag for `rsconnect deploy` commands. It suppresses the
step-by-step progress lines and the streamed build log. It prints only the
deployed content URL to stdout, so you can capture it with
`URL=$(rsconnect deploy ... --quiet)`. Errors still go to stderr. On a
failed deploy, the server task log also goes to stderr. `--quiet` cannot
combine with `-v`/`--verbose`. For shinyapps.io deploys, `--quiet` also
skips opening a browser.
- Reusable Publisher services for frontends such as `posit-cli`.
`initialize_project()` creates a Publisher v3
`.posit/publish/<config>.toml`, and `publish_project()` performs first and
subsequent publishes from that configuration while preserving the selected
deployment record. Configured file curation, integration requests, Python
and Jupyter options, environment variables, and supported Connect
Kubernetes bundle settings are applied during publishing.
- Publisher behavior is explicit: ordinary `rsconnect deploy` and
`rsconnect write-manifest` commands neither read nor write `.posit` state.
A `PublisherContext` opts the shared executor into Publisher file curation,
manifest overlays, and metadata writes.

## [1.30.0] - 2026-07-16

Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ dependencies = [
"click>=8.0.0",
"packaging>=20.0",
"toml>=0.10; python_version < '3.11'",
"tomli-w>=1.0.0",
"pathspec>=0.10.0",
]

[project.scripts]
Expand Down
56 changes: 55 additions & 1 deletion rsconnect/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import base64
import binascii
import dataclasses
import datetime
import hashlib
import hmac
Expand Down Expand Up @@ -53,6 +54,8 @@

from . import validation
from .bundle import _default_title
from .bundle import overlay_manifest as bundle_overlay_manifest
from .bundle import restrict_to_files as bundle_restrict_to_files
from .certificates import read_certificate_file
from .environment import fake_module_file_from_directory
from .exception import DeploymentFailedException, RSConnectException
Expand Down Expand Up @@ -1243,6 +1246,17 @@ class ServerDetails(TypedDict):
python: ServerDetailsPython


@dataclasses.dataclass(frozen=True)
class PublisherContext:
"""Explicit `.posit/publish` inputs for a config-driven deployment."""

project_dir: str
config_name: str
record_name: Optional[str]
include_files: Optional[List[str]]
manifest_overlay: Mapping[str, Any]


class RSConnectExecutor:
def __init__(
self,
Expand Down Expand Up @@ -1275,6 +1289,7 @@ def __init__(
branch: Optional[str] = None,
subdirectory: Optional[str] = None,
polling: bool = True,
publisher_context: Optional[PublisherContext] = None,
) -> None:
self.remote_server: TargetableServer
self.client: RSConnectClient | PositClient
Expand Down Expand Up @@ -1306,6 +1321,9 @@ def __init__(
self.deployed_info: RSConnectClientDeployResult | None = None
self._draft_deploy_supported: bool | None = None

self.publisher_context = publisher_context
self.publisher_metadata_paths: Optional[typing.Tuple[str, str]] = None

self.logger: logging.Logger | None = logger
self.ctx = ctx
self.setup_remote_server(
Expand Down Expand Up @@ -1616,8 +1634,12 @@ def make_bundle(
force_unique_name = self.app_id is None
self.deployment_name = self.make_deployment_name(self.title, force_unique_name)

context = self.publisher_context
include_files = context.include_files if context else None
manifest_overlay: Mapping[str, Any] = context.manifest_overlay if context else {}
try:
self.bundle = func(*args, **kwargs)
with bundle_restrict_to_files(include_files), bundle_overlay_manifest(manifest_overlay):
self.bundle = func(*args, **kwargs)
except IOError as error:
msg = "Unable to include the file %s in the bundle: %s" % (
error.filename,
Expand Down Expand Up @@ -1798,6 +1820,8 @@ def save_deployed_info(self):
app_store = self.app_store
path = self.path
deployed_info = self.deployed_info
if deployed_info is None:
raise RSConnectException("Cannot save deployment information before deploying a bundle.")

app_store.set(
self.remote_server.url,
Expand All @@ -1809,8 +1833,38 @@ def save_deployed_info(self):
self.app_mode,
)

if self.publisher_context and isinstance(self.remote_server, (RSConnectServer, SPCSConnectServer)):
self._save_publisher_metadata(deployed_info)

return self

def _save_publisher_metadata(self, deployed_info: RSConnectClientDeployResult):
"""Write the config and record for an explicit Publisher deployment."""
if self.bundle is None:
raise RSConnectException("Cannot write Publisher metadata before a bundle is built.")
from .publisher import schema
from .publisher.store import write_deployment_metadata

context = self.publisher_context
if context is None:
return
product_type = (
schema.PRODUCT_TYPE_SNOWFLAKE
if isinstance(self.remote_server, SPCSConnectServer)
else schema.PRODUCT_TYPE_CONNECT
)
self.publisher_metadata_paths = write_deployment_metadata(
project_dir=context.project_dir,
server_url=self.remote_server.url,
product_type=product_type,
app_mode=self.app_mode or AppModes.UNKNOWN,
title=deployed_info.get("title") or self.title,
deployed_info=deployed_info,
bundle=self.bundle,
config_name=context.config_name,
record_name=context.record_name,
)

@property
def supports_verify_before_activate(self) -> bool:
"""Whether the target server supports deploying a bundle as a draft and
Expand Down
105 changes: 105 additions & 0 deletions rsconnect/bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

from __future__ import annotations

import contextlib
import contextvars
import hashlib
import io
import json
Expand Down Expand Up @@ -33,6 +35,7 @@
from typing import (
IO,
TYPE_CHECKING,
Any,
Callable,
Iterator,
Literal,
Expand Down Expand Up @@ -82,6 +85,70 @@

mimetypes.add_type("text/ipynb", ".ipynb")

# When set (by a deploy orchestrator via ``restrict_to_files``), ``create_file_list``
# selects from exactly this pre-resolved set of project-relative files instead of
# walking the whole tree. Deploy commands resolve the set from an applicable
# ``.posit/publish`` config's ``files`` allowlist, and leave this unset otherwise;
# see ``rsconnect.publisher.files`` and ``rsconnect.publisher.store.resolve_bundle_files``.
_include_files_override: "contextvars.ContextVar[Optional[list[str]]]" = contextvars.ContextVar(
"rsconnect_include_files_override", default=None
)


@contextlib.contextmanager
def restrict_to_files(files: Optional[typing.Sequence[str]]) -> typing.Iterator[None]:
"""Restrict bundling to ``files`` (project-relative) for the duration of the block.

``None`` leaves the default whole-tree walk in place. The builders' own
``excludes`` (e.g. ``manifest.json`` and the environment file, which are added
to the bundle separately) still apply on top of the restriction.
"""
token = _include_files_override.set(list(files) if files is not None else None)
try:
yield
finally:
_include_files_override.reset(token)


# Manifest fields sourced from a ``.posit/publish`` config that rsconnect cannot
# derive from inspection (e.g. ``integration_requests``). Set by a deploy
# orchestrator via ``overlay_manifest`` and merged by ``Manifest`` so a
# Publisher-authored config's settings propagate into ``manifest.json`` exactly as
# Publisher would emit them, even though rsconnect never originates them.
_manifest_overlay: "contextvars.ContextVar[Optional[dict[str, Any]]]" = contextvars.ContextVar(
"rsconnect_manifest_overlay", default=None
)


@contextlib.contextmanager
def overlay_manifest(fields: Optional[typing.Mapping[str, Any]]) -> typing.Iterator[None]:
"""Merge ``fields`` into every ``Manifest`` built within the block.

``None``/empty is a no-op. Top-level keys are only filled when rsconnect did
not already set them from inspection (so inspected values win); the nested
``metadata`` mapping is merged key-by-key.
"""
token = _manifest_overlay.set(dict(fields) if fields else None)
try:
yield
finally:
_manifest_overlay.reset(token)


def _apply_manifest_overlay(data: "ManifestData") -> None:
"""Merge the active ``overlay_manifest`` fields into ``data`` in place."""
overlay = _manifest_overlay.get()
if not overlay:
return
for key, value in overlay.items():
if key == "metadata" and isinstance(value, dict):
metadata = data.setdefault("metadata", cast("ManifestDataMetadata", {}))
for meta_key, meta_value in value.items():
metadata.setdefault(meta_key, meta_value) # type: ignore[misc]
else:
# Do not clobber a value rsconnect already derived from inspection.
data.setdefault(key, value) # type: ignore[misc]


class ManifestDataFile(TypedDict):
checksum: str
Expand All @@ -95,6 +162,15 @@ class ManifestDataMetadata(TypedDict):
content_category: NotRequired[str]


class ManifestDataIntegrationRequest(TypedDict):
guid: NotRequired[str]
name: NotRequired[str]
description: NotRequired[str]
auth_type: NotRequired[str]
type: NotRequired[str]
config: NotRequired[dict[str, typing.Any]]


class ManifestDataJupyter(TypedDict):
hide_all_input: NotRequired[bool]
hide_tagged_input: NotRequired[bool]
Expand Down Expand Up @@ -160,6 +236,7 @@ class ManifestData(TypedDict):
platform: NotRequired[str]
packages: NotRequired[dict[str, ManifestDataRPackage]]
environment: NotRequired[ManifestDataEnvironment]
integration_requests: NotRequired[list[ManifestDataIntegrationRequest]]


class Manifest:
Expand Down Expand Up @@ -252,6 +329,10 @@ def __init__(
if files:
self.data["files"] = files

# Merge fields sourced from a .posit config (e.g. integration_requests)
# that rsconnect does not derive from inspection.
_apply_manifest_overlay(self.data)

@classmethod
def from_json(cls, json_str: str):
return cls(**json.loads(json_str))
Expand Down Expand Up @@ -1236,6 +1317,7 @@ def create_file_list(
extra_files: Sequence[str],
excludes: Sequence[str],
use_abspath: bool = False,
include_files: Optional[Sequence[str]] = None,
) -> list[str]:
"""
Builds a full list of files under the given path that should be included
Expand All @@ -1245,6 +1327,10 @@ def create_file_list(
:param path: a file, or a directory to walk for files.
:param extra_files: a sequence of any extra files to include in the bundle.
:param excludes: a sequence of glob patterns that will exclude matched files.
:param include_files: when provided (or set via ``restrict_to_files``), select
from exactly these project-relative files instead of walking the tree. The
``excludes`` still apply, so a builder's separately-added files (manifest,
environment file) are not double-counted.
:return: the list of relevant files, relative to the given directory.
"""
extra_files = extra_files or []
Expand All @@ -1258,6 +1344,25 @@ def create_file_list(
file_set.add(path_to_add)
return sorted(file_set)

if include_files is None:
include_files = _include_files_override.get()

if include_files is not None:
# Allowlist mode: consider only the resolved files, applying the same
# exclude/ignore filtering the walk would, so builder-managed files
# (manifest.json, the environment file) are still dropped here.
for rel_path in include_files:
cur_path = os.path.join(path, rel_path)
if not isfile(cur_path):
continue
if Path(cur_path) in exclude_paths:
continue
if keep_manifest_specified_file(rel_path, exclude_paths | directories_to_ignore) and (
rel_path in extra_files or not glob_set.matches(cur_path)
):
file_set.add(abspath(cur_path) if use_abspath else rel_path)
return sorted(file_set)

for cur_dir, _, files in os.walk(path):
if Path(cur_dir) in exclude_paths:
continue
Expand Down
Loading
Loading