From bfd94f03ba4b3d43ae2e1413fba2b374a302d72a Mon Sep 17 00:00:00 2001 From: Matt Conflitti Date: Fri, 24 Jul 2026 16:12:54 -0400 Subject: [PATCH 01/14] Add Posit Publisher .posit/publish TOML interoperability 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. --- conftest.py | 27 +- docs/CHANGELOG.md | 18 + pyproject.toml | 1 + rsconnect/api.py | 37 ++ rsconnect/main.py | 533 +++++++++++++++--- rsconnect/publisher/__init__.py | 10 + rsconnect/publisher/config.py | 173 ++++++ rsconnect/publisher/record.py | 276 +++++++++ rsconnect/publisher/schema.py | 114 ++++ rsconnect/publisher/serialize.py | 73 +++ rsconnect/publisher/store.py | 352 ++++++++++++ tests/test_publisher.py | 338 +++++++++++ tests/test_redeploy.py | 281 ++++++++++ uv.lock | 923 ++++++++++++++++--------------- 14 files changed, 2627 insertions(+), 529 deletions(-) create mode 100644 rsconnect/publisher/__init__.py create mode 100644 rsconnect/publisher/config.py create mode 100644 rsconnect/publisher/record.py create mode 100644 rsconnect/publisher/schema.py create mode 100644 rsconnect/publisher/serialize.py create mode 100644 rsconnect/publisher/store.py create mode 100644 tests/test_publisher.py create mode 100644 tests/test_redeploy.py diff --git a/conftest.py b/conftest.py index 4cb10b077..1e1af61e7 100644 --- a/conftest.py +++ b/conftest.py @@ -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__)) @@ -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() diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 63f3e6b04..80bee78f7 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -14,6 +14,24 @@ 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/.toml`) and deployment + record (`.posit/publish/deployments/.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. ## [1.30.0] - 2026-07-16 diff --git a/pyproject.toml b/pyproject.toml index ad80830c0..59e86965b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "click>=8.0.0", "packaging>=20.0", "toml>=0.10; python_version < '3.11'", + "tomli-w>=1.0.0", ] [project.scripts] diff --git a/rsconnect/api.py b/rsconnect/api.py index 29b0f2381..6a669d1f9 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -1809,8 +1809,45 @@ 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, + ) + 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 diff --git a/rsconnect/main.py b/rsconnect/main.py index 2202872fe..a762f30f9 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -1,6 +1,7 @@ from __future__ import annotations import functools +import glob import json import os import shutil @@ -130,6 +131,8 @@ ) from .log import VERBOSE, LogOutputFormat, logger, warn_user from .metadata import AppStore, ServerStore +from .publisher.store import normalize_url as publisher_normalize_url +from .publisher.store import resolve_publisher_deploy_target from .models import ( AppMode, AppModes, @@ -2042,6 +2045,110 @@ def deploy_bundle( ce.emit_content_url() +def _plan_deploy_bundle( + directory: str, + app_mode: AppMode, + entrypoint: str, + requirements_file: str, + exclude_renv: bool, + unsupported_message: str, +) -> "tuple[str, Callable[..., Any], tuple[Any, ...], dict[str, Any]]": + """Resolve the bundle builder and arguments for a Python/Quarto app mode. + + Shared by ``deploy pyproject`` and ``redeploy``: both resolve a target + (entrypoint, app_mode, requirements file) from a TOML config and then need + the same per-app-mode bundling plan. Returns + ``(path, bundle_builder, bundle_args, bundle_kwargs)`` where ``path`` is what + the executor should treat as the content path. + """ + extra_files: tuple[str, ...] = tuple() + excludes: tuple[str, ...] = tuple() + bundle_builder: Callable[..., Any] + bundle_args: tuple[Any, ...] + bundle_kwargs: dict[str, Any] = {} + path = directory + + # renv.lock detection mirrors the dedicated deploy commands; --exclude-renv + # opts out, otherwise detection is driven by the lockfile's presence. + r_environment = None if exclude_renv else REnvironment.create(directory) + + if app_mode in (AppModes.STREAMLIT_APP, AppModes.PYTHON_SHINY, AppModes.PYTHON_FASTAPI, AppModes.PYTHON_API): + if app_mode == AppModes.PYTHON_SHINY: + entrypoint = resolve_shiny_express_entrypoint(entrypoint, directory) + environment = Environment.create_python_environment( + directory, + requirements_file=requirements_file, + override_python_version=None, + ) + bundle_builder = make_api_bundle + bundle_args = (directory, entrypoint, app_mode, environment, extra_files, excludes) + bundle_kwargs = { + "image": None, + "env_management_py": None, + "env_management_r": None, + "r_environment": r_environment, + } + elif app_mode == AppModes.JUPYTER_NOTEBOOK: # This is "jupyter-static" + path = str(Path(directory) / entrypoint) + environment = Environment.create_python_environment( + directory, + requirements_file=requirements_file, + override_python_version=None, + ) + bundle_builder = make_notebook_source_bundle + # Legacy app mode - no need to override the bundle builder default + bundle_args = (path, environment, extra_files, False, False) + bundle_kwargs = { + "image": None, + "env_management_py": None, + "env_management_r": None, + "r_environment": r_environment, + } + elif app_mode == AppModes.JUPYTER_VOILA: + environment = Environment.create_python_environment( + directory, + requirements_file=requirements_file, + override_python_version=None, + ) + bundle_builder = make_voila_bundle + bundle_args = (directory, entrypoint, extra_files, excludes, True, environment) + bundle_kwargs = { + "image": None, + "env_management_py": None, + "env_management_r": None, + "r_environment": r_environment, + "multi_notebook": False, + } + elif app_mode in (AppModes.STATIC_QUARTO, AppModes.SHINY_QUARTO): + path = str(Path(directory) / entrypoint) + with cli_feedback("Inspecting Quarto project"): + quarto = which_quarto(None) + logger.debug("Quarto: %s" % quarto) + inspect = quarto_inspect(quarto, path) + engines = validate_quarto_engines(inspect) + + environment = None + if "jupyter" in engines: + with cli_feedback("Inspecting Python environment"): + environment = Environment.create_python_environment( + directory, + requirements_file=requirements_file, + override_python_version=None, + ) + bundle_builder = create_quarto_deployment_bundle + bundle_args = (path, extra_files, excludes, app_mode, inspect, environment) + bundle_kwargs = { + "image": None, + "env_management_py": None, + "env_management_r": None, + "r_environment": r_environment, + } + else: + raise RSConnectException(unsupported_message) + + return path, bundle_builder, bundle_args, bundle_kwargs + + @deploy.command( name="pyproject", short_help="Deploy content to Posit Connect or shinyapps.io by pyproject.", @@ -2131,90 +2238,15 @@ def quickstart_hint() -> str: entrypoint = target.entrypoint effective_title = target.title requirements_file = target.requirements_file - extra_files: tuple[str, ...] = tuple() - excludes: tuple[str, ...] = tuple() - bundle_builder: Callable[..., Any] - bundle_args: tuple[Any, ...] - bundle_kwargs: dict[str, Any] = {} - path = directory - - # renv.lock detection mirrors the dedicated deploy commands; --exclude-renv - # opts out, otherwise detection is driven by the lockfile's presence. - r_environment = None if exclude_renv else REnvironment.create(directory) - - if app_mode in (AppModes.STREAMLIT_APP, AppModes.PYTHON_SHINY, AppModes.PYTHON_FASTAPI, AppModes.PYTHON_API): - if app_mode == AppModes.PYTHON_SHINY: - entrypoint = resolve_shiny_express_entrypoint(entrypoint, directory) - environment = Environment.create_python_environment( - directory, - requirements_file=requirements_file, - override_python_version=None, - ) - bundle_builder = make_api_bundle - bundle_args = (directory, entrypoint, app_mode, environment, extra_files, excludes) - bundle_kwargs = { - "image": None, - "env_management_py": None, - "env_management_r": None, - "r_environment": r_environment, - } - elif app_mode == AppModes.JUPYTER_NOTEBOOK: # This is "jupyter-static" - path = str(Path(directory) / entrypoint) - environment = Environment.create_python_environment( - directory, - requirements_file=requirements_file, - override_python_version=None, - ) - bundle_builder = make_notebook_source_bundle - # Legacy app mode - no need to override the bundle builder default - bundle_args = (path, environment, extra_files, False, False) - bundle_kwargs = { - "image": None, - "env_management_py": None, - "env_management_r": None, - "r_environment": r_environment, - } - elif app_mode == AppModes.JUPYTER_VOILA: - environment = Environment.create_python_environment( - directory, - requirements_file=requirements_file, - override_python_version=None, - ) - bundle_builder = make_voila_bundle - bundle_args = (directory, entrypoint, extra_files, excludes, True, environment) - bundle_kwargs = { - "image": None, - "env_management_py": None, - "env_management_r": None, - "r_environment": r_environment, - "multi_notebook": False, - } - elif app_mode in (AppModes.STATIC_QUARTO, AppModes.SHINY_QUARTO): - path = str(Path(directory) / entrypoint) - with cli_feedback("Inspecting Quarto project"): - quarto = which_quarto(None) - logger.debug("Quarto: %s" % quarto) - inspect = quarto_inspect(quarto, path) - engines = validate_quarto_engines(inspect) - environment = None - if "jupyter" in engines: - with cli_feedback("Inspecting Python environment"): - environment = Environment.create_python_environment( - directory, - requirements_file=requirements_file, - override_python_version=None, - ) - bundle_builder = create_quarto_deployment_bundle - bundle_args = (path, extra_files, excludes, app_mode, inspect, environment) - bundle_kwargs = { - "image": None, - "env_management_py": None, - "env_management_r": None, - "r_environment": r_environment, - } - else: - raise RSConnectException(f"Unsupported app_mode '{target.configured_app_mode}' in [tool.rsconnect]") + path, bundle_builder, bundle_args, bundle_kwargs = _plan_deploy_bundle( + directory, + app_mode, + entrypoint, + requirements_file, + exclude_renv, + f"Unsupported app_mode '{target.configured_app_mode}' in [tool.rsconnect]", + ) ce = RSConnectExecutor( ctx=ctx, @@ -2256,6 +2288,304 @@ def quickstart_hint() -> str: ce.emit_content_url() +def _finish_redeploy( + ce: RSConnectExecutor, + directory: str, + app_mode: AppMode, + bundle_builder: Callable[..., Any], + bundle_args: tuple[Any, ...], + bundle_kwargs: dict[str, Any], + draft: bool, + no_verify: bool, + metadata: tuple[str, ...], + no_metadata: bool, +) -> None: + """Run the shared deploy tail for redeploy (metadata, bundle, deploy, verify). + + ``save_deployed_info`` in this chain writes the ``.posit`` config + record, so + a legacy-fallback redeploy also lays down ``.posit`` for next time. + """ + server_version = None + if isinstance(ce.client, RSConnectClient): + server_version = ce.client.server_version() + ce.metadata = prepare_deploy_metadata(directory, metadata, no_metadata, server_version) + + ( + ce.validate_server() + .validate_app_mode(app_mode=app_mode) + .make_bundle(bundle_builder, *bundle_args, **bundle_kwargs) + .deploy_bundle(activate=not ce.should_deploy_as_draft(draft, no_verify)) + .save_deployed_info() + .emit_task_log() + ) + if not no_verify: + ce.verify_deployment() + if not draft and ce.supports_verify_before_activate: + # The draft bundle verified successfully, so activate it. + ce.activate_deployment().emit_task_log() + + +def _legacy_records_for_dir(directory: str) -> list[dict[str, Any]]: + """Read legacy per-directory deployment records from ``rsconnect-python/*.json``. + + Each JSON file maps ``server_url -> AppMetadata``; this flattens them into a + list of entries (the "where": server_url, app_guid, app_mode) for redeploy of + content deployed before ``.posit`` existed. + """ + records: list[dict[str, Any]] = [] + for json_file in sorted(glob.glob(os.path.join(directory, "rsconnect-python", "*.json"))): + try: + with open(json_file) as f: + data = json.load(f) + except (OSError, ValueError): + continue + for entry in data.values(): + if isinstance(entry, dict) and entry.get("server_url"): + records.append(entry) + return records + + +def _redeploy_from_legacy( + ctx: click.Context, + directory: str, + server_filter: Optional[str], + *, + name: Optional[str], + server: Optional[str], + api_key: Optional[str], + snowflake_connection_name: Optional[str], + insecure: bool, + cacert: Optional[str], + app_id: Optional[str], + title: Optional[str], + env_vars: dict[str, str], + no_verify: bool, + draft: bool, + metadata: tuple[str, ...], + no_metadata: bool, +) -> None: + """Redeploy pre-``.posit`` content from a manifest.json + legacy JSON record. + + The manifest supplies the "what" (it is already a built bundle); the legacy + ``rsconnect-python/*.json`` store supplies the "where" (server + GUID). + """ + manifest_path = os.path.join(directory, "manifest.json") + if not os.path.exists(manifest_path): + raise RSConnectException( + "No .posit/publish configuration and no manifest.json found in {}; nothing to redeploy. " + "Deploy it once (e.g. with a `rsconnect deploy` command) to record its destination.".format(directory) + ) + + records = _legacy_records_for_dir(directory) + if server_filter: + normalized = publisher_normalize_url(server_filter) + records = [r for r in records if publisher_normalize_url(r["server_url"]) == normalized] + by_server = {r["server_url"]: r for r in records} + + if not by_server: + raise RSConnectException( + "No prior deployment found for {}. Specify a destination with --server or --name " + "to deploy it for the first time (e.g. `rsconnect deploy manifest`).".format(directory) + ) + if len(by_server) > 1: + raise RSConnectException( + "Multiple prior deployments found for {} ({}); specify one with --server.".format( + directory, ", ".join(sorted(by_server)) + ) + ) + entry = next(iter(by_server.values())) + + app_mode = read_manifest_app_mode(manifest_path) + ce = RSConnectExecutor( + ctx=ctx, + name=name, + server=server or entry["server_url"], + api_key=api_key, + snowflake_connection_name=snowflake_connection_name, + insecure=insecure, + cacert=cacert, + path=manifest_path, + app_id=app_id or entry.get("app_guid") or entry.get("app_id"), + title=title or default_title_from_manifest(manifest_path), + env_vars=env_vars, + ) + _finish_redeploy( + ce, directory, app_mode, make_manifest_bundle, (manifest_path,), {}, draft, no_verify, metadata, no_metadata + ) + + +@cli.command( + name="redeploy", + short_help="Redeploy content described by a .posit/publish project.", + help=( + "Redeploy content using an existing Posit Publisher project (.posit/publish). " + "The deployment record and configuration are read to recover the target server and " + "content identity, so no framework, entrypoint, or server needs to be specified. " + "PATH defaults to the current directory.\n\n" + "For a first-ever deployment (when no deployment record exists yet), name the " + "destination with --server or --name." + ), +) +@server_args +@spcs_args +@metadata_args +@click.option( + "--config-name", + "config_name", + default=None, + help="Name of the .posit/publish configuration to use (without the .toml suffix). " + "Required only when the project has more than one configuration.", +) +@click.option( + "--app-id", + "-a", + default=None, + help="Override the content ID or GUID to redeploy. Defaults to the id recorded in the deployment record.", +) +@click.option("--title", "-t", default=None, help="Override the content title.") +@click.option( + "--environment", + "-E", + "env_vars", + multiple=True, + callback=validate_env_vars, + help="Set an environment variable. Specify a value with NAME=VALUE, or just NAME to use the " + "value from the local environment. May be specified multiple times.", +) +@click.option( + "--no-verify", + is_flag=True, + help="Don't access the deployed content to verify that it started correctly.", +) +@click.option( + "--draft", + is_flag=True, + help="Deploy the application as a draft and verify it, but do not activate it.", +) +@click.option( + "--exclude-renv", + "exclude_renv", + is_flag=True, + default=False, + help="Skip renv.lock detection. R dependencies will not be added to the manifest.", +) +@click.argument("path", required=False, type=click.Path(exists=True, dir_okay=True, file_okay=False)) +@cli_exception_handler +@click.pass_context +def redeploy( + ctx: click.Context, + name: Optional[str], + server: Optional[str], + api_key: Optional[str], + insecure: bool, + cacert: Optional[str], + verbose: int, + snowflake_connection_name: Optional[str], + metadata: tuple[str, ...], + no_metadata: bool, + config_name: Optional[str], + app_id: Optional[str], + title: Optional[str], + env_vars: dict[str, str], + no_verify: bool, + draft: bool, + exclude_renv: bool, + path: Optional[str], +): + set_verbosity(verbose) + output_params(ctx, locals().items()) + + directory = path or os.getcwd() + + # If a saved nickname was given, resolve it to a URL so the right deployment + # record can be matched by its stored server_url. + server_filter = server + if not server_filter and name: + entry = server_store.get_by_name(name) + if entry: + server_filter = entry.get("url") + + try: + target = resolve_publisher_deploy_target(directory, config_name=config_name, server=server_filter) + except RSConnectException as err: + # Fallback for content deployed before .posit existed: a manifest.json + # (the "what") plus the legacy rsconnect-python/*.json store (the "where"). + # Only the "no config at all" case falls back; other resolution errors + # (ambiguous config, unknown config name) propagate. + if "No .posit/publish configuration" not in str(err): + raise + _redeploy_from_legacy( + ctx, + directory, + server_filter, + name=name, + server=server, + api_key=api_key, + snowflake_connection_name=snowflake_connection_name, + insecure=insecure, + cacert=cacert, + app_id=app_id, + title=title, + env_vars=env_vars, + no_verify=no_verify, + draft=draft, + metadata=metadata, + no_metadata=no_metadata, + ) + return + + if target.record is None and not (server or name): + raise RSConnectException( + "No prior deployment found for the .posit/publish project in {}. " + "Specify a destination with --server or --name to deploy it for the first time.".format(directory) + ) + + app_mode = target.app_mode + if app_mode is None or app_mode == AppModes.UNKNOWN: + raise RSConnectException( + "Cannot redeploy: configuration '{}' has an unknown content type '{}'.".format( + target.config_name, target.config.type + ) + ) + entrypoint = target.entrypoint + if not entrypoint: + raise RSConnectException( + "Cannot redeploy: configuration '{}' does not specify an entrypoint.".format(target.config_name) + ) + + requirements_file = target.requirements_file or "requirements.txt" + effective_title = title or target.title + effective_app_id = app_id or target.app_id + # Deploy to the record's server unless the caller overrode the destination. + deploy_server = server or target.server_url + + deploy_path, bundle_builder, bundle_args, bundle_kwargs = _plan_deploy_bundle( + directory, + app_mode, + entrypoint, + requirements_file, + exclude_renv, + "Cannot redeploy: unsupported content type '{}'.".format(target.config.type), + ) + + ce = RSConnectExecutor( + ctx=ctx, + name=name, + server=deploy_server, + api_key=api_key, + snowflake_connection_name=snowflake_connection_name, + insecure=insecure, + cacert=cacert, + path=deploy_path, + app_id=effective_app_id, + title=effective_title, + env_vars=env_vars, + ) + _finish_redeploy( + ce, directory, app_mode, bundle_builder, bundle_args, bundle_kwargs, draft, no_verify, metadata, no_metadata + ) + + @deploy.command( name="git", short_help="Deploy content from a Git repository to Posit Connect.", @@ -3214,6 +3544,31 @@ def deploy_help(): click.echo() +def _write_manifest_publisher_config(manifest_dir: str, app_mode: AppMode) -> None: + """Best-effort: write a Posit Publisher config next to a generated manifest.json. + + Lets ``write-manifest`` output be opened by the Publisher extension too. Only + the config (the "what") is written -- there is no deployment, so no record. + Content types Publisher does not model (e.g. TensorFlow) map to an "unknown" + type and are skipped, as is a manifest that was not written where expected. + A failure here must not affect the primary manifest.json output. + """ + from .publisher import schema as publisher_schema + from .publisher.store import write_config_from_manifest + + if publisher_schema.type_from_app_mode(app_mode) == "unknown": + return + manifest_path = os.path.join(str(manifest_dir), "manifest.json") + if not os.path.exists(manifest_path): + return + try: + with open(manifest_path) as f: + manifest = json.load(f) + write_config_from_manifest(str(manifest_dir), manifest, app_mode=app_mode) + except Exception as e: + logger.warning("Could not write .posit/publish config alongside manifest.json: %s", e) + + @cli.group( name="write-manifest", no_args_is_help=True, @@ -3340,6 +3695,8 @@ def write_manifest_notebook( r_environment, ) + _write_manifest_publisher_config(base_dir, AppModes.JUPYTER_NOTEBOOK) + if environment_file_exists and not generate_env: click.secho( " Warning: %s already exists and will not be overwritten." % environment.filename, @@ -3487,6 +3844,8 @@ def write_manifest_voila( multi_notebook, ) + _write_manifest_publisher_config(base_dir, AppModes.JUPYTER_VOILA) + @write_manifest.command( name="quarto", @@ -3645,6 +4004,8 @@ def write_manifest_quarto( r_environment, ) + _write_manifest_publisher_config(base_dir, AppModes.STATIC_QUARTO) + @write_manifest.command( name="pyproject", @@ -3826,6 +4187,8 @@ def inspect_python_environment() -> Environment: r_environment=r_environment, ) + _write_manifest_publisher_config(str(manifest_dir), app_mode) + # The manifest references environment.filename (e.g. a requirements.txt # generated from pyproject.toml's dependencies), so that file must exist # next to manifest.json or deploying from the manifest fails. Regenerate it @@ -4128,6 +4491,8 @@ def write_manifest_nodejs( env_management_node, ) + _write_manifest_publisher_config(directory, AppModes.NODE_JS) + # noinspection SpellCheckingInspection def _write_framework_manifest( @@ -4205,6 +4570,8 @@ def _write_framework_manifest( r_environment, ) + _write_manifest_publisher_config(directory, app_mode) + generate_env = resolved_requirements_file is None if environment_file_exists and not generate_env: click.secho( diff --git a/rsconnect/publisher/__init__.py b/rsconnect/publisher/__init__.py new file mode 100644 index 000000000..642cff23e --- /dev/null +++ b/rsconnect/publisher/__init__.py @@ -0,0 +1,10 @@ +"""Interoperability with Posit Publisher's ``.posit/publish`` TOML files. + +This package lets rsconnect-python read and write the same on-disk configuration +(``.posit/publish/.toml``) and deployment-record +(``.posit/publish/deployments/.toml``) files that the Posit Publisher +VS Code extension uses, so the two tools interoperate on the same project. + +The format (schema URLs, field names, content-type map, serialization details) +is ported from the ``posit-dev/publisher`` project. +""" diff --git a/rsconnect/publisher/config.py b/rsconnect/publisher/config.py new file mode 100644 index 000000000..1edad46c6 --- /dev/null +++ b/rsconnect/publisher/config.py @@ -0,0 +1,173 @@ +"""The ``.posit/publish/.toml`` configuration file (the "what"). + +A config describes a piece of content independently of where it is deployed: +its content ``type``, ``entrypoint``, ``title``, the ``files`` include-patterns, +and language settings. rsconnect owns a handful of identity fields; everything +else it finds on disk (``connect.*`` runtime settings, ``secrets``, unknown +keys) is preserved on update via read-merge-write. +""" + +from __future__ import annotations + +import dataclasses +import os +import typing + +from ..models import AppMode +from . import schema, serialize + +# Keys rsconnect manages directly. Anything else found on disk is round-tripped +# untouched through ``PublisherConfig.extra``. +_MANAGED_KEYS = frozenset( + { + "$schema", + "product_type", + "type", + "entrypoint", + "title", + "validate", + "files", + "python", + "quarto", + "r", + "connect_cloud", + } +) + + +@dataclasses.dataclass +class PublisherConfig: + """A parsed / to-be-written publishing config.""" + + type: str = "unknown" + entrypoint: str = "" + title: typing.Optional[str] = None + validate: bool = True + files: typing.List[str] = dataclasses.field(default_factory=list) + product_type: str = schema.PRODUCT_TYPE_CONNECT + python: typing.Optional[typing.Dict[str, typing.Any]] = None + quarto: typing.Optional[typing.Dict[str, typing.Any]] = None + r: typing.Optional[typing.Dict[str, typing.Any]] = None + # Connect Cloud settings ({vanity_name, access_control}), preserved for + # interop with Publisher-authored configs. + connect_cloud: typing.Optional[typing.Dict[str, typing.Any]] = None + schema_url: str = schema.CONFIG_SCHEMA_URL + # Fields rsconnect does not manage, preserved verbatim on rewrite. + extra: typing.Dict[str, typing.Any] = dataclasses.field(default_factory=dict) + + @property + def app_mode(self) -> AppMode: + """The Connect ``AppMode`` implied by this config's ``type``.""" + return schema.app_mode_from_type(self.type) + + @property + def requirements_file(self) -> typing.Optional[str]: + """The declared Python package file, if any (e.g. ``requirements.txt``).""" + if self.python: + pkg = self.python.get("package_file") + if pkg: + return typing.cast(str, pkg) + return None + + def to_dict(self) -> typing.Dict[str, typing.Any]: + """Render to an ordered dict ready for TOML serialization. + + Managed keys are emitted first (identity, then language tables); any + preserved ``extra`` keys are merged in without clobbering managed ones. + """ + data: typing.Dict[str, typing.Any] = {} + data["$schema"] = self.schema_url or schema.CONFIG_SCHEMA_URL + data["product_type"] = self.product_type or schema.PRODUCT_TYPE_CONNECT + data["type"] = self.type + data["entrypoint"] = self.entrypoint + if self.title: + data["title"] = self.title + # ``validate`` has no omitempty in Publisher; always written. + data["validate"] = self.validate + data["files"] = list(self.files) + if self.python: + data["python"] = self.python + if self.quarto: + data["quarto"] = self.quarto + if self.r: + data["r"] = self.r + if self.connect_cloud: + data["connect_cloud"] = self.connect_cloud + for key, value in self.extra.items(): + data.setdefault(key, value) + return data + + +def read_config(path: str) -> PublisherConfig: + """Parse a config file into a :class:`PublisherConfig`.""" + data = serialize.load(path) + return from_dict(data) + + +def from_dict(data: typing.Mapping[str, typing.Any]) -> PublisherConfig: + """Build a :class:`PublisherConfig` from an already-parsed mapping. + + Used both for reading files and for hydrating the config embedded in a + deployment record's ``[configuration]`` table. + """ + return PublisherConfig( + type=data.get("type", "unknown"), + entrypoint=data.get("entrypoint", ""), + title=data.get("title"), + validate=data.get("validate", True), + files=list(data.get("files", []) or []), + product_type=data.get("product_type", schema.PRODUCT_TYPE_CONNECT), + python=data.get("python"), + quarto=data.get("quarto"), + r=data.get("r"), + connect_cloud=data.get("connect_cloud"), + schema_url=data.get("$schema", schema.CONFIG_SCHEMA_URL), + extra={k: v for k, v in data.items() if k not in _MANAGED_KEYS}, + ) + + +def write_config(project_dir: str, name: str, cfg: PublisherConfig) -> typing.Tuple[str, typing.Dict[str, typing.Any]]: + """Write ``cfg`` to ``/.posit/publish/.toml``. + + If a config already exists, its unmanaged fields, its ``files`` include-list, + and any user-set ``title``/``python`` are preserved (rsconnect refreshes the + identity fields ``type`` and ``entrypoint`` but does not clobber curation). + + Returns the written path and the final serialized dict (so a record can + embed the identical ``[configuration]`` snapshot). + """ + path = schema.config_path(project_dir, name) + comments: typing.List[str] = [] + if os.path.exists(path): + existing = read_config(path) + # Preserve unmanaged fields and user curation from the existing file. + cfg.extra = {**existing.extra, **cfg.extra} + if existing.files: + cfg.files = existing.files + if existing.title and not cfg.title: + cfg.title = existing.title + if existing.python and not cfg.python: + cfg.python = existing.python + if existing.quarto and not cfg.quarto: + cfg.quarto = existing.quarto + if existing.connect_cloud and not cfg.connect_cloud: + cfg.connect_cloud = existing.connect_cloud + if existing.product_type and cfg.product_type == schema.PRODUCT_TYPE_CONNECT: + # Never silently downgrade a Publisher-authored connect_cloud config + # to connect just because rsconnect defaults to connect. + cfg.product_type = existing.product_type + data = cfg.to_dict() + serialize.write(path, serialize.dumps(data, comments)) + return path, data + + +def discover_configs(project_dir: str) -> typing.List[str]: + """Return the paths of all config files under ``.posit/publish`` (sorted).""" + directory = schema.publish_dir(project_dir) + if not os.path.isdir(directory): + return [] + return sorted( + os.path.join(directory, f) + for f in os.listdir(directory) + if f.endswith(".toml") and os.path.isfile(os.path.join(directory, f)) + ) diff --git a/rsconnect/publisher/record.py b/rsconnect/publisher/record.py new file mode 100644 index 000000000..8cf29b7a5 --- /dev/null +++ b/rsconnect/publisher/record.py @@ -0,0 +1,276 @@ +"""The ``.posit/publish/deployments/.toml`` record (the "where"). + +A record ties a config to a specific destination: the ``server_url`` (which +resolves to saved credentials), the content ``id`` (GUID) to redeploy in place, +the dashboard/direct/logs URLs, the bundle id, and a full embedded snapshot of +the config that was deployed. rsconnect also stores the concrete ``files`` and +``requirements`` that went into the bundle. +""" + +from __future__ import annotations + +import dataclasses +import json +import os +import tarfile +import typing +from datetime import datetime, timezone + +from .. import VERSION +from . import config as config_mod +from . import schema, serialize + +_AUTOGEN_HEADER = "# This file is automatically generated by rsconnect-python; do not edit." + +# Keys rsconnect manages; anything else on disk is preserved via ``extra``. +_MANAGED_KEYS = frozenset( + { + "$schema", + "server_type", + "server_url", + "client_version", + "created_at", + "type", + "configuration_name", + "id", + "dashboard_url", + "direct_url", + "logs_url", + "deployed_at", + "bundle_id", + "bundle_url", + "files", + "requirements", + "configuration", + "connect_cloud", + } +) + + +@dataclasses.dataclass +class PublisherRecord: + """A parsed / to-be-written deployment record.""" + + server_url: str = "" + server_type: str = schema.PRODUCT_TYPE_CONNECT + id: typing.Optional[str] = None + type: str = "unknown" + configuration_name: typing.Optional[str] = None + created_at: typing.Optional[str] = None + deployed_at: typing.Optional[str] = None + client_version: typing.Optional[str] = None + dashboard_url: typing.Optional[str] = None + direct_url: typing.Optional[str] = None + logs_url: typing.Optional[str] = None + bundle_id: typing.Optional[str] = None + files: typing.List[str] = dataclasses.field(default_factory=list) + requirements: typing.List[str] = dataclasses.field(default_factory=list) + configuration: typing.Optional[typing.Dict[str, typing.Any]] = None + # Connect Cloud settings ({account_name}), preserved for interop with + # Publisher-authored records. + connect_cloud: typing.Optional[typing.Dict[str, typing.Any]] = None + schema_url: str = schema.RECORD_SCHEMA_URL + extra: typing.Dict[str, typing.Any] = dataclasses.field(default_factory=dict) + + def config(self) -> typing.Optional[config_mod.PublisherConfig]: + """The embedded ``[configuration]`` snapshot as a :class:`PublisherConfig`.""" + if self.configuration is None: + return None + return config_mod.from_dict(self.configuration) + + def to_dict(self) -> typing.Dict[str, typing.Any]: + """Render to an ordered dict ready for TOML serialization.""" + data: typing.Dict[str, typing.Any] = {} + data["$schema"] = self.schema_url or schema.RECORD_SCHEMA_URL + data["server_type"] = self.server_type or schema.PRODUCT_TYPE_CONNECT + data["server_url"] = self.server_url + data["client_version"] = self.client_version or VERSION + data["created_at"] = self.created_at or now() + data["type"] = self.type + if self.configuration_name: + data["configuration_name"] = self.configuration_name + if self.id: + data["id"] = self.id + if self.dashboard_url: + data["dashboard_url"] = self.dashboard_url + if self.direct_url: + data["direct_url"] = self.direct_url + if self.logs_url: + data["logs_url"] = self.logs_url + if self.deployed_at: + data["deployed_at"] = self.deployed_at + if self.bundle_id: + data["bundle_id"] = self.bundle_id + if self.files: + data["files"] = list(self.files) + if self.requirements: + data["requirements"] = list(self.requirements) + if self.connect_cloud: + data["connect_cloud"] = self.connect_cloud + if self.configuration: + data["configuration"] = self.configuration + for key, value in self.extra.items(): + data.setdefault(key, value) + return data + + +def now() -> str: + """Current time as an RFC 3339 / ISO 8601 string, matching Publisher.""" + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def from_dict(data: typing.Mapping[str, typing.Any]) -> PublisherRecord: + """Build a :class:`PublisherRecord` from an already-parsed mapping.""" + return PublisherRecord( + server_url=data.get("server_url", ""), + server_type=data.get("server_type", schema.PRODUCT_TYPE_CONNECT), + id=data.get("id"), + type=data.get("type", "unknown"), + configuration_name=data.get("configuration_name"), + created_at=data.get("created_at"), + deployed_at=data.get("deployed_at"), + client_version=data.get("client_version"), + dashboard_url=data.get("dashboard_url"), + direct_url=data.get("direct_url"), + logs_url=data.get("logs_url"), + bundle_id=data.get("bundle_id"), + files=list(data.get("files", []) or []), + requirements=list(data.get("requirements", []) or []), + configuration=data.get("configuration"), + connect_cloud=data.get("connect_cloud"), + schema_url=data.get("$schema", schema.RECORD_SCHEMA_URL), + extra={k: v for k, v in data.items() if k not in _MANAGED_KEYS}, + ) + + +def read_record(path: str) -> PublisherRecord: + """Parse a deployment record file into a :class:`PublisherRecord`.""" + return from_dict(serialize.load(path)) + + +def write_record(project_dir: str, name: str, record: PublisherRecord) -> str: + """Write ``record`` to ``.posit/publish/deployments/.toml``. + + Preserves ``created_at`` and any unmanaged fields from an existing record. + """ + path = schema.record_path(project_dir, name) + if os.path.exists(path): + existing = read_record(path) + if not record.created_at: + record.created_at = existing.created_at + record.extra = {**existing.extra, **record.extra} + serialize.write(path, serialize.dumps(record.to_dict(), [_AUTOGEN_HEADER])) + return path + + +def discover_records(project_dir: str) -> typing.List[str]: + """Return the paths of all record files under ``deployments`` (sorted).""" + directory = schema.deployments_dir(project_dir) + if not os.path.isdir(directory): + return [] + return sorted( + os.path.join(directory, f) + for f in os.listdir(directory) + if f.endswith(".toml") and os.path.isfile(os.path.join(directory, f)) + ) + + +# --- reading content details back out of a built bundle -------------------- + + +def _parse_requirements(content: str) -> typing.List[str]: + """Return non-blank, non-comment lines of a requirements file.""" + out: typing.List[str] = [] + for line in content.splitlines(): + stripped = line.strip() + if stripped and not stripped.startswith("#"): + out.append(stripped) + return out + + +def _read_member(tar: tarfile.TarFile, name: str) -> typing.Optional[str]: + try: + member = tar.extractfile(name) + except KeyError: + return None + if member is None: + return None + return member.read().decode("utf-8") + + +@dataclasses.dataclass +class BundleContentDetails: + """Content facts recovered from a built bundle's ``manifest.json``.""" + + entrypoint: str = "" + files: typing.List[str] = dataclasses.field(default_factory=list) + requirements: typing.List[str] = dataclasses.field(default_factory=list) + python: typing.Optional[typing.Dict[str, typing.Any]] = None + quarto: typing.Optional[typing.Dict[str, typing.Any]] = None + + +def details_from_manifest(manifest: typing.Mapping[str, typing.Any]) -> BundleContentDetails: + """Parse content facts out of a ``manifest.json`` dict. + + Recovers the file list, entrypoint, and Python/Quarto settings. Requirements + are not populated here (they live in a separate package file, not in the + manifest); :func:`read_bundle_details` fills them from the bundle. + """ + details = BundleContentDetails() + details.files = sorted((manifest.get("files") or {}).keys()) + meta = manifest.get("metadata") or {} + details.entrypoint = meta.get("entrypoint") or meta.get("primary_rmd") or meta.get("primary_html") or "" + + mpy = manifest.get("python") + if mpy: + pm = mpy.get("package_manager") or {} + pkg_file = pm.get("package_file") + python: typing.Dict[str, typing.Any] = {} + if mpy.get("version"): + python["version"] = mpy["version"] + if pkg_file: + python["package_file"] = pkg_file + if pm.get("name"): + python["package_manager"] = pm["name"] + requires = ((manifest.get("environment") or {}).get("python") or {}).get("requires") + if requires: + python["requires_python"] = requires + details.python = python or None + + mq = manifest.get("quarto") + if mq: + quarto: typing.Dict[str, typing.Any] = {} + if mq.get("version"): + quarto["version"] = mq["version"] + if mq.get("engines"): + quarto["engines"] = mq["engines"] + details.quarto = quarto or None + return details + + +def read_bundle_details(bundle: typing.IO[bytes]) -> BundleContentDetails: + """Extract entrypoint, files, requirements and language settings from a bundle. + + The bundle is a ``.tar.gz`` whose ``manifest.json`` lists the deployed files + (keyed by project-relative path) and points at the Python package file. + Requirements are read from that file's contents inside the tarball. + + The caller is responsible for treating failures as non-fatal. + """ + details = BundleContentDetails() + bundle.seek(0) + try: + with tarfile.open(fileobj=bundle, mode="r:gz") as tar: + raw = _read_member(tar, "manifest.json") + if raw is None: + return details + manifest = json.loads(raw) + details = details_from_manifest(manifest) + pkg_file = (details.python or {}).get("package_file") + if pkg_file: + content = _read_member(tar, pkg_file) + if content is not None: + details.requirements = _parse_requirements(content) + finally: + bundle.seek(0) + return details diff --git a/rsconnect/publisher/schema.py b/rsconnect/publisher/schema.py new file mode 100644 index 000000000..e03dc3a37 --- /dev/null +++ b/rsconnect/publisher/schema.py @@ -0,0 +1,114 @@ +"""Schema constants, content-type maps, and path helpers for ``.posit/publish``. + +The content-type maps are ported verbatim from Posit Publisher's +``extensions/vscode/src/bundler/appMode.ts`` so that the ``type`` we write is +exactly what Publisher expects, and the ``app_mode`` we recover on read matches +Connect's manifest vocabulary. +""" + +from __future__ import annotations + +import os + +from ..models import AppMode, AppModes + +CONFIG_SCHEMA_URL = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" +RECORD_SCHEMA_URL = "https://cdn.posit.co/publisher/schemas/posit-publishing-record-schema-v3.json" + +# ``product_type`` (config) / ``server_type`` (record) values. Publisher's Go +# consts historically only defined connect/connect_cloud, but the published v3 +# JSON schema accepts "snowflake", which is what we emit for SPCS targets. +PRODUCT_TYPE_CONNECT = "connect" +PRODUCT_TYPE_SNOWFLAKE = "snowflake" +PRODUCT_TYPE_CONNECT_CLOUD = "connect_cloud" + +# Connect manifest ``app_mode`` string -> Publisher content ``type``. Keyed by +# ``AppMode.name()``. Ported from publisher appMode.ts (appModeToContentType, +# read in reverse). ``tensorflow-saved-model`` has no Publisher type, so it maps +# to "unknown"; the map-coverage test guards this. +APP_MODE_TO_TYPE = { + "static": "html", + "jupyter-static": "jupyter-notebook", + "jupyter-voila": "jupyter-voila", + "nodejs": "nodejs", + "python-bokeh": "python-bokeh", + "python-dash": "python-dash", + "python-fastapi": "python-fastapi", + "python-api": "python-flask", + "python-shiny": "python-shiny", + "python-streamlit": "python-streamlit", + "python-gradio": "python-gradio", + "python-panel": "python-panel", + "quarto-shiny": "quarto-shiny", + "quarto-static": "quarto-static", + "api": "r-plumber", + "shiny": "r-shiny", + "rmd-shiny": "rmd-shiny", + "rmd-static": "rmd", + "tensorflow-saved-model": "unknown", + "unknown": "unknown", +} + +# Publisher content ``type`` -> Connect manifest ``app_mode`` string. The +# deprecated "quarto" type resolves to "quarto-static", matching publisher's +# reverse map. +TYPE_TO_APP_MODE = { + "html": "static", + "jupyter-notebook": "jupyter-static", + "jupyter-voila": "jupyter-voila", + "nodejs": "nodejs", + "python-bokeh": "python-bokeh", + "python-dash": "python-dash", + "python-fastapi": "python-fastapi", + "python-flask": "python-api", + "python-shiny": "python-shiny", + "python-streamlit": "python-streamlit", + "python-gradio": "python-gradio", + "python-panel": "python-panel", + "quarto-shiny": "quarto-shiny", + "quarto-static": "quarto-static", + "quarto": "quarto-static", + "r-plumber": "api", + "r-shiny": "shiny", + "rmd-shiny": "rmd-shiny", + "rmd": "rmd-static", + "unknown": "unknown", +} + + +def type_from_app_mode(app_mode: "AppMode | str") -> str: + """Return the Publisher content ``type`` for a Connect ``app_mode``.""" + name = app_mode.name() if isinstance(app_mode, AppMode) else str(app_mode) + return APP_MODE_TO_TYPE.get(name, "unknown") + + +def app_mode_from_type(content_type: str) -> AppMode: + """Return the Connect ``AppMode`` for a Publisher content ``type``. + + Unknown types fall through to :data:`AppModes.UNKNOWN`. + """ + name = TYPE_TO_APP_MODE.get(content_type, content_type) + return AppModes.get_by_name(name, return_unknown=True) + + +# --- .posit/publish path helpers ------------------------------------------- + + +def publish_dir(project_dir: str) -> str: + """Return ``/.posit/publish``.""" + return os.path.join(project_dir, ".posit", "publish") + + +def deployments_dir(project_dir: str) -> str: + """Return ``/.posit/publish/deployments``.""" + return os.path.join(publish_dir(project_dir), "deployments") + + +def config_path(project_dir: str, name: str) -> str: + """Return the path to config ``.toml``.""" + return os.path.join(publish_dir(project_dir), name + ".toml") + + +def record_path(project_dir: str, name: str) -> str: + """Return the path to deployment record ``.toml``.""" + return os.path.join(deployments_dir(project_dir), name + ".toml") diff --git a/rsconnect/publisher/serialize.py b/rsconnect/publisher/serialize.py new file mode 100644 index 000000000..35c762b02 --- /dev/null +++ b/rsconnect/publisher/serialize.py @@ -0,0 +1,73 @@ +"""TOML (de)serialization for ``.posit/publish`` files. + +Reading uses the stdlib ``tomllib`` (3.11+) or the ``toml`` backport. Writing +uses ``tomli_w``, whose default output already matches Publisher's format: +multiline 4-space-indented arrays, quoted ``$schema`` key, and tables emitted +after top-level scalars. We only add the leading comment/header block and prune +empty values (which ``tomli_w`` cannot serialize). +""" + +from __future__ import annotations + +import os +import typing + +import tomli_w + +TOMLDecodeError: typing.Type[Exception] +try: + import tomllib + + TOMLDecodeError = tomllib.TOMLDecodeError +except ImportError: + # Python < 3.11 has no stdlib tomllib; fall back to the ``toml`` backport. + import toml as tomllib # type: ignore[no-redef] + + TOMLDecodeError = tomllib.TomlDecodeError + + +def load(path: str) -> typing.Dict[str, typing.Any]: + """Parse the TOML file at ``path`` into a dict.""" + with open(path, encoding="utf-8") as f: + return tomllib.loads(f.read()) + + +def _prune(value: typing.Any) -> typing.Any: + """Recursively drop ``None`` and empty-string values (and now-empty tables). + + Lists and falsy-but-meaningful scalars (``False``, ``0``) are preserved. + ``tomli_w`` raises on ``None``, and Publisher omits empty strings, so this + mirrors its ``omitempty``/``stripEmpty`` behavior. + """ + if isinstance(value, dict): + out: typing.Dict[str, typing.Any] = {} + for key, val in value.items(): + pruned = _prune(val) + if pruned is None: + continue + out[key] = pruned + return out or None + if isinstance(value, str): + return value if value != "" else None + return value + + +def dumps(data: typing.Mapping[str, typing.Any], header_lines: typing.Sequence[str] = ()) -> str: + """Serialize ``data`` to a TOML string, prefixed with ``header_lines``. + + Each header line is written verbatim (callers include the leading ``#``). + """ + pruned = _prune(dict(data)) or {} + body = tomli_w.dumps(pruned) + prefix = "".join(line + "\n" for line in header_lines) + text = prefix + body + if not text.endswith("\n"): + text += "\n" + return text + + +def write(path: str, content: str) -> None: + """Write ``content`` to ``path``, creating parent directories as needed.""" + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + f.write(content) diff --git a/rsconnect/publisher/store.py b/rsconnect/publisher/store.py new file mode 100644 index 000000000..45414722b --- /dev/null +++ b/rsconnect/publisher/store.py @@ -0,0 +1,352 @@ +"""Facade tying the ``.posit/publish`` config + record files to deploy flows. + +Write side: :func:`write_deployment_metadata` is called after a successful +Connect/SPCS deploy to create-or-update the config and the deployment record. + +Read side: :func:`resolve_publisher_deploy_target` reconstructs a ready-to-deploy +target from an existing config (+ record) so a bare ``rsconnect redeploy`` can +run with no other arguments. Records are matched by ``server_url`` content, not +filename, so Publisher-authored files interoperate. +""" + +from __future__ import annotations + +import dataclasses +import os +import random +import re +import typing +from urllib.parse import urlparse + +from ..exception import RSConnectException +from ..models import AppMode, AppModes +from . import config as config_mod +from . import record as record_mod +from . import schema + +if typing.TYPE_CHECKING: + from typing import IO + + +def normalize_url(url: str) -> str: + """Normalize a Connect URL for content comparison. + + Strips a trailing ``/__api__`` and any trailing slash, and lowercases the + scheme+host, so a record's ``server_url`` matches a saved server that may + differ cosmetically. The path (a Connect instance may live under one) is + preserved apart from the ``__api__`` suffix. + """ + if not url: + return "" + parsed = urlparse(url if "//" in url else "//" + url) + netloc = parsed.netloc.lower() + path = parsed.path + if path.endswith("/__api__"): + path = path[: -len("/__api__")] + path = path.rstrip("/") + scheme = (parsed.scheme or "https").lower() + return "{}://{}{}".format(scheme, netloc, path) + + +# --- write side ------------------------------------------------------------ + + +# File-naming mirrors Posit Publisher's utils/names.ts: a random, uppercase, +# base-32 ending appended to a filesystem-safe title. Publisher relies on its +# UI to reuse a chosen file; a CLI has none, so on redeploy we first look for an +# existing record with the same server_url (and its config) and reuse those +# filenames, only minting a new random name for a genuinely new deployment. +_BASE32_UPPER = "0123456789ABCDEFGHIJKLMNOPQRSTUV" + + +def _random_name_ending(length: int = 4) -> str: + """A random uppercase base-32 string, matching Publisher's ``randomNameEnding``.""" + return "".join(random.choice(_BASE32_UPPER) for _ in range(length)) + + +def _filenamify(title: str) -> str: + """Approximate Publisher's ``filenamify(title, {replacement: '-', maxLength: 30})``.""" + slug = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "-", title).strip(". ").strip() + return (slug or "content")[:30] + + +def _basenames(paths: typing.Iterable[str]) -> typing.Set[str]: + return {os.path.splitext(os.path.basename(p))[0].lower() for p in paths} + + +def _new_config_name(project_dir: str, title: str) -> str: + """A fresh ``-<code>`` config name, unique among existing configs. + + Matches Publisher's ``newConfigFileNameFromTitle``.""" + existing = _basenames(config_mod.discover_configs(project_dir)) + base = _filenamify(title) + while True: + candidate = "{}-{}".format(base, _random_name_ending()) + if candidate.lower() not in existing: + return candidate + + +def _new_record_name(project_dir: str) -> str: + """A fresh ``deployment-<code>`` record name, matching Publisher's ``newDeploymentName``.""" + existing = _basenames(record_mod.discover_records(project_dir)) + while True: + candidate = "deployment-{}".format(_random_name_ending()) + if candidate.lower() not in existing: + return candidate + + +def _find_record_name_for_server(project_dir: str, server_url: str) -> typing.Optional[str]: + """Basename of an existing record whose ``server_url`` matches, so a redeploy + updates in place instead of spawning a new random-named file.""" + target = normalize_url(server_url) + for path in record_mod.discover_records(project_dir): + try: + rec = record_mod.read_record(path) + except Exception: + continue + if normalize_url(rec.server_url) == target: + return os.path.splitext(os.path.basename(path))[0] + return None + + +def _find_config_name_for_entrypoint(project_dir: str, entrypoint: str) -> typing.Optional[str]: + """Basename of an existing config with a matching entrypoint, if any.""" + if not entrypoint: + return None + for path in config_mod.discover_configs(project_dir): + try: + cfg = config_mod.read_config(path) + except Exception: + continue + if cfg.entrypoint == entrypoint: + return os.path.splitext(os.path.basename(path))[0] + return None + + +def _config_file_patterns(details: "record_mod.BundleContentDetails") -> typing.List[str]: + """Seed the config ``files`` include-list, matching Publisher's normalize.""" + patterns: typing.List[str] = [] + if details.entrypoint: + patterns.append("/" + details.entrypoint) + if details.python and details.python.get("package_file"): + pkg = "/" + typing.cast(str, details.python["package_file"]) + if pkg not in patterns: + patterns.append(pkg) + return patterns + + +def write_deployment_metadata( + *, + project_dir: str, + server_url: str, + product_type: str, + app_mode: "AppMode | str", + title: typing.Optional[str], + deployed_info: typing.Mapping[str, typing.Any], + bundle: "IO[bytes]", + config_name: typing.Optional[str] = None, +) -> typing.Tuple[str, str]: + """Create/update the ``.posit`` config and deployment record for a deploy. + + Returns ``(config_path, record_path)``. Raises on failure; callers treat + ``.posit`` write failures as non-fatal (the deploy has already succeeded). + """ + details = record_mod.read_bundle_details(bundle) + content_type = schema.type_from_app_mode(app_mode) + + cfg = config_mod.PublisherConfig( + type=content_type, + entrypoint=details.entrypoint, + title=title, + product_type=product_type, + python=details.python, + quarto=details.quarto, + files=_config_file_patterns(details), + ) + # Reuse an existing deployment's filenames on redeploy; only mint new random + # names for a genuinely new deployment. + existing_record_name = _find_record_name_for_server(project_dir, server_url) + existing_config_name = None + if existing_record_name: + existing_config_name = record_mod.read_record( + schema.record_path(project_dir, existing_record_name) + ).configuration_name + + cname = ( + config_name + or existing_config_name + or _find_config_name_for_entrypoint(project_dir, details.entrypoint) + or _new_config_name(project_dir, title or details.entrypoint or "content") + ) + config_path, config_dict = config_mod.write_config(project_dir, cname, cfg) + + dashboard_url = deployed_info.get("dashboard_url") + rec = record_mod.PublisherRecord( + server_url=server_url, + server_type=product_type, + id=deployed_info.get("app_guid"), + type=content_type, + configuration_name=cname, + deployed_at=record_mod.now(), + dashboard_url=dashboard_url, + direct_url=deployed_info.get("app_url"), + logs_url=(dashboard_url + "/logs") if dashboard_url else None, + bundle_id=deployed_info.get("bundle_id"), + files=details.files, + requirements=details.requirements, + configuration=config_dict, + ) + rname = existing_record_name or _new_record_name(project_dir) + record_path = record_mod.write_record(project_dir, rname, rec) + return config_path, record_path + + +def write_config_from_manifest( + project_dir: str, + manifest: typing.Mapping[str, typing.Any], + app_mode: "AppMode | str | None" = None, + title: typing.Optional[str] = None, + config_name: typing.Optional[str] = None, +) -> str: + """Write a ``.posit/publish`` config from a ``manifest.json`` dict. + + Used by ``write-manifest`` (which prepares content but does not deploy) so a + Publisher config accompanies the generated manifest. No record is written, + since there is no deployment. ``app_mode`` defaults to the manifest's + ``metadata.appmode``. Returns the config path. + """ + details = record_mod.details_from_manifest(manifest) + if app_mode is None: + app_mode = AppModes.get_by_name((manifest.get("metadata") or {}).get("appmode", ""), return_unknown=True) + cfg = config_mod.PublisherConfig( + type=schema.type_from_app_mode(app_mode), + entrypoint=details.entrypoint, + title=title, + python=details.python, + quarto=details.quarto, + files=_config_file_patterns(details), + ) + cname = ( + config_name + or _find_config_name_for_entrypoint(project_dir, details.entrypoint) + or _new_config_name(project_dir, title or details.entrypoint or "content") + ) + path, _ = config_mod.write_config(project_dir, cname, cfg) + return path + + +# --- read side ------------------------------------------------------------- + + +@dataclasses.dataclass(frozen=True) +class PublisherDeployTarget: + """A ready-to-deploy target reconstructed from ``.posit`` files. + + Mirrors :class:`rsconnect.pyproject.PyprojectDeployTarget` (the "what") and + adds the record-sourced "where" (``server_url``/``app_id``). ``record`` is + ``None`` on a first deployment (config exists but nothing deployed yet). + """ + + project_dir: str + config_name: str + config: config_mod.PublisherConfig + app_mode: AppMode + entrypoint: str + title: typing.Optional[str] + requirements_file: typing.Optional[str] + server_url: typing.Optional[str] + app_id: typing.Optional[str] + record: typing.Optional[record_mod.PublisherRecord] + + +def _load_configs(project_dir: str) -> typing.Dict[str, config_mod.PublisherConfig]: + """Map config basename (without .toml) -> parsed config.""" + result: typing.Dict[str, config_mod.PublisherConfig] = {} + for path in config_mod.discover_configs(project_dir): + name = os.path.splitext(os.path.basename(path))[0] + result[name] = config_mod.read_config(path) + return result + + +def _select_config( + configs: typing.Dict[str, config_mod.PublisherConfig], config_name: typing.Optional[str] +) -> typing.Tuple[str, config_mod.PublisherConfig]: + if config_name: + if config_name not in configs: + raise RSConnectException( + "No .posit config named '{}'. Found: {}".format(config_name, ", ".join(sorted(configs)) or "none") + ) + return config_name, configs[config_name] + if len(configs) == 1: + name = next(iter(configs)) + return name, configs[name] + raise RSConnectException( + "Multiple .posit configs found ({}); specify one with --config-name.".format(", ".join(sorted(configs))) + ) + + +def _matching_records( + project_dir: str, config_name: str, server: typing.Optional[str] +) -> typing.List[record_mod.PublisherRecord]: + """Records for ``config_name``, optionally filtered to a server URL.""" + records: typing.List[record_mod.PublisherRecord] = [] + normalized_server = normalize_url(server) if server else None + for path in record_mod.discover_records(project_dir): + rec = record_mod.read_record(path) + # Match by content: the record's configuration_name links it to a config; + # records without one are accepted only when there is a single config. + if rec.configuration_name and rec.configuration_name != config_name: + continue + if normalized_server and normalize_url(rec.server_url) != normalized_server: + continue + records.append(rec) + return records + + +def resolve_publisher_deploy_target( + project_dir: str, + config_name: typing.Optional[str] = None, + server: typing.Optional[str] = None, +) -> PublisherDeployTarget: + """Resolve a deploy target from ``.posit`` files under ``project_dir``. + + Raises :class:`RSConnectException` when no config exists, when the config or + record choice is ambiguous, or (for a redeploy) when there is no prior + deployment and no server was supplied to seed a first deploy. + """ + configs = _load_configs(project_dir) + if not configs: + raise RSConnectException( + "No .posit/publish configuration found in {}. This directory has no Publisher project.".format(project_dir) + ) + name, cfg = _select_config(configs, config_name) + + records = _matching_records(project_dir, name, server) + record: typing.Optional[record_mod.PublisherRecord] + if len(records) > 1: + servers = ", ".join(sorted(r.server_url for r in records)) + raise RSConnectException( + "Multiple deployments found for config '{}' ({}); specify one with --server.".format(name, servers) + ) + record = records[0] if records else None + + # Fall back to the record's embedded config snapshot if no standalone config + # file carried the fields we need (e.g. a Publisher-authored record). + effective = cfg + if record is not None and record.config() is not None: + embedded = typing.cast(config_mod.PublisherConfig, record.config()) + if not effective.entrypoint and embedded.entrypoint: + effective = embedded + + return PublisherDeployTarget( + project_dir=project_dir, + config_name=name, + config=effective, + app_mode=effective.app_mode, + entrypoint=effective.entrypoint, + title=effective.title, + requirements_file=effective.requirements_file, + server_url=record.server_url if record else None, + app_id=record.id if record else None, + record=record, + ) diff --git a/tests/test_publisher.py b/tests/test_publisher.py new file mode 100644 index 000000000..babebab8e --- /dev/null +++ b/tests/test_publisher.py @@ -0,0 +1,338 @@ +"""Tests for the ``rsconnect.publisher`` .posit/publish TOML interop package.""" + +import io +import json +import tarfile + +import pytest + +from rsconnect.models import AppModes +from rsconnect.publisher import config, record, schema, serialize, store + + +# --- helpers --------------------------------------------------------------- + + +def make_bundle(manifest, extra_members=None): + """Build an in-memory ``.tar.gz`` bundle with the given manifest dict.""" + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tar: + members = {"manifest.json": json.dumps(manifest)} + members.update(extra_members or {}) + for name, text in members.items(): + data = text.encode("utf-8") + info = tarfile.TarInfo(name) + info.size = len(data) + tar.addfile(info, io.BytesIO(data)) + buf.seek(0) + return buf + + +PY_SHINY_MANIFEST = { + "version": 1, + "metadata": {"appmode": "python-shiny", "entrypoint": "app.py"}, + "files": {"app.py": {"checksum": "a"}, "requirements.txt": {"checksum": "b"}, "helpers.py": {"checksum": "c"}}, + "python": {"version": "3.11.5", "package_manager": {"name": "pip", "package_file": "requirements.txt"}}, + "environment": {"python": {"requires": ">=3.9"}}, +} + +DEPLOYED_INFO = { + "app_guid": "GUID-123", + "app_id": "7", + "app_url": "https://connect.example.com/content/abc/", + "dashboard_url": "https://connect.example.com/connect/#/apps/abc", + "bundle_id": "42", + "title": "My App", +} + + +def deploy(project_dir, server_url="https://connect.example.com/__api__", **overrides): + info = {**DEPLOYED_INFO, **overrides} + bundle = make_bundle(PY_SHINY_MANIFEST, {"requirements.txt": "# a comment\nshiny==1.0\n\nhtmltools>=0.5\n"}) + return store.write_deployment_metadata( + project_dir=project_dir, + server_url=server_url, + product_type="connect", + app_mode=AppModes.PYTHON_SHINY, + title="My App", + deployed_info=info, + bundle=bundle, + ) + + +# --- type map -------------------------------------------------------------- + + +def test_every_app_mode_maps_to_a_valid_publisher_type(): + valid_types = set(schema.TYPE_TO_APP_MODE) | {"unknown"} + for mode in AppModes._modes: + content_type = schema.type_from_app_mode(mode) + assert content_type in valid_types, (mode.name(), content_type) + + +def test_app_mode_type_round_trips_except_tensorflow(): + for mode in AppModes._modes: + content_type = schema.type_from_app_mode(mode) + if mode is AppModes.TENSORFLOW: + # TensorFlow has no Publisher content type. + assert content_type == "unknown" + continue + assert schema.app_mode_from_type(content_type).name() == mode.name() + + +def test_known_type_translations(): + assert schema.type_from_app_mode(AppModes.PYTHON_API) == "python-flask" + assert schema.type_from_app_mode(AppModes.PLUMBER) == "r-plumber" + assert schema.type_from_app_mode(AppModes.SHINY) == "r-shiny" + assert schema.type_from_app_mode(AppModes.RMD) == "rmd" + assert schema.type_from_app_mode(AppModes.STATIC) == "html" + # Publisher's deprecated "quarto" type resolves to the quarto-static app mode. + assert schema.app_mode_from_type("quarto") is AppModes.STATIC_QUARTO + + +# --- serialization --------------------------------------------------------- + + +def test_schema_key_is_quoted_and_first(tmp_path): + path = str(tmp_path / "c.toml") + serialize.write(path, serialize.dumps({"$schema": "https://x", "type": "python-shiny", "files": ["/a", "/b"]})) + text = open(path).read() + assert text.splitlines()[0] == '"$schema" = "https://x"' + # arrays are multiline, matching Publisher's output. + assert "files = [\n " in text + + +def test_prune_drops_none_and_empty_strings_keeps_false(): + dumped = serialize.dumps({"a": None, "b": "", "c": False, "d": 0, "e": "x"}) + assert "a =" not in dumped and "b =" not in dumped + assert "c = false" in dumped and "d = 0" in dumped and 'e = "x"' in dumped + + +# --- write side ------------------------------------------------------------ + + +def test_write_deployment_metadata_creates_config_and_record(tmp_path): + project = str(tmp_path) + config_path, record_path = deploy(project) + + cfg = config.read_config(config_path) + assert cfg.type == "python-shiny" + assert cfg.entrypoint == "app.py" + assert cfg.title == "My App" + assert cfg.validate is True + assert cfg.files == ["/app.py", "/requirements.txt"] + assert cfg.python == { + "version": "3.11.5", + "package_file": "requirements.txt", + "package_manager": "pip", + "requires_python": ">=3.9", + } + + rec = record.read_record(record_path) + assert rec.id == "GUID-123" + assert rec.server_type == "connect" + assert rec.type == "python-shiny" + # configuration_name links to the config file that was written + assert config_path.endswith(rec.configuration_name + ".toml") + assert rec.direct_url == DEPLOYED_INFO["app_url"] + assert rec.dashboard_url == DEPLOYED_INFO["dashboard_url"] + assert rec.logs_url == DEPLOYED_INFO["dashboard_url"] + "/logs" + assert rec.bundle_id == "42" + # concrete manifest file list, sorted; requirements from requirements.txt (no comments/blanks) + assert rec.files == ["app.py", "helpers.py", "requirements.txt"] + assert rec.requirements == ["shiny==1.0", "htmltools>=0.5"] + # embedded configuration snapshot matches the config file + assert rec.config().type == "python-shiny" + + +def test_filenames_use_publisher_random_code_methodology(tmp_path): + """rsconnect mints the same style of names as Publisher's names.ts: a config + ``<filenamified-title>-<CODE>`` and a record ``deployment-<CODE>``, where CODE + is a 4-char uppercase base-32 string.""" + import os + import re + + config_path, record_path = deploy(str(tmp_path)) + config_stem = os.path.splitext(os.path.basename(config_path))[0] + record_stem = os.path.splitext(os.path.basename(record_path))[0] + + # title "My App" -> filenamify keeps it; 4-char base-32 (0-9, A-V) ending + assert re.fullmatch(r"My App-[0-9A-V]{4}", config_stem), config_stem + assert re.fullmatch(r"deployment-[0-9A-V]{4}", record_stem), record_stem + + +def test_redeploy_updates_in_place(tmp_path): + project = str(tmp_path) + config_path, record_path = deploy(project) + # a cosmetically different URL still maps to the same record file + config_path2, record_path2 = deploy(project, server_url="https://connect.example.com", bundle_id="43") + assert config_path2 == config_path + assert record_path2 == record_path + assert len(record.discover_records(project)) == 1 + assert len(config.discover_configs(project)) == 1 + assert record.read_record(record_path2).bundle_id == "43" + + +def test_created_at_preserved_on_redeploy(tmp_path): + project = str(tmp_path) + _, record_path = deploy(project) + first = record.read_record(record_path).created_at + _, record_path = deploy(project, bundle_id="99") + assert record.read_record(record_path).created_at == first + + +def test_snowflake_product_type(tmp_path): + bundle = make_bundle(PY_SHINY_MANIFEST, {"requirements.txt": "shiny\n"}) + _, record_path = store.write_deployment_metadata( + project_dir=str(tmp_path), + server_url="https://acct.snowflakecomputing.app", + product_type=schema.PRODUCT_TYPE_SNOWFLAKE, + app_mode=AppModes.PYTHON_SHINY, + title="x", + deployed_info=DEPLOYED_INFO, + bundle=bundle, + ) + assert record.read_record(record_path).server_type == "snowflake" + + +# --- read side / resolve --------------------------------------------------- + + +def test_resolve_single(tmp_path): + project = str(tmp_path) + deploy(project) + target = store.resolve_publisher_deploy_target(project) + assert target.app_mode is AppModes.PYTHON_SHINY + assert target.entrypoint == "app.py" + assert target.app_id == "GUID-123" + assert target.requirements_file == "requirements.txt" + assert target.server_url is not None + + +def test_resolve_url_normalization(tmp_path): + project = str(tmp_path) + deploy(project, server_url="https://connect.example.com/__api__") + # match despite missing __api__ and trailing slash + target = store.resolve_publisher_deploy_target(project, server="https://connect.example.com/") + assert target.app_id == "GUID-123" + + +def test_resolve_no_config_raises(tmp_path): + from rsconnect.exception import RSConnectException + + with pytest.raises(RSConnectException, match="No .posit/publish configuration"): + store.resolve_publisher_deploy_target(str(tmp_path)) + + +def test_resolve_multiple_configs_requires_name(tmp_path): + from rsconnect.exception import RSConnectException + + project = str(tmp_path) + config.write_config(project, "one", config.PublisherConfig(type="python-shiny", entrypoint="a.py")) + config.write_config(project, "two", config.PublisherConfig(type="python-shiny", entrypoint="b.py")) + with pytest.raises(RSConnectException, match="Multiple .posit configs"): + store.resolve_publisher_deploy_target(project) + # disambiguated by name + target = store.resolve_publisher_deploy_target(project, config_name="two") + assert target.entrypoint == "b.py" + assert target.record is None # no deployment yet + + +# --- interop with a Publisher-authored project ----------------------------- + + +def test_reads_publisher_authored_project(tmp_path): + """A config + record written by Publisher (random filenames, quoted $schema, + connect_cloud table) must resolve and reuse the recorded content id.""" + publish = tmp_path / ".posit" / "publish" + deployments = publish / "deployments" + deployments.mkdir(parents=True) + + (publish / "my-app-AB12.toml").write_text( + '"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json"\n' + 'product_type = "connect"\n' + 'type = "python-shiny"\n' + 'entrypoint = "app.py"\n' + "validate = true\n" + 'files = ["/app.py"]\n\n' + "[python]\n" + 'version = "3.11"\n' + 'package_file = "requirements.txt"\n' + ) + (deployments / "deployment-CD34.toml").write_text( + "# This file is automatically generated by Posit Publisher; do not edit.\n" + '"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-record-schema-v3.json"\n' + 'server_type = "connect"\n' + 'server_url = "https://connect.example.com"\n' + 'type = "python-shiny"\n' + 'id = "PUBLISHER-GUID"\n' + 'configuration_name = "my-app-AB12"\n' + ) + + target = store.resolve_publisher_deploy_target(str(tmp_path)) + assert target.config_name == "my-app-AB12" + assert target.app_mode is AppModes.PYTHON_SHINY + assert target.entrypoint == "app.py" + assert target.app_id == "PUBLISHER-GUID" + assert target.requirements_file == "requirements.txt" + + +def test_write_config_from_manifest(tmp_path): + project = str(tmp_path) + path = store.write_config_from_manifest(project, PY_SHINY_MANIFEST) + cfg = config.read_config(path) + assert cfg.type == "python-shiny" + assert cfg.entrypoint == "app.py" + assert cfg.files == ["/app.py", "/requirements.txt"] + # write-manifest prepares content but does not deploy: no record is written. + assert record.discover_records(project) == [] + + +def test_write_manifest_publisher_config_helper(tmp_path): + """The write-manifest CLI helper writes a config next to an existing + manifest.json, using the explicit app_mode (not the manifest's appmode).""" + import json as _json + + from rsconnect.main import _write_manifest_publisher_config + + (tmp_path / "manifest.json").write_text(_json.dumps(PY_SHINY_MANIFEST)) + _write_manifest_publisher_config(str(tmp_path), AppModes.PYTHON_SHINY) + + configs = config.discover_configs(str(tmp_path)) + assert len(configs) == 1 + cfg = config.read_config(configs[0]) + assert cfg.type == "python-shiny" + assert cfg.entrypoint == "app.py" + # no record: write-manifest does not deploy + assert record.discover_records(str(tmp_path)) == [] + + +def test_write_manifest_publisher_config_skips_unknown_type(tmp_path): + """TensorFlow has no Publisher content type, so no config is written.""" + import json as _json + + from rsconnect.main import _write_manifest_publisher_config + + (tmp_path / "manifest.json").write_text(_json.dumps(PY_SHINY_MANIFEST)) + _write_manifest_publisher_config(str(tmp_path), AppModes.TENSORFLOW) + assert config.discover_configs(str(tmp_path)) == [] + + +def test_connect_cloud_round_trip(tmp_path): + project = str(tmp_path) + cfg = config.PublisherConfig( + type="python-shiny", + entrypoint="app.py", + product_type=schema.PRODUCT_TYPE_CONNECT_CLOUD, + connect_cloud={"vanity_name": "my-app", "access_control": {"public_access": True}}, + ) + path, _ = config.write_config(project, "cloud", cfg) + reread = config.read_config(path) + assert reread.product_type == "connect_cloud" + assert reread.connect_cloud["vanity_name"] == "my-app" + + # rsconnect defaults product_type to connect, but must not downgrade an + # existing connect_cloud config on rewrite. + config.write_config(project, "cloud", config.PublisherConfig(type="python-shiny", entrypoint="app.py")) + assert config.read_config(path).product_type == "connect_cloud" + assert config.read_config(path).connect_cloud["vanity_name"] == "my-app" diff --git a/tests/test_redeploy.py b/tests/test_redeploy.py new file mode 100644 index 000000000..6df43d0e2 --- /dev/null +++ b/tests/test_redeploy.py @@ -0,0 +1,281 @@ +"""Integration tests for the ``rsconnect redeploy`` command. + +Exercises the CLI via ``click.testing.CliRunner``, short-circuiting the deploy +at ``make_bundle`` (as ``tests/test_deploy_pyproject.py`` does) so the full +command wiring -- ``.posit`` resolution -> executor construction -> app_mode +dispatch -- runs without any network call. Asserts that the deployment record's +server and content identity are recovered and reused. +""" + +import pathlib +import textwrap +import types +import typing + +import pytest +from click.testing import CliRunner + +from rsconnect.main import cli + +SERVER_URL = "https://connect.example.com" +GUID = "RECORD-GUID-123" + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +@pytest.fixture +def project_dir(tmp_path: pathlib.Path) -> pathlib.Path: + return tmp_path + + +def _write_posit_project( + project_dir: pathlib.Path, + *, + server_url: str = SERVER_URL, + guid: typing.Optional[str] = GUID, + content_type: str = "python-shiny", + entrypoint: str = "app.py", + config_name: str = "app", + with_record: bool = True, +) -> None: + """Author a ``.posit/publish`` config (+ optional record) on disk.""" + publish = project_dir / ".posit" / "publish" + deployments = publish / "deployments" + deployments.mkdir(parents=True, exist_ok=True) + + (publish / f"{config_name}.toml").write_text( + textwrap.dedent( + f"""\ + "$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json" + product_type = "connect" + type = "{content_type}" + entrypoint = "{entrypoint}" + validate = true + files = ["/{entrypoint}"] + + [python] + version = "3.11" + package_file = "requirements.txt" + """ + ) + ) + if with_record: + record_body = textwrap.dedent( + f"""\ + "$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-record-schema-v3.json" + server_type = "connect" + server_url = "{server_url}" + type = "{content_type}" + configuration_name = "{config_name}" + """ + ) + if guid: + record_body += f'id = "{guid}"\n' + (deployments / "deployment-abc123.toml").write_text(record_body) + + if ":" not in entrypoint: + (project_dir / entrypoint).touch() + + +def _spy_make_bundle(monkeypatch: pytest.MonkeyPatch) -> dict[str, typing.Any]: + """Short-circuit the deploy at ``make_bundle`` and capture executor state.""" + captured: dict[str, typing.Any] = {} + + class _StopDispatch(Exception): + pass + + def spy_make_bundle( + self: typing.Any, builder: typing.Callable[..., typing.Any], *args: typing.Any, **kwargs: typing.Any + ): + captured["builder"] = builder.__name__ + captured["args"] = args + captured["app_id"] = self.app_id + captured["server_url"] = self.remote_server.url + captured["app_mode"] = self.app_mode.name() if self.app_mode else None + captured["title"] = self.title + raise _StopDispatch() + + from rsconnect import api as api_mod + from rsconnect import main as main_mod + + fake_environment = types.SimpleNamespace(python="python") + monkeypatch.setattr( + main_mod.Environment, + "create_python_environment", + classmethod(lambda cls, *args, **kwargs: fake_environment), + ) + monkeypatch.setattr(api_mod.RSConnectClient, "server_settings", lambda self: {}) + monkeypatch.setattr(api_mod.RSConnectExecutor, "validate_server", lambda self: self) + + def fake_validate_app_mode(self: typing.Any, app_mode: typing.Any): + self.app_mode = app_mode + return self + + monkeypatch.setattr(api_mod.RSConnectExecutor, "validate_app_mode", fake_validate_app_mode) + monkeypatch.setattr(api_mod.RSConnectExecutor, "make_bundle", spy_make_bundle) + return captured + + +def test_redeploy_command_is_registered(runner: CliRunner): + result = runner.invoke(cli, ["redeploy", "--help"]) + assert result.exit_code == 0 + assert ".posit/publish" in result.output + + +def test_redeploy_errors_without_posit_project(runner: CliRunner, project_dir: pathlib.Path): + result = runner.invoke(cli, ["redeploy", str(project_dir)]) + assert result.exit_code != 0 + assert "No .posit/publish configuration" in result.output + + +def test_redeploy_errors_on_first_deploy_without_server(runner: CliRunner, project_dir: pathlib.Path): + _write_posit_project(project_dir, with_record=False) + result = runner.invoke(cli, ["redeploy", str(project_dir)]) + assert result.exit_code != 0 + assert "No prior deployment found" in result.output + + +def test_redeploy_reuses_identity_from_record( + runner: CliRunner, project_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch +): + captured = _spy_make_bundle(monkeypatch) + _write_posit_project(project_dir) + + result = runner.invoke(cli, ["redeploy", str(project_dir), "-k", "fake-key"]) + + assert captured.get("builder") == "make_api_bundle", result.output + # server and content identity recovered from the deployment record + assert captured["server_url"] == SERVER_URL + assert captured["app_id"] == GUID + assert captured["app_mode"] == "python-shiny" + + +def test_redeploy_app_id_override(runner: CliRunner, project_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch): + captured = _spy_make_bundle(monkeypatch) + _write_posit_project(project_dir) + + result = runner.invoke(cli, ["redeploy", str(project_dir), "-k", "fake-key", "--app-id", "OVERRIDE-999"]) + + assert captured.get("builder") == "make_api_bundle", result.output + assert captured["app_id"] == "OVERRIDE-999" + + +def test_redeploy_requires_config_name_when_multiple( + runner: CliRunner, project_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch +): + _spy_make_bundle(monkeypatch) + _write_posit_project(project_dir, config_name="one", with_record=False) + _write_posit_project(project_dir, config_name="two", with_record=False) + + result = runner.invoke(cli, ["redeploy", str(project_dir), "-k", "fake-key"]) + assert result.exit_code != 0 + assert "Multiple .posit configs" in result.output + + +def test_redeploy_selects_config_by_name(runner: CliRunner, project_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch): + """--config-name disambiguates among multiple configs and picks that config's + deployment record.""" + captured = _spy_make_bundle(monkeypatch) + # Two configs; only "two" has a prior deployment record. + _write_posit_project(project_dir, config_name="one", entrypoint="one.py", with_record=False) + _write_posit_project(project_dir, config_name="two", entrypoint="two.py", with_record=True) + + result = runner.invoke(cli, ["redeploy", str(project_dir), "-k", "fake-key", "--config-name", "two"]) + + assert captured.get("builder") == "make_api_bundle", result.output + assert captured["app_id"] == GUID + # dispatched with the selected config's entrypoint + assert "two.py" in captured["args"], captured["args"] + + +def _write_legacy_project( + project_dir: pathlib.Path, + *, + server_url: str = SERVER_URL, + guid: str = GUID, + appmode: str = "python-shiny", + with_manifest: bool = True, + with_legacy_json: bool = True, +) -> None: + """Author pre-.posit artifacts: a manifest.json and/or a legacy JSON record.""" + import json + + if with_manifest: + (project_dir / "manifest.json").write_text( + json.dumps( + { + "version": 1, + "metadata": {"appmode": appmode, "entrypoint": "app.py"}, + "files": {"app.py": {"checksum": "x"}}, + } + ) + ) + if with_legacy_json: + legacy = project_dir / "rsconnect-python" + legacy.mkdir(parents=True, exist_ok=True) + (legacy / "app.json").write_text( + json.dumps( + { + server_url: { + "server_url": server_url, + "filename": str(project_dir / "app.py"), + "app_url": "https://connect.example.com/content/xyz/", + "app_id": "7", + "app_guid": guid, + "title": "Legacy App", + "app_mode": appmode, + "app_store_version": 1, + } + } + ) + ) + (project_dir / "app.py").touch() + + +def test_redeploy_legacy_fallback_manifest_and_json( + runner: CliRunner, project_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch +): + """With no .posit but a manifest.json + legacy JSON, redeploy deploys the + manifest bundle to the recorded server/GUID.""" + captured = _spy_make_bundle(monkeypatch) + _write_legacy_project(project_dir) + + result = runner.invoke(cli, ["redeploy", str(project_dir), "-k", "fake-key"]) + + assert captured.get("builder") == "make_manifest_bundle", result.output + assert captured["app_id"] == GUID + assert captured["server_url"] == SERVER_URL + + +def test_redeploy_legacy_requires_manifest(runner: CliRunner, project_dir: pathlib.Path): + """Legacy JSON without a manifest.json has nothing to build from.""" + _write_legacy_project(project_dir, with_manifest=False) + result = runner.invoke(cli, ["redeploy", str(project_dir)]) + assert result.exit_code != 0 + assert "nothing to redeploy" in result.output + + +def test_redeploy_legacy_manifest_without_record_needs_server(runner: CliRunner, project_dir: pathlib.Path): + """A manifest with no prior deployment record is a first deploy.""" + _write_legacy_project(project_dir, with_legacy_json=False) + result = runner.invoke(cli, ["redeploy", str(project_dir)]) + assert result.exit_code != 0 + assert "No prior deployment found" in result.output + + +def test_redeploy_dispatches_quarto(runner: CliRunner, project_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch): + captured = _spy_make_bundle(monkeypatch) + from rsconnect import main as main_mod + + monkeypatch.setattr(main_mod, "which_quarto", lambda quarto=None: "quarto") + monkeypatch.setattr(main_mod, "quarto_inspect", lambda quarto, path: {"engines": []}) + monkeypatch.setattr(main_mod, "validate_quarto_engines", lambda inspect: []) + _write_posit_project(project_dir, content_type="quarto-static", entrypoint="report.qmd", config_name="report") + + result = runner.invoke(cli, ["redeploy", str(project_dir), "-k", "fake-key"]) + + assert captured.get("builder") == "create_quarto_deployment_bundle", result.output + assert captured["app_id"] == GUID diff --git a/uv.lock b/uv.lock index 00b635db9..a7162b5ca 100644 --- a/uv.lock +++ b/uv.lock @@ -214,8 +214,8 @@ resolution-markers = [ "python_full_version < '3.8.1'", ] dependencies = [ - { name = "six" }, - { name = "webencodings" }, + { name = "six", marker = "python_full_version < '3.9'" }, + { name = "webencodings", marker = "python_full_version < '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6d/10/77f32b088738f40d4f5be801daa5f327879eadd4562f36a2b5ab975ae571/bleach-6.1.0.tar.gz", hash = "sha256:0a31f1837963c41d46bbf1331b8778e1308ea0791db03cc4e7357b97cf42a8fe", size = 202119, upload-time = "2023-10-06T19:30:51.304Z" } wheels = [ @@ -224,7 +224,7 @@ wheels = [ [package.optional-dependencies] css = [ - { name = "tinycss2", version = "1.2.1", source = { registry = "https://pypi.org/simple" } }, + { name = "tinycss2", version = "1.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, ] [[package]] @@ -235,7 +235,7 @@ resolution-markers = [ "python_full_version == '3.9.*'", ] dependencies = [ - { name = "webencodings" }, + { name = "webencodings", marker = "python_full_version == '3.9.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/76/9a/0e33f5054c54d349ea62c277191c020c2d6ef1d65ab2cb1993f91ec846d1/bleach-6.2.0.tar.gz", hash = "sha256:123e894118b8a599fd80d3ec1a6d4cc7ce4e5882b1317a7e1ba69b56e95f991f", size = 203083, upload-time = "2024-10-29T18:30:40.477Z" } wheels = [ @@ -244,7 +244,7 @@ wheels = [ [package.optional-dependencies] css = [ - { name = "tinycss2", version = "1.4.0", source = { registry = "https://pypi.org/simple" } }, + { name = "tinycss2", version = "1.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, ] [[package]] @@ -259,7 +259,7 @@ resolution-markers = [ "python_full_version == '3.10.*' and platform_python_implementation == 'PyPy'", ] dependencies = [ - { name = "webencodings" }, + { name = "webencodings", marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/48/3c/e12ac860709702bd5ebeb9b56a4fe334f1001246ee1b8f2b7ee28912df7d/bleach-6.4.0.tar.gz", hash = "sha256:4202482733d85cedd04e59fcb2f89f4e4c7c385a78d3c3c23c30446843a37452", size = 204857, upload-time = "2026-06-05T13:01:13.734Z" } wheels = [ @@ -268,7 +268,7 @@ wheels = [ [package.optional-dependencies] css = [ - { name = "tinycss2", version = "1.5.1", source = { registry = "https://pypi.org/simple" } }, + { name = "tinycss2", version = "1.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] [[package]] @@ -276,9 +276,9 @@ name = "boto3" version = "1.43.37" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "botocore" }, - { name = "jmespath" }, - { name = "s3transfer" }, + { name = "botocore", marker = "python_full_version >= '3.10'" }, + { name = "jmespath", marker = "python_full_version >= '3.10'" }, + { name = "s3transfer", marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fa/8b/281ca08c796322a36a639b76c714dc4c4323cab4563a492e6a923aa5f15d/boto3-1.43.37.tar.gz", hash = "sha256:cf7e75963229b337d1b0e37c46de6f3c2c2290d186157729c8e7afb12909bfc0", size = 112674, upload-time = "2026-06-29T20:29:39.273Z" } wheels = [ @@ -290,9 +290,9 @@ name = "botocore" version = "1.43.37" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "jmespath" }, - { name = "python-dateutil" }, - { name = "urllib3", version = "2.5.0", source = { registry = "https://pypi.org/simple" } }, + { name = "jmespath", marker = "python_full_version >= '3.10'" }, + { name = "python-dateutil", marker = "python_full_version >= '3.10'" }, + { name = "urllib3", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c4/a8/3409b5df7e6a562be82e409ba5a976e7ac3df8d5567552c23d44b367a40b/botocore-1.43.37.tar.gz", hash = "sha256:46a7982815579cfe8c7851036b1f51237e35e7937456341df55bc5c36a316145", size = 15646119, upload-time = "2026-06-29T20:29:25.452Z" } wheels = [ @@ -666,7 +666,7 @@ wheels = [ [package.optional-dependencies] toml = [ - { name = "tomli" }, + { name = "tomli", marker = "python_full_version < '3.9'" }, ] [[package]] @@ -785,7 +785,7 @@ wheels = [ [package.optional-dependencies] toml = [ - { name = "tomli" }, + { name = "tomli", marker = "python_full_version == '3.9.*'" }, ] [[package]] @@ -895,7 +895,7 @@ wheels = [ [package.optional-dependencies] toml = [ - { name = "tomli", marker = "python_full_version <= '3.11'" }, + { name = "tomli", marker = "python_full_version >= '3.10' and python_full_version <= '3.11'" }, ] [[package]] @@ -908,7 +908,7 @@ resolution-markers = [ "python_full_version < '3.8.1'", ] dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "cffi", marker = "python_full_version < '3.10' and platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/93/a7/1498799a2ea06148463a9a2c10ab2f6a921a74fb19e231b27dc412a748e2/cryptography-42.0.8.tar.gz", hash = "sha256:8d09d05439ce7baa8e9e95b07ec5b6c886f548deb7e0f69ef25f64b3bce842f2", size = 671250, upload-time = "2024-06-04T19:55:08.609Z" } wheels = [ @@ -953,7 +953,7 @@ resolution-markers = [ "python_full_version >= '3.14' and platform_python_implementation != 'PyPy'", ] dependencies = [ - { name = "cffi" }, + { name = "cffi", marker = "python_full_version >= '3.14' and platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a7/35/c495bffc2056f2dadb32434f1feedd79abde2a7f8363e1974afa9c33c7e2/cryptography-45.0.7.tar.gz", hash = "sha256:4b1654dfc64ea479c242508eb8c724044f1e964a47d1d1cacc5132292d851971", size = 744980, upload-time = "2025-09-01T11:15:03.146Z" } wheels = [ @@ -1006,8 +1006,8 @@ resolution-markers = [ "python_full_version == '3.10.*' and platform_python_implementation == 'PyPy'", ] dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "cffi", marker = "python_full_version >= '3.10' and python_full_version < '3.14' and platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/80/ee/04cd4314db26ffc951c1ea90bde30dd226880ab9343759d7abbecef377ee/cryptography-46.0.0.tar.gz", hash = "sha256:99f64a6d15f19f3afd78720ad2978f6d8d4c68cd4eb600fab82ab1a7c2071dca", size = 749158, upload-time = "2025-09-16T21:07:49.091Z" } wheels = [ @@ -1156,7 +1156,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9' or python_full_version >= '3.11'" }, + { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } @@ -1257,7 +1257,7 @@ resolution-markers = [ "python_full_version < '3.8.1'", ] dependencies = [ - { name = "gitdb" }, + { name = "gitdb", marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b6/a1/106fd9fa2dd989b6fb36e5893961f82992cf676381707253e0bf93eb1662/GitPython-3.1.43.tar.gz", hash = "sha256:35f314a9f878467f5453cc1fee295c3e18e52f1b99f10f6cf5b1682e968a9e7c", size = 214149, upload-time = "2024-03-31T08:07:34.154Z" } wheels = [ @@ -1276,7 +1276,7 @@ resolution-markers = [ "python_full_version == '3.10.*' and platform_python_implementation == 'PyPy'", ] dependencies = [ - { name = "gitdb" }, + { name = "gitdb", marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c0/89/37df0b71473153574a5cdef8f242de422a0f5d26d7a9e231e6f169b4ad14/gitpython-3.1.44.tar.gz", hash = "sha256:c87e30b26253bf5418b01b0660f818967f3c503193838337fe5e573331249269", size = 214196, upload-time = "2025-01-02T07:32:43.59Z" } wheels = [ @@ -1350,7 +1350,7 @@ resolution-markers = [ "python_full_version < '3.8.1'", ] dependencies = [ - { name = "zipp", version = "3.20.2", source = { registry = "https://pypi.org/simple" } }, + { name = "zipp", version = "3.20.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cd/12/33e59336dca5be0c398a7482335911a33aa0e20776128f038019f1a95f1b/importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7", size = 55304, upload-time = "2024-09-11T14:56:08.937Z" } wheels = [ @@ -1365,7 +1365,7 @@ resolution-markers = [ "python_full_version == '3.9.*'", ] dependencies = [ - { name = "zipp", version = "3.23.1", source = { registry = "https://pypi.org/simple" } }, + { name = "zipp", version = "3.23.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } wheels = [ @@ -1383,7 +1383,7 @@ resolution-markers = [ "python_full_version == '3.10.*' and platform_python_implementation == 'PyPy'", ] dependencies = [ - { name = "zipp", version = "4.1.0", source = { registry = "https://pypi.org/simple" } }, + { name = "zipp", version = "4.1.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.10' and python_full_version < '3.14') or (python_full_version >= '3.10' and platform_python_implementation == 'PyPy')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } wheels = [ @@ -1395,7 +1395,7 @@ name = "importlib-resources" version = "6.4.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp", version = "3.20.2", source = { registry = "https://pypi.org/simple" } }, + { name = "zipp", version = "3.20.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/98/be/f3e8c6081b684f176b761e6a2fef02a0be939740ed6f54109a2951d806f3/importlib_resources-6.4.5.tar.gz", hash = "sha256:980862a1d16c9e147a59603677fa2aa5fd82b87f223b6cb870695bcfce830065", size = 43372, upload-time = "2024-09-09T17:03:14.677Z" } wheels = [ @@ -1441,19 +1441,19 @@ resolution-markers = [ "python_full_version < '3.8.1'", ] dependencies = [ - { name = "appnope", marker = "sys_platform == 'darwin'" }, - { name = "comm" }, - { name = "debugpy" }, - { name = "ipython", version = "8.12.3", source = { registry = "https://pypi.org/simple" } }, - { name = "jupyter-client", version = "8.6.3", source = { registry = "https://pypi.org/simple" } }, - { name = "jupyter-core", version = "5.8.1", source = { registry = "https://pypi.org/simple" } }, - { name = "matplotlib-inline", version = "0.1.7", source = { registry = "https://pypi.org/simple" } }, - { name = "nest-asyncio" }, - { name = "packaging" }, - { name = "psutil" }, - { name = "pyzmq" }, - { name = "tornado", version = "6.4.2", source = { registry = "https://pypi.org/simple" } }, - { name = "traitlets", version = "5.14.3", source = { registry = "https://pypi.org/simple" } }, + { name = "appnope", marker = "python_full_version < '3.9' and sys_platform == 'darwin'" }, + { name = "comm", marker = "python_full_version < '3.9'" }, + { name = "debugpy", marker = "python_full_version < '3.9'" }, + { name = "ipython", version = "8.12.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "jupyter-client", version = "8.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "jupyter-core", version = "5.8.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "matplotlib-inline", version = "0.1.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "nest-asyncio", marker = "python_full_version < '3.9'" }, + { name = "packaging", marker = "python_full_version < '3.9'" }, + { name = "psutil", marker = "python_full_version < '3.9'" }, + { name = "pyzmq", marker = "python_full_version < '3.9'" }, + { name = "tornado", version = "6.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "traitlets", version = "5.14.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e9/5c/67594cb0c7055dc50814b21731c22a601101ea3b1b50a9a1b090e11f5d0f/ipykernel-6.29.5.tar.gz", hash = "sha256:f093a22c4a40f8828f8e330a9c297cb93dcab13bd9678ded6de8e5cf81c56215", size = 163367, upload-time = "2024-07-01T14:07:22.543Z" } wheels = [ @@ -1468,19 +1468,19 @@ resolution-markers = [ "python_full_version == '3.9.*'", ] dependencies = [ - { name = "appnope", marker = "sys_platform == 'darwin'" }, - { name = "comm" }, - { name = "debugpy" }, - { name = "ipython", version = "8.18.1", source = { registry = "https://pypi.org/simple" } }, - { name = "jupyter-client", version = "8.6.3", source = { registry = "https://pypi.org/simple" } }, - { name = "jupyter-core", version = "5.8.1", source = { registry = "https://pypi.org/simple" } }, - { name = "matplotlib-inline", version = "0.2.2", source = { registry = "https://pypi.org/simple" } }, - { name = "nest-asyncio" }, - { name = "packaging" }, - { name = "psutil" }, - { name = "pyzmq" }, - { name = "tornado", version = "6.5.7", source = { registry = "https://pypi.org/simple" } }, - { name = "traitlets", version = "5.15.1", source = { registry = "https://pypi.org/simple" } }, + { name = "appnope", marker = "python_full_version == '3.9.*' and sys_platform == 'darwin'" }, + { name = "comm", marker = "python_full_version == '3.9.*'" }, + { name = "debugpy", marker = "python_full_version == '3.9.*'" }, + { name = "ipython", version = "8.18.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "jupyter-client", version = "8.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "jupyter-core", version = "5.8.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "matplotlib-inline", version = "0.2.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "nest-asyncio", marker = "python_full_version == '3.9.*'" }, + { name = "packaging", marker = "python_full_version == '3.9.*'" }, + { name = "psutil", marker = "python_full_version == '3.9.*'" }, + { name = "pyzmq", marker = "python_full_version == '3.9.*'" }, + { name = "tornado", version = "6.5.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "traitlets", version = "5.15.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a5/1d/d5ba6edbfe6fae4c3105bca3a9c889563cc752c7f2de45e333164c7f4846/ipykernel-6.31.0.tar.gz", hash = "sha256:2372ce8bc1ff4f34e58cafed3a0feb2194b91fc7cad0fc72e79e47b45ee9e8f6", size = 167493, upload-time = "2025-10-20T11:42:39.948Z" } wheels = [ @@ -1499,20 +1499,20 @@ resolution-markers = [ "python_full_version == '3.10.*' and platform_python_implementation == 'PyPy'", ] dependencies = [ - { name = "appnope", marker = "sys_platform == 'darwin'" }, - { name = "comm" }, - { name = "debugpy" }, - { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "appnope", marker = "python_full_version >= '3.10' and sys_platform == 'darwin'" }, + { name = "comm", marker = "python_full_version >= '3.10'" }, + { name = "debugpy", marker = "python_full_version >= '3.10'" }, + { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "ipython", version = "9.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "jupyter-client", version = "8.9.1", source = { registry = "https://pypi.org/simple" } }, - { name = "jupyter-core", version = "5.9.1", source = { registry = "https://pypi.org/simple" } }, - { name = "matplotlib-inline", version = "0.2.2", source = { registry = "https://pypi.org/simple" } }, - { name = "nest-asyncio2" }, - { name = "packaging" }, - { name = "psutil" }, - { name = "pyzmq" }, - { name = "tornado", version = "6.5.7", source = { registry = "https://pypi.org/simple" } }, - { name = "traitlets", version = "5.15.1", source = { registry = "https://pypi.org/simple" } }, + { name = "jupyter-client", version = "8.9.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "jupyter-core", version = "5.9.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "matplotlib-inline", version = "0.2.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "nest-asyncio2", marker = "python_full_version >= '3.10'" }, + { name = "packaging", marker = "python_full_version >= '3.10'" }, + { name = "psutil", marker = "python_full_version >= '3.10'" }, + { name = "pyzmq", marker = "python_full_version >= '3.10'" }, + { name = "tornado", version = "6.5.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "traitlets", version = "5.15.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3d/c4/e4a38f579de4225a561305666f7541cdabb30075def2aa1ac17bd73c1fb5/ipykernel-7.3.0.tar.gz", hash = "sha256:9acaaaf97d16355166e4085afe9d225bfbdf2b7ef520f9df3be8f2b248275e09", size = 184899, upload-time = "2026-06-10T08:41:25.481Z" } wheels = [ @@ -1528,19 +1528,19 @@ resolution-markers = [ "python_full_version < '3.8.1'", ] dependencies = [ - { name = "appnope", marker = "sys_platform == 'darwin'" }, - { name = "backcall" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "decorator" }, - { name = "jedi", version = "0.19.2", source = { registry = "https://pypi.org/simple" } }, - { name = "matplotlib-inline", version = "0.1.7", source = { registry = "https://pypi.org/simple" } }, - { name = "pexpect", marker = "sys_platform != 'win32'" }, - { name = "pickleshare" }, - { name = "prompt-toolkit" }, - { name = "pygments", version = "2.19.2", source = { registry = "https://pypi.org/simple" } }, - { name = "stack-data" }, - { name = "traitlets", version = "5.14.3", source = { registry = "https://pypi.org/simple" } }, - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" } }, + { name = "appnope", marker = "python_full_version < '3.9' and sys_platform == 'darwin'" }, + { name = "backcall", marker = "python_full_version < '3.9'" }, + { name = "colorama", marker = "python_full_version < '3.9' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version < '3.9'" }, + { name = "jedi", version = "0.19.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "matplotlib-inline", version = "0.1.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "pexpect", marker = "python_full_version < '3.9' and sys_platform != 'win32'" }, + { name = "pickleshare", marker = "python_full_version < '3.9'" }, + { name = "prompt-toolkit", marker = "python_full_version < '3.9'" }, + { name = "pygments", version = "2.19.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "stack-data", marker = "python_full_version < '3.9'" }, + { name = "traitlets", version = "5.14.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9e/6a/44ef299b1762f5a73841e87fae8a73a8cc8aee538d6dc8c77a5afe1fd2ce/ipython-8.12.3.tar.gz", hash = "sha256:3910c4b54543c2ad73d06579aa771041b7d5707b033bd488669b4cf544e3b363", size = 5470171, upload-time = "2023-09-29T09:14:37.468Z" } wheels = [ @@ -1555,17 +1555,17 @@ resolution-markers = [ "python_full_version == '3.9.*'", ] dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "decorator" }, - { name = "exceptiongroup" }, - { name = "jedi", version = "0.19.2", source = { registry = "https://pypi.org/simple" } }, - { name = "matplotlib-inline", version = "0.2.2", source = { registry = "https://pypi.org/simple" } }, - { name = "pexpect", marker = "sys_platform != 'win32'" }, - { name = "prompt-toolkit" }, - { name = "pygments", version = "2.20.0", source = { registry = "https://pypi.org/simple" } }, - { name = "stack-data" }, - { name = "traitlets", version = "5.15.1", source = { registry = "https://pypi.org/simple" } }, - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" } }, + { name = "colorama", marker = "python_full_version == '3.9.*' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version == '3.9.*'" }, + { name = "exceptiongroup", marker = "python_full_version == '3.9.*'" }, + { name = "jedi", version = "0.19.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "matplotlib-inline", version = "0.2.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "pexpect", marker = "python_full_version == '3.9.*' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version == '3.9.*'" }, + { name = "pygments", version = "2.20.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "stack-data", marker = "python_full_version == '3.9.*'" }, + { name = "traitlets", version = "5.15.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b1/b9/3ba6c45a6df813c09a48bac313c22ff83efa26cbb55011218d925a46e2ad/ipython-8.18.1.tar.gz", hash = "sha256:ca6f079bb33457c66e233e4580ebfc4128855b4cf6370dddd73842a9563e8a27", size = 5486330, upload-time = "2023-11-27T09:58:34.596Z" } wheels = [ @@ -1581,17 +1581,17 @@ resolution-markers = [ "python_full_version == '3.10.*' and platform_python_implementation == 'PyPy'", ] dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "decorator" }, - { name = "exceptiongroup" }, - { name = "jedi", version = "0.20.0", source = { registry = "https://pypi.org/simple" } }, - { name = "matplotlib-inline", version = "0.2.2", source = { registry = "https://pypi.org/simple" } }, - { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit" }, - { name = "pygments", version = "2.20.0", source = { registry = "https://pypi.org/simple" } }, - { name = "stack-data" }, - { name = "traitlets", version = "5.15.1", source = { registry = "https://pypi.org/simple" } }, - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" } }, + { name = "colorama", marker = "python_full_version == '3.10.*' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version == '3.10.*'" }, + { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, + { name = "jedi", version = "0.20.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "matplotlib-inline", version = "0.2.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "pexpect", marker = "python_full_version == '3.10.*' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version == '3.10.*'" }, + { name = "pygments", version = "2.20.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "stack-data", marker = "python_full_version == '3.10.*'" }, + { name = "traitlets", version = "5.15.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/40/18/f8598d287006885e7136451fdea0755af4ebcbfe342836f24deefaed1164/ipython-8.39.0.tar.gz", hash = "sha256:4110ae96012c379b8b6db898a07e186c40a2a1ef5d57a7fa83166047d9da7624", size = 5513971, upload-time = "2026-03-27T10:02:13.94Z" } wheels = [ @@ -1608,18 +1608,18 @@ resolution-markers = [ "python_full_version >= '3.11' and platform_python_implementation == 'PyPy'", ] dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "decorator" }, - { name = "ipython-pygments-lexers" }, - { name = "jedi", version = "0.20.0", source = { registry = "https://pypi.org/simple" } }, - { name = "matplotlib-inline", version = "0.2.2", source = { registry = "https://pypi.org/simple" } }, - { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit" }, - { name = "psutil", marker = "sys_platform != 'cygwin' and sys_platform != 'emscripten'" }, - { name = "pygments", version = "2.20.0", source = { registry = "https://pypi.org/simple" } }, - { name = "stack-data" }, - { name = "traitlets", version = "5.15.1", source = { registry = "https://pypi.org/simple" } }, - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version >= '3.11'" }, + { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.11'" }, + { name = "jedi", version = "0.20.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "matplotlib-inline", version = "0.2.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pexpect", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version >= '3.11'" }, + { name = "psutil", marker = "python_full_version >= '3.11' and sys_platform != 'cygwin' and sys_platform != 'emscripten'" }, + { name = "pygments", version = "2.20.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "stack-data", marker = "python_full_version >= '3.11'" }, + { name = "traitlets", version = "5.15.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/53/59/165d3b4d75cc34add3122c4417ecb229085140ac573103c223cd01dde96f/ipython-9.15.0.tar.gz", hash = "sha256:da2819ce2aa83135257df830660b1176d986c3d2876db24df01974fa955b2756", size = 4442580, upload-time = "2026-06-26T11:03:35.913Z" } wheels = [ @@ -1631,7 +1631,7 @@ name = "ipython-pygments-lexers" version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pygments", version = "2.20.0", source = { registry = "https://pypi.org/simple" } }, + { name = "pygments", version = "2.20.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } wheels = [ @@ -1657,7 +1657,7 @@ name = "jaraco-context" version = "6.1.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, + { name = "backports-tarfile", marker = "python_full_version >= '3.10' and python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" } wheels = [ @@ -1669,7 +1669,7 @@ name = "jaraco-functools" version = "4.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "more-itertools", version = "11.1.0", source = { registry = "https://pypi.org/simple" } }, + { name = "more-itertools", version = "11.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/36/cf/ea4ef2920830dea3f5ab2ea4da6fb67724e6dca80ee2553788c3607243d0/jaraco_functools-4.5.0.tar.gz", hash = "sha256:3bb5665ea4a020cf78a7040e89154c77edadb3ca74f366479669c5999aa70b03", size = 20272, upload-time = "2026-05-15T21:34:10.025Z" } wheels = [ @@ -1686,7 +1686,7 @@ resolution-markers = [ "python_full_version < '3.8.1'", ] dependencies = [ - { name = "parso" }, + { name = "parso", marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287, upload-time = "2024-11-11T01:41:42.873Z" } wheels = [ @@ -1705,7 +1705,7 @@ resolution-markers = [ "python_full_version == '3.10.*' and platform_python_implementation == 'PyPy'", ] dependencies = [ - { name = "parso" }, + { name = "parso", marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416, upload-time = "2026-05-01T23:38:47.814Z" } wheels = [ @@ -1731,7 +1731,7 @@ resolution-markers = [ "python_full_version < '3.8.1'", ] dependencies = [ - { name = "markupsafe", version = "2.1.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.9.*'" }, + { name = "markupsafe", version = "2.1.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "markupsafe", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ed/55/39036716d19cab0747a5020fc7e907f362fbf48c984b14e62127f7e68e5d/jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369", size = 240245, upload-time = "2024-05-05T23:42:02.455Z" } @@ -1751,7 +1751,7 @@ resolution-markers = [ "python_full_version == '3.10.*' and platform_python_implementation == 'PyPy'", ] dependencies = [ - { name = "markupsafe", version = "3.0.3", source = { registry = "https://pypi.org/simple" } }, + { name = "markupsafe", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } wheels = [ @@ -1776,12 +1776,12 @@ resolution-markers = [ "python_full_version < '3.8.1'", ] dependencies = [ - { name = "attrs", version = "25.3.0", source = { registry = "https://pypi.org/simple" } }, - { name = "importlib-resources" }, - { name = "jsonschema-specifications", version = "2023.12.1", source = { registry = "https://pypi.org/simple" } }, - { name = "pkgutil-resolve-name" }, - { name = "referencing", version = "0.35.1", source = { registry = "https://pypi.org/simple" } }, - { name = "rpds-py", version = "0.20.1", source = { registry = "https://pypi.org/simple" } }, + { name = "attrs", version = "25.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "importlib-resources", marker = "python_full_version < '3.9'" }, + { name = "jsonschema-specifications", version = "2023.12.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "pkgutil-resolve-name", marker = "python_full_version < '3.9'" }, + { name = "referencing", version = "0.35.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "rpds-py", version = "0.20.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/38/2e/03362ee4034a4c917f697890ccd4aec0800ccf9ded7f511971c75451deec/jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4", size = 325778, upload-time = "2024-07-08T18:40:05.546Z" } wheels = [ @@ -1796,10 +1796,10 @@ resolution-markers = [ "python_full_version == '3.9.*'", ] dependencies = [ - { name = "attrs", version = "26.1.0", source = { registry = "https://pypi.org/simple" } }, - { name = "jsonschema-specifications", version = "2025.9.1", source = { registry = "https://pypi.org/simple" } }, - { name = "referencing", version = "0.36.2", source = { registry = "https://pypi.org/simple" } }, - { name = "rpds-py", version = "0.27.1", source = { registry = "https://pypi.org/simple" } }, + { name = "attrs", version = "26.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "jsonschema-specifications", version = "2025.9.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "referencing", version = "0.36.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "rpds-py", version = "0.27.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" } wheels = [ @@ -1818,10 +1818,10 @@ resolution-markers = [ "python_full_version == '3.10.*' and platform_python_implementation == 'PyPy'", ] dependencies = [ - { name = "attrs", version = "26.1.0", source = { registry = "https://pypi.org/simple" } }, - { name = "jsonschema-specifications", version = "2025.9.1", source = { registry = "https://pypi.org/simple" } }, - { name = "referencing", version = "0.37.0", source = { registry = "https://pypi.org/simple" } }, - { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "attrs", version = "26.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "jsonschema-specifications", version = "2025.9.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "referencing", version = "0.37.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } @@ -1838,8 +1838,8 @@ resolution-markers = [ "python_full_version < '3.8.1'", ] dependencies = [ - { name = "importlib-resources" }, - { name = "referencing", version = "0.35.1", source = { registry = "https://pypi.org/simple" } }, + { name = "importlib-resources", marker = "python_full_version < '3.9'" }, + { name = "referencing", version = "0.35.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f8/b9/cc0cc592e7c195fb8a650c1d5990b10175cf13b4c97465c72ec841de9e4b/jsonschema_specifications-2023.12.1.tar.gz", hash = "sha256:48a76787b3e70f5ed53f1160d2b81f586e4ca6d1548c5de7085d1682674764cc", size = 13983, upload-time = "2023-12-25T15:16:53.63Z" } wheels = [ @@ -1859,7 +1859,7 @@ resolution-markers = [ "python_full_version == '3.9.*'", ] dependencies = [ - { name = "referencing", version = "0.36.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "referencing", version = "0.36.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, { name = "referencing", version = "0.37.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } @@ -1877,14 +1877,14 @@ resolution-markers = [ "python_full_version < '3.8.1'", ] dependencies = [ - { name = "importlib-metadata", version = "8.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.9.*'" }, + { name = "importlib-metadata", version = "8.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "importlib-metadata", version = "8.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "jupyter-core", version = "5.8.1", source = { registry = "https://pypi.org/simple" } }, - { name = "python-dateutil" }, - { name = "pyzmq" }, - { name = "tornado", version = "6.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.9.*'" }, + { name = "jupyter-core", version = "5.8.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "python-dateutil", marker = "python_full_version < '3.10'" }, + { name = "pyzmq", marker = "python_full_version < '3.10'" }, + { name = "tornado", version = "6.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "tornado", version = "6.5.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "traitlets", version = "5.14.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.9.*'" }, + { name = "traitlets", version = "5.14.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "traitlets", version = "5.15.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/71/22/bf9f12fdaeae18019a468b68952a60fe6dbab5d67cd2a103cac7659b41ca/jupyter_client-8.6.3.tar.gz", hash = "sha256:35b3a0947c4a6e9d589eb97d7d4cd5e90f910ee73101611f01283732bd6d9419", size = 342019, upload-time = "2024-09-17T10:44:17.613Z" } @@ -1904,12 +1904,12 @@ resolution-markers = [ "python_full_version == '3.10.*' and platform_python_implementation == 'PyPy'", ] dependencies = [ - { name = "jupyter-core", version = "5.9.1", source = { registry = "https://pypi.org/simple" } }, - { name = "python-dateutil" }, - { name = "pyzmq" }, - { name = "tornado", version = "6.5.7", source = { registry = "https://pypi.org/simple" } }, - { name = "traitlets", version = "5.15.1", source = { registry = "https://pypi.org/simple" } }, - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" } }, + { name = "jupyter-core", version = "5.9.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "python-dateutil", marker = "python_full_version >= '3.10'" }, + { name = "pyzmq", marker = "python_full_version >= '3.10'" }, + { name = "tornado", version = "6.5.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "traitlets", version = "5.15.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7d/dc/5512503b088997c2250b8bf18258fba9d9ce5ead641183700960d3c9d342/jupyter_client-8.9.1.tar.gz", hash = "sha256:a58f730dd9e728ba16ba1d62ebccf7ffe1ebbdbce4e95cfae941b7321ae1f4fa", size = 359256, upload-time = "2026-06-09T13:15:01.033Z" } wheels = [ @@ -1926,11 +1926,11 @@ resolution-markers = [ "python_full_version < '3.8.1'", ] dependencies = [ - { name = "platformdirs", version = "4.3.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.9.*'" }, + { name = "platformdirs", version = "4.3.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "platformdirs", version = "4.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "pywin32", version = "311", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.9.*' and platform_python_implementation != 'PyPy' and sys_platform == 'win32'" }, + { name = "pywin32", version = "311", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9' and platform_python_implementation != 'PyPy' and sys_platform == 'win32'" }, { name = "pywin32", version = "312", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*' and platform_python_implementation != 'PyPy' and sys_platform == 'win32'" }, - { name = "traitlets", version = "5.14.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.9.*'" }, + { name = "traitlets", version = "5.14.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "traitlets", version = "5.15.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/99/1b/72906d554acfeb588332eaaa6f61577705e9ec752ddb486f302dafa292d9/jupyter_core-5.8.1.tar.gz", hash = "sha256:0a5f9706f70e64786b75acba995988915ebd4601c8a52e534a40b51c95f59941", size = 88923, upload-time = "2025-05-27T07:38:16.655Z" } @@ -1950,8 +1950,8 @@ resolution-markers = [ "python_full_version == '3.10.*' and platform_python_implementation == 'PyPy'", ] dependencies = [ - { name = "platformdirs", version = "4.10.0", source = { registry = "https://pypi.org/simple" } }, - { name = "traitlets", version = "5.15.1", source = { registry = "https://pypi.org/simple" } }, + { name = "platformdirs", version = "4.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "traitlets", version = "5.15.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" } wheels = [ @@ -1977,13 +1977,13 @@ resolution-markers = [ "python_full_version < '3.8.1'", ] dependencies = [ - { name = "importlib-metadata", version = "8.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.9.*'" }, + { name = "importlib-metadata", version = "8.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "importlib-metadata", version = "8.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "importlib-resources", marker = "python_full_version != '3.9.*'" }, - { name = "jaraco-classes" }, - { name = "jeepney", marker = "sys_platform == 'linux'" }, - { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, - { name = "secretstorage", version = "3.3.3", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform == 'linux'" }, + { name = "importlib-resources", marker = "python_full_version < '3.9'" }, + { name = "jaraco-classes", marker = "python_full_version < '3.10'" }, + { name = "jeepney", marker = "python_full_version < '3.10' and sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, + { name = "secretstorage", version = "3.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and sys_platform == 'linux'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ae/6c/bd2cfc6c708ce7009bdb48c85bb8cad225f5638095ecc8f49f15e8e1f35e/keyring-24.3.1.tar.gz", hash = "sha256:c3327b6ffafc0e8befbdb597cacdb4928ffe5c1212f7645f186e6d9957a898db", size = 60454, upload-time = "2024-02-27T16:49:37.977Z" } wheels = [ @@ -2002,13 +2002,13 @@ resolution-markers = [ "python_full_version == '3.10.*' and platform_python_implementation == 'PyPy'", ] dependencies = [ - { name = "importlib-metadata", version = "9.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "jaraco-classes" }, - { name = "jaraco-context" }, - { name = "jaraco-functools" }, - { name = "jeepney", marker = "sys_platform == 'linux'" }, - { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, - { name = "secretstorage", version = "3.5.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform == 'linux'" }, + { name = "importlib-metadata", version = "9.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and python_full_version < '3.12'" }, + { name = "jaraco-classes", marker = "python_full_version >= '3.10'" }, + { name = "jaraco-context", marker = "python_full_version >= '3.10'" }, + { name = "jaraco-functools", marker = "python_full_version >= '3.10'" }, + { name = "jeepney", marker = "python_full_version >= '3.10' and sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, + { name = "secretstorage", version = "3.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and sys_platform == 'linux'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } wheels = [ @@ -2024,7 +2024,7 @@ resolution-markers = [ "python_full_version < '3.8.1'", ] dependencies = [ - { name = "importlib-metadata", version = "8.5.0", source = { registry = "https://pypi.org/simple" } }, + { name = "importlib-metadata", version = "8.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/54/28/3af612670f82f4c056911fbbbb42760255801b3068c48de792d354ff4472/markdown-3.7.tar.gz", hash = "sha256:2ae2471477cfd02dbbf038d5d9bc226d40def84b4fe2986e49b59b6b472bbed2", size = 357086, upload-time = "2024-08-16T15:55:17.812Z" } wheels = [ @@ -2039,7 +2039,7 @@ resolution-markers = [ "python_full_version == '3.9.*'", ] dependencies = [ - { name = "importlib-metadata", version = "8.7.1", source = { registry = "https://pypi.org/simple" } }, + { name = "importlib-metadata", version = "8.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8d/37/02347f6d6d8279247a5837082ebc26fc0d5aaeaf75aa013fcbb433c777ab/markdown-3.9.tar.gz", hash = "sha256:d2900fe1782bd33bdbbd56859defef70c2e78fc46668f8eb9df3128138f2cb6a", size = 364585, upload-time = "2025-09-04T20:25:22.885Z" } wheels = [ @@ -2072,7 +2072,7 @@ resolution-markers = [ "python_full_version < '3.8.1'", ] dependencies = [ - { name = "mdurl" }, + { name = "mdurl", marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } wheels = [ @@ -2091,7 +2091,7 @@ resolution-markers = [ "python_full_version == '3.10.*' and platform_python_implementation == 'PyPy'", ] dependencies = [ - { name = "mdurl" }, + { name = "mdurl", marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } wheels = [ @@ -2273,7 +2273,7 @@ resolution-markers = [ "python_full_version < '3.8.1'", ] dependencies = [ - { name = "traitlets", version = "5.14.3", source = { registry = "https://pypi.org/simple" } }, + { name = "traitlets", version = "5.14.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/99/5b/a36a337438a14116b16480db471ad061c36c3694df7c2084a0da7ba538b7/matplotlib_inline-0.1.7.tar.gz", hash = "sha256:8423b23ec666be3d16e16b60bdd8ac4e86e840ebd1dd11a30b9f117f2fa0ab90", size = 8159, upload-time = "2024-04-15T13:44:44.803Z" } wheels = [ @@ -2293,7 +2293,7 @@ resolution-markers = [ "python_full_version == '3.9.*'", ] dependencies = [ - { name = "traitlets", version = "5.15.1", source = { registry = "https://pypi.org/simple" } }, + { name = "traitlets", version = "5.15.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79", size = 8150, upload-time = "2026-05-08T17:33:33.49Z" } wheels = [ @@ -2375,8 +2375,8 @@ resolution-markers = [ "python_full_version < '3.8.1'", ] dependencies = [ - { name = "click" }, - { name = "markdown", version = "3.7", source = { registry = "https://pypi.org/simple" } }, + { name = "click", marker = "python_full_version < '3.9'" }, + { name = "markdown", version = "3.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/68/61/d6b68573b4c399cd201502e4ea4cbfc12e274333d9ee622668cfbc9940ac/mkdocs_click-0.8.1.tar.gz", hash = "sha256:0a88cce04870c5d70ff63138e2418219c3c4119cc928a59c66b76eb5214edba6", size = 17874, upload-time = "2023-09-18T18:36:09.887Z" } wheels = [ @@ -2396,8 +2396,8 @@ resolution-markers = [ "python_full_version == '3.9.*'", ] dependencies = [ - { name = "click" }, - { name = "markdown", version = "3.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "click", marker = "python_full_version >= '3.9'" }, + { name = "markdown", version = "3.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, { name = "markdown", version = "3.10.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a1/c7/8c25f3a3b379def41e6d0bb5c4beeab7aa8a394b17e749f498504102cfa5/mkdocs_click-0.9.0.tar.gz", hash = "sha256:6050917628d4740517541422b607404d044117bc31b770c4f9e9e1939a50c908", size = 18720, upload-time = "2025-04-07T16:59:36.387Z" } @@ -2414,10 +2414,10 @@ resolution-markers = [ "python_full_version < '3.8.1'", ] dependencies = [ - { name = "importlib-metadata", version = "8.5.0", source = { registry = "https://pypi.org/simple" } }, - { name = "mergedeep" }, - { name = "platformdirs", version = "4.3.6", source = { registry = "https://pypi.org/simple" } }, - { name = "pyyaml", version = "6.0.1", source = { registry = "https://pypi.org/simple" } }, + { name = "importlib-metadata", version = "8.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "mergedeep", marker = "python_full_version < '3.9'" }, + { name = "platformdirs", version = "4.3.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "pyyaml", version = "6.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/98/f5/ed29cd50067784976f25ed0ed6fcd3c2ce9eb90650aa3b2796ddf7b6870b/mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c", size = 10239, upload-time = "2023-11-20T17:51:09.981Z" } wheels = [ @@ -2437,11 +2437,11 @@ resolution-markers = [ "python_full_version == '3.9.*'", ] dependencies = [ - { name = "importlib-metadata", version = "8.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "mergedeep" }, - { name = "platformdirs", version = "4.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "importlib-metadata", version = "8.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "mergedeep", marker = "python_full_version >= '3.9'" }, + { name = "platformdirs", version = "4.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, { name = "platformdirs", version = "4.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "pyyaml", version = "6.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pyyaml", version = "6.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, { name = "pyyaml", version = "6.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" } @@ -2566,10 +2566,10 @@ resolution-markers = [ "python_full_version < '3.8.1'", ] dependencies = [ - { name = "jupyter-client", version = "8.6.3", source = { registry = "https://pypi.org/simple" } }, - { name = "jupyter-core", version = "5.8.1", source = { registry = "https://pypi.org/simple" } }, - { name = "nbformat" }, - { name = "traitlets", version = "5.14.3", source = { registry = "https://pypi.org/simple" } }, + { name = "jupyter-client", version = "8.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "jupyter-core", version = "5.8.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "nbformat", marker = "python_full_version < '3.9'" }, + { name = "traitlets", version = "5.14.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/db/25929926860ba8a3f6123d2d0a235e558e0e4be7b46e9db063a7dfefa0a2/nbclient-0.10.1.tar.gz", hash = "sha256:3e93e348ab27e712acd46fccd809139e356eb9a31aab641d1a7991a6eb4e6f68", size = 62273, upload-time = "2024-11-29T08:28:38.47Z" } wheels = [ @@ -2584,10 +2584,10 @@ resolution-markers = [ "python_full_version == '3.9.*'", ] dependencies = [ - { name = "jupyter-client", version = "8.6.3", source = { registry = "https://pypi.org/simple" } }, - { name = "jupyter-core", version = "5.8.1", source = { registry = "https://pypi.org/simple" } }, - { name = "nbformat" }, - { name = "traitlets", version = "5.15.1", source = { registry = "https://pypi.org/simple" } }, + { name = "jupyter-client", version = "8.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "jupyter-core", version = "5.8.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "nbformat", marker = "python_full_version == '3.9.*'" }, + { name = "traitlets", version = "5.15.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/87/66/7ffd18d58eae90d5721f9f39212327695b749e23ad44b3881744eaf4d9e8/nbclient-0.10.2.tar.gz", hash = "sha256:90b7fc6b810630db87a6d0c2250b1f0ab4cf4d3c27a299b0cde78a4ed3fd9193", size = 62424, upload-time = "2024-12-19T10:32:27.164Z" } wheels = [ @@ -2606,10 +2606,10 @@ resolution-markers = [ "python_full_version == '3.10.*' and platform_python_implementation == 'PyPy'", ] dependencies = [ - { name = "jupyter-client", version = "8.9.1", source = { registry = "https://pypi.org/simple" } }, - { name = "jupyter-core", version = "5.9.1", source = { registry = "https://pypi.org/simple" } }, - { name = "nbformat" }, - { name = "traitlets", version = "5.15.1", source = { registry = "https://pypi.org/simple" } }, + { name = "jupyter-client", version = "8.9.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "jupyter-core", version = "5.9.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "nbformat", marker = "python_full_version >= '3.10'" }, + { name = "traitlets", version = "5.15.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/28/a5/b3bae4b590c0cbcada2c63a34f7580024e834a8ba213e949a2f906705787/nbclient-0.11.0.tar.gz", hash = "sha256:04a134a5b087f2c5887f228aca155db50169b8cd9334dee6942c8e927e56081a", size = 62535, upload-time = "2026-06-05T07:52:41.746Z" } wheels = [ @@ -2625,21 +2625,21 @@ resolution-markers = [ "python_full_version < '3.8.1'", ] dependencies = [ - { name = "beautifulsoup4" }, - { name = "bleach", version = "6.1.0", source = { registry = "https://pypi.org/simple" }, extra = ["css"] }, - { name = "defusedxml" }, - { name = "importlib-metadata", version = "8.5.0", source = { registry = "https://pypi.org/simple" } }, - { name = "jinja2", version = "3.1.4", source = { registry = "https://pypi.org/simple" } }, - { name = "jupyter-core", version = "5.8.1", source = { registry = "https://pypi.org/simple" } }, - { name = "jupyterlab-pygments" }, - { name = "markupsafe", version = "2.1.5", source = { registry = "https://pypi.org/simple" } }, - { name = "mistune" }, - { name = "nbclient", version = "0.10.1", source = { registry = "https://pypi.org/simple" } }, - { name = "nbformat" }, - { name = "packaging" }, - { name = "pandocfilters" }, - { name = "pygments", version = "2.19.2", source = { registry = "https://pypi.org/simple" } }, - { name = "traitlets", version = "5.14.3", source = { registry = "https://pypi.org/simple" } }, + { name = "beautifulsoup4", marker = "python_full_version < '3.9'" }, + { name = "bleach", version = "6.1.0", source = { registry = "https://pypi.org/simple" }, extra = ["css"], marker = "python_full_version < '3.9'" }, + { name = "defusedxml", marker = "python_full_version < '3.9'" }, + { name = "importlib-metadata", version = "8.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "jinja2", version = "3.1.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "jupyter-core", version = "5.8.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "jupyterlab-pygments", marker = "python_full_version < '3.9'" }, + { name = "markupsafe", version = "2.1.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "mistune", marker = "python_full_version < '3.9'" }, + { name = "nbclient", version = "0.10.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "nbformat", marker = "python_full_version < '3.9'" }, + { name = "packaging", marker = "python_full_version < '3.9'" }, + { name = "pandocfilters", marker = "python_full_version < '3.9'" }, + { name = "pygments", version = "2.19.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "traitlets", version = "5.14.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a3/59/f28e15fc47ffb73af68a8d9b47367a8630d76e97ae85ad18271b9db96fdf/nbconvert-7.16.6.tar.gz", hash = "sha256:576a7e37c6480da7b8465eefa66c17844243816ce1ccc372633c6b71c3c0f582", size = 857715, upload-time = "2025-01-28T09:29:14.724Z" } wheels = [ @@ -2659,25 +2659,25 @@ resolution-markers = [ "python_full_version == '3.9.*'", ] dependencies = [ - { name = "beautifulsoup4" }, - { name = "bleach", version = "6.2.0", source = { registry = "https://pypi.org/simple" }, extra = ["css"], marker = "python_full_version < '3.10'" }, + { name = "beautifulsoup4", marker = "python_full_version >= '3.9'" }, + { name = "bleach", version = "6.2.0", source = { registry = "https://pypi.org/simple" }, extra = ["css"], marker = "python_full_version == '3.9.*'" }, { name = "bleach", version = "6.4.0", source = { registry = "https://pypi.org/simple" }, extra = ["css"], marker = "python_full_version >= '3.10'" }, - { name = "defusedxml" }, - { name = "importlib-metadata", version = "8.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "jinja2", version = "3.1.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "defusedxml", marker = "python_full_version >= '3.9'" }, + { name = "importlib-metadata", version = "8.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "jinja2", version = "3.1.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, { name = "jinja2", version = "3.1.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "jupyter-core", version = "5.8.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "jupyter-core", version = "5.8.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, { name = "jupyter-core", version = "5.9.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "jupyterlab-pygments" }, - { name = "markupsafe", version = "3.0.3", source = { registry = "https://pypi.org/simple" } }, - { name = "mistune" }, - { name = "nbclient", version = "0.10.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "jupyterlab-pygments", marker = "python_full_version >= '3.9'" }, + { name = "markupsafe", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "mistune", marker = "python_full_version >= '3.9'" }, + { name = "nbclient", version = "0.10.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, { name = "nbclient", version = "0.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "nbformat" }, - { name = "packaging" }, - { name = "pandocfilters" }, - { name = "pygments", version = "2.20.0", source = { registry = "https://pypi.org/simple" } }, - { name = "traitlets", version = "5.15.1", source = { registry = "https://pypi.org/simple" } }, + { name = "nbformat", marker = "python_full_version >= '3.9'" }, + { name = "packaging", marker = "python_full_version >= '3.9'" }, + { name = "pandocfilters", marker = "python_full_version >= '3.9'" }, + { name = "pygments", version = "2.20.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "traitlets", version = "5.15.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/01/b1/708e53fe2e429c103c6e6e159106bcf0357ac41aa4c28772bd8402339051/nbconvert-7.17.1.tar.gz", hash = "sha256:34d0d0a7e73ce3cbab6c5aae8f4f468797280b01fd8bd2ca746da8569eddd7d2", size = 865311, upload-time = "2026-04-08T00:44:14.914Z" } wheels = [ @@ -3088,9 +3088,9 @@ resolution-markers = [ "python_full_version < '3.8.1'", ] dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core", version = "2.20.1", source = { registry = "https://pypi.org/simple" } }, - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.9.*'" }, + { name = "annotated-types", marker = "python_full_version < '3.10'" }, + { name = "pydantic-core", version = "2.20.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8c/99/d0a5dca411e0a017762258013ba9905cd6e7baa9a3fd1fe8b6529472902e/pydantic-2.8.2.tar.gz", hash = "sha256:6f62c13d067b0755ad1c21a34bdd06c0c12625a22b0fc09c6b149816604f7c2a", size = 739834, upload-time = "2024-07-04T02:59:49.416Z" } @@ -3110,10 +3110,10 @@ resolution-markers = [ "python_full_version == '3.10.*' and platform_python_implementation == 'PyPy'", ] dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core", version = "2.33.2", source = { registry = "https://pypi.org/simple" } }, - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" } }, - { name = "typing-inspection" }, + { name = "annotated-types", marker = "python_full_version >= '3.10'" }, + { name = "pydantic-core", version = "2.33.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "typing-inspection", marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350, upload-time = "2025-06-14T08:33:17.137Z" } wheels = [ @@ -3130,7 +3130,7 @@ resolution-markers = [ "python_full_version < '3.8.1'", ] dependencies = [ - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.9.*'" }, + { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/12/e3/0d5ad91211dba310f7ded335f4dad871172b9cc9ce204f5a56d76ccd6247/pydantic_core-2.20.1.tar.gz", hash = "sha256:26ca695eeee5f9f1aeeb211ffc12f10bcb6f71e2989988fda61dabd65db878d4", size = 388371, upload-time = "2024-07-03T17:04:13.963Z" } @@ -3237,7 +3237,7 @@ resolution-markers = [ "python_full_version == '3.10.*' and platform_python_implementation == 'PyPy'", ] dependencies = [ - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } wheels = [ @@ -3397,7 +3397,7 @@ resolution-markers = [ "python_full_version == '3.9.*'", ] dependencies = [ - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } wheels = [ @@ -3413,8 +3413,8 @@ resolution-markers = [ "python_full_version < '3.8.1'", ] dependencies = [ - { name = "markdown", version = "3.7", source = { registry = "https://pypi.org/simple" } }, - { name = "pyyaml", version = "6.0.1", source = { registry = "https://pypi.org/simple" } }, + { name = "markdown", version = "3.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "pyyaml", version = "6.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/08/92/a7296491dbf5585b3a987f3f3fc87af0e632121ff3e490c14b5f2d2b4eb5/pymdown_extensions-10.15.tar.gz", hash = "sha256:0e5994e32155f4b03504f939e501b981d306daf7ec2aa1cd2eb6bd300784f8f7", size = 852320, upload-time = "2025-04-27T23:48:29.183Z" } wheels = [ @@ -3429,8 +3429,8 @@ resolution-markers = [ "python_full_version == '3.9.*'", ] dependencies = [ - { name = "markdown", version = "3.9", source = { registry = "https://pypi.org/simple" } }, - { name = "pyyaml", version = "6.0.1", source = { registry = "https://pypi.org/simple" } }, + { name = "markdown", version = "3.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "pyyaml", version = "6.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9e/26/d1015444da4d952a1ca487a236b522eb979766f0295a0bd0c5fc089989a9/pymdown_extensions-10.21.3.tar.gz", hash = "sha256:72cfcf55f07aea0d4af2c4f11dd4e52466ddfb1bb819673146398e0bd3a77354", size = 854140, upload-time = "2026-05-13T12:57:32.267Z" } wheels = [ @@ -3449,8 +3449,8 @@ resolution-markers = [ "python_full_version == '3.10.*' and platform_python_implementation == 'PyPy'", ] dependencies = [ - { name = "markdown", version = "3.10.2", source = { registry = "https://pypi.org/simple" } }, - { name = "pyyaml", version = "6.0.2", source = { registry = "https://pypi.org/simple" } }, + { name = "markdown", version = "3.10.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pyyaml", version = "6.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/47/67/f1e79672a5f91985577c7984c9709ca110e4fd37fe7fd167b60422e6ccc2/pymdown_extensions-11.0.tar.gz", hash = "sha256:8269cef0247f9e2d0a62fcea10860aba05c1cbab5470fd4b63230b96434dc589", size = 857049, upload-time = "2026-06-23T02:27:45.146Z" } wheels = [ @@ -3467,7 +3467,7 @@ resolution-markers = [ "python_full_version < '3.8.1'", ] dependencies = [ - { name = "cryptography", version = "42.0.8", source = { registry = "https://pypi.org/simple" } }, + { name = "cryptography", version = "42.0.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c1/d4/1067b82c4fc674d6f6e9e8d26b3dff978da46d351ca3bac171544693e085/pyopenssl-24.3.0.tar.gz", hash = "sha256:49f7a019577d834746bc55c5fce6ecbcec0f2b4ec5ce1cf43a9a173b8138bb36", size = 178944, upload-time = "2024-11-27T20:43:12.755Z" } wheels = [ @@ -3487,8 +3487,8 @@ resolution-markers = [ ] dependencies = [ { name = "cryptography", version = "45.0.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14' and platform_python_implementation != 'PyPy'" }, - { name = "cryptography", version = "46.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14' or platform_python_implementation == 'PyPy'" }, - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, + { name = "cryptography", version = "46.0.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.10' and python_full_version < '3.14') or (python_full_version >= '3.10' and platform_python_implementation == 'PyPy')" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/80/be/97b83a464498a79103036bc74d1038df4a7ef0e402cfaf4d5e113fb14759/pyopenssl-25.3.0.tar.gz", hash = "sha256:c981cb0a3fd84e8602d7afc209522773b94c1c2446a3c710a75b06fe1beae329", size = 184073, upload-time = "2025-09-17T00:32:21.037Z" } wheels = [ @@ -3518,12 +3518,12 @@ resolution-markers = [ "python_full_version < '3.8.1'", ] dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup" }, - { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "pluggy", version = "1.5.0", source = { registry = "https://pypi.org/simple" } }, - { name = "tomli" }, + { name = "colorama", marker = "python_full_version < '3.9' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.9'" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "packaging", marker = "python_full_version < '3.9'" }, + { name = "pluggy", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "tomli", marker = "python_full_version < '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891, upload-time = "2025-03-02T12:54:54.503Z" } wheels = [ @@ -3538,13 +3538,13 @@ resolution-markers = [ "python_full_version == '3.9.*'", ] dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup" }, - { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "pluggy", version = "1.5.0", source = { registry = "https://pypi.org/simple" } }, - { name = "pygments", version = "2.20.0", source = { registry = "https://pypi.org/simple" } }, - { name = "tomli" }, + { name = "colorama", marker = "python_full_version == '3.9.*' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version == '3.9.*'" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "packaging", marker = "python_full_version == '3.9.*'" }, + { name = "pluggy", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "pygments", version = "2.20.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "tomli", marker = "python_full_version == '3.9.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } wheels = [ @@ -3563,13 +3563,13 @@ resolution-markers = [ "python_full_version == '3.10.*' and platform_python_implementation == 'PyPy'", ] dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" } }, - { name = "pygments", version = "2.20.0", source = { registry = "https://pypi.org/simple" } }, - { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, + { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "packaging", marker = "python_full_version >= '3.10'" }, + { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pygments", version = "2.20.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "tomli", marker = "python_full_version == '3.10.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ @@ -3585,8 +3585,8 @@ resolution-markers = [ "python_full_version < '3.8.1'", ] dependencies = [ - { name = "coverage", version = "7.6.1", source = { registry = "https://pypi.org/simple" }, extra = ["toml"] }, - { name = "pytest", version = "8.3.5", source = { registry = "https://pypi.org/simple" } }, + { name = "coverage", version = "7.6.1", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version < '3.9'" }, + { name = "pytest", version = "8.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/74/67/00efc8d11b630c56f15f4ad9c7f9223f1e5ec275aaae3fa9118c6a223ad2/pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857", size = 63042, upload-time = "2024-03-24T20:16:34.856Z" } wheels = [ @@ -3606,11 +3606,11 @@ resolution-markers = [ "python_full_version == '3.9.*'", ] dependencies = [ - { name = "coverage", version = "7.10.7", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version < '3.10'" }, + { name = "coverage", version = "7.10.7", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version == '3.9.*'" }, { name = "coverage", version = "7.14.3", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version >= '3.10'" }, - { name = "pluggy", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pluggy", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, { name = "pytest", version = "9.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } @@ -3837,7 +3837,7 @@ resolution-markers = [ "python_full_version < '3.8.1'", ] dependencies = [ - { name = "pyyaml", version = "6.0.1", source = { registry = "https://pypi.org/simple" } }, + { name = "pyyaml", version = "6.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fb/8e/da1c6c58f751b70f8ceb1eb25bc25d524e8f14fe16edcce3f4e3ba08629c/pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb", size = 5631, upload-time = "2020-11-12T02:38:26.239Z" } wheels = [ @@ -3857,7 +3857,7 @@ resolution-markers = [ "python_full_version == '3.9.*'", ] dependencies = [ - { name = "pyyaml", version = "6.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pyyaml", version = "6.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, { name = "pyyaml", version = "6.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } @@ -3976,9 +3976,9 @@ resolution-markers = [ "python_full_version < '3.8.1'", ] dependencies = [ - { name = "docutils", version = "0.20.1", source = { registry = "https://pypi.org/simple" } }, - { name = "nh3" }, - { name = "pygments", version = "2.19.2", source = { registry = "https://pypi.org/simple" } }, + { name = "docutils", version = "0.20.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "nh3", marker = "python_full_version < '3.9'" }, + { name = "pygments", version = "2.19.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fe/b5/536c775084d239df6345dccf9b043419c7e3308bc31be4c7882196abc62e/readme_renderer-43.0.tar.gz", hash = "sha256:1818dd28140813509eeed8d62687f7cd4f7bad90d4db586001c5dc09d4fde311", size = 31768, upload-time = "2024-02-26T16:10:59.415Z" } wheels = [ @@ -3993,9 +3993,9 @@ resolution-markers = [ "python_full_version == '3.9.*'", ] dependencies = [ - { name = "docutils", version = "0.23", source = { registry = "https://pypi.org/simple" } }, - { name = "nh3" }, - { name = "pygments", version = "2.20.0", source = { registry = "https://pypi.org/simple" } }, + { name = "docutils", version = "0.23", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "nh3", marker = "python_full_version == '3.9.*'" }, + { name = "pygments", version = "2.20.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5a/a9/104ec9234c8448c4379768221ea6df01260cd6c2ce13182d4eac531c8342/readme_renderer-44.0.tar.gz", hash = "sha256:8712034eabbfa6805cacf1402b4eeb2a73028f72d1166d6f5cb7f9c047c5d1e1", size = 32056, upload-time = "2024-07-08T15:00:57.805Z" } wheels = [ @@ -4014,9 +4014,9 @@ resolution-markers = [ "python_full_version == '3.10.*' and platform_python_implementation == 'PyPy'", ] dependencies = [ - { name = "docutils", version = "0.23", source = { registry = "https://pypi.org/simple" } }, - { name = "nh3" }, - { name = "pygments", version = "2.20.0", source = { registry = "https://pypi.org/simple" } }, + { name = "docutils", version = "0.23", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "nh3", marker = "python_full_version >= '3.10'" }, + { name = "pygments", version = "2.20.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/02/51/d3a6ea424652c60f05600d8c2e01a55c913755e7cdad64afabbd1aa16f44/readme_renderer-45.0.tar.gz", hash = "sha256:030a8fac74904f8fba11ad1bb6964e3f76e896dc7e5e71f16af190c9056696d1", size = 36172, upload-time = "2026-06-09T21:05:17.37Z" } wheels = [ @@ -4032,8 +4032,8 @@ resolution-markers = [ "python_full_version < '3.8.1'", ] dependencies = [ - { name = "attrs", version = "25.3.0", source = { registry = "https://pypi.org/simple" } }, - { name = "rpds-py", version = "0.20.1", source = { registry = "https://pypi.org/simple" } }, + { name = "attrs", version = "25.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "rpds-py", version = "0.20.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/99/5b/73ca1f8e72fff6fa52119dbd185f73a907b1989428917b24cff660129b6d/referencing-0.35.1.tar.gz", hash = "sha256:25b42124a6c8b632a425174f24087783efb348a6f1e0008e63cd4466fedf703c", size = 62991, upload-time = "2024-05-01T20:26:04.574Z" } wheels = [ @@ -4048,9 +4048,9 @@ resolution-markers = [ "python_full_version == '3.9.*'", ] dependencies = [ - { name = "attrs", version = "26.1.0", source = { registry = "https://pypi.org/simple" } }, - { name = "rpds-py", version = "0.27.1", source = { registry = "https://pypi.org/simple" } }, - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" } }, + { name = "attrs", version = "26.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "rpds-py", version = "0.27.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744, upload-time = "2025-01-25T08:48:16.138Z" } wheels = [ @@ -4069,10 +4069,10 @@ resolution-markers = [ "python_full_version == '3.10.*' and platform_python_implementation == 'PyPy'", ] dependencies = [ - { name = "attrs", version = "26.1.0", source = { registry = "https://pypi.org/simple" } }, - { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "attrs", version = "26.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } wheels = [ @@ -4089,11 +4089,11 @@ resolution-markers = [ "python_full_version < '3.8.1'", ] dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna", version = "3.15", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.9.*'" }, + { name = "certifi", marker = "python_full_version < '3.10'" }, + { name = "charset-normalizer", marker = "python_full_version < '3.10'" }, + { name = "idna", version = "3.15", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "idna", version = "3.18", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" } }, + { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218, upload-time = "2024-05-29T15:37:49.536Z" } wheels = [ @@ -4112,10 +4112,10 @@ resolution-markers = [ "python_full_version == '3.10.*' and platform_python_implementation == 'PyPy'", ] dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna", version = "3.18", source = { registry = "https://pypi.org/simple" } }, - { name = "urllib3", version = "2.5.0", source = { registry = "https://pypi.org/simple" } }, + { name = "certifi", marker = "python_full_version >= '3.10'" }, + { name = "charset-normalizer", marker = "python_full_version >= '3.10'" }, + { name = "idna", version = "3.18", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "urllib3", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258, upload-time = "2025-06-09T16:43:07.34Z" } wheels = [ @@ -4145,7 +4145,7 @@ resolution-markers = [ "python_full_version < '3.8.1'", ] dependencies = [ - { name = "types-setuptools", version = "75.8.0.20250110", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.9.*'" }, + { name = "types-setuptools", version = "75.8.0.20250110", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "types-setuptools", version = "81.0.0.20260209", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/db/83/396292f31f8a8ef806bb44e8a50e087f84287b670390a9d9a8d3d34f7752/requirements_parser-0.9.0.tar.gz", hash = "sha256:588f587ab76732d59df4c64bd81f1b4a4f1aaaa9b3eb7ad4f5890685446f03e8", size = 21997, upload-time = "2024-04-03T10:27:52.547Z" } @@ -4165,7 +4165,7 @@ resolution-markers = [ "python_full_version == '3.10.*' and platform_python_implementation == 'PyPy'", ] dependencies = [ - { name = "packaging" }, + { name = "packaging", marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/95/96/fb6dbfebb524d5601d359a47c78fe7ba1eef90fc4096404aa60c9a906fbb/requirements_parser-0.13.0.tar.gz", hash = "sha256:0843119ca2cb2331de4eb31b10d70462e39ace698fd660a915c247d2301a4418", size = 22630, upload-time = "2025-05-21T13:42:05.464Z" } wheels = [ @@ -4191,10 +4191,10 @@ resolution-markers = [ "python_full_version < '3.8.1'", ] dependencies = [ - { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" } }, - { name = "pygments", version = "2.19.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.9.*'" }, + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pygments", version = "2.19.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "pygments", version = "2.20.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.9.*'" }, + { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b3/01/c954e134dc440ab5f96952fe52b4fdc64225530320a910473c1fe270d9aa/rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432", size = 221248, upload-time = "2024-02-28T14:51:19.472Z" } wheels = [ @@ -4213,9 +4213,9 @@ resolution-markers = [ "python_full_version == '3.10.*' and platform_python_implementation == 'PyPy'", ] dependencies = [ - { name = "markdown-it-py", version = "4.2.0", source = { registry = "https://pypi.org/simple" } }, - { name = "pygments", version = "2.20.0", source = { registry = "https://pypi.org/simple" } }, - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "markdown-it-py", version = "4.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pygments", version = "2.20.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078, upload-time = "2025-03-30T14:15:14.23Z" } wheels = [ @@ -4769,6 +4769,8 @@ dependencies = [ { name = "pyjwt", version = "2.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "semver" }, { name = "toml", marker = "python_full_version < '3.11'" }, + { name = "tomli-w", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "tomli-w", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "uv" }, @@ -4826,6 +4828,7 @@ requires-dist = [ { name = "semver", specifier = ">=2.0.0,<4.0.0" }, { name = "snowflake-cli", marker = "extra == 'snowflake'" }, { name = "toml", marker = "python_full_version < '3.11'", specifier = ">=0.10" }, + { name = "tomli-w", specifier = ">=1.0.0" }, { name = "typing-extensions", specifier = ">=4.8.0" }, { name = "uv", specifier = ">=0.9.0" }, ] @@ -4881,7 +4884,7 @@ name = "s3transfer" version = "0.19.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "botocore" }, + { name = "botocore", marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f6/94/dcdaeb1713cab9c84def276cfac7388b17c7d9855bbcfe88d77e4dbafd44/s3transfer-0.19.0.tar.gz", hash = "sha256:ce436931687addc4c1712d52d40b32f53e88315723f107ffa20ba82b05a0f685", size = 165171, upload-time = "2026-06-16T19:44:51.599Z" } wheels = [ @@ -4898,8 +4901,8 @@ resolution-markers = [ "python_full_version < '3.8.1'", ] dependencies = [ - { name = "cryptography", version = "42.0.8", source = { registry = "https://pypi.org/simple" } }, - { name = "jeepney" }, + { name = "cryptography", version = "42.0.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "jeepney", marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/53/a4/f48c9d79cb507ed1373477dbceaba7401fd8a23af63b837fa61f1dcd3691/SecretStorage-3.3.3.tar.gz", hash = "sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77", size = 19739, upload-time = "2022-08-13T16:22:46.976Z" } wheels = [ @@ -4919,8 +4922,8 @@ resolution-markers = [ ] dependencies = [ { name = "cryptography", version = "45.0.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14' and platform_python_implementation != 'PyPy'" }, - { name = "cryptography", version = "46.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14' or platform_python_implementation == 'PyPy'" }, - { name = "jeepney" }, + { name = "cryptography", version = "46.0.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.10' and python_full_version < '3.14') or (python_full_version >= '3.10' and platform_python_implementation == 'PyPy')" }, + { name = "jeepney", marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } wheels = [ @@ -5003,24 +5006,24 @@ resolution-markers = [ "python_full_version < '3.8.1'", ] dependencies = [ - { name = "gitpython", version = "3.1.43", source = { registry = "https://pypi.org/simple" } }, - { name = "jinja2", version = "3.1.4", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "pip", version = "25.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.9.*'" }, + { name = "gitpython", version = "3.1.43", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "jinja2", version = "3.1.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "packaging", marker = "python_full_version < '3.10'" }, + { name = "pip", version = "25.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "pip", version = "26.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "pluggy", version = "1.5.0", source = { registry = "https://pypi.org/simple" } }, - { name = "pydantic", version = "2.8.2", source = { registry = "https://pypi.org/simple" } }, - { name = "pyyaml", version = "6.0.1", source = { registry = "https://pypi.org/simple" } }, - { name = "requests", version = "2.32.3", source = { registry = "https://pypi.org/simple" } }, - { name = "requirements-parser", version = "0.9.0", source = { registry = "https://pypi.org/simple" } }, - { name = "rich", version = "13.7.1", source = { registry = "https://pypi.org/simple" } }, - { name = "setuptools", version = "70.3.0", source = { registry = "https://pypi.org/simple" } }, - { name = "snowflake-connector-python", version = "3.11.0", source = { registry = "https://pypi.org/simple" }, extra = ["secure-local-storage"] }, - { name = "snowflake-core", version = "0.8.0", source = { registry = "https://pypi.org/simple" } }, - { name = "snowflake-snowpark-python", version = "1.24.0", source = { registry = "https://pypi.org/simple" } }, - { name = "tomlkit", version = "0.13.0", source = { registry = "https://pypi.org/simple" } }, - { name = "typer", version = "0.12.3", source = { registry = "https://pypi.org/simple" } }, - { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" } }, + { name = "pluggy", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pydantic", version = "2.8.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pyyaml", version = "6.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "requests", version = "2.32.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "requirements-parser", version = "0.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "rich", version = "13.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "setuptools", version = "70.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "snowflake-connector-python", version = "3.11.0", source = { registry = "https://pypi.org/simple" }, extra = ["secure-local-storage"], marker = "python_full_version < '3.10'" }, + { name = "snowflake-core", version = "0.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "snowflake-snowpark-python", version = "1.24.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "tomlkit", version = "0.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "typer", version = "0.12.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/21/89/f6d1e6a59d23f6ad8b7e036cbe6337db00ea7e67bbe37bd89da33810faf8/snowflake_cli-2.8.2.tar.gz", hash = "sha256:fafc506e995f5b2f527ca8601ff77ac27098d92aa2c3a461aab8aa88af63bfcc", size = 1651547, upload-time = "2024-10-09T11:56:12.96Z" } wheels = [ @@ -5039,26 +5042,26 @@ resolution-markers = [ "python_full_version == '3.10.*' and platform_python_implementation == 'PyPy'", ] dependencies = [ - { name = "click" }, - { name = "gitpython", version = "3.1.44", source = { registry = "https://pypi.org/simple" } }, - { name = "id" }, - { name = "jinja2", version = "3.1.6", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "pip", version = "26.1.2", source = { registry = "https://pypi.org/simple" } }, - { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" } }, - { name = "prompt-toolkit" }, - { name = "pydantic", version = "2.11.7", source = { registry = "https://pypi.org/simple" } }, - { name = "pyyaml", version = "6.0.2", source = { registry = "https://pypi.org/simple" } }, - { name = "requests", version = "2.32.4", source = { registry = "https://pypi.org/simple" } }, - { name = "requirements-parser", version = "0.13.0", source = { registry = "https://pypi.org/simple" } }, - { name = "rich", version = "14.0.0", source = { registry = "https://pypi.org/simple" } }, - { name = "setuptools", version = "80.8.0", source = { registry = "https://pypi.org/simple" } }, - { name = "snowflake-connector-python", version = "3.17.3", source = { registry = "https://pypi.org/simple" }, extra = ["secure-local-storage"] }, - { name = "snowflake-core", version = "1.7.0", source = { registry = "https://pypi.org/simple" } }, - { name = "snowflake-snowpark-python", version = "1.33.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "tomlkit", version = "0.13.3", source = { registry = "https://pypi.org/simple" } }, - { name = "typer", version = "0.17.3", source = { registry = "https://pypi.org/simple" } }, - { name = "urllib3", version = "2.5.0", source = { registry = "https://pypi.org/simple" } }, + { name = "click", marker = "python_full_version >= '3.10'" }, + { name = "gitpython", version = "3.1.44", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "id", marker = "python_full_version >= '3.10'" }, + { name = "jinja2", version = "3.1.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "packaging", marker = "python_full_version >= '3.10'" }, + { name = "pip", version = "26.1.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "prompt-toolkit", marker = "python_full_version >= '3.10'" }, + { name = "pydantic", version = "2.11.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pyyaml", version = "6.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "requests", version = "2.32.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "requirements-parser", version = "0.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "rich", version = "14.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "setuptools", version = "80.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "snowflake-connector-python", version = "3.17.3", source = { registry = "https://pypi.org/simple" }, extra = ["secure-local-storage"], marker = "python_full_version >= '3.10'" }, + { name = "snowflake-core", version = "1.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "snowflake-snowpark-python", version = "1.33.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and python_full_version < '3.12'" }, + { name = "tomlkit", version = "0.13.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "typer", version = "0.17.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "urllib3", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/19/c0/da509cbceebcdfd31988288809157519eff94a034725867214ff85cc92c1/snowflake_cli-3.12.0.tar.gz", hash = "sha256:96dd0baf7a383b31ee4788c475ac85b1f47a9a4f374d90eece93e50c2db93b02", size = 2147191, upload-time = "2025-09-24T15:48:04.108Z" } wheels = [ @@ -5075,28 +5078,28 @@ resolution-markers = [ "python_full_version < '3.8.1'", ] dependencies = [ - { name = "asn1crypto" }, - { name = "certifi" }, - { name = "cffi" }, - { name = "charset-normalizer" }, - { name = "cryptography", version = "42.0.8", source = { registry = "https://pypi.org/simple" } }, - { name = "filelock", version = "3.16.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.9.*'" }, + { name = "asn1crypto", marker = "python_full_version < '3.10'" }, + { name = "certifi", marker = "python_full_version < '3.10'" }, + { name = "cffi", marker = "python_full_version < '3.10'" }, + { name = "charset-normalizer", marker = "python_full_version < '3.10'" }, + { name = "cryptography", version = "42.0.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "filelock", version = "3.16.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "filelock", version = "3.19.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "idna", version = "3.15", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.9.*'" }, + { name = "idna", version = "3.15", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "idna", version = "3.18", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "packaging" }, - { name = "platformdirs", version = "4.3.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.9.*'" }, + { name = "packaging", marker = "python_full_version < '3.10'" }, + { name = "platformdirs", version = "4.3.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "platformdirs", version = "4.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "pyjwt", version = "2.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.9.*'" }, + { name = "pyjwt", version = "2.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "pyjwt", version = "2.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "pyopenssl", version = "24.3.0", source = { registry = "https://pypi.org/simple" } }, - { name = "pytz" }, - { name = "requests", version = "2.32.3", source = { registry = "https://pypi.org/simple" } }, - { name = "sortedcontainers" }, - { name = "tomlkit", version = "0.13.0", source = { registry = "https://pypi.org/simple" } }, - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.9.*'" }, + { name = "pyopenssl", version = "24.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytz", marker = "python_full_version < '3.10'" }, + { name = "requests", version = "2.32.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "sortedcontainers", marker = "python_full_version < '3.10'" }, + { name = "tomlkit", version = "0.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" } }, + { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/95/f2/d5eac05b3850492ad1a8d139b179ca1afe7cebd8581482dd35cd30395901/snowflake_connector_python-3.11.0.tar.gz", hash = "sha256:3169c014a03e5f5855112605e393897a552e558953c69f25a02e33b1998864d0", size = 737315, upload-time = "2024-06-18T13:40:21.189Z" } wheels = [ @@ -5129,7 +5132,7 @@ wheels = [ [package.optional-dependencies] secure-local-storage = [ - { name = "keyring", version = "24.3.1", source = { registry = "https://pypi.org/simple" } }, + { name = "keyring", version = "24.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, ] [[package]] @@ -5144,25 +5147,25 @@ resolution-markers = [ "python_full_version == '3.10.*' and platform_python_implementation == 'PyPy'", ] dependencies = [ - { name = "asn1crypto" }, - { name = "boto3" }, - { name = "botocore" }, - { name = "certifi" }, - { name = "cffi" }, - { name = "charset-normalizer" }, + { name = "asn1crypto", marker = "python_full_version >= '3.10'" }, + { name = "boto3", marker = "python_full_version >= '3.10'" }, + { name = "botocore", marker = "python_full_version >= '3.10'" }, + { name = "certifi", marker = "python_full_version >= '3.10'" }, + { name = "cffi", marker = "python_full_version >= '3.10'" }, + { name = "charset-normalizer", marker = "python_full_version >= '3.10'" }, { name = "cryptography", version = "45.0.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14' and platform_python_implementation != 'PyPy'" }, - { name = "cryptography", version = "46.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14' or platform_python_implementation == 'PyPy'" }, - { name = "filelock", version = "3.29.4", source = { registry = "https://pypi.org/simple" } }, - { name = "idna", version = "3.18", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "platformdirs", version = "4.10.0", source = { registry = "https://pypi.org/simple" } }, - { name = "pyjwt", version = "2.13.0", source = { registry = "https://pypi.org/simple" } }, - { name = "pyopenssl", version = "25.3.0", source = { registry = "https://pypi.org/simple" } }, - { name = "pytz" }, - { name = "requests", version = "2.32.4", source = { registry = "https://pypi.org/simple" } }, - { name = "sortedcontainers" }, - { name = "tomlkit", version = "0.13.3", source = { registry = "https://pypi.org/simple" } }, - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" } }, + { name = "cryptography", version = "46.0.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.10' and python_full_version < '3.14') or (python_full_version >= '3.10' and platform_python_implementation == 'PyPy')" }, + { name = "filelock", version = "3.29.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "idna", version = "3.18", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "packaging", marker = "python_full_version >= '3.10'" }, + { name = "platformdirs", version = "4.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pyjwt", version = "2.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pyopenssl", version = "25.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytz", marker = "python_full_version >= '3.10'" }, + { name = "requests", version = "2.32.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "sortedcontainers", marker = "python_full_version >= '3.10'" }, + { name = "tomlkit", version = "0.13.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/02/cc/375e43ee01d44fbeb375673a45e6c0ca20a17540d1618f20ca78977481fd/snowflake_connector_python-3.17.3.tar.gz", hash = "sha256:8d3847a3738702b58f7416a2adf4b43abaaef36403e4be619c5aabd900e03cf7", size = 794925, upload-time = "2025-09-03T10:45:41.146Z" } wheels = [ @@ -5195,7 +5198,7 @@ wheels = [ [package.optional-dependencies] secure-local-storage = [ - { name = "keyring", version = "25.7.0", source = { registry = "https://pypi.org/simple" } }, + { name = "keyring", version = "25.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] [[package]] @@ -5208,12 +5211,12 @@ resolution-markers = [ "python_full_version < '3.8.1'", ] dependencies = [ - { name = "atpublic", version = "5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.9.*'" }, + { name = "atpublic", version = "5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "atpublic", version = "6.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "pydantic", version = "2.8.2", source = { registry = "https://pypi.org/simple" } }, - { name = "python-dateutil" }, - { name = "snowflake-snowpark-python", version = "1.24.0", source = { registry = "https://pypi.org/simple" } }, - { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" } }, + { name = "pydantic", version = "2.8.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "python-dateutil", marker = "python_full_version < '3.10'" }, + { name = "snowflake-snowpark-python", version = "1.24.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9d/74/43d9da3a3c53863c627bf74fd42ab43335ba1bd8a03ee8e69f99f4031e67/snowflake_core-0.8.0.tar.gz", hash = "sha256:29372c39fae5ab12ee477b16a9555d927f3fad394755252f4533f47717b99cef", size = 340732, upload-time = "2024-05-01T00:53:22.759Z" } wheels = [ @@ -5232,12 +5235,12 @@ resolution-markers = [ "python_full_version == '3.10.*' and platform_python_implementation == 'PyPy'", ] dependencies = [ - { name = "pydantic", version = "2.11.7", source = { registry = "https://pypi.org/simple" } }, - { name = "python-dateutil" }, - { name = "pyyaml", version = "6.0.2", source = { registry = "https://pypi.org/simple" } }, - { name = "requests", version = "2.32.4", source = { registry = "https://pypi.org/simple" } }, - { name = "snowflake-connector-python", version = "3.17.3", source = { registry = "https://pypi.org/simple" } }, - { name = "urllib3", version = "2.5.0", source = { registry = "https://pypi.org/simple" } }, + { name = "pydantic", version = "2.11.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "python-dateutil", marker = "python_full_version >= '3.10'" }, + { name = "pyyaml", version = "6.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "requests", version = "2.32.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "snowflake-connector-python", version = "3.17.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "urllib3", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a1/72/3daea26f941512e2a2d18eda62eb2fc67ffc1be33027b1e74f4c4430a2e2/snowflake_core-1.7.0.tar.gz", hash = "sha256:8655a94c211ae04d1d803dbc876249de6d3f8021cc5738d689aea842d1b66a7f", size = 1314443, upload-time = "2025-08-01T09:57:52.214Z" } wheels = [ @@ -5254,13 +5257,13 @@ resolution-markers = [ "python_full_version < '3.8.1'", ] dependencies = [ - { name = "cloudpickle", version = "2.2.1", source = { registry = "https://pypi.org/simple" } }, - { name = "pyyaml", version = "6.0.1", source = { registry = "https://pypi.org/simple" } }, - { name = "setuptools", version = "70.3.0", source = { registry = "https://pypi.org/simple" } }, - { name = "snowflake-connector-python", version = "3.11.0", source = { registry = "https://pypi.org/simple" } }, - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.9.*'" }, + { name = "cloudpickle", version = "2.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pyyaml", version = "6.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "setuptools", version = "70.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "snowflake-connector-python", version = "3.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "wheel", version = "0.45.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.9.*'" }, + { name = "wheel", version = "0.45.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "wheel", version = "0.47.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/27/84/49dbf28c2fcd4d8aaa96eed33400b810917d6642fc501bcdecd55306bf2e/snowflake_snowpark_python-1.24.0.tar.gz", hash = "sha256:cd8bae93f08f210b57b6ce9c12bca251dd062de57031a496411ac5bfd7fa1105", size = 1271609, upload-time = "2024-10-28T22:58:32.727Z" } @@ -5279,15 +5282,15 @@ resolution-markers = [ "python_full_version == '3.10.*' and platform_python_implementation == 'PyPy'", ] dependencies = [ - { name = "cloudpickle", version = "3.0.0", source = { registry = "https://pypi.org/simple" } }, - { name = "protobuf" }, - { name = "python-dateutil" }, - { name = "pyyaml", version = "6.0.2", source = { registry = "https://pypi.org/simple" } }, - { name = "setuptools", version = "80.8.0", source = { registry = "https://pypi.org/simple" } }, - { name = "snowflake-connector-python", version = "3.17.3", source = { registry = "https://pypi.org/simple" } }, - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" } }, - { name = "tzlocal" }, - { name = "wheel", version = "0.47.0", source = { registry = "https://pypi.org/simple" } }, + { name = "cloudpickle", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.10' and python_full_version < '3.14') or (python_full_version >= '3.10' and platform_python_implementation == 'PyPy')" }, + { name = "protobuf", marker = "(python_full_version >= '3.10' and python_full_version < '3.14') or (python_full_version >= '3.10' and platform_python_implementation == 'PyPy')" }, + { name = "python-dateutil", marker = "(python_full_version >= '3.10' and python_full_version < '3.14') or (python_full_version >= '3.10' and platform_python_implementation == 'PyPy')" }, + { name = "pyyaml", version = "6.0.2", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.10' and python_full_version < '3.14') or (python_full_version >= '3.10' and platform_python_implementation == 'PyPy')" }, + { name = "setuptools", version = "80.8.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.10' and python_full_version < '3.14') or (python_full_version >= '3.10' and platform_python_implementation == 'PyPy')" }, + { name = "snowflake-connector-python", version = "3.17.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.10' and python_full_version < '3.14') or (python_full_version >= '3.10' and platform_python_implementation == 'PyPy')" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.10' and python_full_version < '3.14') or (python_full_version >= '3.10' and platform_python_implementation == 'PyPy')" }, + { name = "tzlocal", marker = "(python_full_version >= '3.10' and python_full_version < '3.14') or (python_full_version >= '3.10' and platform_python_implementation == 'PyPy')" }, + { name = "wheel", version = "0.47.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.10' and python_full_version < '3.14') or (python_full_version >= '3.10' and platform_python_implementation == 'PyPy')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a7/ad/0d5f19532fd9435ae4435add27b47f8dbad054dac80fdb6824fbdc2143cf/snowflake_snowpark_python-1.33.0.tar.gz", hash = "sha256:72eb074f5caf2aa129342a7bb35d5f4525689fee3e72064df843b713859e5720", size = 1609407, upload-time = "2025-06-19T16:30:17.286Z" } wheels = [ @@ -5409,7 +5412,7 @@ resolution-markers = [ "python_full_version < '3.8.1'", ] dependencies = [ - { name = "webencodings" }, + { name = "webencodings", marker = "python_full_version < '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/75/be/24179dfaa1d742c9365cbd0e3f0edc5d3aa3abad415a2327c5a6ff8ca077/tinycss2-1.2.1.tar.gz", hash = "sha256:8cff3a8f066c2ec677c06dbc7b45619804a6938478d9d73c284b29d14ecb0627", size = 65957, upload-time = "2022-10-18T07:04:56.49Z" } wheels = [ @@ -5424,7 +5427,7 @@ resolution-markers = [ "python_full_version == '3.9.*'", ] dependencies = [ - { name = "webencodings" }, + { name = "webencodings", marker = "python_full_version == '3.9.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/fd/7a5ee21fd08ff70d3d33a5781c255cbe779659bd03278feb98b19ee550f4/tinycss2-1.4.0.tar.gz", hash = "sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7", size = 87085, upload-time = "2024-10-24T14:58:29.895Z" } wheels = [ @@ -5443,7 +5446,7 @@ resolution-markers = [ "python_full_version == '3.10.*' and platform_python_implementation == 'PyPy'", ] dependencies = [ - { name = "webencodings" }, + { name = "webencodings", marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a3/ae/2ca4913e5c0f09781d75482874c3a95db9105462a92ddd303c7d285d3df2/tinycss2-1.5.1.tar.gz", hash = "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957", size = 88195, upload-time = "2025-11-23T10:29:10.082Z" } wheels = [ @@ -5513,6 +5516,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] +[[package]] +name = "tomli-w" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.8.1' and python_full_version < '3.9'", + "python_full_version < '3.8.1'", +] +sdist = { url = "https://files.pythonhosted.org/packages/49/05/6bf21838623186b91aedbda06248ad18f03487dc56fbc20e4db384abde6c/tomli_w-1.0.0.tar.gz", hash = "sha256:f463434305e0336248cac9c2dc8076b707d8a12d019dd349f5c1e382dd1ae1b9", size = 6531, upload-time = "2021-12-01T23:55:11.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/01/1da9c66ecb20f31ed5aa5316a957e0b1a5e786a0d9689616ece4ceaf1321/tomli_w-1.0.0-py3-none-any.whl", hash = "sha256:9f2a07e8be30a0729e533ec968016807069991ae2fd921a78d42f429ae5f4463", size = 5984, upload-time = "2021-12-01T23:55:10.364Z" }, +] + +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and platform_python_implementation != 'PyPy'", + "python_full_version >= '3.11' and python_full_version < '3.14' and platform_python_implementation != 'PyPy'", + "python_full_version == '3.10.*' and platform_python_implementation != 'PyPy'", + "python_full_version >= '3.11' and platform_python_implementation == 'PyPy'", + "python_full_version == '3.10.*' and platform_python_implementation == 'PyPy'", + "python_full_version == '3.9.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] + [[package]] name = "tomlkit" version = "0.13.0" @@ -5629,16 +5662,16 @@ resolution-markers = [ "python_full_version < '3.8.1'", ] dependencies = [ - { name = "id" }, - { name = "importlib-metadata", version = "8.5.0", source = { registry = "https://pypi.org/simple" } }, - { name = "keyring", version = "24.3.1", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" }, - { name = "packaging" }, - { name = "readme-renderer", version = "43.0", source = { registry = "https://pypi.org/simple" } }, - { name = "requests", version = "2.32.3", source = { registry = "https://pypi.org/simple" } }, - { name = "requests-toolbelt" }, - { name = "rfc3986" }, - { name = "rich", version = "13.7.1", source = { registry = "https://pypi.org/simple" } }, - { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" } }, + { name = "id", marker = "python_full_version < '3.9'" }, + { name = "importlib-metadata", version = "8.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "keyring", version = "24.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9' and platform_machine != 'ppc64le' and platform_machine != 's390x'" }, + { name = "packaging", marker = "python_full_version < '3.9'" }, + { name = "readme-renderer", version = "43.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "requests", version = "2.32.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "requests-toolbelt", marker = "python_full_version < '3.9'" }, + { name = "rfc3986", marker = "python_full_version < '3.9'" }, + { name = "rich", version = "13.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c8/a2/6df94fc5c8e2170d21d7134a565c3a8fb84f9797c1dd65a5976aaf714418/twine-6.1.0.tar.gz", hash = "sha256:be324f6272eff91d07ee93f251edf232fc647935dd585ac003539b42404a8dbd", size = 168404, upload-time = "2025-01-21T18:45:26.758Z" } wheels = [ @@ -5658,20 +5691,20 @@ resolution-markers = [ "python_full_version == '3.9.*'", ] dependencies = [ - { name = "id" }, - { name = "importlib-metadata", version = "8.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "keyring", version = "24.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and platform_machine != 'ppc64le' and platform_machine != 's390x'" }, + { name = "id", marker = "python_full_version >= '3.9'" }, + { name = "importlib-metadata", version = "8.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "keyring", version = "24.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*' and platform_machine != 'ppc64le' and platform_machine != 's390x'" }, { name = "keyring", version = "25.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and platform_machine != 'ppc64le' and platform_machine != 's390x'" }, - { name = "packaging" }, - { name = "readme-renderer", version = "44.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "packaging", marker = "python_full_version >= '3.9'" }, + { name = "readme-renderer", version = "44.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, { name = "readme-renderer", version = "45.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "requests", version = "2.32.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "requests", version = "2.32.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, { name = "requests", version = "2.32.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "requests-toolbelt" }, - { name = "rfc3986" }, - { name = "rich", version = "13.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "requests-toolbelt", marker = "python_full_version >= '3.9'" }, + { name = "rfc3986", marker = "python_full_version >= '3.9'" }, + { name = "rich", version = "13.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, { name = "rich", version = "14.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, { name = "urllib3", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e0/a8/949edebe3a82774c1ec34f637f5dd82d1cf22c25e963b7d63771083bbee5/twine-6.2.0.tar.gz", hash = "sha256:e5ed0d2fd70c9959770dce51c8f39c8945c574e18173a7b81802dab51b4b75cf", size = 172262, upload-time = "2025-09-04T15:43:17.255Z" } @@ -5689,10 +5722,10 @@ resolution-markers = [ "python_full_version < '3.8.1'", ] dependencies = [ - { name = "click" }, - { name = "rich", version = "13.7.1", source = { registry = "https://pypi.org/simple" } }, - { name = "shellingham" }, - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.9.*'" }, + { name = "click", marker = "python_full_version < '3.10'" }, + { name = "rich", version = "13.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "shellingham", marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ac/0a/d55af35db5f50f486e3eda0ada747eed773859e2699d3ce570b682a9b70a/typer-0.12.3.tar.gz", hash = "sha256:49e73131481d804288ef62598d97a1ceef3058905aa536a1134f90891ba35482", size = 94276, upload-time = "2024-04-09T17:14:03.893Z" } @@ -5712,10 +5745,10 @@ resolution-markers = [ "python_full_version == '3.10.*' and platform_python_implementation == 'PyPy'", ] dependencies = [ - { name = "click" }, - { name = "rich", version = "14.0.0", source = { registry = "https://pypi.org/simple" } }, - { name = "shellingham" }, - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" } }, + { name = "click", marker = "python_full_version >= '3.10'" }, + { name = "rich", version = "14.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "shellingham", marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/dd/82/f4bfed3bc18c6ebd6f828320811bbe4098f92a31adf4040bee59c4ae02ea/typer-0.17.3.tar.gz", hash = "sha256:0c600503d472bcf98d29914d4dcd67f80c24cc245395e2e00ba3603c9332e8ba", size = 103517, upload-time = "2025-08-30T12:35:24.05Z" } wheels = [ @@ -5835,7 +5868,7 @@ name = "typing-inspection" version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ @@ -5856,7 +5889,7 @@ name = "tzlocal" version = "5.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "tzdata", marker = "sys_platform == 'win32'" }, + { name = "tzdata", marker = "(python_full_version >= '3.10' and python_full_version < '3.14' and sys_platform == 'win32') or (python_full_version >= '3.10' and platform_python_implementation == 'PyPy' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/81/5b/879b2f932adfa7a053c360d50bc896c977fa6426109185f7c12ebdd0cb9d/tzlocal-5.4.4.tar.gz", hash = "sha256:8dbb8660838688a7b6ba4fed31d18dedf842afb4d47ca050d6d891c2c15f3be4", size = 31170, upload-time = "2026-06-29T08:03:40.026Z" } wheels = [ @@ -6053,7 +6086,7 @@ resolution-markers = [ "python_full_version == '3.9.*'", ] dependencies = [ - { name = "packaging" }, + { name = "packaging", marker = "(python_full_version >= '3.9' and python_full_version < '3.14') or (python_full_version >= '3.9' and platform_python_implementation == 'PyPy')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/39/62/75f18a0f03b4219c456652c7780e4d749b929eb605c098ce3a5b6b6bc081/wheel-0.47.0.tar.gz", hash = "sha256:cc72bd1009ba0cf63922e28f94d9d83b920aa2bb28f798a31d0691b02fa3c9b3", size = 63854, upload-time = "2026-04-22T15:51:27.727Z" } wheels = [ From 6956a259c191f33010d43fa46af8a75fb93b971e Mon Sep 17 00:00:00 2001 From: Matt Conflitti <matt.conflitti@posit.co> Date: Fri, 24 Jul 2026 17:01:53 -0400 Subject: [PATCH 02/14] Fix redeploy creating a duplicate .posit config 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. --- rsconnect/api.py | 8 ++++++ rsconnect/main.py | 4 +++ rsconnect/publisher/store.py | 42 ++++++++++++++++++++------------ tests/test_publisher.py | 47 ++++++++++++++++++++++++++++++++++++ tests/test_redeploy.py | 5 ++++ 5 files changed, 91 insertions(+), 15 deletions(-) diff --git a/rsconnect/api.py b/rsconnect/api.py index 6a669d1f9..905112e46 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -1306,6 +1306,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( @@ -1844,6 +1850,8 @@ def _save_publisher_metadata(self, deployed_info: RSConnectClientDeployResult): 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) diff --git a/rsconnect/main.py b/rsconnect/main.py index a762f30f9..4a5af6421 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -2581,6 +2581,10 @@ def redeploy( title=effective_title, env_vars=env_vars, ) + # Pin the exact .posit files this redeploy resolved so save_deployed_info + # updates them in place instead of minting duplicates. + ce.publisher_config_name = target.config_name + ce.publisher_record_name = target.record_name _finish_redeploy( ce, directory, app_mode, bundle_builder, bundle_args, bundle_kwargs, draft, no_verify, metadata, no_metadata ) diff --git a/rsconnect/publisher/store.py b/rsconnect/publisher/store.py index 45414722b..7a6dcf7b8 100644 --- a/rsconnect/publisher/store.py +++ b/rsconnect/publisher/store.py @@ -145,9 +145,15 @@ def write_deployment_metadata( deployed_info: typing.Mapping[str, typing.Any], bundle: "IO[bytes]", config_name: typing.Optional[str] = None, + record_name: typing.Optional[str] = None, ) -> typing.Tuple[str, str]: """Create/update the ``.posit`` config and deployment record for a deploy. + ``config_name``/``record_name`` pin the exact files to update -- ``redeploy`` + passes the names it resolved so the write updates those files instead of + re-deriving (and possibly duplicating) them. When omitted, an existing record + for this server (and its config) is reused, otherwise new names are minted. + Returns ``(config_path, record_path)``. Raises on failure; callers treat ``.posit`` write failures as non-fatal (the deploy has already succeeded). """ @@ -164,13 +170,14 @@ def write_deployment_metadata( files=_config_file_patterns(details), ) # Reuse an existing deployment's filenames on redeploy; only mint new random - # names for a genuinely new deployment. - existing_record_name = _find_record_name_for_server(project_dir, server_url) + # names for a genuinely new deployment. A caller-supplied record_name (from + # redeploy) pins the record file; otherwise match one by server_url. + existing_record_name = record_name or _find_record_name_for_server(project_dir, server_url) existing_config_name = None - if existing_record_name: - existing_config_name = record_mod.read_record( - schema.record_path(project_dir, existing_record_name) - ).configuration_name + if existing_record_name and not config_name: + record_file = schema.record_path(project_dir, existing_record_name) + if os.path.exists(record_file): + existing_config_name = record_mod.read_record(record_file).configuration_name cname = ( config_name @@ -257,6 +264,9 @@ class PublisherDeployTarget: server_url: typing.Optional[str] app_id: typing.Optional[str] record: typing.Optional[record_mod.PublisherRecord] + # Basename (no .toml) of the matched record file, so a redeploy updates that + # exact file rather than re-deriving it. + record_name: typing.Optional[str] = None def _load_configs(project_dir: str) -> typing.Dict[str, config_mod.PublisherConfig]: @@ -287,9 +297,9 @@ def _select_config( def _matching_records( project_dir: str, config_name: str, server: typing.Optional[str] -) -> typing.List[record_mod.PublisherRecord]: - """Records for ``config_name``, optionally filtered to a server URL.""" - records: typing.List[record_mod.PublisherRecord] = [] +) -> typing.List[typing.Tuple[str, record_mod.PublisherRecord]]: + """``(record_name, record)`` pairs for ``config_name``, optionally filtered to a server URL.""" + records: typing.List[typing.Tuple[str, record_mod.PublisherRecord]] = [] normalized_server = normalize_url(server) if server else None for path in record_mod.discover_records(project_dir): rec = record_mod.read_record(path) @@ -299,7 +309,7 @@ def _matching_records( continue if normalized_server and normalize_url(rec.server_url) != normalized_server: continue - records.append(rec) + records.append((os.path.splitext(os.path.basename(path))[0], rec)) return records @@ -321,14 +331,15 @@ def resolve_publisher_deploy_target( ) name, cfg = _select_config(configs, config_name) - records = _matching_records(project_dir, name, server) - record: typing.Optional[record_mod.PublisherRecord] - if len(records) > 1: - servers = ", ".join(sorted(r.server_url for r in records)) + matches = _matching_records(project_dir, name, server) + if len(matches) > 1: + servers = ", ".join(sorted(rec.server_url for _, rec in matches)) raise RSConnectException( "Multiple deployments found for config '{}' ({}); specify one with --server.".format(name, servers) ) - record = records[0] if records else None + record_name: typing.Optional[str] + record: typing.Optional[record_mod.PublisherRecord] + record_name, record = matches[0] if matches else (None, None) # Fall back to the record's embedded config snapshot if no standalone config # file carried the fields we need (e.g. a Publisher-authored record). @@ -349,4 +360,5 @@ def resolve_publisher_deploy_target( server_url=record.server_url if record else None, app_id=record.id if record else None, record=record, + record_name=record_name, ) diff --git a/tests/test_publisher.py b/tests/test_publisher.py index babebab8e..08f5141a1 100644 --- a/tests/test_publisher.py +++ b/tests/test_publisher.py @@ -145,6 +145,53 @@ def test_write_deployment_metadata_creates_config_and_record(tmp_path): assert rec.config().type == "python-shiny" +def test_redeploy_pins_resolved_config_and_record(tmp_path): + """Passing config_name/record_name (as redeploy does) updates those exact + files, even when the record lacks a configuration_name and the config's + entrypoint differs from the bundle -- preventing duplicate config/record files. + """ + import os + + project = str(tmp_path) + publish = tmp_path / ".posit" / "publish" + (publish / "deployments").mkdir(parents=True) + # config entrypoint deliberately differs from the bundle manifest's "app.py" + (publish / "chosen.toml").write_text( + '"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-schema-v3.json"\n' + 'product_type = "connect"\n' + 'type = "python-shiny"\n' + 'entrypoint = "different.py"\n' + "validate = true\n" + 'files = ["/different.py"]\n' + ) + # record with NO configuration_name -> auto-derivation would miss the config + (publish / "deployments" / "chosen-rec.toml").write_text( + '"$schema" = "https://cdn.posit.co/publisher/schemas/posit-publishing-record-schema-v3.json"\n' + 'server_type = "connect"\n' + 'server_url = "https://connect.example.com"\n' + 'type = "python-shiny"\n' + ) + + bundle = make_bundle(PY_SHINY_MANIFEST, {"requirements.txt": "shiny\n"}) + store.write_deployment_metadata( + project_dir=project, + server_url="https://connect.example.com", + product_type="connect", + app_mode=AppModes.PYTHON_SHINY, + title="My App", + deployed_info=DEPLOYED_INFO, + bundle=bundle, + config_name="chosen", + record_name="chosen-rec", + ) + + assert {os.path.basename(p) for p in config.discover_configs(project)} == {"chosen.toml"} + assert {os.path.basename(p) for p in record.discover_records(project)} == {"chosen-rec.toml"} + rec = record.read_record(record.discover_records(project)[0]) + assert rec.configuration_name == "chosen" + assert rec.id == "GUID-123" + + def test_filenames_use_publisher_random_code_methodology(tmp_path): """rsconnect mints the same style of names as Publisher's names.ts: a config ``<filenamified-title>-<CODE>`` and a record ``deployment-<CODE>``, where CODE diff --git a/tests/test_redeploy.py b/tests/test_redeploy.py index 6df43d0e2..241b2bb7e 100644 --- a/tests/test_redeploy.py +++ b/tests/test_redeploy.py @@ -96,6 +96,8 @@ def spy_make_bundle( captured["server_url"] = self.remote_server.url captured["app_mode"] = self.app_mode.name() if self.app_mode else None captured["title"] = self.title + captured["publisher_config_name"] = self.publisher_config_name + captured["publisher_record_name"] = self.publisher_record_name raise _StopDispatch() from rsconnect import api as api_mod @@ -151,6 +153,9 @@ def test_redeploy_reuses_identity_from_record( assert captured["server_url"] == SERVER_URL assert captured["app_id"] == GUID assert captured["app_mode"] == "python-shiny" + # the resolved config/record filenames are pinned so save updates them in place + assert captured["publisher_config_name"] == "app" + assert captured["publisher_record_name"] == "deployment-abc123" def test_redeploy_app_id_override(runner: CliRunner, project_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch): From c5d561d85c27e1219aa347523ff63a64c5370059 Mon Sep 17 00:00:00 2001 From: Matt Conflitti <matt.conflitti@posit.co> Date: Mon, 27 Jul 2026 09:43:28 -0400 Subject: [PATCH 03/14] redeploy: reuse a saved credential matched by server URL 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. --- rsconnect/main.py | 48 ++++++++++++++++++++++++- rsconnect/publisher/store.py | 4 ++- tests/test_redeploy.py | 70 ++++++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 2 deletions(-) diff --git a/rsconnect/main.py b/rsconnect/main.py index 4a5af6421..02326e114 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -2325,6 +2325,38 @@ def _finish_redeploy( ce.activate_deployment().emit_task_log() +def _find_saved_server_by_url(server_url: Optional[str]) -> Optional[dict[str, Any]]: + """Find a saved rsconnect-python server whose URL matches ``server_url`` (normalized). + + A ``.posit`` deployment record stores only the ``server_url`` (never the API + key), and Publisher keeps its own credentials in VS Code SecretStorage, which + a CLI cannot read. So to redeploy without re-specifying credentials, we match + the record's server against a credential the user already saved with + rsconnect-python -- using the same normalized-URL comparison Publisher uses to + join a record to a credential. + + Returns the single match, or ``None`` if none match. Raises when more than one + saved server matches (e.g. two credentials for the same URL under different + nicknames), since guessing which credential to use would be unsafe -- the user + disambiguates with ``--name``. + """ + if not server_url: + return None + target = publisher_normalize_url(server_url) + matches = [ + entry + for entry in server_store.get_all_servers() + if entry.get("url") and publisher_normalize_url(entry["url"]) == target + ] + if len(matches) > 1: + raise RSConnectException( + "Multiple saved servers match {} ({}); pick one with --name.".format( + server_url, ", ".join(sorted(str(m.get("name")) for m in matches)) + ) + ) + return matches[0] if matches else None + + def _legacy_records_for_dir(directory: str) -> list[dict[str, Any]]: """Read legacy per-directory deployment records from ``rsconnect-python/*.json``. @@ -2396,10 +2428,17 @@ def _redeploy_from_legacy( entry = next(iter(by_server.values())) app_mode = read_manifest_app_mode(manifest_path) + deploy_server = server or entry["server_url"] + # Reuse a saved rsconnect-python credential matching the recorded server. + if not name and not server and not api_key: + matched = _find_saved_server_by_url(entry["server_url"]) + if matched: + name = matched["name"] + deploy_server = None ce = RSConnectExecutor( ctx=ctx, name=name, - server=server or entry["server_url"], + server=deploy_server, api_key=api_key, snowflake_connection_name=snowflake_connection_name, insecure=insecure, @@ -2558,6 +2597,13 @@ def redeploy( effective_app_id = app_id or target.app_id # Deploy to the record's server unless the caller overrode the destination. deploy_server = server or target.server_url + # With no explicit credential, reuse a saved rsconnect-python server whose URL + # matches the record's (Publisher's own credentials are not reachable here). + if not name and not server and not api_key: + matched = _find_saved_server_by_url(target.server_url) + if matched: + name = matched["name"] + deploy_server = None deploy_path, bundle_builder, bundle_args, bundle_kwargs = _plan_deploy_bundle( directory, diff --git a/rsconnect/publisher/store.py b/rsconnect/publisher/store.py index 7a6dcf7b8..40ead024c 100644 --- a/rsconnect/publisher/store.py +++ b/rsconnect/publisher/store.py @@ -40,7 +40,9 @@ def normalize_url(url: str) -> str: return "" parsed = urlparse(url if "//" in url else "//" + url) netloc = parsed.netloc.lower() - path = parsed.path + # Strip trailing slashes first so a trailing slash after ``__api__`` + # (".../__api__/") still lets the suffix be removed. + path = parsed.path.rstrip("/") if path.endswith("/__api__"): path = path[: -len("/__api__")] path = path.rstrip("/") diff --git a/tests/test_redeploy.py b/tests/test_redeploy.py index 241b2bb7e..834fb234d 100644 --- a/tests/test_redeploy.py +++ b/tests/test_redeploy.py @@ -271,6 +271,76 @@ def test_redeploy_legacy_manifest_without_record_needs_server(runner: CliRunner, assert "No prior deployment found" in result.output +def test_find_saved_server_by_url_matches_normalized(monkeypatch: pytest.MonkeyPatch): + from rsconnect import main as main_mod + + saved = [{"name": "prod", "url": "https://connect.example.com/__api__"}] + monkeypatch.setattr(main_mod, "server_store", types.SimpleNamespace(get_all_servers=lambda: saved)) + + # trailing slash / __api__ differences still match + assert main_mod._find_saved_server_by_url("https://connect.example.com/")["name"] == "prod" + assert main_mod._find_saved_server_by_url("https://other.example.com") is None + assert main_mod._find_saved_server_by_url(None) is None + + +def test_find_saved_server_by_url_ambiguous_raises(monkeypatch: pytest.MonkeyPatch): + """Two saved credentials for the same server must not be guessed between.""" + from rsconnect import main as main_mod + from rsconnect.exception import RSConnectException + + saved = [ + {"name": "prod-a", "url": "https://connect.example.com"}, + {"name": "prod-b", "url": "https://connect.example.com/__api__"}, + ] + monkeypatch.setattr(main_mod, "server_store", types.SimpleNamespace(get_all_servers=lambda: saved)) + + with pytest.raises(RSConnectException, match="Multiple saved servers match"): + main_mod._find_saved_server_by_url("https://connect.example.com/") + + +def test_redeploy_reuses_saved_credential_by_url( + runner: CliRunner, project_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch +): + """With no explicit credential, redeploy matches the record's server_url to a + saved rsconnect-python server (normalized) and deploys under that nickname.""" + from rsconnect import main as main_mod + + saved = {"name": "prod", "url": "https://connect.example.com/__api__/"} + monkeypatch.setattr( + main_mod, + "server_store", + types.SimpleNamespace(get_all_servers=lambda: [saved], get_by_name=lambda n: saved if n == "prod" else None), + ) + + captured: dict[str, typing.Any] = {} + + class FakeExecutor: + def __init__(self, **kwargs: typing.Any): + captured.update(kwargs) + self.client = None + self.supports_verify_before_activate = False + + def __getattr__(self, _name: str): + # every fluent step is a no-op that returns self + return lambda *a, **k: self + + def should_deploy_as_draft(self, *a: typing.Any, **k: typing.Any) -> bool: + return False + + monkeypatch.setattr(main_mod, "RSConnectExecutor", FakeExecutor) + monkeypatch.setattr(main_mod, "prepare_deploy_metadata", lambda *a, **k: None) + fake_env = types.SimpleNamespace(python="python") + monkeypatch.setattr(main_mod.Environment, "create_python_environment", classmethod(lambda cls, *a, **k: fake_env)) + _write_posit_project(project_dir) # record server_url = https://connect.example.com + + result = runner.invoke(cli, ["redeploy", str(project_dir)]) + + assert result.exit_code == 0, result.output + # matched the saved server: deploy under its nickname, no raw server URL + assert captured.get("name") == "prod" + assert captured.get("server") is None + + def test_redeploy_dispatches_quarto(runner: CliRunner, project_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch): captured = _spy_make_bundle(monkeypatch) from rsconnect import main as main_mod From a3a4f55e4b777e9c41419f026331ee1e40e0769c Mon Sep 17 00:00:00 2001 From: Matt Conflitti <matt.conflitti@posit.co> Date: Wed, 29 Jul 2026 10:24:55 -0400 Subject: [PATCH 04/14] Bundle files per .posit config; honor .gitignore otherwise 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. --- docs/CHANGELOG.md | 10 ++ pyproject.toml | 1 + rsconnect/api.py | 30 +++- rsconnect/bundle.py | 50 +++++++ rsconnect/publisher/files.py | 204 +++++++++++++++++++++++++++ rsconnect/publisher/store.py | 93 +++++++++++- tests/test_publisher.py | 14 +- tests/test_publisher_files.py | 256 ++++++++++++++++++++++++++++++++++ uv.lock | 3 + 9 files changed, 653 insertions(+), 8 deletions(-) create mode 100644 rsconnect/publisher/files.py create mode 100644 tests/test_publisher_files.py diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 80bee78f7..149f86ae1 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -32,6 +32,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 such a configuration applies to the content. + The `files` entries use `.gitignore` syntax (a matching pattern includes a + path, a `!` prefix excludes it), matching Posit Publisher. A configuration + rsconnect-python writes now records the concrete set of deployed files, so its + `files`, the generated `manifest.json`, and the deployment record all agree. +- **Behavior change:** deploys _without_ an applicable `.posit/publish` + configuration now honor `.gitignore` (in addition to the pre-existing built-in + ignore list) when choosing which files to bundle. Files ignored by + `.gitignore` are no longer included in the bundle. ## [1.30.0] - 2026-07-16 diff --git a/pyproject.toml b/pyproject.toml index 59e86965b..79e0eb416 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "packaging>=20.0", "toml>=0.10; python_version < '3.11'", "tomli-w>=1.0.0", + "pathspec>=0.10.0", ] [project.scripts] diff --git a/rsconnect/api.py b/rsconnect/api.py index 905112e46..70b0d2df0 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -53,6 +53,7 @@ from . import validation from .bundle import _default_title +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 @@ -1622,8 +1623,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 = self._resolve_bundle_files(func) try: - self.bundle = func(*args, **kwargs) + with bundle_restrict_to_files(include_files): + self.bundle = func(*args, **kwargs) except IOError as error: msg = "Unable to include the file %s in the bundle: %s" % ( error.filename, @@ -1633,6 +1636,31 @@ def make_bundle( return self + def _resolve_bundle_files(self, func: "Callable[..., Any]") -> "Optional[list[str]]": + """Pick the project-relative files this bundle should include. + + Honors a ``.posit/publish`` config's ``files`` when one applies, else a + ``.gitignore``-aware default. Returns ``None`` (leaving the builder's own + whole-tree walk in place) for ``deploy manifest`` -- which is 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 + + return resolve_bundle_files(directory, entrypoint, self.publisher_config_name) + except Exception as exc: # best-effort: never block a deploy on file selection + logger.debug("Could not resolve .posit bundle file selection: %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) diff --git a/rsconnect/bundle.py b/rsconnect/bundle.py index f3d896c6c..37a150e41 100644 --- a/rsconnect/bundle.py +++ b/rsconnect/bundle.py @@ -4,6 +4,8 @@ from __future__ import annotations +import contextlib +import contextvars import hashlib import io import json @@ -82,6 +84,30 @@ 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 a ``.posit/publish`` +# config (allowlist) or a ``.gitignore``-aware default; 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) + class ManifestDataFile(TypedDict): checksum: str @@ -1236,6 +1262,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 @@ -1245,6 +1272,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 [] @@ -1258,6 +1289,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 diff --git a/rsconnect/publisher/files.py b/rsconnect/publisher/files.py new file mode 100644 index 000000000..d9350dfa7 --- /dev/null +++ b/rsconnect/publisher/files.py @@ -0,0 +1,204 @@ +"""File selection for bundling, honoring ``.posit/publish`` config ``files``. + +Two selectors, one matching engine (:mod:`pathspec`, ``gitwildmatch``): + +- :func:`select_config_files` -- an **allowlist**. A config's ``files`` are + gitignore-syntax patterns with include/exclude *inverted*: a matching pattern + *includes* a path, a ``!``-prefixed pattern *excludes* it, and + :data:`STANDARD_EXCLUSIONS` are appended so they always win (last-match-wins). + This mirrors Posit Publisher's bundler (``extensions/vscode/src/bundler/ + collect.ts``). +- :func:`select_default_files` -- a **denylist** used when no config applies: + everything except paths ignored by ``.gitignore`` (project root plus nested) + and the hardcoded :data:`directories_ignore_list`. + +Both return sorted, project-relative, forward-slash paths, so downstream +bundling code treats the two cases identically. +""" + +from __future__ import annotations + +import os +import typing +import warnings + +import pathspec + +from ..bundle import directories_ignore_list + + +def _compile(lines: typing.Sequence[str]) -> pathspec.PathSpec: + """Compile gitignore-style ``lines`` into a spec. + + ``gitwildmatch`` is the pattern-factory name available across the whole + supported ``pathspec`` range; newer releases deprecate the alias in favor of + ``gitignore`` but keep it working, so we silence that one warning here rather + than raise the version floor. + """ + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", "GitWildMatchPattern", DeprecationWarning) + return pathspec.PathSpec.from_lines("gitwildmatch", list(lines)) + + +# Always appended after a config's user patterns. Because resolution is +# last-match-wins, these exclusions always take precedence. Ported verbatim from +# Publisher's ``STANDARD_EXCLUSIONS`` (collect.ts); each is a ``!`` exclusion. +STANDARD_EXCLUSIONS: typing.List[str] = [ + # From rsconnect-python + "!.Rproj.user/", + "!.git/", + "!.svn/", + "!__pycache__/", + "!packrat/", + "!rsconnect-python/", + "!rsconnect/", + # From rsconnect + "!.DS_Store", + "!.Rhistory", + "!.quarto/", + "!*.Rproj", + "!.rscignore", + "!*_cache/", + # Other + "!.ipynb_checkpoints/", + # Exclude existing manifest.json; we will create one. + "!manifest.json", + # renv library cannot be included + "!renv/library", + "!renv/sandbox", + "!renv/staging", + # node_modules shouldn't be deployed and can be very large + "!node_modules/", +] + +# Relative paths (under a candidate directory) whose presence marks the directory +# as a Python virtual environment; such directories are skipped entirely, matching +# Publisher's ``isPythonEnvironmentDir``. +_PYTHON_BIN_PATHS = [ + os.path.join("bin", "python"), + os.path.join("bin", "python3"), + os.path.join("Scripts", "python.exe"), + os.path.join("Scripts", "python3.exe"), +] + + +def _is_python_environment_dir(abs_dir: str) -> bool: + return any(os.path.isfile(os.path.join(abs_dir, bin_path)) for bin_path in _PYTHON_BIN_PATHS) + + +def _is_renv_library_dir(rel_dir: str) -> bool: + parts = rel_dir.split("/") + return len(parts) >= 2 and parts[-2] == "renv" and parts[-1] in ("library", "sandbox", "staging") + + +def _rel_posix(base_dir: str, abs_path: str) -> str: + """Project-relative, forward-slash path (``pathspec`` wants POSIX separators).""" + return os.path.relpath(abs_path, base_dir).replace(os.sep, "/") + + +def _walk( + directory: str, + keep_file: typing.Callable[[str], bool], + prune_dir: typing.Callable[[str], bool], +) -> typing.List[str]: + """Walk ``directory`` collecting project-relative files. + + ``keep_file(rel)`` decides whether a file is included. ``prune_dir(rel)`` + decides whether a directory subtree is skipped entirely (for performance and + to match Publisher's directory-level exclusion). Python virtualenv and renv + library directories are always pruned. + """ + results: typing.List[str] = [] + for cur_dir, dir_names, file_names in os.walk(directory): + # Prune subdirectories in place so os.walk does not descend into them. + kept_dirs: typing.List[str] = [] + for name in dir_names: + abs_sub = os.path.join(cur_dir, name) + rel_sub = _rel_posix(directory, abs_sub) + if prune_dir(rel_sub): + continue + if _is_python_environment_dir(abs_sub) or _is_renv_library_dir(rel_sub): + continue + kept_dirs.append(name) + dir_names[:] = kept_dirs + + for name in file_names: + rel = _rel_posix(directory, os.path.join(cur_dir, name)) + if keep_file(rel): + results.append(rel) + return sorted(results) + + +def select_config_files(directory: str, config_files: typing.Sequence[str]) -> typing.List[str]: + """Return the files under ``directory`` selected by a config's ``files``. + + ``config_files`` are Publisher-style include patterns. A file is selected + only when its last matching pattern is an include (``STANDARD_EXCLUSIONS`` are + appended and win ties). Directories are pruned only when explicitly excluded, + so an include deeper in an otherwise-unmatched directory is still found. + """ + spec = _compile(list(config_files) + STANDARD_EXCLUSIONS) + + def keep_file(rel: str) -> bool: + return spec.check_file(rel).include is True + + def prune_dir(rel: str) -> bool: + # Prune only definitively-excluded directories; an unmatched directory + # may still contain files that match an include pattern. + return spec.check_file(rel + "/").include is False + + return _walk(directory, keep_file, prune_dir) + + +def _gitignore_spec(directory: str) -> pathspec.PathSpec: + """A gitignore-style denylist: nested ``.gitignore`` files + hardcoded dirs. + + Patterns from a nested ``.gitignore`` are anchored to that file's directory + (matching git), so ``build/`` in ``sub/.gitignore`` becomes ``/sub/build/``. + """ + lines: typing.List[str] = list(directories_ignore_list) + for cur_dir, _, file_names in os.walk(directory): + if ".gitignore" not in file_names: + continue + rel_dir = _rel_posix(directory, cur_dir) + prefix = "" if rel_dir == "." else rel_dir + "/" + try: + with open(os.path.join(cur_dir, ".gitignore"), "r", encoding="utf-8") as handle: + raw_lines = handle.read().splitlines() + except OSError: + continue + for raw in raw_lines: + stripped = raw.strip() + if not stripped or stripped.startswith("#"): + lines.append(raw) + continue + negated = stripped.startswith("!") + body = stripped[1:] if negated else stripped + # A rooted (contains a non-trailing slash) pattern is relative to the + # .gitignore's directory; anchor it. An unrooted pattern matches at any + # depth below that directory, so prefix it with "**/". + if body.startswith("/"): + anchored = prefix + body.lstrip("/") + elif "/" in body.rstrip("/"): + anchored = prefix + body + else: + anchored = prefix + "**/" + body if prefix else body + lines.append(("!" if negated else "") + anchored) + return _compile(lines) + + +def select_default_files(directory: str) -> typing.List[str]: + """Return every file under ``directory`` that is not gitignored. + + Used when no ``.posit/publish`` config applies. Honors ``.gitignore`` (root + and nested) plus the hardcoded :data:`directories_ignore_list`. + """ + spec = _gitignore_spec(directory) + + def keep_file(rel: str) -> bool: + return not spec.match_file(rel) + + def prune_dir(rel: str) -> bool: + return spec.match_file(rel + "/") + + return _walk(directory, keep_file, prune_dir) diff --git a/rsconnect/publisher/store.py b/rsconnect/publisher/store.py index 40ead024c..724c06b29 100644 --- a/rsconnect/publisher/store.py +++ b/rsconnect/publisher/store.py @@ -21,6 +21,7 @@ from ..exception import RSConnectException from ..models import AppMode, AppModes from . import config as config_mod +from . import files as files_mod from . import record as record_mod from . import schema @@ -125,18 +126,94 @@ def _find_config_name_for_entrypoint(project_dir: str, entrypoint: str) -> typin return None +def resolve_bundle_files( + directory: str, + entrypoint: typing.Optional[str] = None, + config_name: typing.Optional[str] = None, +) -> typing.List[str]: + """Resolve the concrete project-relative files to bundle for ``directory``. + + If a ``.posit/publish`` config applies -- the one named ``config_name``, else + the sole config, else the config whose entrypoint matches ``entrypoint`` -- and + it declares ``files``, return those selected as an allowlist. Otherwise return + the ``.gitignore``-aware default (everything not ignored). Never raises for an + ambiguous or missing config; it falls back to the default selection so a plain + ``deploy`` still works. + """ + cfg: typing.Optional[config_mod.PublisherConfig] = None + try: + configs = _load_configs(directory) + except Exception: + configs = {} + if config_name and config_name in configs: + cfg = configs[config_name] + elif len(configs) == 1: + cfg = next(iter(configs.values())) + elif entrypoint: + for candidate in configs.values(): + if candidate.entrypoint == entrypoint: + cfg = candidate + break + + if cfg is not None and cfg.files: + selected = files_mod.select_config_files(directory, cfg.files) + # The entrypoint must ship even if the config's patterns don't cover it + # (the bundle builders reference it from this list, not separately). + if cfg.entrypoint: + entry = cfg.entrypoint.replace(os.sep, "/") + if entry not in selected and os.path.isfile(os.path.join(directory, cfg.entrypoint)): + selected = sorted([*selected, entry]) + return selected + return files_mod.select_default_files(directory) + + +def _root_anchor(path: str) -> str: + """Root-anchor a project-relative path (``app.py`` -> ``/app.py``). + + Anchoring prevents an entry from also matching a same-named file deeper in the + tree, matching how Publisher records concrete, root-relative include paths. + """ + return "/" + path.replace(os.sep, "/").lstrip("/") + + def _config_file_patterns(details: "record_mod.BundleContentDetails") -> typing.List[str]: - """Seed the config ``files`` include-list, matching Publisher's normalize.""" + """The config ``files`` include-list: the concrete deployed file set. + + Uses the exact file list from the built bundle's manifest so the config's + ``files``, the manifest's ``files``, and the record's ``files`` all denote the + same set (root-anchored, as Publisher writes them). The entrypoint and the + declared package file are guaranteed present (the schema requires + ``package_file`` to be listed under ``files``). + """ patterns: typing.List[str] = [] + for name in details.files: + anchored = _root_anchor(name) + if anchored not in patterns: + patterns.append(anchored) if details.entrypoint: - patterns.append("/" + details.entrypoint) + entry = _root_anchor(details.entrypoint) + if entry not in patterns: + patterns.insert(0, entry) if details.python and details.python.get("package_file"): - pkg = "/" + typing.cast(str, details.python["package_file"]) + pkg = _root_anchor(typing.cast(str, details.python["package_file"])) if pkg not in patterns: patterns.append(pkg) return patterns +def _posit_bundle_paths(project_dir: str, config_name: str, record_name: typing.Optional[str]) -> typing.List[str]: + """Root-anchored ``.posit`` paths to include in ``files``, mirroring Publisher. + + Publisher adds the driving config (and its deployment record) to the deployment + file list so they ship in the bundle. Returns the config path always and the + record path when ``record_name`` is known. + """ + paths = [_root_anchor(os.path.relpath(schema.config_path(project_dir, config_name), project_dir))] + if record_name: + paths.append(_root_anchor(os.path.relpath(schema.record_path(project_dir, record_name), project_dir))) + return paths + + def write_deployment_metadata( *, project_dir: str, @@ -169,7 +246,6 @@ def write_deployment_metadata( product_type=product_type, python=details.python, quarto=details.quarto, - files=_config_file_patterns(details), ) # Reuse an existing deployment's filenames on redeploy; only mint new random # names for a genuinely new deployment. A caller-supplied record_name (from @@ -187,6 +263,11 @@ def write_deployment_metadata( or _find_config_name_for_entrypoint(project_dir, details.entrypoint) or _new_config_name(project_dir, title or details.entrypoint or "content") ) + rname = existing_record_name or _new_record_name(project_dir) + # For a new config, seed ``files`` with the concrete deployed set plus the + # ``.posit`` files (mirroring Publisher). ``write_config`` preserves an existing + # config's curated ``files``, so this only takes effect when minting one. + cfg.files = _config_file_patterns(details) + _posit_bundle_paths(project_dir, cname, rname) config_path, config_dict = config_mod.write_config(project_dir, cname, cfg) dashboard_url = deployed_info.get("dashboard_url") @@ -233,13 +314,15 @@ def write_config_from_manifest( title=title, python=details.python, quarto=details.quarto, - files=_config_file_patterns(details), ) cname = ( config_name or _find_config_name_for_entrypoint(project_dir, details.entrypoint) or _new_config_name(project_dir, title or details.entrypoint or "content") ) + # No deployment record here (write-manifest does not deploy); include the + # config itself but no record path. + cfg.files = _config_file_patterns(details) + _posit_bundle_paths(project_dir, cname, None) path, _ = config_mod.write_config(project_dir, cname, cfg) return path diff --git a/tests/test_publisher.py b/tests/test_publisher.py index 08f5141a1..42b9459fb 100644 --- a/tests/test_publisher.py +++ b/tests/test_publisher.py @@ -120,7 +120,12 @@ def test_write_deployment_metadata_creates_config_and_record(tmp_path): assert cfg.entrypoint == "app.py" assert cfg.title == "My App" assert cfg.validate is True - assert cfg.files == ["/app.py", "/requirements.txt"] + # config files == the concrete deployed set (root-anchored), aligned with the + # manifest/record, plus the driving .posit config + record (mirrors Publisher). + assert cfg.files[:3] == ["/app.py", "/helpers.py", "/requirements.txt"] + posit_files = [f for f in cfg.files if f.startswith("/.posit/publish/")] + assert len(posit_files) == 2 + assert any("/deployments/" in f for f in posit_files) assert cfg.python == { "version": "3.11.5", "package_file": "requirements.txt", @@ -330,7 +335,12 @@ def test_write_config_from_manifest(tmp_path): cfg = config.read_config(path) assert cfg.type == "python-shiny" assert cfg.entrypoint == "app.py" - assert cfg.files == ["/app.py", "/requirements.txt"] + # concrete deployed set (root-anchored), aligned with the manifest, plus the + # config file itself (mirrors Publisher). No record path: write-manifest does + # not deploy. + assert cfg.files[:3] == ["/app.py", "/helpers.py", "/requirements.txt"] + assert any(f.startswith("/.posit/publish/") and f.endswith(".toml") for f in cfg.files) + assert not any("/deployments/" in f for f in cfg.files) # write-manifest prepares content but does not deploy: no record is written. assert record.discover_records(project) == [] diff --git a/tests/test_publisher_files.py b/tests/test_publisher_files.py new file mode 100644 index 000000000..da146d4cd --- /dev/null +++ b/tests/test_publisher_files.py @@ -0,0 +1,256 @@ +"""Tests for :mod:`rsconnect.publisher.files` selection.""" + +import os + +from rsconnect.publisher.files import ( + STANDARD_EXCLUSIONS, + select_config_files, + select_default_files, +) + + +def _touch(root, rel): + path = os.path.join(root, rel.replace("/", os.sep)) + os.makedirs(os.path.dirname(path) or root, exist_ok=True) + with open(path, "w", encoding="utf-8") as handle: + handle.write("x") + + +def _make_tree(root, rels): + for rel in rels: + _touch(root, rel) + + +def test_config_files_rooted_vs_basename(tmp_path): + root = str(tmp_path) + _make_tree(root, ["app.py", "sub/app.py", "notes.csv", "sub/notes.csv", "requirements.txt"]) + # "/app.py" is rooted (matches only the top-level app.py); "*.csv" matches at any depth. + selected = select_config_files(root, ["/app.py", "*.csv", "requirements.txt"]) + assert selected == ["app.py", "notes.csv", "requirements.txt", "sub/notes.csv"] + + +def test_config_files_directory_include(tmp_path): + root = str(tmp_path) + _make_tree(root, ["app.py", "data/a.csv", "data/nested/b.csv", "other/c.txt"]) + selected = select_config_files(root, ["/app.py", "data/"]) + assert selected == ["app.py", "data/a.csv", "data/nested/b.csv"] + + +def test_config_files_negation_excludes(tmp_path): + root = str(tmp_path) + _make_tree(root, ["data/keep.csv", "data/secret.csv"]) + # Later patterns win: exclude one file that an earlier pattern included. + selected = select_config_files(root, ["data/", "!data/secret.csv"]) + assert selected == ["data/keep.csv"] + + +def test_config_files_standard_exclusions_win(tmp_path): + root = str(tmp_path) + _make_tree( + root, + [ + "app.py", + "manifest.json", + ".git/config", + "__pycache__/app.cpython.pyc", + "node_modules/lib/index.js", + "big_cache/data", + ], + ) + # A broad include cannot override STANDARD_EXCLUSIONS. + selected = select_config_files(root, ["**"]) + assert "app.py" in selected + assert "manifest.json" not in selected + assert not any(f.startswith(".git/") for f in selected) + assert not any(f.startswith("__pycache__/") for f in selected) + assert not any(f.startswith("node_modules/") for f in selected) + assert not any(f.startswith("big_cache/") for f in selected) + + +def test_config_files_unmatched_are_dropped(tmp_path): + root = str(tmp_path) + _make_tree(root, ["app.py", "extra.txt"]) + selected = select_config_files(root, ["/app.py"]) + assert selected == ["app.py"] + + +def test_config_files_skips_python_venv(tmp_path): + root = str(tmp_path) + _make_tree(root, ["app.py", "venv/bin/python", "venv/lib/site.py"]) + selected = select_config_files(root, ["**"]) + assert "app.py" in selected + assert not any(f.startswith("venv/") for f in selected) + + +def test_default_files_honors_gitignore(tmp_path): + root = str(tmp_path) + _make_tree(root, ["app.py", "keep.txt", "build/out.o", "secret.log"]) + _touch(root, ".gitignore") + with open(os.path.join(root, ".gitignore"), "w", encoding="utf-8") as handle: + handle.write("build/\n*.log\n") + selected = select_default_files(root) + assert "app.py" in selected + assert "keep.txt" in selected + assert ".gitignore" in selected + assert not any(f.startswith("build/") for f in selected) + assert "secret.log" not in selected + + +def test_default_files_nested_gitignore(tmp_path): + root = str(tmp_path) + _make_tree(root, ["app.py", "sub/keep.py", "sub/skip.tmp", "skip.tmp"]) + with open(os.path.join(root, "sub", ".gitignore"), "w", encoding="utf-8") as handle: + handle.write("*.tmp\n") + selected = select_default_files(root) + # Nested .gitignore only affects its own subtree. + assert "sub/keep.py" in selected + assert "sub/skip.tmp" not in selected + assert "skip.tmp" in selected + + +def test_default_files_gitignore_negation(tmp_path): + root = str(tmp_path) + _make_tree(root, ["a.log", "keep.log"]) + with open(os.path.join(root, ".gitignore"), "w", encoding="utf-8") as handle: + handle.write("*.log\n!keep.log\n") + selected = select_default_files(root) + assert "keep.log" in selected + assert "a.log" not in selected + + +def test_default_files_skips_hardcoded_dirs(tmp_path): + root = str(tmp_path) + _make_tree(root, ["app.py", ".git/config", "__pycache__/x.pyc", "node_modules/m/i.js"]) + selected = select_default_files(root) + assert selected == ["app.py"] + + +def test_standard_exclusions_are_all_negations(): + assert all(pat.startswith("!") for pat in STANDARD_EXCLUSIONS) + + +# --- integration: create_file_list honors the injected restriction ----------- + + +def test_create_file_list_restrict_applies_builder_excludes(tmp_path): + from rsconnect.bundle import create_file_list, restrict_to_files + + root = str(tmp_path) + _make_tree(root, ["app.py", "helpers.py", "data.csv", "requirements.txt"]) + # The builder excludes its separately-added files (env file + manifest.json). + with restrict_to_files(["app.py", "requirements.txt"]): + files = create_file_list(root, [], ["requirements.txt", "manifest.json"]) + # Restricted to the two, then requirements.txt dropped by the builder exclude. + assert files == ["app.py"] + + +def test_create_file_list_no_restrict_walks_all(tmp_path): + from rsconnect.bundle import create_file_list + + root = str(tmp_path) + _make_tree(root, ["app.py", "helpers.py", "data.csv", "requirements.txt"]) + files = create_file_list(root, [], ["requirements.txt", "manifest.json"]) + assert set(files) == {"app.py", "helpers.py", "data.csv"} + + +def test_create_file_list_explicit_include_files_param(tmp_path): + from rsconnect.bundle import create_file_list + + root = str(tmp_path) + _make_tree(root, ["app.py", "helpers.py"]) + files = create_file_list(root, [], [], include_files=["app.py"]) + assert files == ["app.py"] + + +def test_create_file_list_restrict_skips_missing(tmp_path): + from rsconnect.bundle import create_file_list, restrict_to_files + + root = str(tmp_path) + _make_tree(root, ["app.py"]) + with restrict_to_files(["app.py", "gone.py"]): + files = create_file_list(root, [], []) + assert files == ["app.py"] + + +# --- integration: resolve_bundle_files picks config vs. default -------------- + + +def test_resolve_bundle_files_uses_config_allowlist(tmp_path): + from rsconnect.publisher.store import resolve_bundle_files + + root = str(tmp_path) + _make_tree(root, ["app.py", "helpers.py", "data.csv", "requirements.txt"]) + publish = tmp_path / ".posit" / "publish" + publish.mkdir(parents=True) + (publish / "app.toml").write_text( + '"$schema" = "x"\ntype = "python-shiny"\nentrypoint = "app.py"\n' + 'files = [\n "/app.py",\n "/requirements.txt",\n]\n', + encoding="utf-8", + ) + selected = resolve_bundle_files(root, entrypoint="app.py") + assert selected == ["app.py", "requirements.txt"] + + +def test_resolve_bundle_files_force_includes_entrypoint(tmp_path): + from rsconnect.publisher.store import resolve_bundle_files + + root = str(tmp_path) + _make_tree(root, ["app.py", "data.csv"]) + publish = tmp_path / ".posit" / "publish" + publish.mkdir(parents=True) + # A config whose files omit the entrypoint entirely. + (publish / "app.toml").write_text( + '"$schema" = "x"\ntype = "python-shiny"\nentrypoint = "app.py"\nfiles = [\n "/data.csv",\n]\n', + encoding="utf-8", + ) + selected = resolve_bundle_files(root, entrypoint="app.py") + assert "app.py" in selected # force-included despite not matching a pattern + assert "data.csv" in selected + + +def test_resolve_bundle_files_default_when_no_config(tmp_path): + from rsconnect.publisher.store import resolve_bundle_files + + root = str(tmp_path) + _make_tree(root, ["app.py", "secret.log"]) + (tmp_path / ".gitignore").write_text("*.log\n", encoding="utf-8") + selected = resolve_bundle_files(root) + assert "app.py" in selected + assert ".gitignore" in selected + assert "secret.log" not in selected + + +# --- end-to-end: the executor resolves + restricts around the builder -------- + + +def test_executor_make_bundle_restricts_to_config(tmp_path): + import io + + from rsconnect.api import RSConnectExecutor + from rsconnect.bundle import create_file_list + + root = str(tmp_path) + _make_tree(root, ["app.py", "helpers.py", "requirements.txt"]) + publish = tmp_path / ".posit" / "publish" + publish.mkdir(parents=True) + (publish / "app.toml").write_text( + '"$schema" = "x"\ntype = "python-shiny"\nentrypoint = "app.py"\nfiles = [\n "/app.py",\n]\n', + encoding="utf-8", + ) + + captured = {} + + def fake_builder(*_args, **_kwargs): + # Inside make_bundle the restriction is active, so the shared walker sees + # only the config's allowlisted files. + captured["files"] = create_file_list(root, [], []) + return io.BytesIO(b"bundle") + + # make_bundle keys off the builder name to skip manifest-driven deploys. + fake_builder.__name__ = "make_api_bundle" + + # app_id set so make_deployment_name does not contact a server for a unique name. + ce = RSConnectExecutor(path=root, app_id="1") + ce.make_bundle(fake_builder) + + assert captured["files"] == ["app.py"] diff --git a/uv.lock b/uv.lock index a7162b5ca..bbef8a486 100644 --- a/uv.lock +++ b/uv.lock @@ -4762,6 +4762,8 @@ source = { editable = "." } dependencies = [ { name = "click" }, { name = "packaging" }, + { name = "pathspec", version = "0.12.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "pathspec", version = "1.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "pip", version = "25.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "pip", version = "26.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, { name = "pip", version = "26.1.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, @@ -4823,6 +4825,7 @@ requires-dist = [ { name = "click", specifier = ">=8.0.0" }, { name = "keyring", marker = "extra == 'keyring'", specifier = ">=23.0.0" }, { name = "packaging", specifier = ">=20.0" }, + { name = "pathspec", specifier = ">=0.10.0" }, { name = "pip", specifier = ">=10.0.0" }, { name = "pyjwt", specifier = ">=2.4.0" }, { name = "semver", specifier = ">=2.0.0,<4.0.0" }, From 3f7218b938bdacba25778c81272bb265568fc707 Mon Sep 17 00:00:00 2001 From: Matt Conflitti <matt.conflitti@posit.co> Date: Wed, 29 Jul 2026 14:32:10 -0400 Subject: [PATCH 05/14] Propagate config integration_requests into manifest.json 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. --- docs/CHANGELOG.md | 4 ++ rsconnect/api.py | 42 +++++++++----- rsconnect/bundle.py | 55 ++++++++++++++++++ rsconnect/publisher/store.py | 92 +++++++++++++++++++++++++------ tests/test_publisher_files.py | 101 ++++++++++++++++++++++++++++++++++ 5 files changed, 262 insertions(+), 32 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 149f86ae1..c5d0d9659 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -42,6 +42,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 configuration now honor `.gitignore` (in addition to the pre-existing built-in ignore list) when choosing which files to bundle. Files ignored by `.gitignore` are no longer included in the bundle. +- `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. ## [1.30.0] - 2026-07-16 diff --git a/rsconnect/api.py b/rsconnect/api.py index 70b0d2df0..acdd49bde 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -53,6 +53,7 @@ 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 @@ -1623,9 +1624,9 @@ 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 = self._resolve_bundle_files(func) + include_files, manifest_overlay = self._resolve_publisher_bundle_context(func) try: - with bundle_restrict_to_files(include_files): + 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" % ( @@ -1636,16 +1637,25 @@ def make_bundle( return self - def _resolve_bundle_files(self, func: "Callable[..., Any]") -> "Optional[list[str]]": - """Pick the project-relative files this bundle should include. + 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. - Honors a ``.posit/publish`` config's ``files`` when one applies, else a - ``.gitignore``-aware default. Returns ``None`` (leaving the builder's own - whole-tree walk in place) for ``deploy manifest`` -- which is driven by its - pre-built ``manifest.json`` -- and if resolution fails for any reason. + Returns ``(include_files, manifest_overlay)``: + + - ``include_files`` -- the project-relative files to bundle (a config's + ``files`` allowlist when one applies, else a ``.gitignore``-aware + default), or ``None`` to leave 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 + return None, {} path = self.path if os.path.isdir(path): directory: str = path @@ -1654,12 +1664,14 @@ def _resolve_bundle_files(self, func: "Callable[..., Any]") -> "Optional[list[st directory = os.path.dirname(path) or "." entrypoint = os.path.basename(path) try: - from .publisher.store import resolve_bundle_files - - return resolve_bundle_files(directory, entrypoint, self.publisher_config_name) - except Exception as exc: # best-effort: never block a deploy on file selection - logger.debug("Could not resolve .posit bundle file selection: %s", exc) - return None + 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 diff --git a/rsconnect/bundle.py b/rsconnect/bundle.py index 37a150e41..64e7a0431 100644 --- a/rsconnect/bundle.py +++ b/rsconnect/bundle.py @@ -35,6 +35,7 @@ from typing import ( IO, TYPE_CHECKING, + Any, Callable, Iterator, Literal, @@ -109,6 +110,46 @@ def restrict_to_files(files: Optional[typing.Sequence[str]]) -> typing.Iterator[ _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 @@ -121,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] @@ -186,6 +236,7 @@ class ManifestData(TypedDict): platform: NotRequired[str] packages: NotRequired[dict[str, ManifestDataRPackage]] environment: NotRequired[ManifestDataEnvironment] + integration_requests: NotRequired[list[ManifestDataIntegrationRequest]] class Manifest: @@ -278,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)) diff --git a/rsconnect/publisher/store.py b/rsconnect/publisher/store.py index 724c06b29..6dd268728 100644 --- a/rsconnect/publisher/store.py +++ b/rsconnect/publisher/store.py @@ -126,34 +126,46 @@ def _find_config_name_for_entrypoint(project_dir: str, entrypoint: str) -> typin return None -def resolve_bundle_files( +def _select_applicable_config( directory: str, entrypoint: typing.Optional[str] = None, config_name: typing.Optional[str] = None, -) -> typing.List[str]: - """Resolve the concrete project-relative files to bundle for ``directory``. +) -> typing.Optional[config_mod.PublisherConfig]: + """The ``.posit/publish`` config that applies to a deploy from ``directory``. - If a ``.posit/publish`` config applies -- the one named ``config_name``, else - the sole config, else the config whose entrypoint matches ``entrypoint`` -- and - it declares ``files``, return those selected as an allowlist. Otherwise return - the ``.gitignore``-aware default (everything not ignored). Never raises for an - ambiguous or missing config; it falls back to the default selection so a plain - ``deploy`` still works. + Preference order: the one named ``config_name``, else the sole config, else the + config whose entrypoint matches ``entrypoint``. Returns ``None`` when there is + no config or the choice is ambiguous -- callers then fall back to defaults + rather than failing a plain ``deploy``. """ - cfg: typing.Optional[config_mod.PublisherConfig] = None try: configs = _load_configs(directory) except Exception: - configs = {} + return None if config_name and config_name in configs: - cfg = configs[config_name] - elif len(configs) == 1: - cfg = next(iter(configs.values())) - elif entrypoint: + return configs[config_name] + if len(configs) == 1: + return next(iter(configs.values())) + if entrypoint: for candidate in configs.values(): if candidate.entrypoint == entrypoint: - cfg = candidate - break + return candidate + return None + + +def resolve_bundle_files( + directory: str, + entrypoint: typing.Optional[str] = None, + config_name: typing.Optional[str] = None, +) -> typing.List[str]: + """Resolve the concrete project-relative files to bundle for ``directory``. + + If a ``.posit/publish`` config applies and declares ``files``, return those + selected as an allowlist. Otherwise return the ``.gitignore``-aware default + (everything not ignored). Never raises for an ambiguous or missing config; it + falls back to the default selection so a plain ``deploy`` still works. + """ + cfg = _select_applicable_config(directory, entrypoint, config_name) if cfg is not None and cfg.files: selected = files_mod.select_config_files(directory, cfg.files) @@ -167,6 +179,52 @@ def resolve_bundle_files( return files_mod.select_default_files(directory) +# Integration-request keys carried through to the manifest, in Publisher's order +# (see publisher ``bundler/manifestFromConfig.ts``). +_INTEGRATION_REQUEST_KEYS = ("guid", "name", "description", "auth_type", "type", "config") + + +def config_manifest_overlay(cfg: config_mod.PublisherConfig) -> typing.Dict[str, typing.Any]: + """Manifest fields sourced from a config that rsconnect cannot derive itself. + + Currently just ``integration_requests``: rsconnect never originates these, but + a Publisher-authored config may declare them, and Connect reads them from + ``manifest.json`` (not a separate API). They round-trip through + :attr:`PublisherConfig.extra`; here they are normalized to Publisher's manifest + shape so the emitted manifest matches what Publisher would write. + """ + overlay: typing.Dict[str, typing.Any] = {} + raw_requests = cfg.extra.get("integration_requests") if cfg.extra else None + if isinstance(raw_requests, list): + mapped: typing.List[typing.Dict[str, typing.Any]] = [] + for req in raw_requests: + if not isinstance(req, dict): + continue + item = {key: req[key] for key in _INTEGRATION_REQUEST_KEYS if req.get(key) is not None} + if item: + mapped.append(item) + if mapped: + overlay["integration_requests"] = mapped + return overlay + + +def resolve_manifest_overlay( + directory: str, + entrypoint: typing.Optional[str] = None, + config_name: typing.Optional[str] = None, +) -> typing.Dict[str, typing.Any]: + """Manifest fields to overlay from the applicable ``.posit/publish`` config. + + Empty when no config applies. Mirrors Publisher: config-authored settings that + Connect consumes from the manifest (e.g. ``integration_requests``) are + propagated even though rsconnect never originates them. + """ + cfg = _select_applicable_config(directory, entrypoint, config_name) + if cfg is None: + return {} + return config_manifest_overlay(cfg) + + def _root_anchor(path: str) -> str: """Root-anchor a project-relative path (``app.py`` -> ``/app.py``). diff --git a/tests/test_publisher_files.py b/tests/test_publisher_files.py index da146d4cd..281b8c399 100644 --- a/tests/test_publisher_files.py +++ b/tests/test_publisher_files.py @@ -1,5 +1,6 @@ """Tests for :mod:`rsconnect.publisher.files` selection.""" +import io import os from rsconnect.publisher.files import ( @@ -254,3 +255,103 @@ def fake_builder(*_args, **_kwargs): ce.make_bundle(fake_builder) assert captured["files"] == ["app.py"] + + +# --- integration_requests propagation into the manifest ---------------------- + +INTEGRATION_CONFIG_TOML = ( + '"$schema" = "x"\n' + 'type = "python-shiny"\n' + 'entrypoint = "app.py"\n' + 'files = [\n "/app.py",\n]\n\n' + "[[integration_requests]]\n" + 'name = "My Snowflake"\n' + 'type = "snowflake"\n' + 'auth_type = "Viewer"\n' + 'guid = "abc-123"\n' +) + + +def _write_integration_config(tmp_path): + publish = tmp_path / ".posit" / "publish" + publish.mkdir(parents=True) + (publish / "app.toml").write_text(INTEGRATION_CONFIG_TOML, encoding="utf-8") + + +def test_config_manifest_overlay_maps_integration_requests(): + from rsconnect.publisher import config + from rsconnect.publisher.store import config_manifest_overlay + + cfg = config.from_dict( + { + "type": "python-shiny", + "entrypoint": "app.py", + "integration_requests": [ + {"name": "My Snowflake", "type": "snowflake", "auth_type": "Viewer", "guid": "abc-123"} + ], + } + ) + overlay = config_manifest_overlay(cfg) + assert overlay == { + "integration_requests": [ + {"guid": "abc-123", "name": "My Snowflake", "auth_type": "Viewer", "type": "snowflake"} + ] + } + + +def test_resolve_manifest_overlay_reads_config(tmp_path): + from rsconnect.publisher.store import resolve_manifest_overlay + + _write_integration_config(tmp_path) + overlay = resolve_manifest_overlay(str(tmp_path), entrypoint="app.py") + assert overlay["integration_requests"][0]["name"] == "My Snowflake" + + +def test_resolve_manifest_overlay_empty_without_config(tmp_path): + from rsconnect.publisher.store import resolve_manifest_overlay + + assert resolve_manifest_overlay(str(tmp_path)) == {} + + +def test_manifest_overlay_injects_into_generated_manifest(): + from rsconnect.bundle import make_source_manifest, overlay_manifest + from rsconnect.models import AppModes + + overlay = {"integration_requests": [{"guid": "abc-123", "name": "My Snowflake", "type": "snowflake"}]} + with overlay_manifest(overlay): + manifest = make_source_manifest(AppModes.PYTHON_SHINY, entrypoint="app.py") + assert manifest["integration_requests"] == overlay["integration_requests"] + # base fields are still present and not clobbered + assert manifest["metadata"]["appmode"] == "python-shiny" + + +def test_manifest_overlay_absent_when_no_context(): + from rsconnect.bundle import make_source_manifest + from rsconnect.models import AppModes + + manifest = make_source_manifest(AppModes.PYTHON_SHINY, entrypoint="app.py") + assert "integration_requests" not in manifest + + +def test_executor_propagates_integration_requests_to_manifest(tmp_path): + from rsconnect.api import RSConnectExecutor + from rsconnect.bundle import make_source_manifest + from rsconnect.models import AppModes + + root = str(tmp_path) + _make_tree(root, ["app.py"]) + _write_integration_config(tmp_path) + + captured = {} + + def fake_builder(*_args, **_kwargs): + # Built inside make_bundle, so the overlay context is active. + captured["manifest"] = make_source_manifest(AppModes.PYTHON_SHINY, entrypoint="app.py") + return io.BytesIO(b"bundle") + + fake_builder.__name__ = "make_api_bundle" + + ce = RSConnectExecutor(path=root, app_id="1") + ce.make_bundle(fake_builder) + + assert captured["manifest"]["integration_requests"][0]["guid"] == "abc-123" From f839bceaebc4c346ce9863cc4a524d170e641c9f Mon Sep 17 00:00:00 2001 From: Matt Conflitti <matt.conflitti@posit.co> Date: Wed, 29 Jul 2026 15:25:19 -0400 Subject: [PATCH 06/14] Fix Python 3.8 collection error in test_redeploy.py 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'. --- tests/test_redeploy.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_redeploy.py b/tests/test_redeploy.py index 834fb234d..417f8e73d 100644 --- a/tests/test_redeploy.py +++ b/tests/test_redeploy.py @@ -7,6 +7,8 @@ server and content identity are recovered and reused. """ +from __future__ import annotations + import pathlib import textwrap import types From a649b23cac636b8b9d7b77c9dd430581695406e4 Mon Sep 17 00:00:00 2001 From: Matt Conflitti <matt.conflitti@posit.co> Date: Wed, 29 Jul 2026 16:13:35 -0400 Subject: [PATCH 07/14] Isolate ambient Connect env vars in redeploy tests 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. --- tests/test_redeploy.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_redeploy.py b/tests/test_redeploy.py index 417f8e73d..4f7a9566c 100644 --- a/tests/test_redeploy.py +++ b/tests/test_redeploy.py @@ -23,6 +23,28 @@ GUID = "RECORD-GUID-123" +@pytest.fixture(autouse=True) +def _isolate_connect_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Neutralize ambient Connect credentials so ``redeploy`` resolves the server + and content identity from the ``.posit`` record rather than the environment. + + The integration-test CI job exports ``CONNECT_SERVER``/``CONNECT_API_KEY`` + (pointing at its throwaway Connect); without this those would leak through the + ``--server``/``--api-key`` env-var options into the command under test and + override the record-based resolution these tests assert on. + """ + for var in ( + "CONNECT_SERVER", + "CONNECT_API_KEY", + "CONNECT_INSECURE", + "CONNECT_CA_CERTIFICATE", + "CONNECT_IDENTITY_TOKEN", + "CONNECT_IDENTITY_TOKEN_FILE", + "CONNECT_SERVER_VERSION", + ): + monkeypatch.delenv(var, raising=False) + + @pytest.fixture def runner() -> CliRunner: return CliRunner() From d96ca2206a44cd28a31c810ec268ae6e88a4f90e Mon Sep 17 00:00:00 2001 From: Matt Conflitti <matt.conflitti@posit.co> Date: Thu, 30 Jul 2026 01:56:47 -0400 Subject: [PATCH 08/14] Fix .posit config files list drifting from the bundle 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. --- docs/CHANGELOG.md | 9 ++- rsconnect/publisher/store.py | 35 ++++++---- tests/test_publisher.py | 55 +++++++++++++++ tests/test_publisher_files.py | 23 +++++++ tests/test_redeploy.py | 125 ++++++++++++++++++++++++++++++++++ 5 files changed, 231 insertions(+), 16 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c5d0d9659..5f8a41a7f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -35,9 +35,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Deploys now bundle exactly the files declared by a `.posit/publish` configuration's `files` list when such a configuration applies to the content. The `files` entries use `.gitignore` syntax (a matching pattern includes a - path, a `!` prefix excludes it), matching Posit Publisher. A configuration - rsconnect-python writes now records the concrete set of deployed files, so its - `files`, the generated `manifest.json`, and the deployment record all agree. + path, a `!` prefix excludes it), matching Posit Publisher. A configuration with + an empty or absent `files` list means "everything", as in Publisher, so the + built-in exclusions (`.git`, `__pycache__`, `node_modules`, and the like) still + apply. A configuration rsconnect-python writes now records the concrete set of + deployed files, so its `files`, the generated `manifest.json`, and the + deployment record all agree. - **Behavior change:** deploys _without_ an applicable `.posit/publish` configuration now honor `.gitignore` (in addition to the pre-existing built-in ignore list) when choosing which files to bundle. Files ignored by diff --git a/rsconnect/publisher/store.py b/rsconnect/publisher/store.py index 6dd268728..1e3c20888 100644 --- a/rsconnect/publisher/store.py +++ b/rsconnect/publisher/store.py @@ -160,15 +160,18 @@ def resolve_bundle_files( ) -> typing.List[str]: """Resolve the concrete project-relative files to bundle for ``directory``. - If a ``.posit/publish`` config applies and declares ``files``, return those - selected as an allowlist. Otherwise return the ``.gitignore``-aware default - (everything not ignored). Never raises for an ambiguous or missing config; it - falls back to the default selection so a plain ``deploy`` still works. + When a ``.posit/publish`` config applies, its ``files`` include-list decides + the selection. An empty or absent ``files`` means "everything", spelled ``*`` + -- the same default Publisher's ``collectFiles`` applies -- so + ``STANDARD_EXCLUSIONS`` still prune ``.git``, ``__pycache__``, and friends. + + With no applicable config, fall back to the ``.gitignore``-aware default. + Never raises for an ambiguous or missing config, so a plain ``deploy`` works. """ cfg = _select_applicable_config(directory, entrypoint, config_name) - if cfg is not None and cfg.files: - selected = files_mod.select_config_files(directory, cfg.files) + if cfg is not None: + selected = files_mod.select_config_files(directory, cfg.files or ["*"]) # The entrypoint must ship even if the config's patterns don't cover it # (the bundle builders reference it from this list, not separately). if cfg.entrypoint: @@ -239,19 +242,26 @@ def _config_file_patterns(details: "record_mod.BundleContentDetails") -> typing. Uses the exact file list from the built bundle's manifest so the config's ``files``, the manifest's ``files``, and the record's ``files`` all denote the - same set (root-anchored, as Publisher writes them). The entrypoint and the - declared package file are guaranteed present (the schema requires - ``package_file`` to be listed under ``files``). + same set (root-anchored, as Publisher writes them). The declared package file + is guaranteed present (the schema requires ``package_file`` to be listed under + ``files``). + + The entrypoint is only surfaced when it names one of those files. A manifest's + ``metadata.entrypoint`` may be a module reference rather than a path (Shiny + deploys record ``app`` for ``app.py``), and anchoring that would add a + never-matching ``/app`` entry to the include-list. """ + deployed = {name.replace(os.sep, "/") for name in details.files} patterns: typing.List[str] = [] for name in details.files: anchored = _root_anchor(name) if anchored not in patterns: patterns.append(anchored) - if details.entrypoint: + if details.entrypoint and details.entrypoint.replace(os.sep, "/") in deployed: entry = _root_anchor(details.entrypoint) - if entry not in patterns: - patterns.insert(0, entry) + if entry in patterns: + patterns.remove(entry) + patterns.insert(0, entry) if details.python and details.python.get("package_file"): pkg = _root_anchor(typing.cast(str, details.python["package_file"])) if pkg not in patterns: @@ -344,7 +354,6 @@ def write_deployment_metadata( requirements=details.requirements, configuration=config_dict, ) - rname = existing_record_name or _new_record_name(project_dir) record_path = record_mod.write_record(project_dir, rname, rec) return config_path, record_path diff --git a/tests/test_publisher.py b/tests/test_publisher.py index 42b9459fb..713dcf0c1 100644 --- a/tests/test_publisher.py +++ b/tests/test_publisher.py @@ -150,6 +150,61 @@ def test_write_deployment_metadata_creates_config_and_record(tmp_path): assert rec.config().type == "python-shiny" +def test_config_files_reference_the_record_that_was_written(tmp_path): + """The ``.posit`` record path recorded in the config's ``files`` must be the + record actually written, so the next deploy bundles it instead of a + never-existing name.""" + import os + + project = str(tmp_path) + config_path, record_path = deploy(project) + + cfg = config.read_config(config_path) + recorded = [f for f in cfg.files if "/deployments/" in f] + assert len(recorded) == 1 + assert os.path.basename(recorded[0]) == os.path.basename(record_path) + # only one record file exists -- no orphaned second name was minted + assert len(record.discover_records(project)) == 1 + # every .posit path in files resolves to a real file + for rel in (f for f in cfg.files if f.startswith("/.posit/")): + assert os.path.isfile(os.path.join(project, rel.lstrip("/"))), rel + + +def test_config_files_omit_a_non_path_entrypoint(tmp_path): + """A manifest ``metadata.entrypoint`` may be a module reference rather than a + file (Shiny records ``app`` for ``app.py``). Anchoring it would add a + never-matching ``/app`` include that selects nothing.""" + manifest = { + **PY_SHINY_MANIFEST, + # what rsconnect actually writes for a `deploy shiny` of app.py + "metadata": {"appmode": "python-shiny", "entrypoint": "app"}, + } + bundle = make_bundle(manifest, {"requirements.txt": "shiny==1.0\n"}) + config_path, _ = store.write_deployment_metadata( + project_dir=str(tmp_path), + server_url="https://connect.example.com/__api__", + product_type="connect", + app_mode=AppModes.PYTHON_SHINY, + title="My App", + deployed_info=DEPLOYED_INFO, + bundle=bundle, + ) + cfg = config.read_config(config_path) + assert "/app" not in cfg.files + # the real deployed files are still recorded + assert cfg.files[:3] == ["/app.py", "/helpers.py", "/requirements.txt"] + + +def test_config_files_lead_with_the_entrypoint_when_it_is_a_file(tmp_path): + """When the manifest's entrypoint does name a deployed file, it leads the + include-list (and is not duplicated).""" + project = str(tmp_path) + config_path, _ = deploy(project) + cfg = config.read_config(config_path) + assert cfg.files[0] == "/app.py" + assert cfg.files.count("/app.py") == 1 + + def test_redeploy_pins_resolved_config_and_record(tmp_path): """Passing config_name/record_name (as redeploy does) updates those exact files, even when the record lacks a configuration_name and the config's diff --git a/tests/test_publisher_files.py b/tests/test_publisher_files.py index 281b8c399..461390006 100644 --- a/tests/test_publisher_files.py +++ b/tests/test_publisher_files.py @@ -209,6 +209,29 @@ def test_resolve_bundle_files_force_includes_entrypoint(tmp_path): assert "data.csv" in selected +def test_resolve_bundle_files_empty_files_means_everything(tmp_path): + """A config with no ``files`` key means "everything" (Publisher's ``["*"]`` + default), so STANDARD_EXCLUSIONS still apply but .gitignore does not.""" + from rsconnect.publisher.store import resolve_bundle_files + + root = str(tmp_path) + _make_tree(root, ["app.py", "data.csv", "__pycache__/x.pyc"]) + (tmp_path / ".gitignore").write_text("data.csv\n", encoding="utf-8") + publish = tmp_path / ".posit" / "publish" + publish.mkdir(parents=True) + # no files key at all + (publish / "app.toml").write_text( + '"$schema" = "x"\ntype = "python-shiny"\nentrypoint = "app.py"\n', + encoding="utf-8", + ) + selected = resolve_bundle_files(root, entrypoint="app.py") + assert "app.py" in selected + # the config governs, so .gitignore is not consulted + assert "data.csv" in selected + # but the built-in exclusions still win + assert not any(f.startswith("__pycache__/") for f in selected) + + def test_resolve_bundle_files_default_when_no_config(tmp_path): from rsconnect.publisher.store import resolve_bundle_files diff --git a/tests/test_redeploy.py b/tests/test_redeploy.py index 4f7a9566c..1b49eda0a 100644 --- a/tests/test_redeploy.py +++ b/tests/test_redeploy.py @@ -378,3 +378,128 @@ def test_redeploy_dispatches_quarto(runner: CliRunner, project_dir: pathlib.Path assert captured.get("builder") == "create_quarto_deployment_bundle", result.output assert captured["app_id"] == GUID + + +def _spy_bundle_contents(monkeypatch: pytest.MonkeyPatch) -> dict[str, typing.Any]: + """Let the real bundle get built, then capture its member list at upload time. + + Unlike :func:`_spy_make_bundle` (which stubs bundling out entirely), this + exercises the whole ``.posit`` -> ``restrict_to_files`` -> builder path so the + bundle's actual contents can be asserted. + """ + import tarfile + + from rsconnect import api as api_mod + from rsconnect import main as main_mod + from rsconnect.environment import Environment + + captured: dict[str, typing.Any] = {} + + def fake_deploy_bundle(self: typing.Any, *_a: typing.Any, **_k: typing.Any): + self.bundle.seek(0) + with tarfile.open(fileobj=self.bundle, mode="r:gz") as tar: + captured["files"] = sorted(n for n in tar.getnames() if not tar.getmember(n).isdir()) + self.bundle.seek(0) + self.deployed_info = { + "app_url": SERVER_URL + "/content/abc/", + "app_id": "7", + "app_guid": GUID, + "title": "App", + "app_mode": self.app_mode.name() if self.app_mode else "python-shiny", + "dashboard_url": SERVER_URL + "/connect/#/apps/abc", + "app_store_version": 1, + "bundle_id": "99", + } + return self + + environment = Environment.from_dict( + { + "python": "3.11.0", + "pip": "24.0", + "locale": "en_US.UTF-8", + "package_manager": "pip", + "source": "requirements.txt", + "filename": "requirements.txt", + "contents": "shiny\n", + "error": None, + } + ) + monkeypatch.setattr( + main_mod.Environment, + "create_python_environment", + classmethod(lambda cls, *a, **k: environment), + ) + monkeypatch.setattr(api_mod.RSConnectClient, "server_settings", lambda self: {}) + monkeypatch.setattr(api_mod.RSConnectExecutor, "validate_server", lambda self: self) + + def fake_validate_app_mode(self: typing.Any, app_mode: typing.Any): + self.app_mode = app_mode + return self + + monkeypatch.setattr(api_mod.RSConnectExecutor, "validate_app_mode", fake_validate_app_mode) + monkeypatch.setattr(api_mod.RSConnectExecutor, "deploy_bundle", fake_deploy_bundle) + for method in ("emit_task_log", "verify_deployment", "emit_content_url"): + monkeypatch.setattr(api_mod.RSConnectExecutor, method, lambda self, *a, **k: self) + monkeypatch.setattr(api_mod.RSConnectExecutor, "should_deploy_as_draft", lambda self, *a, **k: False) + return captured + + +def test_redeploy_bundles_only_the_configs_files( + runner: CliRunner, project_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch +): + """redeploy must honor the config's ``files`` allowlist, not bundle the tree.""" + captured = _spy_bundle_contents(monkeypatch) + _write_posit_project(project_dir) + (project_dir / "requirements.txt").write_text("shiny\n") + # noise the config does not list + (project_dir / "scratch.csv").write_text("noise") + (project_dir / "data").mkdir() + (project_dir / "data" / "blob.bin").write_text("blob") + + result = runner.invoke(cli, ["redeploy", str(project_dir), "-k", "fake-key"]) + + assert result.exit_code == 0, result.output + assert captured["files"] == ["app.py", "manifest.json", "requirements.txt"] + + +def test_redeploy_bundle_matches_deploy_bundle( + runner: CliRunner, project_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch +): + """``redeploy`` and an equivalent ``deploy`` select the same files: both resolve + the same ``.posit`` config, so neither is broader than the other.""" + captured = _spy_bundle_contents(monkeypatch) + _write_posit_project(project_dir) + (project_dir / "requirements.txt").write_text("shiny\n") + (project_dir / "scratch.csv").write_text("noise") + + assert runner.invoke(cli, ["redeploy", str(project_dir), "-k", "fake-key"]).exit_code == 0 + redeployed = captured["files"] + + captured.clear() + result = runner.invoke( + cli, + ["deploy", "shiny", str(project_dir), "-k", "fake-key", "-s", SERVER_URL, "--app-id", "1"], + ) + assert result.exit_code == 0, result.output + assert captured["files"] == redeployed + + +def test_repeated_redeploy_keeps_the_same_file_set( + runner: CliRunner, project_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch +): + """Saving the ``.posit`` metadata after a deploy must not widen (or narrow) the + next redeploy's selection -- the config's curated ``files`` is preserved.""" + captured = _spy_bundle_contents(monkeypatch) + _write_posit_project(project_dir) + (project_dir / "requirements.txt").write_text("shiny\n") + (project_dir / "scratch.csv").write_text("noise") + + seen: list[list[str]] = [] + for _ in range(3): + captured.clear() + result = runner.invoke(cli, ["redeploy", str(project_dir), "-k", "fake-key"]) + assert result.exit_code == 0, result.output + seen.append(captured["files"]) + + assert seen[0] == seen[1] == seen[2] + assert "scratch.csv" not in seen[0] From e632c3aeab69e124214339f838a9dc61b2775d8c Mon Sep 17 00:00:00 2001 From: Matt Conflitti <matt.conflitti@posit.co> Date: Thu, 30 Jul 2026 09:18:06 -0400 Subject: [PATCH 09/14] Revert .gitignore-aware default file selection 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. --- docs/CHANGELOG.md | 7 +-- rsconnect/api.py | 6 +-- rsconnect/bundle.py | 6 +-- rsconnect/publisher/files.py | 89 +++++++---------------------------- rsconnect/publisher/store.py | 27 ++++++----- tests/test_publisher_files.py | 67 +++++++------------------- 6 files changed, 57 insertions(+), 145 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 5f8a41a7f..f6fa352fd 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -40,11 +40,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 built-in exclusions (`.git`, `__pycache__`, `node_modules`, and the like) still apply. A configuration rsconnect-python writes now records the concrete set of deployed files, so its `files`, the generated `manifest.json`, and the - deployment record all agree. -- **Behavior change:** deploys _without_ an applicable `.posit/publish` - configuration now honor `.gitignore` (in addition to the pre-existing built-in - ignore list) when choosing which files to bundle. Files ignored by - `.gitignore` are no longer included in the bundle. + deployment record all agree. Deploys _without_ an applicable configuration + bundle files exactly as before. - `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 diff --git a/rsconnect/api.py b/rsconnect/api.py index acdd49bde..fc372753d 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -1644,9 +1644,9 @@ def _resolve_publisher_bundle_context( Returns ``(include_files, manifest_overlay)``: - - ``include_files`` -- the project-relative files to bundle (a config's - ``files`` allowlist when one applies, else a ``.gitignore``-aware - default), or ``None`` to leave the builder's whole-tree walk in place. + - ``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. diff --git a/rsconnect/bundle.py b/rsconnect/bundle.py index 64e7a0431..9e5e1ea73 100644 --- a/rsconnect/bundle.py +++ b/rsconnect/bundle.py @@ -87,9 +87,9 @@ # 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 a ``.posit/publish`` -# config (allowlist) or a ``.gitignore``-aware default; see -# ``rsconnect.publisher.files`` and ``rsconnect.publisher.store.resolve_bundle_files``. +# 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 ) diff --git a/rsconnect/publisher/files.py b/rsconnect/publisher/files.py index d9350dfa7..9a15203bf 100644 --- a/rsconnect/publisher/files.py +++ b/rsconnect/publisher/files.py @@ -1,19 +1,20 @@ -"""File selection for bundling, honoring ``.posit/publish`` config ``files``. - -Two selectors, one matching engine (:mod:`pathspec`, ``gitwildmatch``): - -- :func:`select_config_files` -- an **allowlist**. A config's ``files`` are - gitignore-syntax patterns with include/exclude *inverted*: a matching pattern - *includes* a path, a ``!``-prefixed pattern *excludes* it, and - :data:`STANDARD_EXCLUSIONS` are appended so they always win (last-match-wins). - This mirrors Posit Publisher's bundler (``extensions/vscode/src/bundler/ - collect.ts``). -- :func:`select_default_files` -- a **denylist** used when no config applies: - everything except paths ignored by ``.gitignore`` (project root plus nested) - and the hardcoded :data:`directories_ignore_list`. - -Both return sorted, project-relative, forward-slash paths, so downstream -bundling code treats the two cases identically. +""":func:`select_config_files` -- bundling driven by ``.posit/publish`` ``files``. + +A config's ``files`` are gitignore-syntax patterns with include/exclude +*inverted*: a matching pattern *includes* a path, a ``!``-prefixed pattern +*excludes* it, and :data:`STANDARD_EXCLUSIONS` are appended so they always win +(last-match-wins). Matching uses :mod:`pathspec` (``gitwildmatch``). This mirrors +Posit Publisher's bundler (``extensions/vscode/src/bundler/collect.ts``). + +Returns sorted, project-relative, forward-slash paths. + +There is deliberately no ``.gitignore``-based selector here. When no config +applies, bundling keeps its long-standing whole-tree walk (see +``bundle.create_file_list``), which applies only the built-in +``directories_ignore_list``. Reusing ``.gitignore`` would be wrong for rendered +content: a Quarto project's HTML output is routinely gitignored precisely because +it should not be committed, yet it is exactly what needs to be deployed. Narrowing +the default set is a separate, larger decision than ``.posit`` interop. """ from __future__ import annotations @@ -24,8 +25,6 @@ import pathspec -from ..bundle import directories_ignore_list - def _compile(lines: typing.Sequence[str]) -> pathspec.PathSpec: """Compile gitignore-style ``lines`` into a spec. @@ -148,57 +147,3 @@ def prune_dir(rel: str) -> bool: return spec.check_file(rel + "/").include is False return _walk(directory, keep_file, prune_dir) - - -def _gitignore_spec(directory: str) -> pathspec.PathSpec: - """A gitignore-style denylist: nested ``.gitignore`` files + hardcoded dirs. - - Patterns from a nested ``.gitignore`` are anchored to that file's directory - (matching git), so ``build/`` in ``sub/.gitignore`` becomes ``/sub/build/``. - """ - lines: typing.List[str] = list(directories_ignore_list) - for cur_dir, _, file_names in os.walk(directory): - if ".gitignore" not in file_names: - continue - rel_dir = _rel_posix(directory, cur_dir) - prefix = "" if rel_dir == "." else rel_dir + "/" - try: - with open(os.path.join(cur_dir, ".gitignore"), "r", encoding="utf-8") as handle: - raw_lines = handle.read().splitlines() - except OSError: - continue - for raw in raw_lines: - stripped = raw.strip() - if not stripped or stripped.startswith("#"): - lines.append(raw) - continue - negated = stripped.startswith("!") - body = stripped[1:] if negated else stripped - # A rooted (contains a non-trailing slash) pattern is relative to the - # .gitignore's directory; anchor it. An unrooted pattern matches at any - # depth below that directory, so prefix it with "**/". - if body.startswith("/"): - anchored = prefix + body.lstrip("/") - elif "/" in body.rstrip("/"): - anchored = prefix + body - else: - anchored = prefix + "**/" + body if prefix else body - lines.append(("!" if negated else "") + anchored) - return _compile(lines) - - -def select_default_files(directory: str) -> typing.List[str]: - """Return every file under ``directory`` that is not gitignored. - - Used when no ``.posit/publish`` config applies. Honors ``.gitignore`` (root - and nested) plus the hardcoded :data:`directories_ignore_list`. - """ - spec = _gitignore_spec(directory) - - def keep_file(rel: str) -> bool: - return not spec.match_file(rel) - - def prune_dir(rel: str) -> bool: - return spec.match_file(rel + "/") - - return _walk(directory, keep_file, prune_dir) diff --git a/rsconnect/publisher/store.py b/rsconnect/publisher/store.py index 1e3c20888..83cf18326 100644 --- a/rsconnect/publisher/store.py +++ b/rsconnect/publisher/store.py @@ -157,7 +157,7 @@ def resolve_bundle_files( directory: str, entrypoint: typing.Optional[str] = None, config_name: typing.Optional[str] = None, -) -> typing.List[str]: +) -> typing.Optional[typing.List[str]]: """Resolve the concrete project-relative files to bundle for ``directory``. When a ``.posit/publish`` config applies, its ``files`` include-list decides @@ -165,21 +165,22 @@ def resolve_bundle_files( -- the same default Publisher's ``collectFiles`` applies -- so ``STANDARD_EXCLUSIONS`` still prune ``.git``, ``__pycache__``, and friends. - With no applicable config, fall back to the ``.gitignore``-aware default. - Never raises for an ambiguous or missing config, so a plain ``deploy`` works. + Returns ``None`` when no config applies, leaving the caller's existing + whole-tree walk in place unchanged. Never raises for an ambiguous or missing + config, so a plain ``deploy`` behaves exactly as it always has. """ cfg = _select_applicable_config(directory, entrypoint, config_name) + if cfg is None: + return None - if cfg is not None: - selected = files_mod.select_config_files(directory, cfg.files or ["*"]) - # The entrypoint must ship even if the config's patterns don't cover it - # (the bundle builders reference it from this list, not separately). - if cfg.entrypoint: - entry = cfg.entrypoint.replace(os.sep, "/") - if entry not in selected and os.path.isfile(os.path.join(directory, cfg.entrypoint)): - selected = sorted([*selected, entry]) - return selected - return files_mod.select_default_files(directory) + selected = files_mod.select_config_files(directory, cfg.files or ["*"]) + # The entrypoint must ship even if the config's patterns don't cover it + # (the bundle builders reference it from this list, not separately). + if cfg.entrypoint: + entry = cfg.entrypoint.replace(os.sep, "/") + if entry not in selected and os.path.isfile(os.path.join(directory, cfg.entrypoint)): + selected = sorted([*selected, entry]) + return selected # Integration-request keys carried through to the manifest, in Publisher's order diff --git a/tests/test_publisher_files.py b/tests/test_publisher_files.py index 461390006..ea565c453 100644 --- a/tests/test_publisher_files.py +++ b/tests/test_publisher_files.py @@ -6,7 +6,6 @@ from rsconnect.publisher.files import ( STANDARD_EXCLUSIONS, select_config_files, - select_default_files, ) @@ -83,49 +82,6 @@ def test_config_files_skips_python_venv(tmp_path): assert not any(f.startswith("venv/") for f in selected) -def test_default_files_honors_gitignore(tmp_path): - root = str(tmp_path) - _make_tree(root, ["app.py", "keep.txt", "build/out.o", "secret.log"]) - _touch(root, ".gitignore") - with open(os.path.join(root, ".gitignore"), "w", encoding="utf-8") as handle: - handle.write("build/\n*.log\n") - selected = select_default_files(root) - assert "app.py" in selected - assert "keep.txt" in selected - assert ".gitignore" in selected - assert not any(f.startswith("build/") for f in selected) - assert "secret.log" not in selected - - -def test_default_files_nested_gitignore(tmp_path): - root = str(tmp_path) - _make_tree(root, ["app.py", "sub/keep.py", "sub/skip.tmp", "skip.tmp"]) - with open(os.path.join(root, "sub", ".gitignore"), "w", encoding="utf-8") as handle: - handle.write("*.tmp\n") - selected = select_default_files(root) - # Nested .gitignore only affects its own subtree. - assert "sub/keep.py" in selected - assert "sub/skip.tmp" not in selected - assert "skip.tmp" in selected - - -def test_default_files_gitignore_negation(tmp_path): - root = str(tmp_path) - _make_tree(root, ["a.log", "keep.log"]) - with open(os.path.join(root, ".gitignore"), "w", encoding="utf-8") as handle: - handle.write("*.log\n!keep.log\n") - selected = select_default_files(root) - assert "keep.log" in selected - assert "a.log" not in selected - - -def test_default_files_skips_hardcoded_dirs(tmp_path): - root = str(tmp_path) - _make_tree(root, ["app.py", ".git/config", "__pycache__/x.pyc", "node_modules/m/i.js"]) - selected = select_default_files(root) - assert selected == ["app.py"] - - def test_standard_exclusions_are_all_negations(): assert all(pat.startswith("!") for pat in STANDARD_EXCLUSIONS) @@ -232,16 +188,29 @@ def test_resolve_bundle_files_empty_files_means_everything(tmp_path): assert not any(f.startswith("__pycache__/") for f in selected) -def test_resolve_bundle_files_default_when_no_config(tmp_path): +def test_resolve_bundle_files_none_when_no_config(tmp_path): + """Without a config there is no restriction at all: the caller keeps its + long-standing whole-tree walk, so .gitignore is never consulted.""" from rsconnect.publisher.store import resolve_bundle_files root = str(tmp_path) _make_tree(root, ["app.py", "secret.log"]) (tmp_path / ".gitignore").write_text("*.log\n", encoding="utf-8") - selected = resolve_bundle_files(root) - assert "app.py" in selected - assert ".gitignore" in selected - assert "secret.log" not in selected + assert resolve_bundle_files(root) is None + + +def test_no_config_bundles_gitignored_files(tmp_path): + """A .gitignore'd build artifact (a Quarto project's rendered HTML, say) is + still bundled when no config applies -- that output is exactly what deploys.""" + from rsconnect.bundle import create_file_list, restrict_to_files + from rsconnect.publisher.store import resolve_bundle_files + + root = str(tmp_path) + _make_tree(root, ["report.qmd", "_site/report.html"]) + (tmp_path / ".gitignore").write_text("_site/\n", encoding="utf-8") + with restrict_to_files(resolve_bundle_files(root)): + files = create_file_list(root, [], []) + assert "_site/report.html" in files # --- end-to-end: the executor resolves + restricts around the builder -------- From f2f35a25d3d740f6b564b295d1581e064568212c Mon Sep 17 00:00:00 2001 From: Matt Conflitti <matt.conflitti@posit.co> Date: Thu, 30 Jul 2026 09:50:00 -0400 Subject: [PATCH 10/14] Don't let a written config narrow the next deploy 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. --- docs/CHANGELOG.md | 17 ++++---- rsconnect/publisher/store.py | 81 +++++++++++++++++------------------ tests/test_publisher.py | 46 +++++++++++--------- tests/test_publisher_files.py | 37 +++++++++++----- tests/test_redeploy.py | 42 ++++++++++++++++++ 5 files changed, 141 insertions(+), 82 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f6fa352fd..a6d8dc683 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -33,15 +33,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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 such a configuration applies to the content. + 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. A configuration with - an empty or absent `files` list means "everything", as in Publisher, so the - built-in exclusions (`.git`, `__pycache__`, `node_modules`, and the like) still - apply. A configuration rsconnect-python writes now records the concrete set of - deployed files, so its `files`, the generated `manifest.json`, and the - deployment record all agree. Deploys _without_ an applicable configuration - bundle files exactly as before. + path, a `!` prefix excludes it), matching Posit Publisher. A configuration whose + `files` is absent, empty, or `["*"]` declares no restriction, so bundling falls + through to the existing behavior. File selection is therefore unchanged for any + content that does not have 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 diff --git a/rsconnect/publisher/store.py b/rsconnect/publisher/store.py index 83cf18326..2eae0f30e 100644 --- a/rsconnect/publisher/store.py +++ b/rsconnect/publisher/store.py @@ -153,6 +153,17 @@ def _select_applicable_config( return None +def _is_unrestricted(config_files: typing.Sequence[str]) -> bool: + """Whether ``config_files`` expresses "everything" rather than a curated subset. + + ``.posit`` paths are discounted: Publisher lists the driving config and record + so they ship in the bundle, which says nothing about curating content files. + What remains is unrestricted when it is empty or just ``*``. + """ + meaningful = [pat for pat in config_files if not pat.lstrip("/").startswith(".posit/")] + return not meaningful or meaningful == ["*"] + + def resolve_bundle_files( directory: str, entrypoint: typing.Optional[str] = None, @@ -160,20 +171,22 @@ def resolve_bundle_files( ) -> typing.Optional[typing.List[str]]: """Resolve the concrete project-relative files to bundle for ``directory``. - When a ``.posit/publish`` config applies, its ``files`` include-list decides - the selection. An empty or absent ``files`` means "everything", spelled ``*`` - -- the same default Publisher's ``collectFiles`` applies -- so - ``STANDARD_EXCLUSIONS`` still prune ``.git``, ``__pycache__``, and friends. + When a ``.posit/publish`` config curates a subset of files, its ``files`` + include-list decides the selection. - Returns ``None`` when no config applies, leaving the caller's existing - whole-tree walk in place unchanged. Never raises for an ambiguous or missing - config, so a plain ``deploy`` behaves exactly as it always has. + Returns ``None`` -- meaning "do not restrict", leaving the caller's existing + whole-tree walk in place unchanged -- when no config applies, and also when a + config declares no real restriction (see :func:`_is_unrestricted`). That second + case matters because rsconnect's own deploys write ``files = ["*"]``: a project + that never had ``.posit`` must keep bundling exactly as it always has, deploy + after deploy, rather than start obeying a list this tool invented. Never raises + for an ambiguous or missing config, so a plain ``deploy`` is unaffected. """ cfg = _select_applicable_config(directory, entrypoint, config_name) - if cfg is None: + if cfg is None or _is_unrestricted(cfg.files): return None - selected = files_mod.select_config_files(directory, cfg.files or ["*"]) + selected = files_mod.select_config_files(directory, cfg.files) # The entrypoint must ship even if the config's patterns don't cover it # (the bundle builders reference it from this list, not separately). if cfg.entrypoint: @@ -238,36 +251,20 @@ def _root_anchor(path: str) -> str: return "/" + path.replace(os.sep, "/").lstrip("/") -def _config_file_patterns(details: "record_mod.BundleContentDetails") -> typing.List[str]: - """The config ``files`` include-list: the concrete deployed file set. - - Uses the exact file list from the built bundle's manifest so the config's - ``files``, the manifest's ``files``, and the record's ``files`` all denote the - same set (root-anchored, as Publisher writes them). The declared package file - is guaranteed present (the schema requires ``package_file`` to be listed under - ``files``). +def _default_config_file_patterns() -> typing.List[str]: + """The ``files`` include-list for a config rsconnect is minting: everything. - The entrypoint is only surfaced when it names one of those files. A manifest's - ``metadata.entrypoint`` may be a module reference rather than a path (Shiny - deploys record ``app`` for ``app.py``), and anchoring that would add a - never-matching ``/app`` entry to the include-list. + Deliberately ``["*"]`` rather than the concrete set just deployed. A snapshot + would read as user curation on the *next* deploy and silently pin the content + to whatever files happened to exist the first time -- a newly added module, or + freshly rendered output, would stop being bundled with no diagnostic. + rsconnect cannot know which files a user *meant* to exclude, so it claims no + restriction and leaves the long-standing whole-tree walk in charge (see + :func:`resolve_bundle_files`). ``*`` is also what Publisher's ``collectFiles`` + defaults an empty pattern list to, so the file stays Publisher-compatible and + is a sensible starting point for hand-curation. """ - deployed = {name.replace(os.sep, "/") for name in details.files} - patterns: typing.List[str] = [] - for name in details.files: - anchored = _root_anchor(name) - if anchored not in patterns: - patterns.append(anchored) - if details.entrypoint and details.entrypoint.replace(os.sep, "/") in deployed: - entry = _root_anchor(details.entrypoint) - if entry in patterns: - patterns.remove(entry) - patterns.insert(0, entry) - if details.python and details.python.get("package_file"): - pkg = _root_anchor(typing.cast(str, details.python["package_file"])) - if pkg not in patterns: - patterns.append(pkg) - return patterns + return ["*"] def _posit_bundle_paths(project_dir: str, config_name: str, record_name: typing.Optional[str]) -> typing.List[str]: @@ -333,10 +330,10 @@ def write_deployment_metadata( or _new_config_name(project_dir, title or details.entrypoint or "content") ) rname = existing_record_name or _new_record_name(project_dir) - # For a new config, seed ``files`` with the concrete deployed set plus the - # ``.posit`` files (mirroring Publisher). ``write_config`` preserves an existing - # config's curated ``files``, so this only takes effect when minting one. - cfg.files = _config_file_patterns(details) + _posit_bundle_paths(project_dir, cname, rname) + # For a new config, seed ``files`` with "everything" plus the ``.posit`` files + # (mirroring Publisher). ``write_config`` preserves an existing config's curated + # ``files``, so this only takes effect when minting one. + cfg.files = _default_config_file_patterns() + _posit_bundle_paths(project_dir, cname, rname) config_path, config_dict = config_mod.write_config(project_dir, cname, cfg) dashboard_url = deployed_info.get("dashboard_url") @@ -390,7 +387,7 @@ def write_config_from_manifest( ) # No deployment record here (write-manifest does not deploy); include the # config itself but no record path. - cfg.files = _config_file_patterns(details) + _posit_bundle_paths(project_dir, cname, None) + cfg.files = _default_config_file_patterns() + _posit_bundle_paths(project_dir, cname, None) path, _ = config_mod.write_config(project_dir, cname, cfg) return path diff --git a/tests/test_publisher.py b/tests/test_publisher.py index 713dcf0c1..f252d2ac7 100644 --- a/tests/test_publisher.py +++ b/tests/test_publisher.py @@ -120,9 +120,10 @@ def test_write_deployment_metadata_creates_config_and_record(tmp_path): assert cfg.entrypoint == "app.py" assert cfg.title == "My App" assert cfg.validate is True - # config files == the concrete deployed set (root-anchored), aligned with the - # manifest/record, plus the driving .posit config + record (mirrors Publisher). - assert cfg.files[:3] == ["/app.py", "/helpers.py", "/requirements.txt"] + # files is "everything" -- not a snapshot of the deployed set, which would + # silently pin the content on the next deploy -- plus the driving .posit + # config + record (mirrors Publisher). + assert cfg.files[0] == "*" posit_files = [f for f in cfg.files if f.startswith("/.posit/publish/")] assert len(posit_files) == 2 assert any("/deployments/" in f for f in posit_files) @@ -170,10 +171,24 @@ def test_config_files_reference_the_record_that_was_written(tmp_path): assert os.path.isfile(os.path.join(project, rel.lstrip("/"))), rel +def test_config_files_do_not_snapshot_the_deployed_set(tmp_path): + """``files`` must not enumerate the files that happened to deploy. + + A snapshot reads as user curation on the next deploy, which would pin the + content to that set and silently drop anything added later. The deployed set + is still recorded on the *record* (see + ``test_write_deployment_metadata_creates_config_and_record``).""" + project = str(tmp_path) + config_path, _ = deploy(project) + cfg = config.read_config(config_path) + content_patterns = [f for f in cfg.files if not f.startswith("/.posit/")] + assert content_patterns == ["*"] + + def test_config_files_omit_a_non_path_entrypoint(tmp_path): """A manifest ``metadata.entrypoint`` may be a module reference rather than a - file (Shiny records ``app`` for ``app.py``). Anchoring it would add a - never-matching ``/app`` include that selects nothing.""" + file (Shiny records ``app`` for ``app.py``); it must never leak into ``files`` + as a never-matching ``/app`` include.""" manifest = { **PY_SHINY_MANIFEST, # what rsconnect actually writes for a `deploy shiny` of app.py @@ -191,18 +206,8 @@ def test_config_files_omit_a_non_path_entrypoint(tmp_path): ) cfg = config.read_config(config_path) assert "/app" not in cfg.files - # the real deployed files are still recorded - assert cfg.files[:3] == ["/app.py", "/helpers.py", "/requirements.txt"] - - -def test_config_files_lead_with_the_entrypoint_when_it_is_a_file(tmp_path): - """When the manifest's entrypoint does name a deployed file, it leads the - include-list (and is not duplicated).""" - project = str(tmp_path) - config_path, _ = deploy(project) - cfg = config.read_config(config_path) - assert cfg.files[0] == "/app.py" - assert cfg.files.count("/app.py") == 1 + # the entrypoint is still recorded as the config's entrypoint, verbatim + assert cfg.entrypoint == "app" def test_redeploy_pins_resolved_config_and_record(tmp_path): @@ -390,10 +395,9 @@ def test_write_config_from_manifest(tmp_path): cfg = config.read_config(path) assert cfg.type == "python-shiny" assert cfg.entrypoint == "app.py" - # concrete deployed set (root-anchored), aligned with the manifest, plus the - # config file itself (mirrors Publisher). No record path: write-manifest does - # not deploy. - assert cfg.files[:3] == ["/app.py", "/helpers.py", "/requirements.txt"] + # "everything", plus the config file itself (mirrors Publisher). No record + # path: write-manifest does not deploy. + assert cfg.files[0] == "*" assert any(f.startswith("/.posit/publish/") and f.endswith(".toml") for f in cfg.files) assert not any("/deployments/" in f for f in cfg.files) # write-manifest prepares content but does not deploy: no record is written. diff --git a/tests/test_publisher_files.py b/tests/test_publisher_files.py index ea565c453..9c73a601e 100644 --- a/tests/test_publisher_files.py +++ b/tests/test_publisher_files.py @@ -165,27 +165,42 @@ def test_resolve_bundle_files_force_includes_entrypoint(tmp_path): assert "data.csv" in selected -def test_resolve_bundle_files_empty_files_means_everything(tmp_path): - """A config with no ``files`` key means "everything" (Publisher's ``["*"]`` - default), so STANDARD_EXCLUSIONS still apply but .gitignore does not.""" +def test_resolve_bundle_files_none_for_unrestricted_config(tmp_path): + """A config that declares no real restriction imposes none. + + An absent ``files``, an explicit ``["*"]``, and ``["*"]`` plus the ``.posit`` + paths rsconnect writes all mean "everything", so bundling must fall through to + the caller's unchanged whole-tree walk rather than a re-derived allowlist.""" + from rsconnect.publisher.store import resolve_bundle_files + + root = str(tmp_path) + _make_tree(root, ["app.py", "data.csv"]) + publish = tmp_path / ".posit" / "publish" + publish.mkdir(parents=True) + header = '"$schema" = "x"\ntype = "python-shiny"\nentrypoint = "app.py"\n' + for files_line in ( + "", # no files key at all + 'files = ["*"]\n', + 'files = ["*", "/.posit/publish/app.toml"]\n', + ): + (publish / "app.toml").write_text(header + files_line, encoding="utf-8") + assert resolve_bundle_files(root, entrypoint="app.py") is None, files_line + + +def test_resolve_bundle_files_restricts_for_a_curated_config(tmp_path): + """A config listing real content patterns *is* honored -- that is the feature.""" from rsconnect.publisher.store import resolve_bundle_files root = str(tmp_path) _make_tree(root, ["app.py", "data.csv", "__pycache__/x.pyc"]) - (tmp_path / ".gitignore").write_text("data.csv\n", encoding="utf-8") publish = tmp_path / ".posit" / "publish" publish.mkdir(parents=True) - # no files key at all (publish / "app.toml").write_text( - '"$schema" = "x"\ntype = "python-shiny"\nentrypoint = "app.py"\n', + '"$schema" = "x"\ntype = "python-shiny"\nentrypoint = "app.py"\nfiles = ["/app.py"]\n', encoding="utf-8", ) selected = resolve_bundle_files(root, entrypoint="app.py") - assert "app.py" in selected - # the config governs, so .gitignore is not consulted - assert "data.csv" in selected - # but the built-in exclusions still win - assert not any(f.startswith("__pycache__/") for f in selected) + assert selected == ["app.py"] def test_resolve_bundle_files_none_when_no_config(tmp_path): diff --git a/tests/test_redeploy.py b/tests/test_redeploy.py index 1b49eda0a..d3a82ddf2 100644 --- a/tests/test_redeploy.py +++ b/tests/test_redeploy.py @@ -462,6 +462,48 @@ def test_redeploy_bundles_only_the_configs_files( assert captured["files"] == ["app.py", "manifest.json", "requirements.txt"] +def test_second_deploy_without_posit_bundles_the_same_files( + runner: CliRunner, project_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch +): + """A project that never had ``.posit`` must keep bundling as it always has. + + The first deploy writes a config, so the second deploy finds one. That must not + narrow the selection: if the config recorded the concrete first-deploy file set, + a module added afterwards (or output rendered afterwards) would silently stop + being bundled. The only files the second bundle gains are the ``.posit`` files + themselves. + """ + captured = _spy_bundle_contents(monkeypatch) + (project_dir / "app.py").write_text("x") + (project_dir / "helpers.py").write_text("x") + (project_dir / "requirements.txt").write_text("shiny\n") + # gitignored rendered output: not committed, but must still deploy + (project_dir / ".gitignore").write_text("build/\n") + (project_dir / "build").mkdir() + (project_dir / "build" / "out.html").write_text("rendered") + + args = ["deploy", "shiny", str(project_dir), "-k", "fake-key", "-s", SERVER_URL, "--app-id", "1"] + + assert runner.invoke(cli, args).exit_code == 0 + first = captured["files"] + assert "build/out.html" in first # .gitignore is not consulted + + # the user adds a module and re-renders, then deploys again + (project_dir / "newmodule.py").write_text("x") + (project_dir / "build" / "out2.html").write_text("rendered") + + captured.clear() + result = runner.invoke(cli, args) + assert result.exit_code == 0, result.output + second = captured["files"] + + assert "newmodule.py" in second + assert "build/out2.html" in second + gained = set(second) - set(first) + assert all(f.startswith(".posit/") for f in gained - {"newmodule.py", "build/out2.html"}) + assert not set(first) - set(second) # nothing was dropped + + def test_redeploy_bundle_matches_deploy_bundle( runner: CliRunner, project_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch ): From 6084f6704679d07908af60810b798695cb832bc2 Mon Sep 17 00:00:00 2001 From: Matt Conflitti <matt.conflitti@posit.co> Date: Thu, 30 Jul 2026 10:24:53 -0400 Subject: [PATCH 11/14] Pin the Publisher-curation round-trip with tests 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. --- docs/CHANGELOG.md | 19 ++++++++------- tests/test_publisher_files.py | 42 +++++++++++++++++++++++++++++++++ tests/test_redeploy.py | 44 +++++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 8 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a6d8dc683..165e5eb29 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -35,14 +35,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. A configuration whose - `files` is absent, empty, or `["*"]` declares no restriction, so bundling falls - through to the existing behavior. File selection is therefore unchanged for any - content that does not have 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. + 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 diff --git a/tests/test_publisher_files.py b/tests/test_publisher_files.py index 9c73a601e..722cf4c85 100644 --- a/tests/test_publisher_files.py +++ b/tests/test_publisher_files.py @@ -203,6 +203,48 @@ def test_resolve_bundle_files_restricts_for_a_curated_config(tmp_path): assert selected == ["app.py"] +def test_resolve_bundle_files_honors_curation_alongside_posit_paths(tmp_path): + """Publisher writes the curated content patterns *and* the ``.posit`` paths. + + The ``.posit`` entries must not make the list look unrestricted -- the whole + point of curating in Publisher is that the listed files are the ones that + ship.""" + from rsconnect.publisher.store import resolve_bundle_files + + root = str(tmp_path) + _make_tree(root, ["app.py", "helpers.py", "secrets.txt", "junk.csv"]) + publish = tmp_path / ".posit" / "publish" + (publish / "deployments").mkdir(parents=True) + (publish / "app.toml").write_text( + '"$schema" = "x"\ntype = "python-shiny"\nentrypoint = "app.py"\n' + 'files = ["/app.py", "/helpers.py", "/.posit/publish/app.toml"]\n', + encoding="utf-8", + ) + selected = resolve_bundle_files(root, entrypoint="app.py") + assert selected == [".posit/publish/app.toml", "app.py", "helpers.py"] + + +def test_resolve_bundle_files_honors_curation_by_exclusion(tmp_path): + """``["*", "!x"]`` is curation too: everything *except* x. + + ``*`` alone means "no restriction", but ``*`` followed by a ``!`` exclusion is a + deliberate, and natural, way to curate -- it must not be flattened away.""" + from rsconnect.publisher.store import resolve_bundle_files + + root = str(tmp_path) + _make_tree(root, ["app.py", "helpers.py", "secrets.txt"]) + publish = tmp_path / ".posit" / "publish" + publish.mkdir(parents=True) + (publish / "app.toml").write_text( + '"$schema" = "x"\ntype = "python-shiny"\nentrypoint = "app.py"\nfiles = ["*", "!/secrets.txt"]\n', + encoding="utf-8", + ) + selected = resolve_bundle_files(root, entrypoint="app.py") + assert "app.py" in selected + assert "helpers.py" in selected + assert "secrets.txt" not in selected + + def test_resolve_bundle_files_none_when_no_config(tmp_path): """Without a config there is no restriction at all: the caller keeps its long-standing whole-tree walk, so .gitignore is never consulted.""" diff --git a/tests/test_redeploy.py b/tests/test_redeploy.py index d3a82ddf2..7ccde4c64 100644 --- a/tests/test_redeploy.py +++ b/tests/test_redeploy.py @@ -504,6 +504,50 @@ def test_second_deploy_without_posit_bundles_the_same_files( assert not set(first) - set(second) # nothing was dropped +def test_curating_files_in_publisher_is_honored_and_preserved( + runner: CliRunner, project_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch +): + """The interop round-trip: rsconnect deploys, the user curates in Publisher, + rsconnect redeploys. + + Deploy #1 writes ``files = ["*"]`` (no restriction). The user edits that down to + three explicit files in Publisher. From then on rsconnect must bundle exactly + those -- and must not clobber the curation back to ``*``. + """ + import re + + from rsconnect.publisher import config as config_mod + + captured = _spy_bundle_contents(monkeypatch) + for rel in ("app.py", "helpers.py", "secrets.txt", "scratch.csv", "notes.md"): + (project_dir / rel).write_text("x") + (project_dir / "requirements.txt").write_text("shiny\n") + + args = ["deploy", "shiny", str(project_dir), "-k", "fake-key", "-s", SERVER_URL, "--app-id", "1"] + assert runner.invoke(cli, args).exit_code == 0 + # nothing curated yet, so normal bundling logic applies + assert "scratch.csv" in captured["files"] + + (cfg_path,) = config_mod.discover_configs(str(project_dir)) + assert "*" in config_mod.read_config(cfg_path).files + + # the user curates in Publisher, which rewrites the whole `files` array + text = pathlib.Path(cfg_path).read_text() + curated = 'files = [\n "/app.py",\n "/helpers.py",\n "/requirements.txt",\n]\n' + pathlib.Path(cfg_path).write_text(re.sub(r"files = \[[^\]]*\]\n", curated, text, count=1)) + + for command in ( + ["redeploy", str(project_dir), "-k", "fake-key"], + args, # a plain deploy must honor it too + ): + captured.clear() + result = runner.invoke(cli, command) + assert result.exit_code == 0, result.output + assert captured["files"] == ["app.py", "helpers.py", "manifest.json", "requirements.txt"] + # rsconnect must not overwrite the user's curation with its own default + assert config_mod.read_config(cfg_path).files == ["/app.py", "/helpers.py", "/requirements.txt"] + + def test_redeploy_bundle_matches_deploy_bundle( runner: CliRunner, project_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch ): From a6930e9d8e074178d9151c6a09183149cac66e44 Mon Sep 17 00:00:00 2001 From: Matt Conflitti <matt.conflitti@posit.co> Date: Thu, 30 Jul 2026 10:31:31 -0400 Subject: [PATCH 12/14] Fix Windows path separator in gitignore regression test 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. --- tests/test_publisher_files.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_publisher_files.py b/tests/test_publisher_files.py index 722cf4c85..0c4e92fc0 100644 --- a/tests/test_publisher_files.py +++ b/tests/test_publisher_files.py @@ -267,7 +267,7 @@ def test_no_config_bundles_gitignored_files(tmp_path): (tmp_path / ".gitignore").write_text("_site/\n", encoding="utf-8") with restrict_to_files(resolve_bundle_files(root)): files = create_file_list(root, [], []) - assert "_site/report.html" in files + assert os.path.join("_site", "report.html") in files # --- end-to-end: the executor resolves + restricts around the builder -------- From dc7fca27ca144e19a27a44a1f4c1b59aa7aa22f0 Mon Sep 17 00:00:00 2001 From: Matt Conflitti <matt.conflitti@posit.co> Date: Thu, 30 Jul 2026 11:35:56 -0400 Subject: [PATCH 13/14] Normalize server_url before writing a .posit deployment record 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/publisher/store.py | 31 +++++++++++++++++++++++-------- tests/test_publisher.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/rsconnect/publisher/store.py b/rsconnect/publisher/store.py index 2eae0f30e..3050d92dd 100644 --- a/rsconnect/publisher/store.py +++ b/rsconnect/publisher/store.py @@ -29,25 +29,40 @@ from typing import IO +_DEFAULT_PORTS = {"http": "80", "https": "443"} + + def normalize_url(url: str) -> str: """Normalize a Connect URL for content comparison. - Strips a trailing ``/__api__`` and any trailing slash, and lowercases the - scheme+host, so a record's ``server_url`` matches a saved server that may - differ cosmetically. The path (a Connect instance may live under one) is - preserved apart from the ``__api__`` suffix. + Strips a trailing ``/__api__`` and any trailing slash, lowercases the + scheme+host, drops an explicit default port, and collapses duplicate slashes + in the path, so a record's ``server_url`` matches a saved server that may + differ only cosmetically. + + This must produce the same result Publisher's Go backend would for the same + input: a record's ``server_url`` is compared there with plain string equality + against ``purell.NormalizeURLString(url, purell.FlagsSafe | + FlagRemoveTrailingSlash | FlagRemoveDotSegments | FlagRemoveDuplicateSlashes)`` + (``internal/util/urls.go``), which lowercases scheme+host, strips a default + port, and collapses duplicate slashes. Anything written here that doesn't + match that exactly makes Publisher reject a genuinely matching account with + "the account provided is for a different server" (``ErrServerURLMismatch``). """ if not url: return "" parsed = urlparse(url if "//" in url else "//" + url) - netloc = parsed.netloc.lower() + scheme = (parsed.scheme or "https").lower() + hostname = (parsed.hostname or "").lower() + netloc = hostname + if parsed.port is not None and str(parsed.port) != _DEFAULT_PORTS.get(scheme): + netloc = "{}:{}".format(hostname, parsed.port) # Strip trailing slashes first so a trailing slash after ``__api__`` # (".../__api__/") still lets the suffix be removed. - path = parsed.path.rstrip("/") + path = re.sub(r"/{2,}", "/", parsed.path).rstrip("/") if path.endswith("/__api__"): path = path[: -len("/__api__")] path = path.rstrip("/") - scheme = (parsed.scheme or "https").lower() return "{}://{}{}".format(scheme, netloc, path) @@ -338,7 +353,7 @@ def write_deployment_metadata( dashboard_url = deployed_info.get("dashboard_url") rec = record_mod.PublisherRecord( - server_url=server_url, + server_url=normalize_url(server_url), server_type=product_type, id=deployed_info.get("app_guid"), type=content_type, diff --git a/tests/test_publisher.py b/tests/test_publisher.py index f252d2ac7..c36d560eb 100644 --- a/tests/test_publisher.py +++ b/tests/test_publisher.py @@ -138,6 +138,11 @@ def test_write_deployment_metadata_creates_config_and_record(tmp_path): assert rec.id == "GUID-123" assert rec.server_type == "connect" assert rec.type == "python-shiny" + # written normalized (no /__api__ suffix, despite deploy() passing that as + # server_url): Publisher compares this byte-for-byte against its own + # normalized account URL, so an unnormalized value here would make Publisher + # reject a perfectly matching account with "Server URL Mismatch". + assert rec.server_url == "https://connect.example.com" # configuration_name links to the config file that was written assert config_path.endswith(rec.configuration_name + ".toml") assert rec.direct_url == DEPLOYED_INFO["app_url"] @@ -151,6 +156,29 @@ def test_write_deployment_metadata_creates_config_and_record(tmp_path): assert rec.config().type == "python-shiny" +def test_written_server_url_matches_publisher_normalization(tmp_path): + """rsconnect-python must write the same normalized ``server_url`` Publisher's + own Go backend would write, or a Publisher-side exact-string comparison against + its normalized account URL rejects a genuinely matching account with + "the account provided is for a different server" (``ErrServerURLMismatch`` in + Publisher's ``internal/state/state.go``, compared against ``purell``-normalized + ``Account.URL``: lowercase scheme+host, no trailing slash, no ``:443``). + """ + cases = { + "https://Dogfood.example.com/": "https://dogfood.example.com", + "https://connect.example.com:443/rsc": "https://connect.example.com/rsc", + "http://connect.example.com:80/rsc": "http://connect.example.com/rsc", + "https://connect.example.com:8443/rsc": "https://connect.example.com:8443/rsc", + "https://connect.example.com///rsc/": "https://connect.example.com/rsc", + "https://connect.example.com/__api__": "https://connect.example.com", + "https://connect.example.com/__api__/": "https://connect.example.com", + } + for i, (raw, expected) in enumerate(cases.items()): + project = str(tmp_path / f"case-{i}") + _, record_path = deploy(project, server_url=raw) + assert record.read_record(record_path).server_url == expected, raw + + def test_config_files_reference_the_record_that_was_written(tmp_path): """The ``.posit`` record path recorded in the config's ``files`` must be the record actually written, so the next deploy bundles it instead of a From 47316f81513780373c01a41eaaf080ee3d1e2de2 Mon Sep 17 00:00:00 2001 From: Matt Conflitti <matt.conflitti@posit.co> Date: Thu, 30 Jul 2026 11:53:11 -0400 Subject: [PATCH 14/14] Recover the literal entrypoint file for a written .posit config 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. --- docs/CHANGELOG.md | 16 +++++++++++++++ rsconnect/publisher/record.py | 28 +++++++++++++++++++++++++- tests/test_publisher.py | 37 +++++++++++++++++++++++++++++------ 3 files changed, 74 insertions(+), 7 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 165e5eb29..bd15d78ce 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -50,6 +50,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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 diff --git a/rsconnect/publisher/record.py b/rsconnect/publisher/record.py index 8cf29b7a5..5f981dce3 100644 --- a/rsconnect/publisher/record.py +++ b/rsconnect/publisher/record.py @@ -209,6 +209,27 @@ class BundleContentDetails: quarto: typing.Optional[typing.Dict[str, typing.Any]] = None +def _recover_entrypoint_path(entrypoint: str, files: typing.Sequence[str]) -> str: + """Resolve a bare Python module reference back to its literal file path. + + rsconnect-python's own Python deploys record ``metadata.entrypoint`` as an + importable module reference rather than a path -- e.g. ``"app"`` (or + ``"app:app"``) for ``app.py``. Posit Publisher's schema documents + ``entrypoint`` as "Name of the primary file containing the content", and its + own detectors (``pyshiny.ts``, ``pythonApp.ts``) always record the literal + filename for Flask/FastAPI/Dash/Shiny; only Shiny Express writes a + ``module:object`` reference, and that module never names a real file. So + recovering the literal path here, when the manifest confirms it exists, + matches what Publisher itself would write -- a config a human (or Publisher) + can act on, not an rsconnect-internal module name. + """ + if not entrypoint or entrypoint.endswith(".py"): + return entrypoint + module = entrypoint.split(":", 1)[0] + candidate = module + ".py" + return candidate if candidate in files else entrypoint + + def details_from_manifest(manifest: typing.Mapping[str, typing.Any]) -> BundleContentDetails: """Parse content facts out of a ``manifest.json`` dict. @@ -219,7 +240,12 @@ def details_from_manifest(manifest: typing.Mapping[str, typing.Any]) -> BundleCo details = BundleContentDetails() details.files = sorted((manifest.get("files") or {}).keys()) meta = manifest.get("metadata") or {} - details.entrypoint = meta.get("entrypoint") or meta.get("primary_rmd") or meta.get("primary_html") or "" + entrypoint = meta.get("entrypoint") + if entrypoint: + details.entrypoint = _recover_entrypoint_path(entrypoint, details.files) + else: + # primary_rmd/primary_html are always literal paths already. + details.entrypoint = meta.get("primary_rmd") or meta.get("primary_html") or "" mpy = manifest.get("python") if mpy: diff --git a/tests/test_publisher.py b/tests/test_publisher.py index c36d560eb..caf0ccff7 100644 --- a/tests/test_publisher.py +++ b/tests/test_publisher.py @@ -213,10 +213,13 @@ def test_config_files_do_not_snapshot_the_deployed_set(tmp_path): assert content_patterns == ["*"] -def test_config_files_omit_a_non_path_entrypoint(tmp_path): - """A manifest ``metadata.entrypoint`` may be a module reference rather than a - file (Shiny records ``app`` for ``app.py``); it must never leak into ``files`` - as a never-matching ``/app`` include.""" +def test_config_entrypoint_recovers_the_real_file_from_a_module_reference(tmp_path): + """A manifest ``metadata.entrypoint`` may be a bare module reference rather + than a file (rsconnect records ``app`` for ``app.py``). Publisher's schema + documents ``entrypoint`` as the name of the primary file, and Publisher's own + detectors always write the literal filename -- so the config must record + ``app.py``, not the module name, and it must never leak into ``files`` as a + never-matching ``/app`` include either.""" manifest = { **PY_SHINY_MANIFEST, # what rsconnect actually writes for a `deploy shiny` of app.py @@ -233,9 +236,31 @@ def test_config_files_omit_a_non_path_entrypoint(tmp_path): bundle=bundle, ) cfg = config.read_config(config_path) + assert cfg.entrypoint == "app.py" assert "/app" not in cfg.files - # the entrypoint is still recorded as the config's entrypoint, verbatim - assert cfg.entrypoint == "app" + + +def test_config_entrypoint_keeps_a_module_object_reference_with_no_matching_file(tmp_path): + """``module:object`` form (Flask/FastAPI/Dash customization, or Shiny Express) + is left alone when no ``<module>.py`` exists in the bundle -- recovery is only + applied when the manifest confirms the literal file is actually there.""" + manifest = { + **PY_SHINY_MANIFEST, + "metadata": {"appmode": "python-dash", "entrypoint": "app:my_app"}, + "files": {"server.py": {"checksum": "a"}, "requirements.txt": {"checksum": "b"}}, + } + bundle = make_bundle(manifest, {"requirements.txt": "dash==1.0\n"}) + config_path, _ = store.write_deployment_metadata( + project_dir=str(tmp_path), + server_url="https://connect.example.com/__api__", + product_type="connect", + app_mode=AppModes.DASH_APP, + title="My App", + deployed_info=DEPLOYED_INFO, + bundle=bundle, + ) + cfg = config.read_config(config_path) + assert cfg.entrypoint == "app:my_app" def test_redeploy_pins_resolved_config_and_record(tmp_path):