Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
27 changes: 26 additions & 1 deletion conftest.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import glob
import os
import shutil
import sys

from os.path import abspath, dirname
from os.path import abspath, dirname, join

import pytest


HERE = dirname(abspath(__file__))
Expand All @@ -12,3 +16,24 @@
# default argument value at import time, so this must be set before any test
# module imports rsconnect. (Previously injected by the Makefile's TEST_ENV.)
os.environ.setdefault("CONNECT_CONTENT_BUILD_DIR", "rsconnect-build-test")

_TESTDATA = join(HERE, "tests", "testdata")


def _remove_stray_posit_dirs():
"""Delete any ``.posit`` directories under tests/testdata.

Deploying or writing a manifest for a directory now emits Posit Publisher
``.posit/publish`` files next to the content. Tests that run those flows
against the shared testdata fixtures would otherwise leave stray artifacts in
the working tree; no ``.posit`` fixtures are committed there.
"""
for path in glob.glob(join(_TESTDATA, "**", ".posit"), recursive=True):
shutil.rmtree(path, ignore_errors=True)


@pytest.fixture(scope="session", autouse=True)
def _clean_publisher_artifacts():
_remove_stray_posit_dirs()
yield
_remove_stray_posit_dirs()
52 changes: 52 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
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 interoperability with Posit Publisher's `.posit/publish` project files.
When deploying to Posit Connect or Snowflake (SPCS), rsconnect-python now
writes a Publisher configuration (`.posit/publish/<name>.toml`) and deployment
record (`.posit/publish/deployments/<name>.toml`) alongside the existing
`rsconnect-python/` metadata, so the same project can be published with either
tool. Publisher-authored configurations and records are read and preserved.
- Added a `rsconnect redeploy [PATH]` command that redeploys content using an
existing `.posit/publish` project, recovering the target server and content
identity from the deployment record so no framework, entrypoint, or server
needs to be specified. `PATH` defaults to the current directory. When a
project predates `.posit` but has a `manifest.json` and a legacy
`rsconnect-python/` deployment record, `redeploy` falls back to those and
writes `.posit` files going forward.
- The `rsconnect write-manifest` commands now also write a `.posit/publish`
configuration next to the generated `manifest.json`.
- Connect Cloud (`connect.posit.cloud`) `.posit` files are read and preserved
for interoperability, but deploying to Connect Cloud is not supported by this
tool; only Posit Connect and Snowflake (SPCS) targets write `.posit` metadata.
- Deploys now bundle exactly the files declared by a `.posit/publish`
configuration's `files` list when that list curates a subset of the project.
The `files` entries use `.gitignore` syntax (a matching pattern includes a
path, a `!` prefix excludes it), matching Posit Publisher. So a project can be
deployed with rsconnect-python, have its file list narrowed in Posit Publisher,
and every later `rsconnect deploy`/`redeploy` will bundle just those files —
rsconnect-python honors the curated list and never overwrites it.
A configuration whose `files` is absent, empty, or `["*"]` declares no
restriction, so bundling falls through to the existing file-selection logic.
File selection is therefore unchanged for any content without a hand-curated
`files` list, including on repeat deploys of a project that had no `.posit`
metadata to begin with: a configuration rsconnect-python writes records
`files = ["*"]` rather than a snapshot of the files that happened to deploy, so
newly added source files and freshly rendered output keep being bundled.
- `integration_requests` declared in a `.posit/publish` configuration are now
propagated into the generated `manifest.json` (matching Posit Publisher), so
OAuth integration requests authored in Publisher are honored on deploy even
though rsconnect-python cannot create them itself.
- Fixed a bug where a project deployed first with `rsconnect deploy`/`redeploy`
could fail to redeploy from Posit Publisher with "the account provided is for
a different server; it must match the server for this deployment", even
though Publisher had resolved the correct account. rsconnect-python was
writing the deployment record's `server_url` verbatim (whatever cosmetic form
`--server`/a saved server happened to be in); Publisher compares that value
byte-for-byte against its own normalized account URL. `server_url` is now
normalized the same way Publisher normalizes it (lowercase scheme+host, no
trailing slash, no `/__api__` suffix, no default port, no duplicate slashes)
before being written.
- Fixed a bug where the `entrypoint` written to a `.posit/publish` configuration
could be a bare Python module reference (e.g. `app` for `app.py`) rather than
the literal file path Posit Publisher's schema expects. It's now resolved
back to the real file when the manifest confirms it exists; a genuine
`module:object` reference with no matching file (e.g. Shiny Express) is left
unchanged.

