Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 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
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,8 +1,12 @@
import glob
import os
import shutil
import ssl
import sys

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

import pytest

import httpretty.core

Expand All @@ -16,6 +20,27 @@
# 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()


# httpretty (1.1.4, released 2021) mocks TLS with
# `ssl.SSLContext.wrap_socket = functools.partial(fake_wrap_socket, ...)`.
Expand Down
58 changes: 50 additions & 8 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,56 @@ 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.
- Interoperability with Posit Publisher `.posit/publish` project files. On
deploys to Posit Connect or Snowflake (SPCS), rsconnect-python writes a
Publisher configuration (`.posit/publish/<name>.toml`) and a deployment
record (`.posit/publish/deployments/<name>.toml`) next to the existing
`rsconnect-python/` metadata, and reads and preserves configurations and
records that Publisher wrote. One project can publish with either tool.
- `rsconnect redeploy [PATH]` command. It redeploys content from an existing

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.

To my earlier question: one thing that sounds odd to me about adding a redeploy command is that we already can re-deploy. rsconnect-python records somewhere where it has previously deployed, so rsconnect deploy fastapi . for example will use the same content guid as the last time. With this change, does it mean that rsconnect deploy fastapi . and rsconnect redeploy . will both re-deploy but go through distinct code paths?

Why instead not have just rsconnect deploy ., and (in the case here where there is no pseudo-appmode given) it first looks for the TOML file, then falls back to looking for the old rsconnect-python record, etc.? Since the appmode is immutable on Connect, you don't need to specify it when you're re-deploying content, it must be whatever it already is on Connect so you can query it from the API. (That's what connect-actions does.) So even without a publisher TOML you can reconstruct enough to deploy with just the content GUID (from the rsconnect-python record, or as an argument).

`.posit/publish` project, reading the target server and content identity
from the deployment record, so you do not need to give a framework,
entrypoint, or server. `PATH` defaults to the current directory. It also
works from a legacy `manifest.json` and `rsconnect-python/` deployment
record, and writes `.posit` files for future use. `redeploy` supports
every content type rsconnect-python can bundle except R, since
rsconnect-python has no R bundler. It carries forward only the
entrypoint, requirements, title, and content ID from a configuration; if
the configuration sets `description`, `environment`, `secrets`, or
`connect.*`, it prints a warning and deploys anyway, since applying those
settings needs a Connect API call `redeploy` does not yet make.

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.

"yet" implies that it will, and I'm not sure it should.

- `.posit/publish` output from `rsconnect write-manifest` commands. Each
command now writes a configuration next to the generated `manifest.json`.
- Read support for Connect Cloud (`connect.posit.cloud`) `.posit` files.
rsconnect-python reads and preserves these files for interoperability, but
does not support deploying to Connect Cloud.
- File curation from a `.posit/publish` configuration's `files` list. When

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.

These bullets sound more like documentation about how it works, not separate changelog entries.

the list names a subset of the project, deploys bundle exactly those
files, using `.gitignore` syntax (a `!` prefix excludes a path), matching
Posit Publisher. rsconnect-python never overwrites a curated list. A
configuration with no `files` list, an empty list, or `["*"]` sets no
restriction, so file selection is unchanged from before.
- `integration_requests` propagation from a `.posit/publish` configuration
into the generated `manifest.json`, matching Posit Publisher. This honors
OAuth integration requests that Publisher authored, even though
rsconnect-python cannot create them itself.
- Literal package-file listing (for example `requirements.txt`) in a
`.posit/publish` configuration that rsconnect-python writes for Python
content, alongside the default `["*"]`. Posit Publisher's redeploy checks
a configuration's `files` list for the package file by literal name
rather than by expanding `*`, so this lets Publisher redeploy content
whose configuration rsconnect-python wrote. It changes nothing about
which files actually bundle.

## [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
Loading