Posit Publisher .posit/publish: TOML interop, redeploy, and config-driven bundling - #830
Posit Publisher .posit/publish: TOML interop, redeploy, and config-driven bundling#830mconflitti-pbc wants to merge 18 commits into
Conversation
|
Follow-up work is tracked in #831: applying a |
5fa320b to
3f7218b
Compare
|
☂️ Python Coverage
Overall Coverage
New Files
Modified Files
|
I'm not sure that's what we want, the reason IIRC that there is a separate tracking of included/excluded files is that for rendered content, you might gitignore the build output (the html that a quarto project produces, for example) because you don't want to check it in, but might want to deploy exactly that. That's at least why .gitignore wasn't just used before. It's possible that that's not the right tradeoff, and that there's a better way to solve for that. But I wouldn't just casually do that here, I think that's a more significant behavior change. |
Yeah that is fair! Will revert that |
|
I'm a bit confused about the interactions with |
|
Why do we need |
to avoid breaking changes/behavior with existing deploy commands. Could try to trigger redeploy behavior with empty deploy command but figured we could avoid that. |
That is fair. Was punting on UX improvements of existing commands if I can help it. If you have ideas, I am open to them! |
I think because when users say "I want to deploy",
and now there would be
that's why I was wondering how we can ease reasoning about this for users. Especially seems it looks like that this PR would always introduce the |
Read and write Publisher's .posit/publish config + deployment record files alongside the legacy rsconnect-python JSON store, so content can be published with either tool. - New rsconnect/publisher package (schema, serialize, config, record, store) porting Publisher's format: content-type map, $schema-first TOML with multiline arrays, and the random base-32 file-naming methodology. - Dual-write .posit on Connect/SPCS deploys via save_deployed_info (best-effort). - New 'rsconnect redeploy [PATH]' command driven by .posit, with a fallback to manifest.json + legacy rsconnect-python/*.json for pre-.posit content. - 'write-manifest' commands also emit a .posit config. - Connect Cloud files are read/preserved for interop but not deployable here. - Adds tomli-w dependency; tests in tests/test_publisher.py and test_redeploy.py.
save_deployed_info re-derived the config/record filenames independently of what redeploy resolved, so when the resolved record lacked a configuration_name (or its entrypoint differed from the bundle), a fresh <title>-<CODE>.toml config was minted instead of updating the resolved one. redeploy now threads the resolved config_name and record_name through the executor to write_deployment_metadata, which uses them to update those exact files. Adds tests covering the pin and the threading.
Publisher keeps its own credentials in VS Code SecretStorage, which a CLI cannot read, and a .posit record stores only server_url (never the key). So redeploy now matches the record's server_url against rsconnect-python's own saved servers (ServerStore) by normalized URL -- the same join Publisher uses -- and deploys under that nickname when the caller gave no explicit credential. Ambiguous matches (>1 saved server for the same URL) raise, asking for --name. Also fixes normalize_url to strip a trailing slash before the /__api__ suffix so '.../__api__/' compares equal to the base URL.
When a .posit/publish config applies to the content, deploys now bundle exactly the files its `files` patterns select (gitignore-syntax, include by default, `!` excludes), matching Posit Publisher's bundler. Without a config, deploys now honor .gitignore in addition to the built-in ignore list. A config rsconnect writes records the concrete deployed file set so config, manifest, and record all agree. - New rsconnect/publisher/files.py: pathspec-based select_config_files (allowlist) and select_default_files (.gitignore denylist). - bundle.create_file_list gains include_files + a restrict_to_files contextmanager; the executor resolves the selection and wraps builders. - store.resolve_bundle_files chooses config vs. default and force-includes the entrypoint; _config_file_patterns now emits the concrete, root-anchored list plus the .posit config/record paths (mirrors Publisher). - Add pathspec dependency.
A .posit/publish config can declare integration_requests that rsconnect cannot originate itself. Connect reads these from manifest.json (as Posit Publisher's manifestFromConfig does), so when a config applies its integration_requests are now merged into the generated manifest. - bundle.py: overlay_manifest contextmanager + _apply_manifest_overlay, merged in Manifest.__init__ (the single manifest choke point; make_manifest_bundle stays manifest-driven). ManifestData gains integration_requests. - store.py: config_manifest_overlay maps a config's integration_requests to Publisher's manifest shape; resolve_manifest_overlay + _select_applicable_config (extracted from resolve_bundle_files). - api.py: RSConnectExecutor resolves file selection + manifest overlay together and wraps the builder in both contexts.
A function return annotation used the PEP 585 builtin generic dict[...], which Python 3.8 cannot subscript at definition time, breaking collection of the whole suite on 3.8. Add 'from __future__ import annotations'.
The integration-test CI job exports CONNECT_SERVER/CONNECT_API_KEY, which leaked through the --server/--api-key env-var options into the redeploy CLI under test and overrode the .posit-record-based server/identity resolution (6 failures, e.g. deploy server resolving to http://localhost:3939 instead of the record's URL). Add an autouse fixture that scrubs the CONNECT_* credential/server env vars so these tests resolve from the record as intended.
Three defects in how a deploy's ``.posit`` metadata is written and read back, all of which made a subsequent ``redeploy`` bundle the wrong file set: - ``write_deployment_metadata`` recomputed ``rname`` after using it to build the config's ``files``. When no record existed yet, ``_new_record_name`` minted a second random name, so the config's ``files`` referenced a deployment record that was never written -- and the record that *was* written never shipped in the next bundle. - ``_config_file_patterns`` anchored the manifest's ``metadata.entrypoint`` even when it is a module reference rather than a path (``deploy shiny`` records ``app`` for ``app.py``), seeding configs with a ``/app`` include that matches nothing. It now only surfaces the entrypoint when it names a deployed file, and still leads the list in that case. - ``resolve_bundle_files`` treated a config with an empty/absent ``files`` list as "no config", falling through to the ``.gitignore``-aware whole-tree default. Publisher's ``collectFiles`` defaults such a list to ``["*"]``; match that, so the config governs and STANDARD_EXCLUSIONS still apply. Adds regression coverage that asserts ``redeploy``'s actual bundle members (the existing redeploy tests stub out ``make_bundle``, so they never saw the file list): only the config's files are bundled, ``redeploy`` and an equivalent ``deploy`` agree, and repeated redeploys stay stable.
Config-less deploys now bundle exactly as they always have: the whole-tree walk with only the built-in ignore list. .gitignore is the wrong signal for bundling. Rendered content is routinely gitignored precisely because it shouldn't be committed --- a Quarto project's HTML output, for example --- yet that output is exactly what needs to deploy. That is why bundling tracked included/excluded files separately in the first place. Narrowing the default file set may still be worth doing, but it is a larger behavior change than .posit interop and belongs in its own change. The config-driven path is unaffected: when a .posit/publish config applies, its `files` list still decides the selection. Drops `select_default_files` and `_gitignore_spec`; `resolve_bundle_files` now returns None when no config applies, which `restrict_to_files` already treats as a no-op.
A project that never had .posit was still changing behavior on its *second* deploy. Deploy #1 writes a config recording the concrete set of files it deployed; deploy #2 finds that config and treats the snapshot as user curation. A module added in between, or output rendered in between, silently stopped being bundled --- no warning, no diagnostic. rsconnect can't know which files a user *meant* to exclude, so it no longer guesses: a config it mints records files = ["*"], and resolve_bundle_files treats an absent/empty/["*"] list (ignoring the .posit paths Publisher adds so they ship) as "no restriction", falling through to the unchanged whole-tree walk. Hand-curated files lists are still honored --- that's the actual feature. Verified a first deploy of a plain project selects a byte-identical file list on this branch and on main, and that a second deploy adds only the .posit files themselves.
Covers the interop workflow end to end: rsconnect deploys (writing
files = ["*"]), the user narrows the list to three files in Publisher,
then rsconnect redeploys. Asserts the curated list is honored by both
`redeploy` and a plain `deploy`, and that rsconnect does not overwrite
the curation with its own default.
Also pins two `files` shapes that could plausibly regress:
- curated patterns alongside the .posit paths Publisher appends --- the
.posit entries must not make the list read as unrestricted
- ["*", "!secrets.txt"], which is curation by exclusion and must not be
flattened away by the "* means everything" check
CHANGELOG now states the round-trip contract explicitly.
test_no_config_bundles_gitignored_files asserted a hardcoded forward-slash path, but create_file_list's whole-tree walk returns os.path.relpath results, which use the native separator. Failed CI on windows-latest (py3.13) with backslash paths; passed everywhere else.
Publisher's Go backend rejects a redeploy with "the account provided is for a different server; it must match the server for this deployment" whenever a record's server_url isn't byte-identical to the selected account's URL (internal/state/state.go: `target.ServerURL != account.URL`, a plain string comparison -- no normalization at compare time). Publisher's own writers always store the account's URL, which is normalized via purell (FlagsSafe | RemoveTrailingSlash | RemoveDotSegments | RemoveDuplicateSlashes) whenever a credential is created or loaded. rsconnect-python was writing server_url verbatim -- whatever string the user typed for --server or saved via `rsconnect add`, e.g. with a trailing slash, mixed-case host, or an /__api__ suffix. A project deployed first with rsconnect-python and then opened in Publisher could therefore fail with a server-URL mismatch even though Publisher correctly resolved the intended account by name (visible in Publisher's access log as req.account=<name>) -- the account was right, but the stored URL didn't match its normalized form byte-for-byte. Fix: write normalize_url(server_url) into the record instead of the raw value. Also brought normalize_url's own normalization up to match purell's rules exactly -- it was already stripping /__api__ and lowercasing scheme+host, but didn't strip an explicit default port (:443 for https, :80 for http) or collapse duplicate slashes in the path, both of which purell does. Verified: test_written_server_url_matches_publisher_normalization pins Publisher's exact set of cosmetic variations (case, trailing slash, default port, duplicate slashes, /__api__ with and without a trailing slash) against the values rsconnect-python now writes.
rsconnect-python's Python deploys record metadata.entrypoint as a bare
importable module reference ("app" for app.py, sometimes "module:object")
rather than a path. That value flowed unmodified into a written
.posit/publish config's entrypoint field, even though Publisher's schema
documents entrypoint as "Name of the primary file containing the
content", and Publisher's own detectors (pyshiny.ts, pythonApp.ts) always
record the literal filename for Shiny/Flask/FastAPI/Dash -- only Shiny
Express writes a module:object reference, and that module never names a
real file.
_recover_entrypoint_path (record.py) strips a trailing ":object" and
checks whether "<module>.py" is one of the manifest's actual files; if
so that's the real, verified path to record. Left unchanged when no
matching file exists, so a genuine module:object reference (Shiny
Express, or an explicit --entrypoint override with no corresponding
file) is untouched. Verified against Publisher's own detector source
(posit-dev/publisher) that Flask/FastAPI/Dash/plain-Shiny all expect the
literal filename and only Shiny Express uses shiny.express.app:<var>.
Sits in details_from_manifest, the single choke point both the deploy
write path and write-manifest go through.
Widen redeploy/deploy-pyproject dispatch to Dash, Bokeh, Gradio, Panel, HTML, and Node.js (R stays out of scope: rsconnect-python has no R bundle builder). Warn, rather than silently no-op, when a resolved .posit config sets description/environment/secrets/connect.* settings that redeploy cannot yet push to Connect. Fix two data-loss bugs: an existing config's [r] table was dropped on rewrite, and a record's bundle_url field was declared managed but never modeled, so it vanished on the first rewrite. Disambiguate deployment-record matching by content GUID so two apps on one project deployed to the same server no longer collide onto a single record.
60c2c58 to
c3e77d6
Compare
|
@nealrichardson i reverted the gitignore change. @amol- re: the new command adding a parallel path allows this to be added and used by our agentic deployment skill alongside projects that have been deployed using publisher or vice versa. the goal of this pr was not to change the fundamental structure of the deploy command. definitelty open to folding that behavior into the deploy command in a follow up. there are other things to address as well regarding how we want to rsconnect-python to handle settings which are tracked in #831 |
Publisher's redeploy preflight (connectPublish.ts) checks for requirements.txt/environment.yml by literal pattern.endsWith(name) against a config's files, rather than expanding glob patterns the way its own bundler does. rsconnect-python's default files = ["*"] already selects that file for bundling, but fails Publisher's own redeploy because "*" never matches the endsWith check. write_deployment_metadata and write_config_from_manifest now also append the literal, root-anchored package-file path (from the manifest's python.package_manager.package_file) to a freshly minted config's files, alongside "*". This changes nothing about what actually bundles. _is_unrestricted is taught that a bare "*" plus only non-negated literal entries is still "everything" -- a !-free pattern list containing "*" can never select less than everything, so the extra literal is redundant for selection purposes. Without this, the next deploy would misread the appended literal as user curation and switch from the whole-tree walk to the config-driven selector, changing default file selection for content that was never curated.
Reformats each entry to short, active-voice sentences and adds section headings, and documents the Python package-file redeploy fix as a single paragraph alongside the other Unreleased entries.
Nothing in the Unreleased section has shipped yet, so there is no prior release for this to have been broken in -- it is part of building the .posit interop feature correctly the first time, not a fix to it.
| 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. |
There was a problem hiding this comment.
(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.
| 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 |
There was a problem hiding this comment.
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).
| 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. |
There was a problem hiding this comment.
"yet" implies that it will, and I'm not sure it should.
| - 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 |
There was a problem hiding this comment.
These bullets sound more like documentation about how it works, not separate changelog entries.
| @@ -0,0 +1,175 @@ | |||
| """The ``.posit/publish/<name>.toml`` configuration file (the "what"). | |||
dotNomad
left a comment
There was a problem hiding this comment.
Looking at how this is setup I think it could cause some confusion.
My read is that deploy will not use .posit/publish files to determine what or how to deploy, but will write the .posit/publish files.
The redeploy command will use and write the .posit/publish files.
But there is a pretty large difference in how re-deployment and --new work. rsconnect will, when targeting the same server, will forget about previous deployments - only storing one content item per-server. That will cause the .posit/publish files to have a single configuration and two deployments - that is totally fine, Publisher handles that and it is fully expected however rsconnect doesn't play nicely there.
rsconnect deploy will use the new deployment and rsconnect redeploy will error because it doesn't know which deployment of the two to use. If you remove one of the deployment records you could have rsconnect deploy going to the new content item and rsconnect redeploy going to the old which is very confusing.
I think it would be best for us to sync about this and come up with a minimal path forward to get the behavior, but not try to re-design rsconnect too much.
My preference would be to use .posit/publish configuration and deployment files, but mimic the rsconnect behavior of only having one deployment per server to start. It means that you cannot easily have two content items on a server like you can with Publisher, but it avoids some real confusing cases like the one above and reduces the need, initially at least, of the redeploy command.
What if we made a new command for real? A parallel deploy path we can describe as the one for people using both reconnect and the publisher to deploy the same content. |
If we were to do that, we're basically making a v2 deploy CLI, and I would advocate for doing that in I'm not trying to put extra requirements on here to stop forward progress, but if that's the direction we're headed, let's just do it. |
Summary
Adds interoperability with Posit Publisher's
.posit/publishproject files, so content can be published with either rsconnect-python or the Publisher VS Code extension. Addresses #829.rsconnect-python now reads and writes Publisher's TOML configuration (
.posit/publish/<name>.toml) and deployment record (.posit/publish/deployments/<name>.toml) alongside the existing legacyrsconnect-python/*.jsonstore, and gains aredeploycommand that redeploys straight from a.positproject.Bundling becomes config-driven only when a configuration curates a subset: if a
.positconfig'sfileslist names real content patterns, exactly those files are bundled (Publisher's.gitignore-syntax include/exclude semantics — a matching pattern includes,!excludes). Otherwise — no config, or a config declaringfiles = ["*"]— file selection is unchanged from today: the whole-tree walk with only the built-in ignore list. A config'sintegration_requestsare propagated into the generatedmanifest.jsonexactly as Publisher emits them, so integrations authored in Publisher are honored on deploy.Usage examples
1. Deploy as usual — now also writes
.posit2. Zero-arg redeploy from an existing
.positproject3. Redeploy a project by path
$ rsconnect redeploy ./my-app4. Interop: redeploy a project that was published from the Publisher VS Code extension
5. Credentials are matched by the record's server URL
6. Disambiguate when a project has multiple configs or servers
7. First-ever deploy from a Publisher config (config exists, no record yet)
8.
write-manifestnow also emits a.positconfig9. Redeploy pre-
.positcontent (manifest + legacy JSON fallback)10. Config-driven bundling — only the config's files are bundled
A config rsconnect-python writes itself records
files = ["*"], not a snapshot ofthe files that happened to deploy — so a project that never had
.positkeepsbundling identically on every subsequent deploy, and newly added modules or freshly
rendered output are still picked up.
What's included
rsconnect/publisher/package porting Publisher's format (posit-dev/publisher):schema.py— schema URLs, theapp_mode ↔ content-typemap (frombundler/appMode.ts),.positpath helpers.serialize.py—tomli_wwriter matching Publisher (quoted$schemafirst, multiline arrays, tables last); reader via stdlibtomllib/tomlbackport.config.py/record.py— dataclasses with read‑merge‑write that preserves fields rsconnect doesn't own (incl. first-classconnect_cloud, and an existing[r]table); recoversfiles/requirements/entrypoint from the built bundle manifest, resolving a bare Python module reference (e.g.appforapp.py) back to the literal file path Publisher's schema expects, so long as the manifest confirms that file exists.store.py— dual-write on deploy +resolve_publisher_deploy_targetfor reads; Publisher-compatible random file naming (<title>-<CODE>/deployment-<CODE>, 4-char base‑32) with find-or-reuse so redeploys update in place, disambiguated by content GUID when more than one deployment record shares a server; normalizesserver_urlthe same way Publisher's own writers do (lowercase scheme+host, no trailing slash, no/__api__suffix, no default port, no duplicate slashes), so a project deployed first with rsconnect-python doesn't fail a later Publisher redeploy on a byte-for-byte URL mismatch.RSConnectExecutor.save_deployed_info(best-effort; never fails the deploy).rsconnect redeploy [PATH]with the behaviors shown above, dispatching to every content type rsconnect-python can build a bundle for: Shiny, FastAPI, Flask/API, Streamlit, Dash, Bokeh, Gradio, Panel, HTML, Node.js, Jupyter notebooks (incl. Voila), and Quarto.write-manifestnow also emits a.positconfig.rsconnect/publisher/files.py(apathspec/gitwildmatch engine reproducing Publisher'sbundler/collect.ts:select_config_filesallowlist +STANDARD_EXCLUSIONS). Wired throughbundle.create_file_listvia arestrict_to_filescontext set by the executor, so all deploy commands honor it.store.resolve_bundle_filesreturns the config's selection (force-including the entrypoint), orNone— impose no restriction, leaving today's whole-tree walk untouched — when no config applies or when the config declares no real restriction (filesabsent, empty, or["*"], discounting the.positpaths Publisher adds so they ship). Configs rsconnect mints recordfiles = ["*"]; the concrete deployed set lives on the deployment record, where it can't be mistaken for curation.integration_requests→manifest.json—bundle.overlay_manifestmerges config-authored manifest fields rsconnect can't derive (currentlyintegration_requests) inManifest.__init__;store.config_manifest_overlay/resolve_manifest_overlaymap them to Publisher's manifest shape._plan_deploy_bundlehelper extracted fromdeploy pyproject(reused byredeploy, including its widened content-type coverage above).tomli-wandpathspecdependencies.Scope notes
rsconnectpackage), so.positinterop can't originate R content either, andredeploynever dispatches to r-shiny/r-plumber/rmd/rmd-shiny. The one R-related change here: rewriting a config now preserves an existing[r]table (matching howpython/quartoare preserved), so rsconnect-python never destroys Publisher-authored R metadata it doesn't understand.connect.posit.cloud).positfiles are read/preserved for interop, but deploying to Connect Cloud is not supported by this tool (no client) — only Posit Connect and Snowflake/SPCS write.posit. shinyapps.io / Posit Cloud stay legacy-JSON-only (as Publisher also writes no.positfor them).fileslist. Two revisions of this PR got this wrong and were fixed:.gitignore. Reverted —.gitignoreis the wrong signal for bundling: a Quarto project's rendered HTML is routinely gitignored precisely because it shouldn't be committed, yet it's exactly what needs to deploy. Narrowing the default file set is a larger behavior change than.positinterop and belongs in its own PR.files = ["*"], and an unrestricted list imposes no restriction.Verified by capturing the tarball member list for a first deploy of a plain project on this branch and on
main: byte-identical. A second deploy adds only the two.positfiles.rsconnect sync#831): a config's Connect-side settings that Publisher applies via the Connect API rather than the manifest —description,connect.runtime.*,connect.access.*,connect.kubernetes.*, andenvironment/secrets— are preserved on disk but never read for behavior;redeployonly ever carries forwardentrypoint,requirements,title, and the content id, and doesn't issue the Connect API calls (PATCH content, env vars) that would apply the rest — whether or not they were just edited in the TOML. This is a no-op, not a clobber: the live Connect deployment is left exactly as it was, never reset to a default.redeploynow prints a warning when a resolved configuration sets any of these, so an edit isn't silently swallowed with no feedback. Full application is tracked in Apply .posit/publish config Connect-side settings: post-deploy hook +rsconnect sync#831.uv.lockdiff is large but only addstomli-w/pathspec(a reserialization; nothing removed or version-bumped).Testing
tests/test_publisher.py— type-map coverage (incl. TensorFlow →unknown), round-trips, idempotent redeploy, URL normalization, a Publisher-authored interop fixture,connect_cloudround-trip, naming methodology, config/record pinning, that a written config'sfilesreference the record actually written,bundle_urlround-tripping (including surviving a redeploy that doesn't set it), the settings-warning helper (unhonored_redeploy_settings), and that two apps in one project deployed to the same server get distinct, non-colliding records.tests/test_redeploy.py— command registration, identity reuse, config-name selection, legacy fallback, credential matching by URL (incl. ambiguous → error), and tarball-level assertions thatredeploybundles exactly the config's files, matchesdeploy's bundle, stays stable across repeated redeploys, and — for a project with no.posit— that a second deploy still bundles a later-added module and gitignored rendered output.tests/test_publisher_files.py— config allowlist matching (rooted vs. basename,!exclusion, dir includes,**,STANDARD_EXCLUSIONS, venv/renv skipping), thecreate_file_listrestriction + builder-exclude interaction,resolve_bundle_filesreturningNonewithout a config (and a gitignored build artifact still bundling) and for an unrestricted config (absent /["*"]/["*"]+.positpaths), restriction for a genuinely curated config, entrypoint force-include, andintegration_requestspropagation into the manifest (incl. an end-to-end executor test).tests/test_deploy_pyproject.py— updated so the "unsupported app_mode" coverage still exercises a genuinely unhandled mode (R Shiny) now that Dash/Bokeh/Gradio/Panel/HTML/Node.js are supported, using a synthetic aliased-name case to keep covering that an unsupported alias fails quoting the configured string, not its resolved target.ruff format/ruff checkclean.Try it
uvx --from "git+https://github.com/posit-dev/rsconnect-python.git@feat/publisher-toml-support" rsconnect redeploy --help🤖 Generated with Claude Code