## [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
87 changes: 86 additions & 1 deletion rsconnect/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,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 @@ -1306,6 +1308,12 @@ def __init__(
self.deployed_info: RSConnectClientDeployResult | None = None
self._draft_deploy_supported: bool | None = None

# When a deploy originates from an existing .posit project (``redeploy``),
# these pin the exact config/record files to update so the write does not
# re-derive (and duplicate) them.
self.publisher_config_name: str | None = None
self.publisher_record_name: str | None = None

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

include_files, manifest_overlay = self._resolve_publisher_bundle_context(func)
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 All @@ -1627,6 +1637,42 @@ def make_bundle(

return self

def _resolve_publisher_bundle_context(
self, func: "Callable[..., Any]"
) -> "tuple[Optional[list[str]], dict[str, Any]]":
"""Resolve the ``.posit/publish`` inputs for this bundle build.

Returns ``(include_files, manifest_overlay)``:

- ``include_files`` -- the project-relative files to bundle, taken from an
applicable config's ``files`` allowlist, or ``None`` when no config
applies, which leaves the builder's whole-tree walk in place.
- ``manifest_overlay`` -- config-authored manifest fields rsconnect does
not derive from inspection (e.g. ``integration_requests``), propagated
into ``manifest.json`` exactly as Publisher would emit them.

Both are inert for ``deploy manifest`` (driven by its pre-built
``manifest.json``) and if resolution fails for any reason.
"""
if getattr(func, "__name__", "") == "make_manifest_bundle":
return None, {}
path = self.path
if os.path.isdir(path):
directory: str = path
entrypoint: Optional[str] = None
else:
directory = os.path.dirname(path) or "."
entrypoint = os.path.basename(path)
try:
from .publisher.store import resolve_bundle_files, resolve_manifest_overlay

include_files = resolve_bundle_files(directory, entrypoint, self.publisher_config_name)
overlay = resolve_manifest_overlay(directory, entrypoint, self.publisher_config_name)
return include_files, overlay
except Exception as exc: # best-effort: never block a deploy on .posit resolution
logger.debug("Could not resolve .posit bundle context: %s", exc)
return None, {}

def upload_posit_bundle(self, prepare_deploy_result: PrepareDeployResult, bundle_size: int, contents: bytes):
upload_url = prepare_deploy_result.presigned_url
parsed_upload_url = urlparse(upload_url)
Expand Down Expand Up @@ -1809,8 +1855,47 @@ def save_deployed_info(self):
self.app_mode,
)

# Dual-write Posit Publisher's .posit/publish config + deployment record
# for Connect/SPCS targets so the two tools interoperate. shinyapps.io /
# Posit Cloud (which lack a content GUID and dashboard URLs) stay on the
# legacy JSON store only.
if isinstance(self.remote_server, (RSConnectServer, SPCSConnectServer)):
self._save_publisher_metadata(deployed_info)

return self

def _save_publisher_metadata(self, deployed_info: RSConnectClientDeployResult):
"""Best-effort write of the ``.posit/publish`` config + record.

The deploy has already succeeded by the time metadata is saved, so any
failure here warns rather than aborting (mirroring the legacy save)."""
if self.bundle is None:
return
try:
from .publisher import schema
from .publisher.store import write_deployment_metadata

path = self.path
project_dir = path if os.path.isdir(path) else os.path.dirname(abspath(path))
product_type = (
schema.PRODUCT_TYPE_SNOWFLAKE
if isinstance(self.remote_server, SPCSConnectServer)
else schema.PRODUCT_TYPE_CONNECT
)
write_deployment_metadata(
project_dir=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=self.publisher_config_name,
record_name=self.publisher_record_name,
)
except Exception as e:
logger.warning("Could not write .posit/publish metadata: %s", e)

@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