├── .coveragerc ├── .github └── workflows │ ├── board.yaml │ ├── ci.yaml │ ├── conventional-commits.yaml │ ├── coverage.yaml │ ├── release.yaml │ └── site.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── docs ├── .gitignore ├── Makefile ├── _quarto.yml ├── index.qmd ├── installation.qmd └── quickstart.qmd ├── examples ├── README.md └── __init__.py ├── integration ├── .gitignore ├── Makefile ├── compose.yaml ├── resources │ └── connect │ │ └── bundles │ │ ├── example-flask-minimal │ │ ├── bundle.tar.gz │ │ ├── hello.py │ │ ├── manifest.json │ │ └── requirements.txt │ │ └── example-quarto-minimal │ │ ├── .gitignore │ │ ├── _quarto.yml │ │ ├── about.qmd │ │ ├── bundle.tar.gz │ │ ├── index.qmd │ │ ├── manifest.json │ │ └── styles.css └── tests │ └── posit │ └── connect │ ├── __init__.py │ ├── oauth │ ├── __init__.py │ ├── test_associations.py │ └── test_integrations.py │ ├── test_bundles.py │ ├── test_client.py │ ├── test_content.py │ ├── test_content_item_permissions.py │ ├── test_content_item_repository.py │ ├── test_env.py │ ├── test_environments.py │ ├── test_groups.py │ ├── test_jobs.py │ ├── test_packages.py │ ├── test_system.py │ ├── test_tags.py │ ├── test_users.py │ └── test_vanities.py ├── pyproject.toml ├── src └── posit │ ├── __init__.py │ ├── connect │ ├── README.md │ ├── __init__.py │ ├── _utils.py │ ├── auth.py │ ├── bundles.py │ ├── client.py │ ├── config.py │ ├── content.py │ ├── context.py │ ├── cursors.py │ ├── env.py │ ├── environments.py │ ├── errors.py │ ├── external │ │ ├── __init__.py │ │ ├── aws.py │ │ ├── databricks.py │ │ └── snowflake.py │ ├── groups.py │ ├── hooks.py │ ├── jobs.py │ ├── me.py │ ├── metrics │ │ ├── __init__.py │ │ ├── hits.py │ │ ├── metrics.py │ │ ├── rename_params.py │ │ ├── shiny_usage.py │ │ ├── usage.py │ │ └── visits.py │ ├── oauth │ │ ├── __init__.py │ │ ├── associations.py │ │ ├── integrations.py │ │ ├── oauth.py │ │ └── sessions.py │ ├── packages.py │ ├── paginator.py │ ├── permissions.py │ ├── repository.py │ ├── resources.py │ ├── sessions.py │ ├── system.py │ ├── tags.py │ ├── tasks.py │ ├── urls.py │ ├── users.py │ ├── vanities.py │ └── variants.py │ └── workbench │ ├── __init__.py │ └── external │ ├── __init__.py │ └── databricks.py ├── tests └── posit │ ├── connect │ ├── __api__ │ │ ├── applications │ │ │ └── f2f37341-e21d-3d80-c698-a935ad614066 │ │ │ │ └── variants.json │ │ ├── v1 │ │ │ ├── content.json │ │ │ ├── content │ │ │ │ ├── f2f37341-e21d-3d80-c698-a935ad614066.json │ │ │ │ └── f2f37341-e21d-3d80-c698-a935ad614066 │ │ │ │ │ ├── bundles.json │ │ │ │ │ ├── bundles │ │ │ │ │ ├── 101 │ │ │ │ │ │ └── download │ │ │ │ │ │ │ └── bundle.tar.gz │ │ │ │ │ └── 101.json │ │ │ │ │ ├── jobs.json │ │ │ │ │ ├── jobs │ │ │ │ │ └── tHawGvHZTosJA2Dx.json │ │ │ │ │ ├── oauth │ │ │ │ │ └── integrations │ │ │ │ │ │ └── associations.json │ │ │ │ │ ├── packages.json │ │ │ │ │ ├── permissions.json │ │ │ │ │ ├── permissions │ │ │ │ │ ├── 59.json │ │ │ │ │ └── 94.json │ │ │ │ │ ├── repository.json │ │ │ │ │ ├── repository_patch.json │ │ │ │ │ ├── tag-add.json │ │ │ │ │ └── tags.json │ │ │ ├── content?owner_guid=20a79ce3-6e87-4522-9faf-be24228800a4.json │ │ │ ├── environments.json │ │ │ ├── environments │ │ │ │ └── 25438b83-ea6d-4839-ae8e-53c52ac5f9ce.json │ │ │ ├── groups.json │ │ │ ├── groups │ │ │ │ ├── 6f300623-1e0c-48e6-a473-ddf630c0c0c3.json │ │ │ │ ├── 6f300623-1e0c-48e6-a473-ddf630c0c0c3 │ │ │ │ │ └── members.json │ │ │ │ ├── empty-group-guid │ │ │ │ │ └── members.json │ │ │ │ └── groups.json │ │ │ ├── instrumentation │ │ │ │ ├── content │ │ │ │ │ ├── hits.json │ │ │ │ │ ├── visits?limit=500&next=23948901087.json │ │ │ │ │ └── visits?limit=500.json │ │ │ │ └── shiny │ │ │ │ │ ├── usage?limit=500&next=23948901087.json │ │ │ │ │ └── usage?limit=500.json │ │ │ ├── oauth │ │ │ │ ├── integrations.json │ │ │ │ ├── integrations │ │ │ │ │ ├── 22644575-a27b-4118-ad06-e24459b05126.json │ │ │ │ │ └── 22644575-a27b-4118-ad06-e24459b05126 │ │ │ │ │ │ └── associations.json │ │ │ │ ├── sessions.json │ │ │ │ └── sessions │ │ │ │ │ └── 32c04dc6-0318-41b7-bc74-7e321b196f14.json │ │ │ ├── packages.json │ │ │ ├── system │ │ │ │ └── caches │ │ │ │ │ └── runtime.json │ │ │ ├── tags.json │ │ │ ├── tags │ │ │ │ ├── 3 │ │ │ │ │ └── content.json │ │ │ │ ├── 33 │ │ │ │ │ └── content.json │ │ │ │ ├── 29.json │ │ │ │ ├── 3.json │ │ │ │ ├── 33-patched.json │ │ │ │ └── 33.json │ │ │ ├── tags?name=academy.json │ │ │ ├── tags?parent_id=3.json │ │ │ ├── tasks │ │ │ │ └── jXhOhdm5OOSkGhJw.json │ │ │ ├── user.json │ │ │ ├── users │ │ │ │ ├── 20a79ce3-6e87-4522-9faf-be24228800a4.json │ │ │ │ └── a1b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6.json │ │ │ ├── users?page_number=1&page_size=500.jsonc │ │ │ └── users?page_number=2&page_size=500.jsonc │ │ └── variants │ │ │ └── 6627 │ │ │ └── render.json │ ├── api.py │ ├── external │ │ ├── test_aws.py │ │ ├── test_databricks.py │ │ └── test_snowflake.py │ ├── metrics │ │ ├── test_hits.py │ │ ├── test_rename_params.py │ │ ├── test_shiny_usage.py │ │ ├── test_usage.py │ │ └── test_visits.py │ ├── oauth │ │ ├── test_associations.py │ │ ├── test_integrations.py │ │ ├── test_oauth.py │ │ └── test_sessions.py │ ├── test_auth.py │ ├── test_bundles.py │ ├── test_client.py │ ├── test_config.py │ ├── test_content.py │ ├── test_context.py │ ├── test_env.py │ ├── test_environments.py │ ├── test_errors.py │ ├── test_groups.py │ ├── test_hooks.py │ ├── test_internal_code.py │ ├── test_jobs.py │ ├── test_packages.py │ ├── test_permissions.py │ ├── test_resources.py │ ├── test_sessions.py │ ├── test_system.py │ ├── test_tags.py │ ├── test_tasks.py │ ├── test_urls.py │ ├── test_users.py │ └── test_vanities.py │ └── workbench │ └── external │ └── test_databricks.py └── vars.mk /.coveragerc: -------------------------------------------------------------------------------- 1 | # This file contains the configuration settings for the coverage report generated. 2 | 3 | [report] 4 | exclude_also = 5 | \.\.\. 6 | exclude_lines = 7 | if TYPE_CHECKING: 8 | 9 | fail_under = 80 10 | -------------------------------------------------------------------------------- /.github/workflows/board.yaml: -------------------------------------------------------------------------------- 1 | name: Project Board 2 | 3 | on: 4 | issues: 5 | types: 6 | - opened 7 | - labeled 8 | - reopened 9 | 10 | jobs: 11 | add: 12 | name: Add Issue 13 | runs-on: ubuntu-latest 14 | permissions: 15 | issues: write 16 | steps: 17 | - run: gh issue edit "$NUMBER" --add-label "$LABELS" 18 | env: 19 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 20 | GH_REPO: ${{ github.repository }} 21 | NUMBER: ${{ github.event.issue.number }} 22 | LABELS: sdk 23 | - uses: actions/add-to-project@v1.0.2 24 | continue-on-error: true 25 | with: 26 | project-url: https://github.com/orgs/rstudio/projects/207 27 | github-token: ${{ secrets.CONNECT_ADD_TO_PROJECT_PAT }} 28 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - pull_request 4 | concurrency: 5 | group: ${{ github.workflow }}-${{ github.ref }} 6 | cancel-in-progress: true 7 | jobs: 8 | lint: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: astral-sh/setup-uv@v6 13 | - run: uv python install 14 | - run: make dev 15 | - run: make lint 16 | - run: make fmt 17 | 18 | test: 19 | runs-on: ubuntu-latest 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | python-version: 24 | - "3.8" 25 | - "3.9" 26 | - "3.10" 27 | - "3.11" 28 | - "3.12" 29 | - "3.13" 30 | steps: 31 | - uses: actions/checkout@v4 32 | - uses: astral-sh/setup-uv@v6 33 | - run: uv python install ${{ matrix.python-version }} 34 | - run: make dev 35 | - run: make test 36 | 37 | setup-integration-test: 38 | runs-on: ubuntu-latest 39 | outputs: 40 | versions: ${{ steps.versions.outputs.versions }} 41 | steps: 42 | - uses: actions/checkout@v4 43 | - id: versions 44 | working-directory: ./integration 45 | # The `jq` command is "output compact, raw input, slurp, split on new lines, and remove the last element". This results in a JSON array of Connect versions (e.g., ["2025.01.0", "2024.12.0"]). 46 | run: | 47 | versions=$(make print-versions | jq -c -Rs 'split("\n") | .[:-1]') 48 | echo "versions=$versions" >> "$GITHUB_OUTPUT" 49 | 50 | integration-test: 51 | runs-on: ubuntu-latest 52 | needs: setup-integration-test 53 | strategy: 54 | fail-fast: false 55 | matrix: 56 | CONNECT_VERSION: ${{ fromJson(needs.setup-integration-test.outputs.versions) }} 57 | steps: 58 | - uses: actions/checkout@v4 59 | - uses: docker/setup-buildx-action@v3 60 | - name: Write Posit Connect license to disk 61 | run: echo "$CONNECT_LICENSE" > ./integration/license.lic 62 | env: 63 | CONNECT_LICENSE: ${{ secrets.CONNECT_LICENSE }} 64 | - uses: astral-sh/setup-uv@v6 65 | - run: uv python install 66 | - run: make -C ./integration ${{ matrix.CONNECT_VERSION }} 67 | - uses: actions/upload-artifact@v4 68 | if: always() 69 | with: 70 | name: ${{ matrix.CONNECT_VERSION }} - Integration Test Report 71 | path: integration/reports/*.xml 72 | 73 | integration-test-report: 74 | needs: integration-test 75 | runs-on: ubuntu-latest 76 | permissions: 77 | checks: write 78 | pull-requests: write 79 | if: always() 80 | steps: 81 | - uses: actions/download-artifact@v4 82 | with: 83 | path: artifacts 84 | - uses: EnricoMi/publish-unit-test-result-action@v2 85 | with: 86 | check_name: integration-test-results 87 | comment_mode: off 88 | files: "artifacts/**/*.xml" 89 | report_individual_runs: true 90 | 91 | build: 92 | runs-on: ubuntu-latest 93 | steps: 94 | - uses: actions/checkout@v4 95 | - uses: astral-sh/setup-uv@v6 96 | - run: uv python install 97 | - run: make dev 98 | - run: make build 99 | -------------------------------------------------------------------------------- /.github/workflows/conventional-commits.yaml: -------------------------------------------------------------------------------- 1 | name: Conventional Commits 2 | on: 3 | pull_request: 4 | types: 5 | - opened 6 | - edited 7 | - synchronize 8 | jobs: 9 | default: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: amannn/action-semantic-pull-request@v5 13 | id: lint 14 | env: 15 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 16 | with: 17 | types: | 18 | build 19 | chore 20 | ci 21 | docs 22 | feat 23 | fix 24 | perf 25 | style 26 | refactor 27 | test 28 | - uses: marocchino/sticky-pull-request-comment@v2 29 | if: always() && (steps.lint.outputs.error_message != null) 30 | with: 31 | header: lint-error 32 | message: | 33 | Hey there! 👋 34 | 35 | We noticed that the title of your pull request doesn't follow the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification. To ensure consistency, we kindly ask you to adjust the title accordingly. 36 | 37 | Here are the details: 38 | 39 | ``` 40 | ${{ steps.lint.outputs.error_message }} 41 | ``` 42 | - if: ${{ steps.lint.outputs.error_message == null }} 43 | uses: marocchino/sticky-pull-request-comment@v2 44 | with: 45 | header: lint-error 46 | delete: true 47 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yaml: -------------------------------------------------------------------------------- 1 | name: Coverage 2 | on: 3 | - pull_request 4 | concurrency: 5 | group: ${{ github.workflow }}-${{ github.ref }} 6 | cancel-in-progress: true 7 | jobs: 8 | cov: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: astral-sh/setup-uv@v6 13 | - run: uv python install 14 | - run: make dev 15 | - run: make test 16 | - run: make cov-xml 17 | - if: ${{ ! github.event.pull_request.head.repo.fork }} 18 | uses: orgoro/coverage@v3.2 19 | with: 20 | coverageFile: coverage.xml 21 | token: ${{ secrets.GITHUB_TOKEN }} 22 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: 5 | - "v*.*.*" 6 | jobs: 7 | default: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | id-token: write 11 | steps: 12 | - uses: actions/checkout@v4 13 | with: 14 | fetch-depth: 0 15 | - uses: astral-sh/setup-uv@v6 16 | - run: uv python install 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: 22 20 | - run: make build 21 | - run: make install 22 | - id: release 23 | uses: pypa/gh-action-pypi-publish@release/v1 24 | -------------------------------------------------------------------------------- /.github/workflows/site.yaml: -------------------------------------------------------------------------------- 1 | name: Site 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*.*.*" 7 | pull_request: 8 | 9 | permissions: 10 | id-token: write 11 | pages: write 12 | 13 | concurrency: 14 | group: ${{ github.workflow }}-${{ github.ref }} 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | site: 19 | if: github.event_name == 'push' 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | with: 24 | fetch-depth: 0 25 | - uses: astral-sh/setup-uv@v6 26 | - run: uv python install 27 | - run: make build install 28 | - uses: quarto-dev/quarto-actions/setup@v2 29 | - run: make docs 30 | - uses: actions/configure-pages@v3 31 | - uses: actions/upload-pages-artifact@v3 32 | with: 33 | path: "./docs/_site" 34 | - uses: actions/deploy-pages@v4 35 | 36 | preview: 37 | if: github.event_name == 'pull_request' 38 | runs-on: ubuntu-latest 39 | steps: 40 | - uses: actions/checkout@v4 41 | with: 42 | fetch-depth: 0 43 | - uses: astral-sh/setup-uv@v6 44 | - run: uv python install 45 | - uses: actions/setup-node@v4 46 | with: 47 | node-version: 22 48 | - uses: quarto-dev/quarto-actions/setup@v2 49 | - run: make dev 50 | - run: make docs 51 | - id: preview 52 | working-directory: docs 53 | env: 54 | NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} 55 | run: | 56 | preview_url=$(make deploy | jq '.deploy_url' | tail -n 1 | tr -d '"') 57 | echo "# 🚀 Site Preview" >> $GITHUB_STEP_SUMMARY 58 | echo "$preview_url" >> $GITHUB_STEP_SUMMARY 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.orig 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | share/python-wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | uv.lock 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | *.py,cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | cover/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | .pybuilder/ 79 | target/ 80 | 81 | # Jupyter Notebook 82 | .ipynb_checkpoints 83 | 84 | # IPython 85 | profile_default/ 86 | ipython_config.py 87 | 88 | # pyenv 89 | # For a library or package, you might want to ignore these files since the code is 90 | # intended to run in multiple environments; otherwise, check them in: 91 | .python-version 92 | 93 | # pipenv 94 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 95 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 96 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 97 | # install all needed dependencies. 98 | #Pipfile.lock 99 | 100 | # poetry 101 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 102 | # This is especially recommended for binary packages to ensure reproducibility, and is more 103 | # commonly ignored for libraries. 104 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 105 | #poetry.lock 106 | 107 | # pdm 108 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 109 | #pdm.lock 110 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 111 | # in version control. 112 | # https://pdm.fming.dev/#use-with-ide 113 | .pdm.toml 114 | 115 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 116 | __pypackages__/ 117 | 118 | # Celery stuff 119 | celerybeat-schedule 120 | celerybeat.pid 121 | 122 | # SageMath parsed files 123 | *.sage.py 124 | 125 | # Environments 126 | .env 127 | .venv 128 | env/ 129 | venv/ 130 | ENV/ 131 | env.bak/ 132 | venv.bak/ 133 | 134 | # Spyder project settings 135 | .spyderproject 136 | .spyproject 137 | 138 | # Rope project settings 139 | .ropeproject 140 | 141 | # mkdocs documentation 142 | /site 143 | 144 | # mypy 145 | .mypy_cache/ 146 | .dmypy.json 147 | dmypy.json 148 | 149 | # Pyre type checker 150 | .pyre/ 151 | 152 | # pytype static type analyzer 153 | .pytype/ 154 | 155 | # Cython debug symbols 156 | cython_debug/ 157 | 158 | # PyCharm 159 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 160 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 161 | # and can be added to the global gitignore or merged into this file. For a more nuclear 162 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 163 | .idea/ 164 | 165 | # Version file 166 | /src/posit/_version.py 167 | 168 | # Ruff 169 | .ruff_cache/ 170 | 171 | /.luarc.json 172 | _dev/ 173 | 174 | # license files should not be commited to this repository 175 | *.lic 176 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: local 5 | hooks: 6 | - id: format 7 | name: format 8 | entry: bash -c "make fmt" 9 | language: system 10 | - id: lint 11 | name: lint 12 | entry: bash -c "make lint" 13 | language: system 14 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "charliermarsh.ruff" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Python: Current File", 9 | "type": "python", 10 | "request": "launch", 11 | "program": "${file}", 12 | "console": "integratedTerminal", 13 | "justMyCode": true 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.trimTrailingWhitespace": true, 3 | "files.insertFinalNewline": true, 4 | "files.encoding": "utf8", 5 | "files.eol": "\n", 6 | "python.testing.pytestArgs": [ 7 | "tests" 8 | ], 9 | "python.testing.unittestEnabled": false, 10 | "python.testing.pytestEnabled": true, 11 | "[python]": { 12 | "editor.defaultFormatter": "charliermarsh.ruff", 13 | "editor.formatOnSave": true, 14 | "editor.tabSize": 4, 15 | "editor.codeActionsOnSave": { 16 | "source.organizeImports": "explicit" 17 | } 18 | }, 19 | "files.exclude": { 20 | "**/__pycache__": true, 21 | "build/**": true 22 | }, 23 | "editor.rulers": [99], 24 | } 25 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @tdstein 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Overview 4 | 5 | The `posit-sdk` is a software development kit (SDK) for working with Posit's professional products. 6 | 7 | ## Prerequisites 8 | 9 | Before contributing to the `posit-sdk`, ensure that the following prerequisites are met: 10 | 11 | - Python >=3.9 12 | 13 | > [!INFO] 14 | > We require using virtual environments to maintain a clean and consistent development environment. 15 | > Any Python virtual environment will do. 16 | 17 | ## Instructions 18 | 19 | > [!WARNING] 20 | > Executing `make` will install third-party packages in your `.venv` virtual Python environment. Please review the [`Makefile`](./Makefile) to verify behavior before executing any commands. 21 | 22 | 1. Fork the repository and open it in your development environment. 23 | 2. Activate your Python environment (e.g., `source .venv/bin/activate`) 24 | 3. Run `make` to run the default development workflow. 25 | 4. Make your changes and test them thoroughly using `make test` 26 | 5. Run `make fmt` and `make lint` to verify adherence to the project style guide. 27 | 6. Commit your changes and push them to your forked repository. 28 | 7. Submit a pull request to the main repository. 29 | 30 | ## Tooling 31 | 32 | Use the default make target to execute the full build pipeline. For details on specific targets, run `make help`, or review the [Makefile](./Makefile) itself. 33 | 34 | ## Style Guide 35 | 36 | We use [Ruff](https://docs.astral.sh/ruff/) for linting and code formatting. 37 | 38 | All proposed changes must successfully pass the `make lint` rules prior to merging. 39 | 40 | Utilize `make fmt lint` to format and lint your changes. 41 | 42 | ### (Optional) pre-commit 43 | 44 | This project is configured for [pre-commit](https://pre-commit.com). Once enabled, a `git commit` hook is created, which invokes `make fmt lint`. 45 | 46 | To enable pre-commit on your machine, run `pre-commit install`. 47 | 48 | ## Release 49 | 50 | ### Instructions 51 | 52 | To start a release create a semver compatible tag. 53 | 54 | _For this example, we will use the tag `v0.1.0`. This tag already exists, so you will not be able run the following commands verbatim._ 55 | 56 | ```bash 57 | export TAG=v0.1.0 58 | ``` 59 | 60 | **Step 1** 61 | 62 | Create a proper SemVer compatible tag. Consult the [SemVer specification](https://semver.org/spec/v2.0.0.html) if you are unsure what this means. 63 | 64 | ```bash 65 | git tag $TAG 66 | ``` 67 | 68 | **Step 2** 69 | 70 | Push the tag GitHub. 71 | 72 | ```bash 73 | git push origin $TAG 74 | ``` 75 | 76 | This command will trigger the [Release GitHub Action](https://github.com/posit-dev/posit-sdk-py/actions/workflows/release.yaml). 77 | 78 | Once complete, the release will be available on [PyPI](https://pypi.org/project/posit-sdk). 79 | 80 | **Step 3** 81 | 82 | Create a release on GitHub. Please follow the pattern established in previous releases. Set the title to the tag name (e.g., `v0.1.0`) and the body to the generated release notes. Enable the "Create a discussion for this release" option and set the category to "Announcements". For reference, see . 83 | 84 | You can do this via the GitHub UI or using the following command: 85 | 86 | ```bash 87 | gh release create $TAG --verify-tag --generate-notes --discussion-category "Announcements"` 88 | ``` 89 | 90 | ### Pre-Releases 91 | 92 | Any tags denoted as a pre-release as defined by the [SemVer 2.0.0](https://semver.org/spec/v2.0.0.html) specification will be marked as such in GitHub. For example, the `v0.1.rc1` is a pre-release. Tag `v0.1.0` is a standard-release. Please consult the specification for additional information. 93 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3 2 | 3 | RUN apt-get update && apt-get install -y make 4 | 5 | WORKDIR /sdk 6 | 7 | COPY Makefile pyproject.toml vars.mk uv.lock ./ 8 | 9 | # Run before `COPY src src` to cache dependencies for faster iterative builds 10 | RUN --mount=type=cache,mode=0755,target=/root/.cache/pip make docker-deps 11 | 12 | COPY .git .git 13 | COPY src src 14 | 15 | RUN --mount=type=cache,mode=0755,target=/root/.cache/pip make dev 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 posit-dev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | include vars.mk 2 | 3 | .DEFAULT_GOAL := all 4 | 5 | .PHONY: build clean cov default dev docker-deps docs ensure-uv fmt fix install it lint test uninstall version help 6 | 7 | $(UV_LOCK): dev 8 | $(UV) lock 9 | 10 | all: dev test lint build 11 | 12 | build: dev 13 | $(UV) build 14 | 15 | clean: 16 | $(MAKE) -C ./docs $@ 17 | $(MAKE) -C ./integration $@ 18 | rm -rf .coverage .pytest_cache .ruff_cache *.egg-info build coverage.xml dist htmlcov coverage.xml 19 | find src -name "_version.py" -exec rm -rf {} + 20 | find . -name "*.egg-info" -exec rm -rf {} + 21 | find . -name "*.pyc" -exec rm -f {} + 22 | find . -name "__pycache__" -exec rm -rf {} + 23 | find . -type d -empty -delete 24 | 25 | cov: dev 26 | $(UV) run coverage report 27 | 28 | cov-html: dev 29 | $(UV) run coverage html 30 | open htmlcov/index.html 31 | 32 | cov-xml: dev 33 | $(UV) run coverage xml 34 | 35 | dev: ensure-uv 36 | $(UV) pip install --upgrade -e . 37 | 38 | docker-deps: ensure-uv 39 | # Sync given the `uv.lock` file 40 | # --frozen : assert that the lock file exists 41 | # --no-install-project : do not install the project itself, but install its dependencies 42 | $(UV) sync --frozen --no-install-project 43 | 44 | docs: ensure-uv 45 | $(MAKE) -C ./docs 46 | 47 | $(VIRTUAL_ENV): 48 | $(UV) venv $(VIRTUAL_ENV) 49 | ensure-uv: 50 | @if ! command -v $(UV) >/dev/null; then \ 51 | $(PYTHON) -m ensurepip && $(PYTHON) -m pip install "uv >= 0.4.27"; \ 52 | fi 53 | @# Install virtual environment (before calling `uv pip install ...`) 54 | @$(MAKE) $(VIRTUAL_ENV) 1>/dev/null 55 | @# Be sure recent uv is installed 56 | @$(UV) pip install "uv >= 0.4.27" --quiet 57 | 58 | fmt: dev 59 | $(UV) run ruff check --fix 60 | $(UV) run ruff format 61 | 62 | install: build 63 | $(UV) pip install dist/*.whl 64 | 65 | it: $(UV_LOCK) 66 | $(MAKE) -C ./integration 67 | 68 | lint: dev 69 | $(UV) run ruff check 70 | $(UV) run pyright 71 | 72 | test: dev 73 | $(UV) run coverage run --source=src -m pytest tests 74 | 75 | uninstall: ensure-uv 76 | $(UV) pip uninstall $(PROJECT_NAME) 77 | 78 | version: 79 | @$(MAKE) ensure-uv &>/dev/null 80 | @$(UV) run --quiet --with "setuptools_scm" python -m setuptools_scm 81 | 82 | help: 83 | @echo "Makefile Targets" 84 | @echo " all Run dev, test, lint, and build" 85 | @echo " build Build the project" 86 | @echo " clean Clean up project artifacts" 87 | @echo " cov Generate a coverage report" 88 | @echo " cov-html Generate an HTML coverage report and open it" 89 | @echo " cov-xml Generate an XML coverage report" 90 | @echo " dev Install the project in editable mode" 91 | @echo " docs Build the documentation" 92 | @echo " ensure-uv Ensure 'uv' is installed" 93 | @echo " fmt Format the code" 94 | @echo " install Install the built project" 95 | @echo " it Run integration tests" 96 | @echo " lint Lint the code" 97 | @echo " test Run unit tests with coverage" 98 | @echo " uninstall Uninstall the project" 99 | @echo " version Display the project version" 100 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Posit SDK for Python 2 | 3 | This package provides a Pythonic interface for developers to work against the public APIs of Posit's professional products. It is intended to be lightweight yet expressive. 4 | 5 | > The Posit SDK is in the very early stages of development, and currently only Posit Connect has any support. 6 | 7 | ## Installation 8 | 9 | ```shell 10 | pip install posit-sdk 11 | ``` 12 | 13 | ## Usage 14 | 15 | Establish server information and credentials using the following environment variables or when initializing a client. Then checkout the [Posit Connect Cookbook](https://docs.posit.co/connect/cookbook/) to get started. 16 | 17 | > [!CAUTION] 18 | > It is important to keep your API key safe and secure. Your API key grants access to your account and allows you to make authenticated requests to the Posit API. Treat your API key like a password and avoid sharing it with others. If you suspect that your API key has been compromised, regenerate a new one immediately to maintain the security of your account. 19 | 20 | ### Option 1 (Preferred) 21 | 22 | ```shell 23 | export CONNECT_API_KEY="my-secret-api-key" 24 | export CONNECT_SERVER="https://example.com/" 25 | ``` 26 | 27 | ```python 28 | from posit.connect import Client 29 | 30 | client = Client() 31 | ``` 32 | 33 | ### Option 2 34 | 35 | ```shell 36 | export CONNECT_API_KEY="my-secret-api-key" 37 | ``` 38 | 39 | ```python 40 | from posit.connect import Client 41 | 42 | Client("https://example.com") 43 | ``` 44 | 45 | ### Option 3 46 | 47 | ```python 48 | from posit.connect import Client 49 | 50 | Client("https://example.com", "my-secret-api-key") 51 | ``` 52 | 53 | ## Contributing 54 | 55 | We welcome contributions to the Posit SDK for Python! If you would like to contribute, see the [CONTRIBUTING](CONTRIBUTING.md) guide for instructions. 56 | 57 | ## Issues 58 | 59 | If you encounter any issues or have any questions, please [open an issue](https://github.com/posit-dev/posit-sdk-py/issues). We appreciate your feedback. 60 | 61 | ## License 62 | 63 | This project is licensed under the [MIT License](LICENSE). Feel free to use, modify, and distribute the code as you see fit. 64 | 65 | ## Code of Conduct 66 | 67 | We expect all contributors to adhere to the project's [Code of Conduct](CODE_OF_CONDUCT.md) and create a positive and inclusive community. 68 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | _extensions/ 2 | _inv/ 3 | _site/ 4 | .quarto/ 5 | objects.json 6 | reference/ 7 | 8 | /.quarto/ 9 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | include ../vars.mk 2 | 3 | # Site settings 4 | PROJECT_VERSION ?= $(shell $(MAKE) -C ../ -s version) 5 | CURRENT_YEAR ?= $(shell date +%Y) 6 | 7 | # Quarto settings 8 | QUARTO ?= quarto 9 | # quartodoc doesn't like py3.8; Run using `--with` as it can conflict with the project's dependencies 10 | QUARTODOC ?= --no-cache --with "quartodoc==0.8.1" quartodoc 11 | 12 | # Netlify settings 13 | NETLIFY_SITE_ID ?= 5cea1f56-7935-4387-975a-18a7905d15ee 14 | NETLIFY_ARGS := 15 | ifeq ($(ENV), prod) 16 | NETLIFY_ARGS = --prod 17 | endif 18 | 19 | .DEFAULT_GOAL := all 20 | 21 | .PHONY: all api build clean deps preview deploy 22 | 23 | all: deps api build 24 | 25 | ensure-dev: 26 | $(MAKE) -C .. dev 27 | 28 | api: ensure-dev 29 | @echo "::group::quartodoc interlinks" 30 | $(UV) tool run --with ../ $(QUARTODOC) interlinks 31 | @echo "::endgroup::" 32 | @echo "::group::quartodoc build" 33 | $(UV) tool run --with ../ $(QUARTODOC) build --verbose 34 | @echo "::endgroup::" 35 | cp -r _extensions/ reference/_extensions # Required to render footer 36 | 37 | build: ensure-dev 38 | CURRENT_YEAR=$(CURRENT_YEAR) \ 39 | PROJECT_VERSION=$(PROJECT_VERSION) \ 40 | $(QUARTO) render 41 | 42 | clean: 43 | rm -rf _extensions _inv _site .quarto reference objects.json 44 | find . -type d -empty -delete 45 | 46 | _extensions/posit-dev/posit-docs/_extension.yml: 47 | $(QUARTO) add --no-prompt posit-dev/product-doc-theme@v4.0.2 48 | _extensions/machow/interlinks/_extension.yml: 49 | $(QUARTO) add --no-prompt machow/quartodoc 50 | 51 | deps: ensure-dev _extensions/posit-dev/posit-docs/_extension.yml _extensions/machow/interlinks/_extension.yml 52 | 53 | preview: ensure-dev 54 | CURRENT_YEAR=$(CURRENT_YEAR) \ 55 | PROJECT_VERSION=$(PROJECT_VERSION) \ 56 | $(QUARTO) preview 57 | 58 | deploy: 59 | @NETLIFY_SITE_ID=$(NETLIFY_SITE_ID) npx -y netlify-cli deploy --dir _site --json $(NETLIFY_ARGS) 60 | -------------------------------------------------------------------------------- /docs/_quarto.yml: -------------------------------------------------------------------------------- 1 | project: 2 | type: website 3 | 4 | execute: 5 | freeze: auto 6 | 7 | website: 8 | title: "Posit SDK {{< env PROJECT_VERSION >}}" 9 | bread-crumbs: true 10 | favicon: "_extensions/posit-dev/posit-docs/assets/images/favicon.svg" 11 | navbar: 12 | pinned: true 13 | logo: "_extensions/posit-dev/posit-docs/assets/images/posit-icon-fullcolor.svg" 14 | logo-alt: "Posit Documentation" 15 | left: 16 | - text: Installation 17 | file: installation.qmd 18 | - text: Quick Start 19 | file: quickstart.qmd 20 | - text: API 21 | file: reference/index.qmd 22 | right: 23 | - icon: "list" 24 | aria-label: 'Drop-down menu for additional Posit resources' 25 | menu: 26 | - text: "docs.posit.co" 27 | href: "https://docs.posit.co" 28 | - text: "Posit Support" 29 | href: "https://support.posit.co/hc/en-us/" 30 | page-footer: 31 | left: | 32 | Copyright © 2000-{{< env CURRENT_YEAR >}} Posit Software, PBC. All Rights Reserved. 33 | center: | 34 | Posit {{< env PROJECT_VERSION >}} 35 | right: 36 | - icon: question-circle-fill 37 | aria-label: 'Link to Posit Support' 38 | href: "https://support.posit.co/hc/en-us" 39 | - icon: lightbulb-fill 40 | aria-label: 'Link to Posit Solutions' 41 | href: "https://solutions.posit.co/" 42 | - text: "" 43 | href: "https://docs.posit.co/" 44 | - text: "" 45 | href: "https://posit.co/" 46 | search: 47 | copy-button: true 48 | show-item-context: true 49 | 50 | filters: 51 | - interlinks 52 | 53 | format: 54 | html: 55 | theme: 56 | light: 57 | - _extensions/posit-dev/posit-docs/theme.scss 58 | dark: 59 | - _extensions/posit-dev/posit-docs/theme-dark.scss 60 | include-in-header: "_extensions/posit-dev/posit-docs/assets/_analytics.html" 61 | link-external-icon: true 62 | link-external-newwindow: true 63 | toc: true 64 | toc-expand: true 65 | 66 | interlinks: 67 | sources: 68 | python: 69 | url: https://docs.python.org/3/ 70 | requests: 71 | url: https://requests.readthedocs.io/en/latest/ 72 | 73 | quartodoc: 74 | title: API Reference 75 | style: pkgdown 76 | dir: reference 77 | package: posit 78 | render_interlinks: true 79 | options: 80 | include_classes: true 81 | include_functions: true 82 | include_empty: true 83 | sections: 84 | - title: Clients 85 | desc: > 86 | The `Client` is the entrypoint for each Posit product. Initialize a `Client` to get started. 87 | contents: 88 | - name: connect.Client 89 | members: 90 | # methods 91 | - request 92 | - get 93 | - post 94 | - put 95 | - patch 96 | - delete 97 | - title: Resources 98 | contents: 99 | - connect.bundles 100 | - connect.content 101 | - connect.env 102 | - connect.environments 103 | - connect.groups 104 | - connect.jobs 105 | - connect.metrics 106 | - connect.metrics.usage 107 | - connect.oauth 108 | - connect.oauth.associations 109 | - connect.oauth.integrations 110 | - connect.oauth.sessions 111 | - connect.packages 112 | - connect.permissions 113 | - connect.repository 114 | - connect.system 115 | - connect.tags 116 | - connect.tasks 117 | - connect.users 118 | - connect.vanities 119 | - title: Third-Party Integrations 120 | contents: 121 | - connect.external.databricks 122 | - connect.external.snowflake 123 | -------------------------------------------------------------------------------- /docs/index.qmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: Posit SDK for Python 3 | --- 4 | 5 | > A Pythonic interface for developers to work with Posit professional products. It's lightweight and expressive. 6 | 7 | Welcome to the Posit SDK for Python documentation! Get started with [Installation](./installation.qmd) and then check out [Quickstart](./quickstart.qmd). A full API reference is available in the [API](./reference/index.qmd) section. 8 | -------------------------------------------------------------------------------- /docs/installation.qmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Installation" 3 | format: html 4 | toc: true 5 | toc-title: "Contents" 6 | toc-depth: 2 7 | --- 8 | 9 | ## Python version 10 | 11 | We recommend using the latest version of Python available to you. The Posit SDK supports Python 3.8 and newer. 12 | 13 | ## Dependencies 14 | 15 | These dependencies are installed automatically during installation. 16 | 17 | - [Requests](https://requests.readthedocs.io/en/latest/) provides the HTTP layer. 18 | 19 | ## Install the Posit SDK 20 | 21 | Use the following command to install the Posit SDK: 22 | 23 | ```bash 24 | $ pip install posit-sdk 25 | ``` 26 | 27 | The SDK is now installed. Check out the [Quickstart](./quickstart.qmd) section. 28 | -------------------------------------------------------------------------------- /docs/quickstart.qmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Quickstart" 3 | format: html 4 | toc: true 5 | toc-title: "Contents" 6 | toc-depth: 2 7 | --- 8 | 9 | ## Setup 10 | 11 | After [installing](./installation.qmd) the SDK, you need to import it. Here's a simple example to get you started: 12 | 13 | ```python 14 | >>> import posit 15 | ``` 16 | 17 | ## Basic usage 18 | 19 | ### Initialize a client 20 | 21 | To get started, initialize a client to work with your Posit products. For this example, we will work with Posit Connect. 22 | 23 | ::: {.callout-warning} 24 | Keeping your API key secret is essential to protect your application's security, prevent unauthorized access and usage, and ensure data privacy and regulatory compliance. By default, the API key is read from the `CONNECT_API_KEY` environment variable when not provided by during client initialization. 25 | ::: 26 | 27 | 28 | ```python 29 | >>> from posit import connect 30 | >>> client = connect.Client(api_key="btOVKLXjt8CoGP2gXvSuTqu025MJV4da", url="https://connect.example.com") 31 | ``` 32 | 33 | ### Who am I? 34 | 35 | Your API key is your secret identity. Now that we're connected, let's double check our username. 36 | 37 | ```python 38 | >>> client.me.username 39 | 'gandalf' 40 | ``` 41 | 42 | ### How much content do I have access to? 43 | 44 | One of the best features of Connect is the ability to share content with your peers. Let's see just how much content we have access to! 45 | 46 | ```python 47 | >>> client.content.count() 48 | 5754 49 | ``` 50 | 51 | Voilà! I have a whopping 5,754 pieces of content to browse. 52 | 53 | 54 | ## Next steps 55 | 56 | Check out the [API Reference](./reference/index.qmd) for more information. 57 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | ### Posit SDK Examples 2 | 3 | For more in-depth SDK examples, covering a variety of use cases, check out the 4 | [Posit Connect Cookbook](https://docs.posit.co/connect/cookbook/getting-started/). 5 | 6 | > [!NOTE] 7 | > The databricks and snowflake examples will be removed from this repo is a future SDK release. 8 | > Please see the updated examples in the [OAuth Integrations](https://docs.posit.co/connect/cookbook/oauth-integrations/) 9 | > section of the Connect Cookbook. 10 | 11 | -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- 1 | # This file fixes the mypy errors "duplicate module named app.py" 2 | -------------------------------------------------------------------------------- /integration/.gitignore: -------------------------------------------------------------------------------- 1 | logs 2 | reports 3 | -------------------------------------------------------------------------------- /integration/compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | tests: 3 | image: ${DOCKER_PROJECT_IMAGE_TAG} 4 | # Run integration test suite. 5 | # 6 | # Target is relative to the ./integration directory, not the project root 7 | # directory. The execution base directory is determined by the 'WORKDIR' 8 | # in the Dockerfile. 9 | command: make -C ./integration test 10 | environment: 11 | - CONNECT_BOOTSTRAP_SECRETKEY=${CONNECT_BOOTSTRAP_SECRETKEY} 12 | # Port 3939 is the default port for Connect 13 | - CONNECT_SERVER=http://connect:3939 14 | - CONNECT_VERSION=${CONNECT_VERSION} 15 | - PYTEST_ARGS=${PYTEST_ARGS} 16 | volumes: 17 | - .:/sdk/integration 18 | depends_on: 19 | connect: 20 | condition: service_healthy 21 | networks: 22 | - test 23 | connect: 24 | image: ${DOCKER_CONNECT_IMAGE}:${DOCKER_CONNECT_IMAGE_TAG} 25 | pull_policy: always 26 | environment: 27 | - CONNECT_BOOTSTRAP_ENABLED=true 28 | - CONNECT_BOOTSTRAP_SECRETKEY=${CONNECT_BOOTSTRAP_SECRETKEY} 29 | - CONNECT_APPLICATIONS_PACKAGEAUDITINGENABLED=true 30 | networks: 31 | - test 32 | privileged: true 33 | volumes: 34 | - /var/lib/rstudio-connect 35 | - ./license.lic:/var/lib/rstudio-connect/rstudio-connect.lic:ro 36 | healthcheck: 37 | test: ["CMD", "curl", "-f", "http://localhost:3939"] 38 | interval: 10s 39 | timeout: 5s 40 | retries: 3 41 | start_period: 30s 42 | 43 | networks: 44 | test: 45 | driver: bridge 46 | -------------------------------------------------------------------------------- /integration/resources/connect/bundles/example-flask-minimal/bundle.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/posit-dev/posit-sdk-py/def37c77153d01d6a93e3f4d283535e1ccff244f/integration/resources/connect/bundles/example-flask-minimal/bundle.tar.gz -------------------------------------------------------------------------------- /integration/resources/connect/bundles/example-flask-minimal/hello.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | 3 | app = Flask(__name__) 4 | 5 | 6 | @app.route("/") 7 | def hello_world(): 8 | return "

Hello, World!

" 9 | -------------------------------------------------------------------------------- /integration/resources/connect/bundles/example-flask-minimal/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "locale": "en_US.UTF-8", 4 | "metadata": { 5 | "appmode": "python-api", 6 | "entrypoint": "hello" 7 | }, 8 | "python": { 9 | "version": "3.12.2", 10 | "package_manager": { 11 | "name": "pip", 12 | "version": "24.1.2", 13 | "package_file": "requirements.txt" 14 | } 15 | }, 16 | "files": { 17 | "requirements.txt": { 18 | "checksum": "2861f6872b39701536a1fdf2c7bff86b" 19 | }, 20 | "hello.py": { 21 | "checksum": "09f4dee97c8b7e2770157cf5d7fb6a73" 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /integration/resources/connect/bundles/example-flask-minimal/requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==3.0.3 2 | -------------------------------------------------------------------------------- /integration/resources/connect/bundles/example-quarto-minimal/.gitignore: -------------------------------------------------------------------------------- 1 | /.quarto/ 2 | _site/ 3 | -------------------------------------------------------------------------------- /integration/resources/connect/bundles/example-quarto-minimal/_quarto.yml: -------------------------------------------------------------------------------- 1 | project: 2 | type: website 3 | 4 | website: 5 | title: "example-quarto-minimal" 6 | navbar: 7 | left: 8 | - href: index.qmd 9 | text: Home 10 | - about.qmd 11 | 12 | format: 13 | html: 14 | theme: cosmo 15 | css: styles.css 16 | toc: true 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /integration/resources/connect/bundles/example-quarto-minimal/about.qmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "About" 3 | --- 4 | 5 | About this site 6 | -------------------------------------------------------------------------------- /integration/resources/connect/bundles/example-quarto-minimal/bundle.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/posit-dev/posit-sdk-py/def37c77153d01d6a93e3f4d283535e1ccff244f/integration/resources/connect/bundles/example-quarto-minimal/bundle.tar.gz -------------------------------------------------------------------------------- /integration/resources/connect/bundles/example-quarto-minimal/index.qmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "example-quarto-minimal" 3 | --- 4 | 5 | This is a Quarto website. 6 | 7 | To learn more about Quarto websites visit . 8 | -------------------------------------------------------------------------------- /integration/resources/connect/bundles/example-quarto-minimal/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "metadata": { 4 | "appmode": "quarto-static", 5 | "content_category": "site" 6 | }, 7 | "quarto": { 8 | "version": "1.5.54", 9 | "engines": [ 10 | "markdown" 11 | ] 12 | }, 13 | "files": { 14 | ".gitignore": { 15 | "checksum": "ebea58ee833ccab90d803cd345b2c81f" 16 | }, 17 | "_quarto.yml": { 18 | "checksum": "619323d181451c463ed77284cb31da12" 19 | }, 20 | "about.qmd": { 21 | "checksum": "b3260e8597e68ac0d3a7951d26a2e945" 22 | }, 23 | "index.qmd": { 24 | "checksum": "8395ee08073124f3ca275ed29ec1a24a" 25 | }, 26 | "styles.css": { 27 | "checksum": "e31c3cdea03dfab8a29456978017bd10" 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /integration/resources/connect/bundles/example-quarto-minimal/styles.css: -------------------------------------------------------------------------------- 1 | /* css styles */ 2 | -------------------------------------------------------------------------------- /integration/tests/posit/connect/__init__.py: -------------------------------------------------------------------------------- 1 | from packaging.version import parse 2 | 3 | from posit import connect 4 | 5 | client = connect.Client() 6 | version = client.version 7 | assert version 8 | CONNECT_VERSION = parse(version) 9 | -------------------------------------------------------------------------------- /integration/tests/posit/connect/oauth/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/posit-dev/posit-sdk-py/def37c77153d01d6a93e3f4d283535e1ccff244f/integration/tests/posit/connect/oauth/__init__.py -------------------------------------------------------------------------------- /integration/tests/posit/connect/oauth/test_associations.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | from packaging import version 5 | 6 | from posit import connect 7 | 8 | from .. import CONNECT_VERSION 9 | 10 | 11 | @pytest.mark.skipif( 12 | CONNECT_VERSION <= version.parse("2024.06.0"), 13 | reason="OAuth Integrations not supported.", 14 | ) 15 | class TestAssociations: 16 | @classmethod 17 | def setup_class(cls): 18 | cls.client = connect.Client() 19 | cls.integration = cls.client.oauth.integrations.create( 20 | name="example integration", 21 | description="integration description", 22 | template="custom", 23 | config={ 24 | "auth_mode": "Confidential", 25 | "authorization_uri": "https://example.com/__tenand_id__/oauth2/v2.0/authorize", 26 | "client_id": "client_id", 27 | "client_secret": "client_secret", 28 | "scopes": "a b c", 29 | "token_endpoint_auth_method": "client_secret_post", 30 | "token_uri": "https://example.com/__tenant_id__/oauth2/v2.0/token", 31 | }, 32 | ) 33 | 34 | cls.another_integration = cls.client.oauth.integrations.create( 35 | name="another example integration", 36 | description="another integration description", 37 | template="custom", 38 | config={ 39 | "auth_mode": "Confidential", 40 | "authorization_uri": "https://example.com/__tenand_id__/oauth2/v2.0/authorize", 41 | "client_id": "client_id", 42 | "client_secret": "client_secret", 43 | "scopes": "a b c", 44 | "token_endpoint_auth_method": "client_secret_post", 45 | "token_uri": "https://example.com/__tenant_id__/oauth2/v2.0/token", 46 | }, 47 | ) 48 | 49 | # create content 50 | # requires full bundle deployment to produce an interactive content type 51 | cls.content = cls.client.content.create(name="example-flask-minimal") 52 | # create bundle 53 | path = Path("../../../../resources/connect/bundles/example-flask-minimal/bundle.tar.gz") 54 | path = (Path(__file__).parent / path).resolve() 55 | bundle = cls.content.bundles.create(str(path)) 56 | # deploy bundle 57 | task = bundle.deploy() 58 | task.wait_for() 59 | 60 | cls.content.oauth.associations.update(cls.integration["guid"]) 61 | 62 | @classmethod 63 | def teardown_class(cls): 64 | cls.integration.delete() 65 | cls.another_integration.delete() 66 | assert len(cls.client.oauth.integrations.find()) == 0 67 | 68 | cls.content.delete() 69 | assert cls.client.content.count() == 0 70 | 71 | def test_find_by_integration(self): 72 | associations = self.integration.associations.find() 73 | assert len(associations) == 1 74 | assert associations[0]["oauth_integration_guid"] == self.integration["guid"] 75 | 76 | no_associations = self.another_integration.associations.find() 77 | assert len(no_associations) == 0 78 | 79 | def test_find_update_by_content(self): 80 | associations = self.content.oauth.associations.find() 81 | assert len(associations) == 1 82 | assert associations[0]["app_guid"] == self.content["guid"] 83 | assert associations[0]["oauth_integration_guid"] == self.integration["guid"] 84 | 85 | # update content association to another_integration 86 | self.content.oauth.associations.update(self.another_integration["guid"]) 87 | updated_associations = self.content.oauth.associations.find() 88 | assert len(updated_associations) == 1 89 | assert updated_associations[0]["app_guid"] == self.content["guid"] 90 | assert ( 91 | updated_associations[0]["oauth_integration_guid"] == self.another_integration["guid"] 92 | ) 93 | 94 | # unset content association 95 | self.content.oauth.associations.delete() 96 | no_associations = self.content.oauth.associations.find() 97 | assert len(no_associations) == 0 98 | -------------------------------------------------------------------------------- /integration/tests/posit/connect/oauth/test_integrations.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from packaging import version 3 | 4 | from posit import connect 5 | 6 | from .. import CONNECT_VERSION 7 | 8 | 9 | @pytest.mark.skipif( 10 | CONNECT_VERSION <= version.parse("2024.06.0"), 11 | reason="OAuth Integrations not supported.", 12 | ) 13 | class TestIntegrations: 14 | @classmethod 15 | def setup_class(cls): 16 | cls.client = connect.Client() 17 | cls.integration = cls.client.oauth.integrations.create( 18 | name="example integration", 19 | description="integration description", 20 | template="custom", 21 | config={ 22 | "auth_mode": "Confidential", 23 | "authorization_uri": "https://example.com/__tenand_id__/oauth2/v2.0/authorize", 24 | "client_id": "client_id", 25 | "client_secret": "client_secret", 26 | "scopes": "a b c", 27 | "token_endpoint_auth_method": "client_secret_post", 28 | "token_uri": "https://example.com/__tenant_id__/oauth2/v2.0/token", 29 | }, 30 | ) 31 | 32 | cls.another_integration = cls.client.oauth.integrations.create( 33 | name="another example integration", 34 | description="another integration description", 35 | template="custom", 36 | config={ 37 | "auth_mode": "Confidential", 38 | "authorization_uri": "https://example.com/__tenand_id__/oauth2/v2.0/authorize", 39 | "client_id": "client_id", 40 | "client_secret": "client_secret", 41 | "scopes": "a b c", 42 | "token_endpoint_auth_method": "client_secret_post", 43 | "token_uri": "https://example.com/__tenant_id__/oauth2/v2.0/token", 44 | }, 45 | ) 46 | 47 | @classmethod 48 | def teardown_class(cls): 49 | cls.integration.delete() 50 | cls.another_integration.delete() 51 | assert len(cls.client.oauth.integrations.find()) == 0 52 | 53 | def test_get(self): 54 | result = self.client.oauth.integrations.get(self.integration["guid"]) 55 | assert result == self.integration 56 | 57 | def test_find(self): 58 | results = self.client.oauth.integrations.find() 59 | assert len(results) == 2 60 | assert results[0] == self.integration 61 | assert results[1] == self.another_integration 62 | 63 | def test_create_update_delete(self): 64 | # create a new integration 65 | 66 | integration = self.client.oauth.integrations.create( 67 | name="new integration", 68 | description="new integration description", 69 | template="custom", 70 | config={ 71 | "auth_mode": "Confidential", 72 | "authorization_uri": "https://example.com/__tenand_id__/oauth2/v2.0/authorize", 73 | "client_id": "client_id", 74 | "client_secret": "client_secret", 75 | "scopes": "a b c", 76 | "token_endpoint_auth_method": "client_secret_post", 77 | "token_uri": "https://example.com/__tenant_id__/oauth2/v2.0/token", 78 | }, 79 | ) 80 | 81 | created = self.client.oauth.integrations.get(integration["guid"]) 82 | assert created == integration 83 | 84 | all_integrations = self.client.oauth.integrations.find() 85 | assert len(all_integrations) == 3 86 | 87 | # update the new integration 88 | 89 | created.update(name="updated integration name") 90 | updated = self.client.oauth.integrations.get(integration["guid"]) 91 | assert updated["name"] == "updated integration name" 92 | 93 | # delete the new integration 94 | 95 | created.delete() 96 | all_integrations_after_delete = self.client.oauth.integrations.find() 97 | assert len(all_integrations_after_delete) == 2 98 | -------------------------------------------------------------------------------- /integration/tests/posit/connect/test_bundles.py: -------------------------------------------------------------------------------- 1 | import time 2 | from pathlib import Path 3 | 4 | import pytest 5 | from packaging import version 6 | 7 | from posit import connect 8 | 9 | from . import CONNECT_VERSION 10 | 11 | 12 | class TestBundles: 13 | @classmethod 14 | def setup_class(cls): 15 | cls.client = connect.Client() 16 | cls.content = cls.client.content.create( 17 | name=f"test-bundles-{int(time.time())}", 18 | title="Test Bundles", 19 | access_type="all", 20 | ) 21 | # Path to the test bundle 22 | bundle_path = Path( 23 | "../../../resources/connect/bundles/example-flask-minimal/bundle.tar.gz" 24 | ) 25 | cls.bundle_path = (Path(__file__).parent / bundle_path).resolve() 26 | 27 | @classmethod 28 | def teardown_class(cls): 29 | cls.content.delete() 30 | 31 | def test_create_bundle(self): 32 | """Test creating a bundle.""" 33 | bundle = self.content.bundles.create(str(self.bundle_path)) 34 | assert bundle["id"] 35 | assert bundle["content_guid"] == self.content["guid"] 36 | 37 | def test_find_bundles(self): 38 | """Test finding all bundles.""" 39 | # Create a bundle first 40 | self.content.bundles.create(str(self.bundle_path)) 41 | 42 | # Find all bundles 43 | bundles = self.content.bundles.find() 44 | assert len(bundles) >= 1 45 | 46 | def test_find_one_bundle(self): 47 | """Test finding a single bundle.""" 48 | # Create a bundle first 49 | self.content.bundles.create(str(self.bundle_path)) 50 | 51 | # Find one bundle 52 | bundle = self.content.bundles.find_one() 53 | assert bundle is not None 54 | assert bundle["content_guid"] == self.content["guid"] 55 | 56 | def test_get_bundle(self): 57 | """Test getting a specific bundle.""" 58 | # Create a bundle first 59 | created_bundle = self.content.bundles.create(str(self.bundle_path)) 60 | 61 | # Get the bundle by ID 62 | bundle = self.content.bundles.get(created_bundle["id"]) 63 | assert bundle["id"] == created_bundle["id"] 64 | 65 | @pytest.mark.skipif( 66 | CONNECT_VERSION < version.parse("2025.02.0"), reason="Requires Connect 2025.02.0 or later" 67 | ) 68 | def test_active_bundle(self): 69 | """Test retrieving the active bundle.""" 70 | # Initially, no bundle should be active 71 | assert self.content.bundles.active() is None 72 | 73 | # Create and deploy a bundle 74 | bundle = self.content.bundles.create(str(self.bundle_path)) 75 | task = bundle.deploy() 76 | task.wait_for() 77 | 78 | # Wait for the bundle to become active 79 | max_retries = 10 80 | active_bundle = None 81 | for _ in range(max_retries): 82 | active_bundle = self.content.bundles.active() 83 | if active_bundle is not None: 84 | break 85 | time.sleep(1) 86 | 87 | # Verify the bundle is now active 88 | assert active_bundle is not None 89 | assert active_bundle["id"] == bundle["id"] 90 | assert active_bundle.get("active") is True 91 | 92 | # Create another bundle but don't deploy it 93 | bundle2 = self.content.bundles.create(str(self.bundle_path)) 94 | 95 | # Verify the active bundle is still the first one 96 | active_bundle = self.content.bundles.active() 97 | assert active_bundle is not None 98 | assert active_bundle["id"] == bundle["id"] 99 | assert active_bundle["id"] != bundle2["id"] 100 | -------------------------------------------------------------------------------- /integration/tests/posit/connect/test_client.py: -------------------------------------------------------------------------------- 1 | from posit import connect 2 | 3 | 4 | def test_version(): 5 | client = connect.Client() 6 | assert client.version 7 | -------------------------------------------------------------------------------- /integration/tests/posit/connect/test_content.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | from packaging import version 5 | 6 | from posit import connect 7 | 8 | from . import CONNECT_VERSION 9 | 10 | 11 | class TestContent: 12 | @classmethod 13 | def setup_class(cls): 14 | cls.client = connect.Client() 15 | cls.content = cls.client.content.create() 16 | 17 | @classmethod 18 | def teardown_class(cls): 19 | cls.content.delete() 20 | assert cls.client.content.count() == 0 21 | 22 | def test_count(self): 23 | assert self.client.content.count() == 1 24 | 25 | def test_get(self): 26 | assert self.client.content.get(self.content["guid"]) == self.content 27 | 28 | def test_find(self): 29 | assert self.client.content.find() 30 | 31 | def test_find_by(self): 32 | assert self.client.content.find_by(name=self.content["name"]) == self.content 33 | 34 | def test_find_one(self): 35 | assert self.client.content.find_one() 36 | 37 | def test_content_item_owner(self): 38 | item = self.client.content.find_one(include=None) 39 | assert item 40 | owner = item.owner 41 | assert owner["guid"] == self.client.me["guid"] 42 | 43 | def test_content_item_owner_from_include(self): 44 | item = self.client.content.find_one(include="owner") 45 | assert item 46 | owner = item.owner 47 | assert owner["guid"] == self.client.me["guid"] 48 | 49 | @pytest.mark.skipif( 50 | CONNECT_VERSION <= version.parse("2024.04.1"), 51 | reason="Python 3.12 not available", 52 | ) 53 | def test_restart(self): 54 | # create content 55 | content = self.client.content.create(name="example-flask-minimal") 56 | # create bundle 57 | path = Path("../../../resources/connect/bundles/example-flask-minimal/bundle.tar.gz") 58 | path = (Path(__file__).parent / path).resolve() 59 | bundle = content.bundles.create(str(path)) 60 | # deploy bundle 61 | task = bundle.deploy() 62 | task.wait_for() 63 | # restart 64 | content.restart() 65 | # delete content 66 | content.delete() 67 | 68 | @pytest.mark.skipif( 69 | CONNECT_VERSION <= version.parse("2023.01.1"), 70 | reason="Quarto not available", 71 | ) 72 | def test_render(self): 73 | # create content 74 | content = self.client.content.create(name="example-quarto-minimal") 75 | # create bundle 76 | path = Path("../../../resources/connect/bundles/example-quarto-minimal/bundle.tar.gz") 77 | path = (Path(__file__).parent / path).resolve() 78 | bundle = content.bundles.create(str(path)) 79 | # deploy bundle 80 | task = bundle.deploy() 81 | task.wait_for() 82 | # render 83 | task = content.render() 84 | task.wait_for() 85 | # delete content 86 | content.delete() 87 | -------------------------------------------------------------------------------- /integration/tests/posit/connect/test_content_item_permissions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | from typing_extensions import TYPE_CHECKING 5 | 6 | from posit import connect 7 | 8 | if TYPE_CHECKING: 9 | from posit.connect.content import ContentItem 10 | from posit.connect.permissions import Permission 11 | 12 | 13 | class TestContentPermissions: 14 | content: ContentItem 15 | 16 | @classmethod 17 | def setup_class(cls): 18 | cls.client = connect.Client() 19 | cls.content = cls.client.content.create(name="example") 20 | 21 | cls.user_aron = cls.client.users.create( 22 | username="permission_aron", 23 | email="permission_aron@example.com", 24 | password="permission_s3cur3p@ssword", 25 | ) 26 | cls.user_bill = cls.client.users.create( 27 | username="permission_bill", 28 | email="permission_bill@example.com", 29 | password="permission_s3cur3p@ssword", 30 | ) 31 | 32 | cls.group_friends = cls.client.groups.create(name="Friends") 33 | 34 | @classmethod 35 | def teardown_class(cls): 36 | cls.content.delete() 37 | assert cls.client.content.count() == 0 38 | 39 | cls.group_friends.delete() 40 | assert cls.client.groups.count() == 0 41 | 42 | def test_permissions_add_destroy(self): 43 | assert self.client.groups.count() == 1 44 | assert self.client.users.count() == 3 45 | assert self.content.permissions.find() == [] 46 | 47 | # Add permissions 48 | self.content.permissions.create( 49 | principal_guid=self.user_aron["guid"], 50 | principal_type="user", 51 | role="viewer", 52 | ) 53 | self.content.permissions.create( 54 | principal_guid=self.group_friends["guid"], 55 | principal_type="group", 56 | role="owner", 57 | ) 58 | 59 | def assert_permissions_match_guids(permissions: list[Permission], objs_with_guid): 60 | for permission, obj_with_guid in zip(permissions, objs_with_guid): 61 | assert permission["principal_guid"] == obj_with_guid["guid"] 62 | 63 | # Prove they have been added 64 | assert_permissions_match_guids( 65 | self.content.permissions.find(), 66 | [self.user_aron, self.group_friends], 67 | ) 68 | 69 | # Remove permissions (and from some that isn't an owner) 70 | self.content.permissions.destroy(self.user_aron) 71 | with pytest.raises(ValueError): 72 | self.content.permissions.destroy(self.user_bill) 73 | 74 | # Prove they have been removed 75 | assert_permissions_match_guids( 76 | self.content.permissions.find(), 77 | [self.group_friends], 78 | ) 79 | 80 | # Remove the last permission 81 | self.content.permissions.destroy(self.group_friends) 82 | 83 | # Prove they have been removed 84 | assert self.content.permissions.find() == [] 85 | -------------------------------------------------------------------------------- /integration/tests/posit/connect/test_content_item_repository.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from packaging import version 3 | 4 | from posit import connect 5 | from posit.connect.content import ContentItem 6 | from posit.connect.repository import ContentItemRepository 7 | 8 | from . import CONNECT_VERSION 9 | 10 | 11 | class TestContentItemRepository: 12 | content: ContentItem 13 | 14 | @classmethod 15 | def setup_class(cls): 16 | cls.client = connect.Client() 17 | cls.content = cls.client.content.create(name="example") 18 | 19 | @classmethod 20 | def teardown_class(cls): 21 | cls.content.delete() 22 | assert cls.client.content.count() == 0 23 | 24 | @property 25 | def repo_repository(self): 26 | return "https://github.com/posit-dev/posit-sdk-py" 27 | 28 | @property 29 | def repo_branch(self): 30 | return "1dacc4dd" 31 | 32 | @property 33 | def repo_directory(self): 34 | return "integration/resources/connect/bundles/example-quarto-minimal" 35 | 36 | @property 37 | def repo_polling(self): 38 | return False 39 | 40 | @property 41 | def default_repository(self): 42 | return { 43 | "repository": self.repo_repository, 44 | "branch": self.repo_branch, 45 | "directory": self.repo_directory, 46 | "polling": self.repo_polling, 47 | } 48 | 49 | @pytest.mark.skipif( 50 | # Added to the v2022.12.0 milestone 51 | # https://github.com/rstudio/connect/issues/22242#event-7859377097 52 | CONNECT_VERSION < version.parse("2022.12.0"), 53 | reason="Repository routes not implemented", 54 | ) 55 | def test_create_get_update_delete(self): 56 | content = self.content 57 | 58 | # None by default 59 | assert content.repository is None 60 | 61 | # Create 62 | new_repo = content.create_repository(**self.default_repository) 63 | 64 | # Get 65 | content_repo = content.repository 66 | assert content_repo is not None 67 | 68 | def assert_repo(r: ContentItemRepository): 69 | assert isinstance(content_repo, ContentItemRepository) 70 | assert r["repository"] == self.repo_repository 71 | assert r["branch"] == self.repo_branch 72 | assert r["directory"] == self.repo_directory 73 | assert r["polling"] is self.repo_polling 74 | 75 | assert_repo(new_repo) 76 | assert_repo(content_repo) 77 | 78 | # Update 79 | ex_branch = "main" 80 | content_repo.update(branch=ex_branch) 81 | assert content_repo["branch"] == ex_branch 82 | 83 | assert content_repo["repository"] == self.repo_repository 84 | assert content_repo["directory"] == self.repo_directory 85 | assert content_repo["polling"] is self.repo_polling 86 | 87 | # Delete 88 | content_repo.destroy() 89 | assert content.repository is None 90 | -------------------------------------------------------------------------------- /integration/tests/posit/connect/test_env.py: -------------------------------------------------------------------------------- 1 | from posit import connect 2 | 3 | 4 | class TestEnvVars: 5 | @classmethod 6 | def setup_class(cls): 7 | cls.client = connect.Client() 8 | cls.content = cls.client.content.create( 9 | name="Sample", 10 | description="Simple sample content for testing", 11 | access_type="acl", 12 | ) 13 | 14 | @classmethod 15 | def teardown_class(cls): 16 | cls.content.delete() 17 | assert cls.client.content.count() == 0 18 | 19 | def test_clear(self): 20 | self.content.environment_variables.create("KEY", "value") 21 | assert self.content.environment_variables.find() == ["KEY"] 22 | self.content.environment_variables.clear() 23 | assert self.content.environment_variables.find() == [] 24 | 25 | def test_create(self): 26 | self.content.environment_variables.create("KEY", "value") 27 | assert self.content.environment_variables.find() == ["KEY"] 28 | 29 | def test_delete(self): 30 | self.content.environment_variables.create("KEY", "value") 31 | assert self.content.environment_variables.find() == ["KEY"] 32 | self.content.environment_variables.delete("KEY") 33 | assert self.content.environment_variables.find() == [] 34 | 35 | def test_find(self): 36 | self.content.environment_variables.create("KEY", "value") 37 | assert self.content.environment_variables.find() == ["KEY"] 38 | 39 | def test_update(self): 40 | self.content.environment_variables.update(KEY="value") 41 | assert self.content.environment_variables.find() == ["KEY"] 42 | -------------------------------------------------------------------------------- /integration/tests/posit/connect/test_environments.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from packaging import version 3 | 4 | from posit import connect 5 | 6 | from . import CONNECT_VERSION 7 | 8 | 9 | @pytest.mark.skipif( 10 | CONNECT_VERSION < version.parse("2023.05.0"), 11 | reason="Environments API unavailable", 12 | ) 13 | class TestEnvironments: 14 | @classmethod 15 | def setup_class(cls): 16 | cls.client = connect.Client() 17 | cls.environment = cls.client.environments.create( 18 | title="title", 19 | name="name", 20 | cluster_name="Kubernetes", 21 | ) 22 | 23 | @classmethod 24 | def teardown_class(cls): 25 | cls.environment.destroy() 26 | assert len(cls.client.environments) == 0 27 | 28 | def test_find(self): 29 | uid = self.environment["guid"] 30 | environment = self.client.environments.find(uid) 31 | assert environment == self.environment 32 | 33 | def test_find_by(self): 34 | environment = self.client.environments.find_by(name="name") 35 | assert environment == self.environment 36 | 37 | def test_update(self): 38 | assert self.environment["title"] == "title" 39 | self.environment.update(title="new-title") 40 | assert self.environment["title"] == "new-title" 41 | -------------------------------------------------------------------------------- /integration/tests/posit/connect/test_groups.py: -------------------------------------------------------------------------------- 1 | from posit import connect 2 | 3 | 4 | class TestGroups: 5 | @classmethod 6 | def setup_class(cls): 7 | cls.client = connect.Client() 8 | cls.item = cls.client.groups.create(name="Friends") 9 | 10 | @classmethod 11 | def teardown_class(cls): 12 | cls.item.delete() 13 | assert cls.client.groups.count() == 0 14 | 15 | def test_count(self): 16 | assert self.client.groups.count() == 1 17 | 18 | def test_get(self): 19 | assert self.client.groups.get(self.item["guid"]) 20 | 21 | def test_find(self): 22 | assert self.client.groups.find() == [self.item] 23 | 24 | def test_find_one(self): 25 | assert self.client.groups.find_one() == self.item 26 | -------------------------------------------------------------------------------- /integration/tests/posit/connect/test_jobs.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | from packaging import version 5 | 6 | from posit import connect 7 | 8 | from . import CONNECT_VERSION 9 | 10 | 11 | @pytest.mark.skipif( 12 | CONNECT_VERSION <= version.parse("2023.01.1"), 13 | reason="Quarto not available", 14 | ) 15 | class TestJobs: 16 | @classmethod 17 | def setup_class(cls): 18 | cls.client = connect.Client() 19 | cls.content = cls.client.content.create(name="example-quarto-minimal") 20 | 21 | @classmethod 22 | def teardown_class(cls): 23 | cls.content.delete() 24 | assert cls.client.content.count() == 0 25 | 26 | def test(self): 27 | content = self.content 28 | 29 | path = Path("../../../resources/connect/bundles/example-quarto-minimal/bundle.tar.gz") 30 | path = Path(__file__).parent / path 31 | path = path.resolve() 32 | path = str(path) 33 | 34 | bundle = content.bundles.create(path) 35 | bundle.deploy() 36 | 37 | jobs = content.jobs 38 | assert len(jobs) == 1 39 | 40 | def test_find_by(self): 41 | content = self.content 42 | 43 | path = Path("../../../resources/connect/bundles/example-quarto-minimal/bundle.tar.gz") 44 | path = Path(__file__).parent / path 45 | path = path.resolve() 46 | path = str(path) 47 | 48 | bundle = content.bundles.create(path) 49 | task = bundle.deploy() 50 | task.wait_for() 51 | 52 | jobs = content.jobs 53 | assert len(jobs) != 0 54 | 55 | job = jobs[0] 56 | key = job["key"] 57 | assert content.jobs.find_by(key=key) == job 58 | -------------------------------------------------------------------------------- /integration/tests/posit/connect/test_packages.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | from packaging import version 5 | 6 | from posit import connect 7 | 8 | from . import CONNECT_VERSION 9 | 10 | 11 | @pytest.mark.skipif( 12 | CONNECT_VERSION < version.parse("2024.10.0-dev"), 13 | reason="Packages API unavailable", 14 | ) 15 | class TestPackages: 16 | @classmethod 17 | def setup_class(cls): 18 | cls.client = connect.Client() 19 | cls.content = cls.client.content.create(name=cls.__name__) 20 | path = Path("../../../resources/connect/bundles/example-flask-minimal/bundle.tar.gz") 21 | path = (Path(__file__).parent / path).resolve() 22 | bundle = cls.content.bundles.create(str(path)) 23 | task = bundle.deploy() 24 | task.wait_for() 25 | 26 | @classmethod 27 | def teardown_class(cls): 28 | cls.content.delete() 29 | 30 | def test(self): 31 | assert self.client.packages 32 | assert self.content.packages 33 | 34 | def test_find_by(self): 35 | package = self.client.packages.find_by(name="flask") 36 | assert package 37 | assert package["name"] == "flask" 38 | 39 | package = self.content.packages.find_by(name="flask") 40 | assert package 41 | assert package["name"] == "flask" 42 | -------------------------------------------------------------------------------- /integration/tests/posit/connect/test_system.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | from packaging import version 5 | 6 | from posit.connect import Client 7 | from posit.connect.system import SystemRuntimeCache 8 | from posit.connect.tasks import Task 9 | 10 | from . import CONNECT_VERSION 11 | 12 | 13 | @pytest.mark.skipif( 14 | # Added to the v2023.05.0 milestone 15 | # https://github.com/rstudio/connect/pull/23148 16 | CONNECT_VERSION < version.parse("2023.05.0"), 17 | reason="Cache runtimes not implemented", 18 | ) 19 | class TestSystem: 20 | @classmethod 21 | def setup_class(cls): 22 | cls.client = Client() 23 | assert cls.client.content.count() == 0 24 | cls.content_item = cls.client.content.create(name="Content_A") 25 | 26 | # Copied from from integration/tests/posit/connect/test_packages.py 27 | def _deploy_python_bundle(self): 28 | path = Path("../../../resources/connect/bundles/example-flask-minimal/bundle.tar.gz") 29 | path = (Path(__file__).parent / path).resolve() 30 | bundle = self.content_item.bundles.create(str(path)) 31 | task = bundle.deploy() 32 | task.wait_for() 33 | 34 | def _remove_all_caches(self): 35 | caches: list[SystemRuntimeCache] = self.client.system.caches.runtime.find() 36 | for cache in caches: 37 | assert isinstance(cache, SystemRuntimeCache) 38 | none_val = cache.destroy(dry_run=True) 39 | assert none_val is None 40 | task: Task = cache.destroy() 41 | assert isinstance(task, Task) 42 | task.wait_for() 43 | assert len(self.client.system.caches.runtime.find()) == 0 44 | 45 | @classmethod 46 | def teardown_class(cls): 47 | cls.content_item.delete() 48 | assert cls.client.content.count() == 0 49 | 50 | def test_runtime_caches(self): 51 | # Get current caches 52 | caches: list[SystemRuntimeCache] = self.client.system.caches.runtime.find() 53 | assert isinstance(caches, list) 54 | 55 | # Remove all caches 56 | self._remove_all_caches() 57 | 58 | # Deploy a new cache 59 | self._deploy_python_bundle() 60 | 61 | # Check if the cache is deployed 62 | caches = self.client.system.caches.runtime.find() 63 | 64 | # Barret 2024/12: 65 | # Caches only showing up in Connect versions >= 2024.05.0 66 | # I do not know why. 67 | # Since we are not logic testing Connect, we can confirm our code works given more recent versions of Connect. 68 | if CONNECT_VERSION >= version.parse("2024.05.0"): 69 | assert len(caches) > 0 70 | 71 | # Remove all caches 72 | self._remove_all_caches() 73 | -------------------------------------------------------------------------------- /integration/tests/posit/connect/test_vanities.py: -------------------------------------------------------------------------------- 1 | from posit import connect 2 | 3 | 4 | class TestVanities: 5 | @classmethod 6 | def setup_class(cls): 7 | cls.client = connect.Client() 8 | 9 | @classmethod 10 | def teardown_class(cls): 11 | assert cls.client.content.count() == 0 12 | 13 | def test_all(self): 14 | content = self.client.content.create(name="example") 15 | 16 | # None by default 17 | vanities = self.client.vanities.all() 18 | assert len(vanities) == 0 19 | 20 | # Set 21 | content.vanity = "example" 22 | 23 | # Get 24 | vanities = self.client.vanities.all() 25 | assert len(vanities) == 1 26 | 27 | # Cleanup 28 | content.delete() 29 | 30 | vanities = self.client.vanities.all() 31 | assert len(vanities) == 0 32 | 33 | def test_property(self): 34 | content = self.client.content.create(name="example") 35 | 36 | # None by default 37 | assert content.vanity is None 38 | 39 | # Set 40 | content.vanity = "example" 41 | 42 | # Get 43 | vanity = content.vanity 44 | assert vanity == "/example/" 45 | 46 | # Delete 47 | del content.vanity 48 | assert content.vanity is None 49 | 50 | # Cleanup 51 | content.delete() 52 | 53 | def test_destroy(self): 54 | content = self.client.content.create(name="example") 55 | 56 | # None by default 57 | assert content.vanity is None 58 | 59 | # Set 60 | content.vanity = "example" 61 | 62 | # Get 63 | vanity = content.find_vanity() 64 | assert vanity 65 | assert vanity["path"] == "/example/" 66 | 67 | # Delete 68 | vanity.destroy() 69 | content.reset_vanity() 70 | assert content.vanity is None 71 | 72 | # Cleanup 73 | content.delete() 74 | -------------------------------------------------------------------------------- /src/posit/__init__.py: -------------------------------------------------------------------------------- 1 | """The Posit SDK.""" 2 | 3 | from . import connect as connect 4 | from . import workbench as workbench 5 | -------------------------------------------------------------------------------- /src/posit/connect/__init__.py: -------------------------------------------------------------------------------- 1 | from .client import Client as Client 2 | -------------------------------------------------------------------------------- /src/posit/connect/_utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | 5 | from typing_extensions import Any 6 | 7 | 8 | def update_dict_values(obj: dict[str, Any], /, **kwargs: Any) -> None: 9 | """ 10 | Update the values of a dictionary. 11 | 12 | This helper method exists as a workaround for the `dict.update` method. Sometimes, `super()` does not return the `dict` class and. If `super().update(**kwargs)` is called unintended behavior will occur. 13 | 14 | Therefore, this helper method exists to update the `dict`'s values. 15 | 16 | Parameters 17 | ---------- 18 | obj : dict[str, Any] 19 | The object to update. 20 | kwargs : Any 21 | The key-value pairs to update the object with. 22 | 23 | See Also 24 | -------- 25 | * https://github.com/posit-dev/posit-sdk-py/pull/366#discussion_r1887845267 26 | """ 27 | # Could also be performed with: 28 | # for key, value in kwargs.items(): 29 | # obj[key] = value 30 | 31 | # Use the `dict` class to explicity update the object in-place 32 | dict.update(obj, **kwargs) 33 | 34 | 35 | def is_workbench() -> bool: 36 | """Attempts to return true if called from a piece of content running on Posit Workbench. 37 | 38 | There is not yet a definitive way to determine if the content is running on Workbench. This method is best-effort. 39 | """ 40 | return ( 41 | "RSW_LAUNCHER" in os.environ 42 | or "RSTUDIO_MULTI_SESSION" in os.environ 43 | or "RS_SERVER_ADDRESS" in os.environ 44 | or "RS_SERVER_URL" in os.environ 45 | ) 46 | 47 | 48 | def is_connect() -> bool: 49 | """Returns true if called from a piece of content running on Posit Connect. 50 | 51 | The Connect content will always set the environment variable `RSTUDIO_PRODUCT=CONNECT`. 52 | """ 53 | return os.getenv("RSTUDIO_PRODUCT") == "CONNECT" 54 | 55 | 56 | def is_local() -> bool: 57 | """Returns true if called from a piece of content running locally.""" 58 | return not is_connect() and not is_workbench() 59 | -------------------------------------------------------------------------------- /src/posit/connect/auth.py: -------------------------------------------------------------------------------- 1 | """Provides authentication functionality.""" 2 | 3 | from requests import PreparedRequest 4 | from requests.auth import AuthBase 5 | 6 | from .config import Config 7 | 8 | 9 | class Auth(AuthBase): 10 | """Handles authentication for API requests.""" 11 | 12 | def __init__(self, config: Config) -> None: 13 | self._config = config 14 | 15 | def __call__(self, r: PreparedRequest) -> PreparedRequest: 16 | """Add authorization header to the request.""" 17 | r.headers["Authorization"] = f"Key {self._config.api_key}" 18 | return r 19 | -------------------------------------------------------------------------------- /src/posit/connect/config.py: -------------------------------------------------------------------------------- 1 | """Client configuration.""" 2 | 3 | import os 4 | 5 | from typing_extensions import Optional 6 | 7 | from . import urls 8 | 9 | 10 | def _get_api_key() -> str: 11 | """Return the system configured api key. 12 | 13 | Reads the environment variable 'CONNECT_API_KEY'. 14 | 15 | Raises 16 | ------ 17 | ValueError: If CONNECT_API_KEY is not set or invalid 18 | 19 | Returns 20 | ------- 21 | str 22 | """ 23 | value = os.environ.get("CONNECT_API_KEY") 24 | if not value: 25 | raise ValueError("Invalid value for 'CONNECT_API_KEY': Must be a non-empty string.") 26 | return value 27 | 28 | 29 | def _get_url() -> str: 30 | """Return the system configured url. 31 | 32 | Reads the environment variable 'CONNECT_SERVER'. 33 | 34 | Raises 35 | ------ 36 | ValueError: If CONNECT_SERVER is not set or invalid 37 | 38 | Returns 39 | ------- 40 | str 41 | """ 42 | value = os.environ.get("CONNECT_SERVER") 43 | if not value: 44 | raise ValueError("Invalid value for 'CONNECT_SERVER': Must be a non-empty string.") 45 | return value 46 | 47 | 48 | class Config: 49 | """Configuration object.""" 50 | 51 | def __init__(self, api_key: Optional[str] = None, url: Optional[str] = None) -> None: 52 | self.api_key = api_key or _get_api_key() 53 | self.url = urls.Url(url or _get_url()) 54 | -------------------------------------------------------------------------------- /src/posit/connect/context.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import functools 4 | import weakref 5 | 6 | from packaging.version import Version 7 | from typing_extensions import TYPE_CHECKING, Protocol 8 | 9 | if TYPE_CHECKING: 10 | from .client import Client 11 | 12 | 13 | def requires(version: str): 14 | def decorator(func): 15 | @functools.wraps(func) 16 | def wrapper(instance: ContextManager, *args, **kwargs): 17 | ctx = instance._ctx 18 | if ctx.version and Version(ctx.version) < Version(version): 19 | raise RuntimeError( 20 | f"This API is not available in Connect version {ctx.version}. Please upgrade to version {version} or later.", 21 | ) 22 | return func(instance, *args, **kwargs) 23 | 24 | return wrapper 25 | 26 | return decorator 27 | 28 | 29 | class Context: 30 | def __init__(self, client: Client): 31 | # Since this is a child object of the client, we use a weak reference to avoid circular 32 | # references (which would prevent garbage collection) 33 | self.client: Client = weakref.proxy(client) 34 | 35 | @property 36 | def version(self) -> str | None: 37 | if not hasattr(self, "_version"): 38 | response = self.client.get("server_settings") 39 | result = response.json() 40 | self._version: str | None = result.get("version") 41 | 42 | return self._version 43 | 44 | @version.setter 45 | def version(self, value: str | None): 46 | self._version = value 47 | 48 | 49 | class ContextManager(Protocol): 50 | _ctx: Context 51 | -------------------------------------------------------------------------------- /src/posit/connect/cursors.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | from typing_extensions import TYPE_CHECKING, Any, Generator, List 6 | 7 | if TYPE_CHECKING: 8 | from .context import Context 9 | 10 | # The maximum page size supported by the API. 11 | _MAX_PAGE_SIZE = 500 12 | 13 | 14 | @dataclass 15 | class CursorPage: 16 | paging: dict 17 | results: List[dict] 18 | 19 | 20 | class CursorPaginator: 21 | def __init__( 22 | self, 23 | ctx: Context, 24 | path: str, 25 | params: dict[str, Any] | None = None, 26 | ) -> None: 27 | if params is None: 28 | params = {} 29 | 30 | self._ctx = ctx 31 | self._path = path 32 | self._params = params 33 | 34 | def fetch_results(self) -> List[dict]: 35 | """Fetch results. 36 | 37 | Collects all results from all pages. 38 | 39 | Returns 40 | ------- 41 | List[dict] 42 | A coalesced list of all results. 43 | """ 44 | results = [] 45 | for page in self.fetch_pages(): 46 | results.extend(page.results) 47 | return results 48 | 49 | def fetch_pages(self) -> Generator[CursorPage, None, None]: 50 | """Fetch pages. 51 | 52 | Yields 53 | ------ 54 | Generator[Page, None, None] 55 | """ 56 | next_page = None 57 | while True: 58 | page = self.fetch_page(next_page) 59 | yield page 60 | cursors: dict = page.paging.get("cursors", {}) 61 | next_page = cursors.get("next") 62 | if not next_page: 63 | # stop if a next page is not defined 64 | return 65 | 66 | def fetch_page(self, next_page: str | None = None) -> CursorPage: 67 | """Fetch a page. 68 | 69 | Parameters 70 | ---------- 71 | next : str | None, optional 72 | the next page identifier or None to fetch the first page, by default None 73 | 74 | Returns 75 | ------- 76 | Page 77 | """ 78 | params = { 79 | **self._params, 80 | "next": next_page, 81 | "limit": _MAX_PAGE_SIZE, 82 | } 83 | response = self._ctx.client.get(self._path, params=params) 84 | return CursorPage(**response.json()) 85 | -------------------------------------------------------------------------------- /src/posit/connect/errors.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from typing_extensions import Any 4 | 5 | 6 | class ClientError(Exception): 7 | def __init__( 8 | self, 9 | error_code: int, 10 | error_message: str, 11 | http_status: int, 12 | http_message: str, 13 | payload: Any = None, 14 | ): 15 | self.error_code = error_code 16 | self.error_message = error_message 17 | self.http_status = http_status 18 | self.http_message = http_message 19 | self.payload = payload 20 | super().__init__( 21 | json.dumps( 22 | { 23 | "error_code": error_code, 24 | "error_message": error_message, 25 | "http_status": http_status, 26 | "http_message": http_message, 27 | "payload": payload, 28 | }, 29 | ), 30 | ) 31 | -------------------------------------------------------------------------------- /src/posit/connect/external/__init__.py: -------------------------------------------------------------------------------- 1 | """External integrations. 2 | 3 | Notes 4 | ----- 5 | The APIs in this module are provided as a convenience and are subject to breaking changes. 6 | """ 7 | -------------------------------------------------------------------------------- /src/posit/connect/external/snowflake.py: -------------------------------------------------------------------------------- 1 | """Snowflake SDK integration. 2 | 3 | Snowflake SDK credentials implementations which support interacting with Posit OAuth integrations on Connect. 4 | 5 | Notes 6 | ----- 7 | The APIs in this module are provided as a convenience and are subject to breaking changes. 8 | """ 9 | 10 | from typing_extensions import Optional 11 | 12 | from .._utils import is_local 13 | from ..client import Client 14 | 15 | 16 | class PositAuthenticator: 17 | """ 18 | Authenticator for Snowflake SDK which supports Posit OAuth integrations on Connect. 19 | 20 | Examples 21 | -------- 22 | ```python 23 | import os 24 | 25 | import pandas as pd 26 | import snowflake.connector 27 | import streamlit as st 28 | 29 | from posit.connect.external.snowflake import PositAuthenticator 30 | 31 | ACCOUNT = os.getenv("SNOWFLAKE_ACCOUNT") 32 | WAREHOUSE = os.getenv("SNOWFLAKE_WAREHOUSE") 33 | 34 | # USER is only required when running the example locally with external browser auth 35 | USER = os.getenv("SNOWFLAKE_USER") 36 | 37 | # https://docs.snowflake.com/en/user-guide/sample-data-using 38 | DATABASE = os.getenv("SNOWFLAKE_DATABASE", "snowflake_sample_data") 39 | SCHEMA = os.getenv("SNOWFLAKE_SCHEMA", "tpch_sf1") 40 | TABLE = os.getenv("SNOWFLAKE_TABLE", "lineitem") 41 | 42 | session_token = st.context.headers.get("Posit-Connect-User-Session-Token") 43 | auth = PositAuthenticator( 44 | local_authenticator="EXTERNALBROWSER", user_session_token=session_token 45 | ) 46 | 47 | con = snowflake.connector.connect( 48 | user=USER, 49 | account=ACCOUNT, 50 | warehouse=WAREHOUSE, 51 | database=DATABASE, 52 | schema=SCHEMA, 53 | authenticator=auth.authenticator, 54 | token=auth.token, 55 | ) 56 | 57 | snowflake_user = con.cursor().execute("SELECT CURRENT_USER()").fetchone() 58 | st.write(f"Hello, {snowflake_user[0]}!") 59 | 60 | with st.spinner("Loading data from Snowflake..."): 61 | df = pd.read_sql_query(f"SELECT * FROM {TABLE} LIMIT 10", con) 62 | 63 | st.dataframe(df) 64 | ``` 65 | """ 66 | 67 | def __init__( 68 | self, 69 | local_authenticator: Optional[str] = None, 70 | client: Optional[Client] = None, 71 | user_session_token: Optional[str] = None, 72 | ): 73 | self._local_authenticator = local_authenticator 74 | self._client = client 75 | self._user_session_token = user_session_token 76 | 77 | @property 78 | def authenticator(self) -> Optional[str]: 79 | if is_local(): 80 | return self._local_authenticator 81 | return "oauth" 82 | 83 | @property 84 | def token(self) -> Optional[str]: 85 | if is_local(): 86 | return None 87 | 88 | # If the user-session-token wasn't provided and we're running on Connect then we raise an exception. 89 | # user_session_token is required to impersonate the viewer. 90 | if self._user_session_token is None: 91 | raise ValueError("The user-session-token is required for viewer authentication.") 92 | 93 | if self._client is None: 94 | self._client = Client() 95 | 96 | credentials = self._client.oauth.get_credentials(self._user_session_token) 97 | return credentials.get("access_token") 98 | -------------------------------------------------------------------------------- /src/posit/connect/hooks.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | from http.client import responses 3 | 4 | from requests import JSONDecodeError, Response 5 | 6 | from .errors import ClientError 7 | 8 | 9 | def handle_errors( 10 | response: Response, 11 | # Arguments for the hook callback signature 12 | *request_hook_args, # noqa: ARG001 13 | **request_hook_kwargs, # noqa: ARG001 14 | ) -> Response: 15 | if response.status_code >= 400: 16 | try: 17 | data = response.json() 18 | error_code = data["code"] 19 | message = data["error"] 20 | payload = data.get("payload") 21 | http_status = response.status_code 22 | http_status_message = responses[http_status] 23 | raise ClientError(error_code, message, http_status, http_status_message, payload) 24 | except JSONDecodeError: 25 | # No JSON error message from Connect, so just raise 26 | response.raise_for_status() 27 | return response 28 | 29 | 30 | def check_for_deprecation_header( 31 | response: Response, 32 | # Extra arguments for the hook callback signature 33 | *args, # noqa: ARG001 34 | **kwargs, # noqa: ARG001 35 | ) -> Response: 36 | """ 37 | Check for deprecation warnings from the server. 38 | 39 | You might get these if you've upgraded the Connect server but not posit-sdk. 40 | posit-sdk will make the right request based on the version of the server, 41 | but if you have an old version of the package, it won't know the new URL 42 | to request. 43 | """ 44 | if "X-Deprecated-Endpoint" in response.headers: 45 | msg = ( 46 | response.url 47 | + " is deprecated and will be removed in a future version of Connect." 48 | + " Please upgrade `posit-sdk` in order to use the new APIs." 49 | ) 50 | warnings.warn(msg, DeprecationWarning, stacklevel=3) 51 | return response 52 | -------------------------------------------------------------------------------- /src/posit/connect/me.py: -------------------------------------------------------------------------------- 1 | from .context import Context 2 | from .users import User 3 | 4 | 5 | def get(ctx: Context) -> User: 6 | """ 7 | Gets the current user. 8 | 9 | Args: 10 | config (Config): The configuration object containing the URL. 11 | session (requests.Session): The session object used for making HTTP requests. 12 | 13 | Returns 14 | ------- 15 | User: The current user. 16 | """ 17 | path = "v1/user" 18 | response = ctx.client.get(path) 19 | return User(ctx, **response.json()) 20 | -------------------------------------------------------------------------------- /src/posit/connect/metrics/__init__.py: -------------------------------------------------------------------------------- 1 | """Metric resources.""" 2 | 3 | from .metrics import Metrics as Metrics 4 | -------------------------------------------------------------------------------- /src/posit/connect/metrics/hits.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing_extensions import ( 4 | Iterable, 5 | Protocol, 6 | ) 7 | 8 | from ..resources import Resource, ResourceSequence, _ResourceSequence 9 | from .rename_params import rename_params 10 | 11 | 12 | class Hit(Resource, Protocol): 13 | pass 14 | 15 | 16 | class Hits(ResourceSequence[Hit], Protocol): 17 | def fetch( 18 | self, 19 | *, 20 | start: str = ..., 21 | end: str = ..., 22 | ) -> Iterable[Hit]: 23 | """ 24 | Fetch all content hit records matching the specified conditions. 25 | 26 | Parameters 27 | ---------- 28 | start : str, not required 29 | The timestamp that starts the time window of interest in RFC 3339 format. 30 | Any hit information that happened prior to this timestamp will not be returned. 31 | Example: "2025-05-01T00:00:00Z" 32 | end : str, not required 33 | The timestamp that ends the time window of interest in RFC 3339 format. 34 | Any hit information that happened after this timestamp will not be returned. 35 | Example: "2025-05-02T00:00:00Z" 36 | 37 | Returns 38 | ------- 39 | Iterable[Hit] 40 | All content hit records matching the specified conditions. 41 | """ 42 | ... 43 | 44 | def find_by( 45 | self, 46 | *, 47 | id: str = ..., # noqa: A002 48 | content_guid: str = ..., 49 | user_guid: str = ..., 50 | timestamp: str = ..., 51 | ) -> Hit | None: 52 | """ 53 | Find the first hit record matching the specified conditions. 54 | 55 | There is no implied ordering, so if order matters, you should specify it yourself. 56 | 57 | Parameters 58 | ---------- 59 | id : str, not required 60 | The ID of the activity record. 61 | content_guid : str, not required 62 | The GUID, in RFC4122 format, of the content this information pertains to. 63 | user_guid : str, not required 64 | The GUID, in RFC4122 format, of the user that visited the content. 65 | May be null when the target content does not require a user session. 66 | timestamp : str, not required 67 | The timestamp, in RFC 3339 format, when the user visited the content. 68 | 69 | Returns 70 | ------- 71 | Hit | None 72 | The first hit record matching the specified conditions, or `None` if no such record exists. 73 | """ 74 | ... 75 | 76 | 77 | class _Hits(_ResourceSequence, Hits): 78 | def fetch( 79 | self, 80 | **kwargs, 81 | ) -> Iterable[Hit]: 82 | """ 83 | Fetch all content hit records matching the specified conditions. 84 | 85 | Parameters 86 | ---------- 87 | start : str, not required 88 | The timestamp that starts the time window of interest in RFC 3339 format. 89 | Any hit information that happened prior to this timestamp will not be returned. 90 | This corresponds to the `from` parameter in the API. 91 | Example: "2025-05-01T00:00:00Z" 92 | end : str, not required 93 | The timestamp that ends the time window of interest in RFC 3339 format. 94 | Any hit information that happened after this timestamp will not be returned. 95 | This corresponds to the `to` parameter in the API. 96 | Example: "2025-05-02T00:00:00Z" 97 | 98 | Returns 99 | ------- 100 | Iterable[Hit] 101 | All content hit records matching the specified conditions. 102 | """ 103 | params = rename_params(kwargs) 104 | return super().fetch(**params) 105 | -------------------------------------------------------------------------------- /src/posit/connect/metrics/metrics.py: -------------------------------------------------------------------------------- 1 | """Metric resources.""" 2 | 3 | from .. import resources 4 | from ..context import requires 5 | from .hits import Hits, _Hits 6 | from .usage import Usage 7 | 8 | 9 | class Metrics(resources.Resources): 10 | """Metrics resource. 11 | 12 | Attributes 13 | ---------- 14 | usage: Usage 15 | Usage resource. 16 | """ 17 | 18 | @property 19 | def usage(self) -> Usage: 20 | return Usage(self._ctx) 21 | 22 | @property 23 | @requires(version="2025.04.0") 24 | def hits(self) -> Hits: 25 | return _Hits(self._ctx, "v1/instrumentation/content/hits", uid="id") 26 | -------------------------------------------------------------------------------- /src/posit/connect/metrics/rename_params.py: -------------------------------------------------------------------------------- 1 | def rename_params(params: dict) -> dict: 2 | """Rename params from the internal to the external signature. 3 | 4 | The API accepts `from` as a querystring parameter. Since `from` is a reserved word in Python, the SDK uses the name `start` instead. The querystring parameter `to` takes the same form for consistency. 5 | 6 | Parameters 7 | ---------- 8 | params : dict 9 | 10 | Returns 11 | ------- 12 | dict 13 | """ 14 | if "start" in params: 15 | params["from"] = params["start"] 16 | del params["start"] 17 | 18 | if "end" in params: 19 | params["to"] = params["end"] 20 | del params["end"] 21 | 22 | return params 23 | -------------------------------------------------------------------------------- /src/posit/connect/oauth/__init__.py: -------------------------------------------------------------------------------- 1 | """OAuth resources.""" 2 | 3 | from .oauth import Credentials as Credentials 4 | from .oauth import OAuth as OAuth 5 | -------------------------------------------------------------------------------- /src/posit/connect/oauth/associations.py: -------------------------------------------------------------------------------- 1 | """OAuth association resources.""" 2 | 3 | from typing_extensions import List 4 | 5 | from ..context import Context 6 | from ..resources import BaseResource, Resources 7 | 8 | 9 | class Association(BaseResource): 10 | pass 11 | 12 | 13 | class IntegrationAssociations(Resources): 14 | """IntegrationAssociations resource.""" 15 | 16 | def __init__(self, ctx: Context, integration_guid: str) -> None: 17 | super().__init__(ctx) 18 | self.integration_guid = integration_guid 19 | 20 | def find(self) -> List[Association]: 21 | """Find OAuth associations. 22 | 23 | Returns 24 | ------- 25 | List[Association] 26 | """ 27 | path = f"v1/oauth/integrations/{self.integration_guid}/associations" 28 | response = self._ctx.client.get(path) 29 | return [ 30 | Association( 31 | self._ctx, 32 | **result, 33 | ) 34 | for result in response.json() 35 | ] 36 | 37 | 38 | class ContentItemAssociations(Resources): 39 | """ContentItemAssociations resource.""" 40 | 41 | def __init__(self, ctx, content_guid: str): 42 | super().__init__(ctx) 43 | self.content_guid = content_guid 44 | 45 | def find(self) -> List[Association]: 46 | """Find OAuth associations. 47 | 48 | Returns 49 | ------- 50 | List[Association] 51 | """ 52 | path = f"v1/content/{self.content_guid}/oauth/integrations/associations" 53 | response = self._ctx.client.get(path) 54 | return [ 55 | Association( 56 | self._ctx, 57 | **result, 58 | ) 59 | for result in response.json() 60 | ] 61 | 62 | def delete(self) -> None: 63 | """Delete integration associations.""" 64 | data = [] 65 | 66 | path = f"v1/content/{self.content_guid}/oauth/integrations/associations" 67 | self._ctx.client.put(path, json=data) 68 | 69 | def update(self, integration_guid: str) -> None: 70 | """Set integration associations.""" 71 | data = [{"oauth_integration_guid": integration_guid}] 72 | 73 | path = f"v1/content/{self.content_guid}/oauth/integrations/associations" 74 | self._ctx.client.put(path, json=data) 75 | -------------------------------------------------------------------------------- /src/posit/connect/oauth/integrations.py: -------------------------------------------------------------------------------- 1 | """OAuth integration resources.""" 2 | 3 | from typing_extensions import List, Optional, overload 4 | 5 | from ..resources import BaseResource, Resources 6 | from .associations import IntegrationAssociations 7 | 8 | 9 | class Integration(BaseResource): 10 | """OAuth integration resource.""" 11 | 12 | @property 13 | def associations(self) -> IntegrationAssociations: 14 | return IntegrationAssociations(self._ctx, integration_guid=self["guid"]) 15 | 16 | def delete(self) -> None: 17 | """Delete the OAuth integration.""" 18 | path = f"v1/oauth/integrations/{self['guid']}" 19 | self._ctx.client.delete(path) 20 | 21 | @overload 22 | def update( 23 | self, 24 | *args, 25 | name: str = ..., 26 | description: str = ..., 27 | config: dict = ..., 28 | **kwargs, 29 | ) -> None: 30 | """Update the OAuth integration. 31 | 32 | Parameters 33 | ---------- 34 | name: str, optional 35 | description: str, optional 36 | config: dict, optional 37 | """ 38 | 39 | @overload 40 | def update(self, *args, **kwargs) -> None: 41 | """Update the OAuth integration.""" 42 | 43 | def update(self, *args, **kwargs) -> None: 44 | """Update the OAuth integration.""" 45 | body = dict(*args, **kwargs) 46 | path = f"v1/oauth/integrations/{self['guid']}" 47 | response = self._ctx.client.patch(path, json=body) 48 | super().update(**response.json()) 49 | 50 | 51 | class Integrations(Resources): 52 | """Integrations resource.""" 53 | 54 | @overload 55 | def create( 56 | self, 57 | *, 58 | name: str, 59 | description: Optional[str], 60 | template: str, 61 | config: dict, 62 | ) -> Integration: 63 | """Create an OAuth integration. 64 | 65 | Parameters 66 | ---------- 67 | name : str 68 | description : Optional[str] 69 | template : str 70 | config : dict 71 | 72 | Returns 73 | ------- 74 | Integration 75 | """ 76 | 77 | @overload 78 | def create(self, **kwargs) -> Integration: 79 | """Create an OAuth integration. 80 | 81 | Returns 82 | ------- 83 | Integration 84 | """ 85 | 86 | def create(self, **kwargs) -> Integration: 87 | """Create an OAuth integration. 88 | 89 | Parameters 90 | ---------- 91 | name : str 92 | description : Optional[str] 93 | template : str 94 | config : dict 95 | 96 | Returns 97 | ------- 98 | Integration 99 | """ 100 | path = "v1/oauth/integrations" 101 | response = self._ctx.client.post(path, json=kwargs) 102 | return Integration(self._ctx, **response.json()) 103 | 104 | def find(self) -> List[Integration]: 105 | """Find OAuth integrations. 106 | 107 | Returns 108 | ------- 109 | List[Integration] 110 | """ 111 | path = "v1/oauth/integrations" 112 | response = self._ctx.client.get(path) 113 | return [ 114 | Integration( 115 | self._ctx, 116 | **result, 117 | ) 118 | for result in response.json() 119 | ] 120 | 121 | def get(self, guid: str) -> Integration: 122 | """Get an OAuth integration. 123 | 124 | Parameters 125 | ---------- 126 | guid: str 127 | 128 | Returns 129 | ------- 130 | Integration 131 | """ 132 | path = f"v1/oauth/integrations/{guid}" 133 | response = self._ctx.client.get(path) 134 | return Integration(self._ctx, **response.json()) 135 | -------------------------------------------------------------------------------- /src/posit/connect/oauth/oauth.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | from enum import Enum 5 | 6 | from typing_extensions import TYPE_CHECKING, Optional, TypedDict 7 | 8 | from ..resources import Resources 9 | from .integrations import Integrations 10 | from .sessions import Sessions 11 | 12 | if TYPE_CHECKING: 13 | from ..context import Context 14 | 15 | GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange" 16 | 17 | 18 | class OAuthTokenType(str, Enum): 19 | ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token" 20 | AWS_CREDENTIALS = "urn:ietf:params:aws:token-type:credentials" 21 | API_KEY = "urn:posit:connect:api-key" 22 | CONTENT_SESSION_TOKEN = "urn:posit:connect:content-session-token" 23 | USER_SESSION_TOKEN = "urn:posit:connect:user-session-token" 24 | 25 | 26 | def _get_content_session_token() -> str: 27 | """Return the content session token. 28 | 29 | Reads the environment variable 'CONNECT_CONTENT_SESSION_TOKEN'. 30 | 31 | Raises 32 | ------ 33 | ValueError: If CONNECT_CONTENT_SESSION_TOKEN is not set or invalid 34 | 35 | Returns 36 | ------- 37 | str 38 | """ 39 | value = os.environ.get("CONNECT_CONTENT_SESSION_TOKEN") 40 | if not value: 41 | raise ValueError( 42 | "Invalid value for 'CONNECT_CONTENT_SESSION_TOKEN': Must be a non-empty string." 43 | ) 44 | return value 45 | 46 | 47 | class OAuth(Resources): 48 | def __init__(self, ctx: Context, api_key: str) -> None: 49 | super().__init__(ctx) 50 | self.api_key = api_key 51 | self._path = "v1/oauth/integrations/credentials" 52 | 53 | @property 54 | def integrations(self): 55 | return Integrations(self._ctx) 56 | 57 | @property 58 | def sessions(self): 59 | return Sessions(self._ctx) 60 | 61 | def get_credentials( 62 | self, 63 | user_session_token: Optional[str] = None, 64 | requested_token_type: Optional[str | OAuthTokenType] = None, 65 | ) -> Credentials: 66 | """Perform an oauth credential exchange with a user-session-token.""" 67 | # craft a credential exchange request 68 | data = {} 69 | data["grant_type"] = GRANT_TYPE 70 | data["subject_token_type"] = OAuthTokenType.USER_SESSION_TOKEN 71 | if user_session_token: 72 | data["subject_token"] = user_session_token 73 | if requested_token_type: 74 | data["requested_token_type"] = requested_token_type 75 | 76 | response = self._ctx.client.post(self._path, data=data) 77 | return Credentials(**response.json()) 78 | 79 | def get_content_credentials( 80 | self, 81 | content_session_token: Optional[str] = None, 82 | requested_token_type: Optional[str | OAuthTokenType] = None, 83 | ) -> Credentials: 84 | """Perform an oauth credential exchange with a content-session-token.""" 85 | # craft a credential exchange request 86 | data = {} 87 | data["grant_type"] = GRANT_TYPE 88 | data["subject_token_type"] = OAuthTokenType.CONTENT_SESSION_TOKEN 89 | data["subject_token"] = content_session_token or _get_content_session_token() 90 | if requested_token_type: 91 | data["requested_token_type"] = requested_token_type 92 | 93 | response = self._ctx.client.post(self._path, data=data) 94 | return Credentials(**response.json()) 95 | 96 | 97 | class Credentials(TypedDict, total=False): 98 | access_token: str 99 | issued_token_type: str 100 | token_type: str 101 | -------------------------------------------------------------------------------- /src/posit/connect/oauth/sessions.py: -------------------------------------------------------------------------------- 1 | """OAuth session resources.""" 2 | 3 | from typing_extensions import List, Optional, overload 4 | 5 | from ..resources import BaseResource, Resources 6 | 7 | 8 | class Session(BaseResource): 9 | """OAuth session resource.""" 10 | 11 | def delete(self) -> None: 12 | path = f"v1/oauth/sessions/{self['guid']}" 13 | self._ctx.client.delete(path) 14 | 15 | 16 | class Sessions(Resources): 17 | @overload 18 | def find( 19 | self, 20 | *, 21 | all: Optional[bool] = ..., 22 | ) -> List[Session]: ... 23 | 24 | @overload 25 | def find(self, **kwargs) -> List[Session]: ... 26 | 27 | def find(self, **kwargs) -> List[Session]: 28 | path = "v1/oauth/sessions" 29 | response = self._ctx.client.get(path, params=kwargs) 30 | results = response.json() 31 | return [Session(self._ctx, **result) for result in results] 32 | 33 | def get(self, guid: str) -> Session: 34 | """Get an OAuth session. 35 | 36 | Parameters 37 | ---------- 38 | guid: str 39 | 40 | Returns 41 | ------- 42 | Session 43 | """ 44 | path = f"v1/oauth/sessions/{guid}" 45 | response = self._ctx.client.get(path) 46 | return Session(self._ctx, **response.json()) 47 | -------------------------------------------------------------------------------- /src/posit/connect/paginator.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | from typing_extensions import TYPE_CHECKING, Generator, List 6 | 7 | if TYPE_CHECKING: 8 | from .context import Context 9 | 10 | # The maximum page size supported by the API. 11 | _MAX_PAGE_SIZE = 500 12 | 13 | 14 | @dataclass 15 | class Page: 16 | """ 17 | Represents a page of results returned by the paginator. 18 | 19 | Attributes 20 | ---------- 21 | current_page (int): The current page number. 22 | total (int): The total number of results. 23 | results (List[dict]): The list of results on the current page. 24 | """ 25 | 26 | current_page: int 27 | total: int 28 | results: List[dict] 29 | 30 | 31 | class Paginator: 32 | """ 33 | A class for paginating through API results. 34 | 35 | Args: 36 | session (requests.Session): The session object to use for making API requests. 37 | url (str): The URL of the paginated API endpoint. 38 | 39 | Attributes 40 | ---------- 41 | session (requests.Session): The session object to use for making API requests. 42 | url (str): The URL of the paginated API endpoint. 43 | """ 44 | 45 | def __init__( 46 | self, ctx: Context, path: str, params: dict | None = None, page_size: int | None = None 47 | ) -> None: 48 | if params is None: 49 | params = {} 50 | self._ctx = ctx 51 | self._path = path 52 | self._params = params 53 | self._page_size = page_size or _MAX_PAGE_SIZE 54 | 55 | def fetch_results(self) -> List[dict]: 56 | """ 57 | Fetches and returns all the results from the paginated API endpoint. 58 | 59 | Returns 60 | ------- 61 | A list of dictionaries representing the fetched results. 62 | """ 63 | results = [] 64 | for page in self.fetch_pages(): 65 | results.extend(page.results) 66 | return results 67 | 68 | def fetch_pages(self) -> Generator[Page, None, None]: 69 | """ 70 | Fetches pages of results from the API. 71 | 72 | Yields 73 | ------ 74 | Page: A page of results from the API. 75 | """ 76 | count = 0 77 | page_number = 1 78 | while True: 79 | page = self.fetch_page(page_number) 80 | page_number += 1 81 | if len(page.results) > 0: 82 | yield page 83 | else: 84 | # stop if the result set is empty 85 | return 86 | 87 | count += len(page.results) 88 | # Check if the local count has reached the total threshold. 89 | # It is possible for count to exceed total if the total changes 90 | # during execution of this loop. 91 | # It is also possible for the total to change between iterations. 92 | if count >= page.total: 93 | break 94 | 95 | def fetch_page(self, page_number: int) -> Page: 96 | """ 97 | Fetches a specific page of data from the API. 98 | 99 | Args: 100 | page_number (int): The page number to fetch. 101 | 102 | Returns 103 | ------- 104 | Page: The fetched page object. 105 | 106 | """ 107 | params = { 108 | **self._params, 109 | "page_number": page_number, 110 | "page_size": self._page_size, 111 | } 112 | response = self._ctx.client.get(self._path, params=params) 113 | return Page(**response.json()) 114 | -------------------------------------------------------------------------------- /src/posit/connect/repository.py: -------------------------------------------------------------------------------- 1 | """Repository resources.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing_extensions import ( 6 | Optional, 7 | Protocol, 8 | overload, 9 | runtime_checkable, 10 | ) 11 | 12 | from ._utils import update_dict_values 13 | from .errors import ClientError 14 | from .resources import Resource, _Resource 15 | 16 | 17 | # ContentItem Repository uses a PATCH method, not a PUT for updating. 18 | class _ContentItemRepository(_Resource): 19 | def update(self, **attributes) -> None: 20 | response = self._ctx.client.patch(self._path, json=attributes) 21 | result = response.json() 22 | 23 | update_dict_values(self, **result) 24 | 25 | 26 | @runtime_checkable 27 | class ContentItemRepository(Resource, Protocol): 28 | """ 29 | Content items GitHub repository information. 30 | 31 | See Also 32 | -------- 33 | * Get info: https://docs.posit.co/connect/api/#get-/v1/content/-guid-/repository 34 | * Delete info: https://docs.posit.co/connect/api/#delete-/v1/content/-guid-/repository 35 | * Update info: https://docs.posit.co/connect/api/#patch-/v1/content/-guid-/repository 36 | """ 37 | 38 | def destroy(self) -> None: 39 | """ 40 | Delete the content's git repository location. 41 | 42 | See Also 43 | -------- 44 | * https://docs.posit.co/connect/api/#delete-/v1/content/-guid-/repository 45 | """ 46 | ... 47 | 48 | def update( 49 | self, 50 | *, 51 | repository: Optional[str] = None, 52 | branch: str = "main", 53 | directory: str = ".", 54 | polling: bool = False, 55 | ) -> None: 56 | """Update the content's repository. 57 | 58 | Parameters 59 | ---------- 60 | repository: str, optional 61 | URL for the repository. Default is None. 62 | branch: str, optional 63 | The tracked Git branch. Default is 'main'. 64 | directory: str, optional 65 | Directory containing the content. Default is '.' 66 | polling: bool, optional 67 | Indicates that the Git repository is regularly polled. Default is False. 68 | 69 | Returns 70 | ------- 71 | None 72 | 73 | See Also 74 | -------- 75 | * https://docs.posit.co/connect/api/#patch-/v1/content/-guid-/repository 76 | """ 77 | ... 78 | 79 | 80 | class ContentItemRepositoryMixin: 81 | @property 82 | def repository(self: Resource) -> ContentItemRepository | None: 83 | try: 84 | path = f"v1/content/{self['guid']}/repository" 85 | response = self._ctx.client.get(path) 86 | result = response.json() 87 | return _ContentItemRepository( 88 | self._ctx, 89 | path, 90 | **result, 91 | ) 92 | except ClientError: 93 | return None 94 | 95 | @overload 96 | def create_repository( 97 | self: Resource, 98 | /, 99 | *, 100 | repository: Optional[str] = None, 101 | branch: str = "main", 102 | directory: str = ".", 103 | polling: bool = False, 104 | ) -> ContentItemRepository: ... 105 | 106 | @overload 107 | def create_repository(self: Resource, /, **attributes) -> ContentItemRepository: ... 108 | 109 | def create_repository(self: Resource, /, **attributes) -> ContentItemRepository: 110 | """Create repository. 111 | 112 | Parameters 113 | ---------- 114 | repository : str 115 | URL for the respository. 116 | branch : str, optional 117 | The tracked Git branch. Default is 'main'. 118 | directory : str, optional 119 | Directory containing the content. Default is '.'. 120 | polling : bool, optional 121 | Indicates that the Git repository is regularly polled. Default is False. 122 | 123 | Returns 124 | ------- 125 | ContentItemRepository 126 | """ 127 | path = f"v1/content/{self['guid']}/repository" 128 | response = self._ctx.client.put(path, json=attributes) 129 | result = response.json() 130 | 131 | return _ContentItemRepository( 132 | self._ctx, 133 | path, 134 | **result, 135 | ) 136 | -------------------------------------------------------------------------------- /src/posit/connect/sessions.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urljoin 2 | 3 | import requests 4 | 5 | 6 | class Session(requests.Session): 7 | """Custom session that implements CURLOPT_POSTREDIR. 8 | 9 | This class mimics the functionality of CURLOPT_POSTREDIR from libcurl by 10 | providing a custom implementation of the POST method. It allows the caller 11 | to control whether the original POST data is preserved on redirects or if the 12 | request should be converted to a GET when a redirect occurs. This is achieved 13 | by disabling automatic redirect handling and manually following the redirect 14 | chain with the desired behavior. 15 | 16 | Notes 17 | ----- 18 | The custom `post` method in this class: 19 | 20 | - Disables automatic redirect handling by setting ``allow_redirects=False``. 21 | - Manually follows redirects up to a specified ``max_redirects``. 22 | - Determines the HTTP method for subsequent requests based on the response 23 | status code and the ``preserve_post`` flag: 24 | 25 | - For HTTP status codes 307 and 308, the method and request body are 26 | always preserved as POST. 27 | - For other redirects (e.g., 301, 302, 303), the behavior is determined 28 | by ``preserve_post``: 29 | - If ``preserve_post=True``, the POST method is maintained. 30 | - If ``preserve_post=False``, the method is converted to GET and the 31 | request body is discarded. 32 | 33 | Examples 34 | -------- 35 | Create a session and send a POST request while preserving POST data on redirects: 36 | 37 | >>> session = Session() 38 | >>> response = session.post( 39 | ... "https://example.com/api", data={"key": "value"}, preserve_post=True 40 | ... ) 41 | >>> print(response.status_code) 42 | 43 | See Also 44 | -------- 45 | requests.Session : The base session class from the requests library. 46 | """ 47 | 48 | def post(self, url, data=None, json=None, preserve_post=True, max_redirects=5, **kwargs): 49 | """ 50 | Send a POST request and handle redirects manually. 51 | 52 | Parameters 53 | ---------- 54 | url : str 55 | The URL to send the POST request to. 56 | data : dict, bytes, or file-like object, optional 57 | The form data to send. 58 | json : any, optional 59 | The JSON data to send. 60 | preserve_post : bool, optional 61 | If True, re-send POST data on redirects (mimicking CURLOPT_POSTREDIR); 62 | if False, converts to GET on 301/302/303 responses. 63 | max_redirects : int, optional 64 | Maximum number of redirects to follow. 65 | **kwargs 66 | Additional keyword arguments passed to the request. 67 | 68 | Returns 69 | ------- 70 | requests.Response 71 | The final response after following redirects. 72 | """ 73 | # Force manual redirect handling by disabling auto redirects. 74 | kwargs["allow_redirects"] = False 75 | 76 | # Initial POST request 77 | response = super().post(url, data=data, json=json, **kwargs) 78 | redirect_count = 0 79 | 80 | # Manually follow redirects, if any 81 | while response.is_redirect and redirect_count < max_redirects: 82 | redirect_url = response.headers.get("location") 83 | if not redirect_url: 84 | break # No redirect URL; exit loop 85 | 86 | redirect_url = urljoin(response.url, redirect_url) 87 | 88 | # For 307 and 308 the HTTP spec mandates preserving the method and body. 89 | if response.status_code in (307, 308): 90 | method = "POST" 91 | else: 92 | if preserve_post: 93 | method = "POST" 94 | else: 95 | method = "GET" 96 | data = None 97 | json = None 98 | 99 | # Perform the next request in the redirect chain. 100 | response = self.request(method, redirect_url, data=data, json=json, **kwargs) 101 | redirect_count += 1 102 | 103 | return response 104 | -------------------------------------------------------------------------------- /src/posit/connect/urls.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import posixpath 4 | from urllib.parse import urlsplit, urlunsplit 5 | 6 | 7 | class Url(str): 8 | """URL representation for Connect. 9 | 10 | An opinionated URL representation of a Connect URL. Maintains various 11 | conventions: 12 | - It begins with a scheme. 13 | - It is absolute. 14 | - It contains '__api__'. 15 | 16 | Supports Python builtin __add__ for append. 17 | 18 | Methods 19 | ------- 20 | append(path: str) 21 | Append a path to the URL. 22 | 23 | Examples 24 | -------- 25 | >>> url = Url("http://connect.example.com/") 26 | http://connect.example.com/__api__ 27 | >>> url + "endpoint" 28 | http://connect.example.com/__api__/endpoint 29 | 30 | Append works with string-like objects (e.g., objects that support casting to string) 31 | >>> url = Url("http://connect.example.com/__api__/endpoint") 32 | http://connect.example.com/__api__/endpoint 33 | >>> url + 1 34 | http://connect.example.com/__api__/endpoint/1 35 | """ 36 | 37 | def __new__(cls, value: str): 38 | url = _create(value) 39 | return super(Url, cls).__new__(cls, url) 40 | 41 | def __add__(self, path: str): 42 | return self.append(path) 43 | 44 | def append(self, path: str) -> Url: 45 | return Url(_append(self, path)) 46 | 47 | 48 | def _create(url: str) -> str: 49 | """Create a URL. 50 | 51 | Asserts that the URL is a proper Posit Connect endpoint. The path '__api__' is appended to the URL if it is missing. 52 | 53 | Parameters 54 | ---------- 55 | url : str 56 | The original URL. 57 | 58 | Returns 59 | ------- 60 | Url 61 | The validated and formatted URL. 62 | 63 | Raises 64 | ------ 65 | ValueError 66 | The Url is missing a scheme. 67 | ValueError 68 | The Url is missing a network location (i.e., a domain name). 69 | 70 | Examples 71 | -------- 72 | >>> _create("http://example.com") 73 | http://example.com/__api__ 74 | 75 | >>> _create("http://example.com/__api__") 76 | http://example.com/__api__ 77 | """ 78 | split = urlsplit(url, allow_fragments=False) 79 | if not split.scheme: 80 | raise ValueError(f"URL must specify a scheme (e.g., http://example.com/__api__): {url}") 81 | if not split.netloc: 82 | raise ValueError(f"URL must be absolute (e.g., http://example.com/__api__): {url}") 83 | 84 | url = url.rstrip("/") 85 | if "/__api__" not in url: 86 | url = _append(url, "__api__") 87 | 88 | return url 89 | 90 | 91 | def _append(url: str, path) -> str: 92 | """Append a path to a Url. 93 | 94 | Parameters 95 | ---------- 96 | url : str 97 | A valid URL. 98 | path : str 99 | A valid path. 100 | 101 | Returns 102 | ------- 103 | Url 104 | The original Url with the path appended to the end. 105 | 106 | Examples 107 | -------- 108 | >>> url = _create("http://example.com/__api__") 109 | >>> _append(url, "path") 110 | http://example.com/__api__/path 111 | """ 112 | path = str(path).strip("/") 113 | split = urlsplit(url, allow_fragments=False) 114 | new_path = posixpath.join(split.path, path) 115 | return urlunsplit((split.scheme, split.netloc, new_path, split.query, None)) 116 | -------------------------------------------------------------------------------- /src/posit/connect/variants.py: -------------------------------------------------------------------------------- 1 | from typing_extensions import List 2 | 3 | from .context import Context 4 | from .resources import BaseResource, Resources 5 | from .tasks import Task 6 | 7 | 8 | class Variant(BaseResource): 9 | def render(self) -> Task: 10 | path = f"variants/{self['id']}/render" 11 | response = self._ctx.client.post(path) 12 | return Task(self._ctx, **response.json()) 13 | 14 | 15 | class Variants(Resources): 16 | def __init__(self, ctx: Context, content_guid: str) -> None: 17 | super().__init__(ctx) 18 | self.content_guid = content_guid 19 | 20 | def find(self) -> List[Variant]: 21 | path = f"applications/{self.content_guid}/variants" 22 | response = self._ctx.client.get(path) 23 | results = response.json() or [] 24 | return [Variant(self._ctx, **result) for result in results] 25 | -------------------------------------------------------------------------------- /src/posit/workbench/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/posit-dev/posit-sdk-py/def37c77153d01d6a93e3f4d283535e1ccff244f/src/posit/workbench/__init__.py -------------------------------------------------------------------------------- /src/posit/workbench/external/__init__.py: -------------------------------------------------------------------------------- 1 | """External integrations. 2 | 3 | Notes 4 | ----- 5 | The APIs in this module are provided as a convenience and are subject to breaking changes. 6 | """ 7 | -------------------------------------------------------------------------------- /src/posit/workbench/external/databricks.py: -------------------------------------------------------------------------------- 1 | """Databricks SDK integration. 2 | 3 | Databricks SDK credentials implementations which support interacting with Posit Workbench-managed Databricks credentials. 4 | 5 | Notes 6 | ----- 7 | These APIs are provided as a convenience and are subject to breaking changes: 8 | https://github.com/databricks/databricks-sdk-py#interface-stability 9 | """ 10 | 11 | from __future__ import annotations 12 | 13 | from typing_extensions import Optional 14 | 15 | try: 16 | from databricks.sdk.core import Config 17 | from databricks.sdk.credentials_provider import ( 18 | CredentialsProvider, 19 | CredentialsStrategy, 20 | ) 21 | except ImportError as e: 22 | raise ImportError("The 'databricks-sdk' package is required to use this module.") from e 23 | 24 | 25 | POSIT_WORKBENCH_AUTH_TYPE = "posit-workbench" 26 | 27 | 28 | class WorkbenchStrategy(CredentialsStrategy): 29 | """`CredentialsStrategy` implementation which uses a bearer token authentication provider for Workbench environments. 30 | 31 | This strategy can be used as a valid `credentials_strategy` when constructing a [](`databricks.sdk.core.Config`). 32 | 33 | It should be used when content running on a Posit Workbench server needs to access a Databricks token 34 | that is manged by Posit Workbench-managed Databricks Credentials. If you need to author content that can 35 | run in multiple environments (local content, Posit Workbench, _and_ Posit Connect), consider using the 36 | `posit.connect.external.databricks.databricks_config()` helper method. 37 | 38 | See Also 39 | -------- 40 | * https://docs.posit.co/ide/server-pro/user/posit-workbench/guide/databricks.html#databricks-with-python 41 | 42 | Examples 43 | -------- 44 | This example shows how authenticate to Databricks using Posit Workbench-managed Databricks Credentials. 45 | 46 | ```python 47 | import os 48 | 49 | from databricks.sdk.core import ApiClient, Config 50 | from databricks.sdk.service.iam import CurrentUserAPI 51 | from shiny import reactive 52 | from shiny.express import render 53 | 54 | from posit.workbench.external.databricks import WorkbenchStrategy 55 | 56 | 57 | @reactive.calc 58 | def cfg(): 59 | return Config( 60 | credentials_strategy=WorkbenchStrategy(), 61 | host=os.getenv("DATABRICKS_HOST"), 62 | ) 63 | 64 | 65 | @render.text 66 | def text(): 67 | current_user_api = CurrentUserAPI(ApiClient(cfg())) 68 | databricks_user_info = current_user_api.me() 69 | return f"Hello, {databricks_user_info.display_name}!" 70 | ``` 71 | """ 72 | 73 | def __init__(self, config: Optional[Config] = None): 74 | self._config = config or Config(profile="workbench") 75 | 76 | def auth_type(self) -> str: 77 | return POSIT_WORKBENCH_AUTH_TYPE 78 | 79 | def __call__(self, *args, **kwargs) -> CredentialsProvider: # noqa: ARG002 80 | if self._config.token is None: 81 | raise ValueError("Missing value for field 'token' in Config.") 82 | 83 | def cp(): 84 | return {"Authorization": f"Bearer {self._config.token}"} 85 | 86 | return cp 87 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/applications/f2f37341-e21d-3d80-c698-a935ad614066/variants.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 6627, 4 | "app_id": 50941, 5 | "key": "txvRW8SG", 6 | "bundle_id": 120726, 7 | "is_default": true, 8 | "name": "default", 9 | "email_collaborators": false, 10 | "email_viewers": false, 11 | "email_all": false, 12 | "created_time": "2024-07-02T19:26:45.878442Z", 13 | "rendering_id": 3055012, 14 | "render_time": "2024-07-17T18:33:49.284709Z", 15 | "render_duration": 5695616577, 16 | "visibility": "public", 17 | "owner_id": 0 18 | } 19 | ] 20 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066.json: -------------------------------------------------------------------------------- 1 | { 2 | "guid": "f2f37341-e21d-3d80-c698-a935ad614066", 3 | "name": "Performance-Data-1671216053560", 4 | "title": "Performance Data", 5 | "description": "", 6 | "access_type": "logged_in", 7 | "connection_timeout": null, 8 | "read_timeout": null, 9 | "init_timeout": null, 10 | "idle_timeout": null, 11 | "max_processes": null, 12 | "min_processes": null, 13 | "max_conns_per_process": null, 14 | "load_factor": null, 15 | "memory_request": null, 16 | "memory_limit": null, 17 | "cpu_request": null, 18 | "cpu_limit": null, 19 | "amd_gpu_limit": null, 20 | "nvidia_gpu_limit": null, 21 | "service_account_name": null, 22 | "default_image_name": null, 23 | "created_time": "2022-12-16T18:40:53Z", 24 | "last_deployed_time": "2024-02-24T09:56:30Z", 25 | "bundle_id": "401171", 26 | "app_mode": "quarto-static", 27 | "content_category": "", 28 | "parameterized": false, 29 | "cluster_name": "Local", 30 | "image_name": null, 31 | "r_version": null, 32 | "py_version": "3.9.17", 33 | "quarto_version": "1.3.340", 34 | "r_environment_management": null, 35 | "default_r_environment_management": null, 36 | "py_environment_management": true, 37 | "default_py_environment_management": null, 38 | "run_as": null, 39 | "run_as_current_user": false, 40 | "owner_guid": "20a79ce3-6e87-4522-9faf-be24228800a4", 41 | "content_url": "https://connect.example/content/f2f37341-e21d-3d80-c698-a935ad614066/", 42 | "dashboard_url": "https://connect.example/connect/#/apps/f2f37341-e21d-3d80-c698-a935ad614066", 43 | "app_role": "viewer", 44 | "id": "8274" 45 | } 46 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/bundles.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "101", 4 | "content_guid": "f2f37341-e21d-3d80-c698-a935ad614066", 5 | "created_time": "2006-01-02T15:04:05Z07:00", 6 | "cluster_name": "Local", 7 | "image_name": "Local", 8 | "r_version": "3.5.1", 9 | "r_environment_management": true, 10 | "py_version": "3.8.2", 11 | "py_environment_management": true, 12 | "quarto_version": "0.2.22", 13 | "active": false, 14 | "size": 1000000, 15 | "metadata": { 16 | "source": "string", 17 | "source_repo": "string", 18 | "source_branch": "string", 19 | "source_commit": "string", 20 | "archive_md5": "37324238a80595c453c706b22adb83d3", 21 | "archive_sha1": "a2f7d13d87657df599aeeabdb70194d508cfa92f" 22 | } 23 | } 24 | ] 25 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/bundles/101.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "101", 3 | "content_guid": "f2f37341-e21d-3d80-c698-a935ad614066", 4 | "created_time": "2006-01-02T15:04:05Z07:00", 5 | "cluster_name": "Local", 6 | "image_name": "Local", 7 | "r_version": "3.5.1", 8 | "r_environment_management": true, 9 | "py_version": "3.8.2", 10 | "py_environment_management": true, 11 | "quarto_version": "0.2.22", 12 | "active": false, 13 | "size": 1000000, 14 | "metadata": { 15 | "source": "string", 16 | "source_repo": "string", 17 | "source_branch": "string", 18 | "source_commit": "string", 19 | "archive_md5": "37324238a80595c453c706b22adb83d3", 20 | "archive_sha1": "a2f7d13d87657df599aeeabdb70194d508cfa92f" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/bundles/101/download/bundle.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/posit-dev/posit-sdk-py/def37c77153d01d6a93e3f4d283535e1ccff244f/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/bundles/101/download/bundle.tar.gz -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "54", 4 | "ppid": "20253", 5 | "pid": "20253", 6 | "key": "tHawGvHZTosJA2Dx", 7 | "remote_id": "S3ViZXJuZXRlczpyZW5kZXItci1tYXJrZG93bi1zaXRlLWtnODJo", 8 | "app_id": "54", 9 | "variant_id": "54", 10 | "bundle_id": "54", 11 | "start_time": "2006-01-02T15:04:05-07:00", 12 | "end_time": "2006-01-02T15:04:05-07:00", 13 | "last_heartbeat_time": "2006-01-02T15:04:05-07:00", 14 | "queued_time": "2006-01-02T15:04:05-07:00", 15 | "queue_name": "default", 16 | "tag": "build_report", 17 | "exit_code": 0, 18 | "status": 0, 19 | "hostname": "connect", 20 | "cluster": "Kubernetes", 21 | "image": "someorg/image:jammy", 22 | "run_as": "rstudio-connect" 23 | } 24 | ] 25 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs/tHawGvHZTosJA2Dx.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "54", 3 | "ppid": "20253", 4 | "pid": "20253", 5 | "key": "tHawGvHZTosJA2Dx", 6 | "remote_id": "S3ViZXJuZXRlczpyZW5kZXItci1tYXJrZG93bi1zaXRlLWtnODJo", 7 | "app_id": "54", 8 | "variant_id": "54", 9 | "bundle_id": "54", 10 | "start_time": "2006-01-02T15:04:05-07:00", 11 | "end_time": "2006-01-02T15:04:05-07:00", 12 | "last_heartbeat_time": "2006-01-02T15:04:05-07:00", 13 | "queued_time": "2006-01-02T15:04:05-07:00", 14 | "queue_name": "default", 15 | "tag": "build_report", 16 | "exit_code": 0, 17 | "status": 0, 18 | "hostname": "connect", 19 | "cluster": "Kubernetes", 20 | "image": "someorg/image:jammy", 21 | "run_as": "rstudio-connect" 22 | } 23 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/oauth/integrations/associations.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "app_guid": "f2f37341-e21d-3d80-c698-a935ad614066", 4 | "oauth_integration_guid": "22644575-a27b-4118-ad06-e24459b05126", 5 | "oauth_integration_name": "keycloak integration", 6 | "oauth_integration_description": "integration description", 7 | "oauth_integration_template": "custom", 8 | "created_time": "2024-10-01T18:16:09Z" 9 | } 10 | ] 11 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/packages.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "language": "python", 4 | "name": "posit", 5 | "version": "0.6.0" 6 | } 7 | ] 8 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/permissions.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 94, 4 | "content_guid": "f2f37341-e21d-3d80-c698-a935ad614066", 5 | "principal_guid": "20a79ce3-6e87-4522-9faf-be24228800a4", 6 | "principal_type": "user", 7 | "role": "owner" 8 | }, 9 | { 10 | "id": 59, 11 | "content_guid": "f2f37341-e21d-3d80-c698-a935ad614066", 12 | "principal_guid": "6f300623-1e0c-48e6-a473-ddf630c0c0c3", 13 | "principal_type": "group", 14 | "role": "viewer" 15 | } 16 | ] 17 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/permissions/59.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 59, 3 | "content_guid": "f2f37341-e21d-3d80-c698-a935ad614066", 4 | "principal_guid": "6f300623-1e0c-48e6-a473-ddf630c0c0c3", 5 | "principal_type": "group", 6 | "role": "viewer" 7 | } 8 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/permissions/94.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "94", 3 | "content_guid": "f2f37341-e21d-3d80-c698-a935ad614066", 4 | "principal_guid": "20a79ce3-6e87-4522-9faf-be24228800a4", 5 | "principal_type": "user", 6 | "role": "owner" 7 | } 8 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/repository.json: -------------------------------------------------------------------------------- 1 | { 2 | "repository": "https://github.com/posit-dev/posit-sdk-py/", 3 | "branch": "main", 4 | "directory": "integration/resources/connect/bundles/example-flask-minimal", 5 | "polling": true 6 | } 7 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/repository_patch.json: -------------------------------------------------------------------------------- 1 | { 2 | "repository": "https://github.com/posit-dev/posit-sdk-py/", 3 | "branch": "testing-main", 4 | "directory": "integration/resources/connect/bundles/example-flask-minimal", 5 | "polling": true 6 | } 7 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/tag-add.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "34", 3 | "name": "Support", 4 | "parent_id": "3", 5 | "created_time": "2023-05-18T16:41:59Z", 6 | "updated_time": "2023-05-18T16:41:59Z" 7 | } 8 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/tags.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "3", 4 | "name": "Internal Solutions", 5 | "parent_id": null, 6 | "created_time": "2019-10-08T19:44:49Z", 7 | "updated_time": "2019-10-08T19:44:49Z" 8 | }, 9 | { 10 | "id": "5", 11 | "name": "Life Cycle", 12 | "parent_id": null, 13 | "created_time": "2019-10-08T19:45:21Z", 14 | "updated_time": "2019-10-08T19:45:21Z" 15 | } 16 | ] 17 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/content?owner_guid=20a79ce3-6e87-4522-9faf-be24228800a4.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "guid": "93a3cd6d-5a1b-236c-9808-6045f2a73fb5", 4 | "name": "My-Streamlit-app", 5 | "title": "My Streamlit app", 6 | "description": "", 7 | "access_type": "logged_in", 8 | "connection_timeout": null, 9 | "read_timeout": null, 10 | "init_timeout": null, 11 | "idle_timeout": null, 12 | "max_processes": null, 13 | "min_processes": null, 14 | "max_conns_per_process": null, 15 | "load_factor": null, 16 | "memory_request": null, 17 | "memory_limit": null, 18 | "cpu_request": null, 19 | "cpu_limit": null, 20 | "amd_gpu_limit": null, 21 | "nvidia_gpu_limit": null, 22 | "service_account_name": null, 23 | "default_image_name": null, 24 | "created_time": "2023-02-28T14:00:17Z", 25 | "last_deployed_time": "2023-03-01T14:12:21Z", 26 | "bundle_id": "217640", 27 | "app_mode": "python-streamlit", 28 | "content_category": "", 29 | "parameterized": false, 30 | "cluster_name": "Local", 31 | "image_name": null, 32 | "r_version": null, 33 | "py_version": "3.9.17", 34 | "quarto_version": null, 35 | "r_environment_management": null, 36 | "default_r_environment_management": null, 37 | "py_environment_management": true, 38 | "default_py_environment_management": null, 39 | "run_as": null, 40 | "run_as_current_user": false, 41 | "owner_guid": "20a79ce3-6e87-4522-9faf-be24228800a4", 42 | "content_url": "https://connect.example/content/93a3cd6d-5a1b-236c-9808-6045f2a73fb5/", 43 | "dashboard_url": "https://connect.example/connect/#/apps/93a3cd6d-5a1b-236c-9808-6045f2a73fb5", 44 | "app_role": "viewer", 45 | "id": "8462", 46 | "owner": { 47 | "guid": "20a79ce3-6e87-4522-9faf-be24228800a4", 48 | "username": "carlos12", 49 | "first_name": "Carlos", 50 | "last_name": "User" 51 | } 52 | } 53 | ] 54 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/environments.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "314", 4 | "guid": "25438b83-ea6d-4839-ae8e-53c52ac5f9ce", 5 | "created_time": "2006-01-02T15:04:05-07:00", 6 | "updated_time": "2006-01-02T15:04:05-07:00", 7 | "title": "Project Alpha (R 4.1.1, Python 3.10)", 8 | "description": "This is my description of the environment", 9 | "cluster_name": "Kubernetes", 10 | "name": "ghcr.io/rstudio/content-base:r4.1.3-py3.10.4-ubuntu1804", 11 | "environment_type": "Kubernetes", 12 | "matching": "any", 13 | "supervisor": "/usr/local/bin/supervisor.sh", 14 | "python": null, 15 | "quarto": null, 16 | "r": null, 17 | "tensorflow": null 18 | } 19 | 20 | ] 21 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/environments/25438b83-ea6d-4839-ae8e-53c52ac5f9ce.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "314", 3 | "guid": "25438b83-ea6d-4839-ae8e-53c52ac5f9ce", 4 | "created_time": "2006-01-02T15:04:05-07:00", 5 | "updated_time": "2006-01-02T15:04:05-07:00", 6 | "title": "Project Alpha (R 4.1.1, Python 3.10)", 7 | "description": "This is my description of the environment", 8 | "cluster_name": "Kubernetes", 9 | "name": "ghcr.io/rstudio/content-base:r4.1.3-py3.10.4-ubuntu1804", 10 | "environment_type": "Kubernetes", 11 | "matching": "any", 12 | "supervisor": "/usr/local/bin/supervisor.sh", 13 | "python": null, 14 | "quarto": null, 15 | "r": null, 16 | "tensorflow": null 17 | } 18 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/groups.json: -------------------------------------------------------------------------------- 1 | { 2 | "results": [ 3 | { 4 | "guid": "empty-group-guid", 5 | "name": "Empty Friends", 6 | "owner_guid": "empty-owner-guid" 7 | }, 8 | { 9 | "guid": "6f300623-1e0c-48e6-a473-ddf630c0c0c3", 10 | "name": "Friends", 11 | "owner_guid": "20a79ce3-6e87-4522-9faf-be24228800a4" 12 | } 13 | ], 14 | "current_page": 1, 15 | "total": 2 16 | } 17 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/groups/6f300623-1e0c-48e6-a473-ddf630c0c0c3.json: -------------------------------------------------------------------------------- 1 | { 2 | "guid": "6f300623-1e0c-48e6-a473-ddf630c0c0c3", 3 | "name": "Friends", 4 | "owner_guid": "20a79ce3-6e87-4522-9faf-be24228800a4" 5 | } 6 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/groups/6f300623-1e0c-48e6-a473-ddf630c0c0c3/members.json: -------------------------------------------------------------------------------- 1 | { 2 | "results": [ 3 | { 4 | "email": "alice@connect.example", 5 | "username": "al", 6 | "first_name": "Alice", 7 | "last_name": "User", 8 | "user_role": "publisher", 9 | "created_time": "2017-08-08T15:24:32Z", 10 | "updated_time": "2023-03-02T20:25:06Z", 11 | "active_time": "2018-05-09T16:58:45Z", 12 | "confirmed": true, 13 | "locked": false, 14 | "guid": "a01792e3-2e67-402e-99af-be04a48da074" 15 | }, 16 | { 17 | "email": "bob@connect.example", 18 | "username": "robert", 19 | "first_name": "Bob", 20 | "last_name": "Loblaw", 21 | "user_role": "publisher", 22 | "created_time": "2023-01-06T19:47:29Z", 23 | "updated_time": "2023-05-05T19:08:45Z", 24 | "active_time": "2023-05-05T20:29:11Z", 25 | "confirmed": true, 26 | "locked": false, 27 | "guid": "87c12c08-11cd-4de1-8da3-12a7579c4998" 28 | } 29 | ], 30 | "current_page": 1, 31 | "total": 2 32 | } 33 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/groups/empty-group-guid/members.json: -------------------------------------------------------------------------------- 1 | { 2 | "results": [], 3 | "current_page": 1, 4 | "total": 0 5 | } 6 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/groups/groups.json: -------------------------------------------------------------------------------- 1 | { 2 | "results": [ 3 | { 4 | "guid": "empty-group-guid", 5 | "name": "Empty Friends", 6 | "owner_guid": "empty-owner-guid" 7 | }, 8 | { 9 | "guid": "6f300623-1e0c-48e6-a473-ddf630c0c0c3", 10 | "name": "Friends", 11 | "owner_guid": "20a79ce3-6e87-4522-9faf-be24228800a4" 12 | } 13 | ], 14 | "current_page": 1, 15 | "total": 2 16 | } 17 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/instrumentation/content/hits.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 1001, 4 | "content_guid": "bd1d2285-6c80-49af-8a83-a200effe3cb3", 5 | "user_guid": "08e3a41d-1f8e-47f2-8855-f05ea3b0d4b2", 6 | "timestamp": "2025-05-01T10:00:00-05:00", 7 | "data": { 8 | "path": "/dashboard", 9 | "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" 10 | } 11 | }, 12 | { 13 | "id": 1002, 14 | "content_guid": "bd1d2285-6c80-49af-8a83-a200effe3cb3", 15 | "user_guid": "a5e2b41d-3f8e-47f2-9955-f05ea3b0d5c3", 16 | "timestamp": "2025-05-01T10:05:00-05:00", 17 | "data": { 18 | "path": "/dashboard/details", 19 | "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36" 20 | } 21 | } 22 | ] 23 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/instrumentation/content/visits?limit=500&next=23948901087.json: -------------------------------------------------------------------------------- 1 | { 2 | "paging": { 3 | "cursors": { 4 | "previous": "23948901087" 5 | }, 6 | "first": "http://localhost:3443/__api__/v1/instrumentation/content/visits", 7 | "previous": "http://localhost:3443/__api__/v1/instrumentation/content/visits?previous=23948901087" 8 | }, 9 | "results": [] 10 | } 11 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/instrumentation/content/visits?limit=500.json: -------------------------------------------------------------------------------- 1 | { 2 | "paging": { 3 | "cursors": { 4 | "previous": "23948901087", 5 | "next": "23948901087" 6 | }, 7 | "first": "http://localhost:3443/__api__/v1/instrumentation/content/visits", 8 | "previous": "http://localhost:3443/__api__/v1/instrumentation/content/visits?previous=23948901087", 9 | "next": "http://localhost:3443/__api__/v1/instrumentation/content/visits?next=23948901087", 10 | "last": "http://localhost:3443/__api__/v1/instrumentation/content/visits?last=true" 11 | }, 12 | "results": [ 13 | { 14 | "content_guid": "bd1d2285-6c80-49af-8a83-a200effe3cb3", 15 | "user_guid": "08e3a41d-1f8e-47f2-8855-f05ea3b0d4b2", 16 | "variant_key": "HidI2Kwq", 17 | "rendering_id": 7, 18 | "bundle_id": 33, 19 | "time": "2018-09-15T18:00:00-05:00", 20 | "data_version": 1, 21 | "path": "/logs" 22 | } 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/instrumentation/shiny/usage?limit=500&next=23948901087.json: -------------------------------------------------------------------------------- 1 | { 2 | "paging": { 3 | "cursors": { 4 | "previous": "23948901087" 5 | }, 6 | "first": "http://localhost:3443/__api__/v1/instrumentation/content/visits", 7 | "previous": "http://localhost:3443/__api__/v1/instrumentation/content/visits?previous=23948901087" 8 | }, 9 | "results": [] 10 | } 11 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/instrumentation/shiny/usage?limit=500.json: -------------------------------------------------------------------------------- 1 | { 2 | "paging": { 3 | "cursors": { 4 | "previous": "23948901087", 5 | "next": "23948901087" 6 | }, 7 | "first": "http://localhost:3443/__api__/v1/instrumentation/shiny/usage", 8 | "previous": "http://localhost:3443/__api__/v1/instrumentation/shiny/usage?previous=23948901087", 9 | "next": "http://localhost:3443/__api__/v1/instrumentation/shiny/usage?next=23948901087", 10 | "last": "http://localhost:3443/__api__/v1/instrumentation/shiny/usage?last=true" 11 | }, 12 | "results": [ 13 | { 14 | "content_guid": "bd1d2285-6c80-49af-8a83-a200effe3cb3", 15 | "user_guid": "08e3a41d-1f8e-47f2-8855-f05ea3b0d4b2", 16 | "started": "2018-09-15T18:00:00-05:00", 17 | "ended": "2018-09-15T18:01:00-05:00", 18 | "data_version": 1 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/oauth/integrations.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "3", 4 | "guid": "22644575-a27b-4118-ad06-e24459b05126", 5 | "created_time": "2024-07-16T19:28:05Z", 6 | "updated_time": "2024-07-17T19:28:05Z", 7 | "name": "keycloak integration", 8 | "description": "integration description", 9 | "template": "custom", 10 | "config": { 11 | "auth_mode": "Confidential", 12 | "authorization_uri": "http://keycloak:8080/realms/rsconnect/protocol/openid-connect/auth", 13 | "client_id": "rsconnect-oidc", 14 | "scopes": "email", 15 | "token_endpoint_auth_method": "client_secret_basic", 16 | "token_uri": "http://keycloak:8080/realms/rsconnect/protocol/openid-connect/token" 17 | } 18 | }, 19 | { 20 | "id": "4", 21 | "guid": "967f0ad3-3e3b-4491-8539-1a193b35a415", 22 | "created_time": "2024-07-18T20:35:11Z", 23 | "updated_time": "2024-07-19T20:35:11Z", 24 | "name": "keycloak-post", 25 | "description": "another integration description", 26 | "template": "custom", 27 | "config": { 28 | "auth_mode": "Confidential", 29 | "authorization_uri": "http://keycloak:8080/realms/rsconnect/protocol/openid-connect/auth", 30 | "client_id": "rsconnect-oidc", 31 | "scopes": "email", 32 | "token_endpoint_auth_method": "client_secret_post", 33 | "token_uri": "http://keycloak:8080/realms/rsconnect/protocol/openid-connect/token" 34 | } 35 | } 36 | ] 37 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/oauth/integrations/22644575-a27b-4118-ad06-e24459b05126.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "3", 3 | "guid": "22644575-a27b-4118-ad06-e24459b05126", 4 | "created_time": "2024-07-16T19:28:05Z", 5 | "updated_time": "2024-07-17T19:28:05Z", 6 | "name": "keycloak integration", 7 | "description": "integration description", 8 | "template": "custom", 9 | "config": { 10 | "auth_mode": "Confidential", 11 | "authorization_uri": "http://keycloak:8080/realms/rsconnect/protocol/openid-connect/auth", 12 | "client_id": "rsconnect-oidc", 13 | "scopes": "email", 14 | "token_endpoint_auth_method": "client_secret_basic", 15 | "token_uri": "http://keycloak:8080/realms/rsconnect/protocol/openid-connect/token" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/oauth/integrations/22644575-a27b-4118-ad06-e24459b05126/associations.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "app_guid": "f2f37341-e21d-3d80-c698-a935ad614066", 4 | "oauth_integration_guid": "22644575-a27b-4118-ad06-e24459b05126", 5 | "oauth_integration_name": "keycloak integration", 6 | "oauth_integration_description": "integration description", 7 | "oauth_integration_template": "custom", 8 | "created_time": "2024-10-01T18:16:09Z" 9 | } 10 | ] 11 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/oauth/sessions.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "54", 4 | "guid": "32c04dc6-0318-41b7-bc74-7e321b196f14", 5 | "user_guid": "217be1f2-6a32-46b9-af78-e3f4b89f2e74", 6 | "oauth_integration_guid": "767f0ad3-3e3b-4491-8539-1a193b35a415", 7 | "has_refresh_token": true, 8 | "created_time": "2024-07-24T15:59:51Z", 9 | "updated_time": "2024-07-25T15:59:51Z" 10 | }, 11 | { 12 | "id": "55", 13 | "guid": "42c04dc6-0318-41b7-bc74-7e321b196f14", 14 | "user_guid": "217be1f2-6a32-46b9-af78-e3f4b89f2e74", 15 | "oauth_integration_guid": "867f0ad3-3e3b-4491-8539-1a193b35a415", 16 | "has_refresh_token": true, 17 | "created_time": "2024-07-26T15:59:51Z", 18 | "updated_time": "2024-07-27T15:59:51Z" 19 | }, 20 | { 21 | "id": "56", 22 | "guid": "52c04dc6-0318-41b7-bc74-7e321b196f14", 23 | "user_guid": "217be1f2-6a32-46b9-af78-e3f4b89f2e74", 24 | "oauth_integration_guid": "967f0ad3-3e3b-4491-8539-1a193b35a415", 25 | "has_refresh_token": true, 26 | "created_time": "2024-07-28T15:59:51Z", 27 | "updated_time": "2024-07-29T15:59:51Z" 28 | } 29 | ] 30 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/oauth/sessions/32c04dc6-0318-41b7-bc74-7e321b196f14.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "54", 3 | "guid": "32c04dc6-0318-41b7-bc74-7e321b196f14", 4 | "user_guid": "217be1f2-6a32-46b9-af78-e3f4b89f2e74", 5 | "oauth_integration_guid": "967f0ad3-3e3b-4491-8539-1a193b35a415", 6 | "has_refresh_token": true, 7 | "created_time": "2024-07-24T15:59:51Z", 8 | "updated_time": "2024-07-24T16:59:51Z" 9 | } 10 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/packages.json: -------------------------------------------------------------------------------- 1 | { 2 | "results": [ 3 | { 4 | "language": "python", 5 | "name": "posit", 6 | "version": "0.6.0" 7 | } 8 | ], 9 | "current_page": 1, 10 | "total": 1 11 | } 12 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/system/caches/runtime.json: -------------------------------------------------------------------------------- 1 | { 2 | "caches": [ 3 | { "language": "python", "version": "3.12.0", "image_name": "Local" } 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/tags/29.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "29", 3 | "name": "product management", 4 | "parent_id": "3", 5 | "created_time": "2020-08-17T20:16:24Z", 6 | "updated_time": "2020-08-17T20:16:24Z" 7 | } 8 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/tags/3.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "3", 3 | "name": "Internal Solutions", 4 | "parent_id": null, 5 | "created_time": "2019-10-08T19:44:49Z", 6 | "updated_time": "2019-10-08T19:44:49Z" 7 | } 8 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/tags/3/content.json: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/tags/33-patched.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "33", 3 | "name": "academy-updated", 4 | "parent_id": null, 5 | "created_time": "2021-10-18T18:37:56Z", 6 | "updated_time": "2021-10-18T18:37:56Z" 7 | } 8 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/tags/33.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "33", 3 | "name": "academy", 4 | "parent_id": "3", 5 | "created_time": "2021-10-18T18:37:56Z", 6 | "updated_time": "2021-10-18T18:37:56Z" 7 | } 8 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/tags?name=academy.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "33", 4 | "name": "academy", 5 | "parent_id": "3", 6 | "created_time": "2021-10-18T18:37:56Z", 7 | "updated_time": "2021-10-18T18:37:56Z" 8 | } 9 | ] 10 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/tags?parent_id=3.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "12", 4 | "name": "diagnostics", 5 | "parent_id": "3", 6 | "created_time": "2020-04-30T21:30:22Z", 7 | "updated_time": "2020-04-30T21:30:22Z" 8 | }, 9 | { 10 | "id": "13", 11 | "name": "white-glove", 12 | "parent_id": "3", 13 | "created_time": "2020-05-22T13:04:35Z", 14 | "updated_time": "2020-05-22T13:04:35Z" 15 | }, 16 | { 17 | "id": "14", 18 | "name": "sol-eng", 19 | "parent_id": "3", 20 | "created_time": "2020-06-22T16:52:18Z", 21 | "updated_time": "2020-06-22T16:52:18Z" 22 | }, 23 | { 24 | "id": "29", 25 | "name": "product management", 26 | "parent_id": "3", 27 | "created_time": "2020-08-17T20:16:24Z", 28 | "updated_time": "2020-08-17T20:16:24Z" 29 | }, 30 | { 31 | "id": "31", 32 | "name": "RSC", 33 | "parent_id": "3", 34 | "created_time": "2020-08-17T20:16:45Z", 35 | "updated_time": "2020-08-17T20:16:45Z" 36 | }, 37 | { 38 | "id": "33", 39 | "name": "academy", 40 | "parent_id": "3", 41 | "created_time": "2021-10-18T18:37:56Z", 42 | "updated_time": "2021-10-18T18:37:56Z" 43 | }, 44 | { 45 | "id": "34", 46 | "name": "Support", 47 | "parent_id": "3", 48 | "created_time": "2023-05-18T16:41:59Z", 49 | "updated_time": "2023-05-18T16:41:59Z" 50 | } 51 | ] 52 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/tasks/jXhOhdm5OOSkGhJw.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "jXhOhdm5OOSkGhJw", 3 | "output": [ 4 | "Building static content...", 5 | "Launching static content..." 6 | ], 7 | "finished": true, 8 | "code": 1, 9 | "error": "Unable to render: Rendering exited abnormally: exit status 1", 10 | "last": 2, 11 | "result": null 12 | } 13 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/user.json: -------------------------------------------------------------------------------- 1 | { 2 | "email": "carlos@connect.example", 3 | "username": "carlos12", 4 | "first_name": "Carlos", 5 | "last_name": "User", 6 | "user_role": "publisher", 7 | "created_time": "2019-09-09T15:24:32Z", 8 | "updated_time": "2022-03-02T20:25:06Z", 9 | "active_time": "2020-05-11T16:58:45Z", 10 | "confirmed": true, 11 | "locked": true, 12 | "guid": "20a79ce3-6e87-4522-9faf-be24228800a4" 13 | } -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/users/20a79ce3-6e87-4522-9faf-be24228800a4.json: -------------------------------------------------------------------------------- 1 | { 2 | "email": "carlos@connect.example", 3 | "username": "carlos12", 4 | "first_name": "Carlos", 5 | "last_name": "User", 6 | "user_role": "publisher", 7 | "created_time": "2019-09-09T15:24:32Z", 8 | "updated_time": "2022-03-02T20:25:06Z", 9 | "active_time": "2020-05-11T16:58:45Z", 10 | "confirmed": true, 11 | "locked": false, 12 | "guid": "20a79ce3-6e87-4522-9faf-be24228800a4" 13 | } 14 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/users/a1b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6.json: -------------------------------------------------------------------------------- 1 | { 2 | "email": "random_email@example.com", 3 | "username": "random_username", 4 | "first_name": "Random", 5 | "last_name": "User", 6 | "user_role": "admin", 7 | "created_time": "2022-01-01T00:00:00Z", 8 | "updated_time": "2022-03-15T12:34:56Z", 9 | "active_time": "2022-02-28T18:30:00Z", 10 | "confirmed": false, 11 | "locked": true, 12 | "guid": "a1b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6" 13 | } 14 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/users?page_number=1&page_size=500.jsonc: -------------------------------------------------------------------------------- 1 | // A single page response from the '/v1/users' endpoint. 2 | // 3 | // This file is typically used in conjunction with v1/users?page_number=2&page_size=500.jsonc 4 | 5 | { 6 | "results": [ 7 | { 8 | "email": "alice@connect.example", 9 | "username": "al", 10 | "first_name": "Alice", 11 | "last_name": "User", 12 | "user_role": "publisher", 13 | "created_time": "2017-08-08T15:24:32Z", 14 | "updated_time": "2023-03-02T20:25:06Z", 15 | "active_time": "2018-05-09T16:58:45Z", 16 | "confirmed": true, 17 | "locked": false, 18 | "guid": "a01792e3-2e67-402e-99af-be04a48da074" 19 | }, 20 | { 21 | "email": "bob@connect.example", 22 | "username": "robert", 23 | "first_name": "Bob", 24 | "last_name": "Loblaw", 25 | "user_role": "publisher", 26 | "created_time": "2023-01-06T19:47:29Z", 27 | "updated_time": "2023-05-05T19:08:45Z", 28 | "active_time": "2023-05-05T20:29:11Z", 29 | "confirmed": true, 30 | "locked": false, 31 | "guid": "87c12c08-11cd-4de1-8da3-12a7579c4998" 32 | } 33 | ], 34 | "current_page": 1, 35 | "total": 3 36 | } 37 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/v1/users?page_number=2&page_size=500.jsonc: -------------------------------------------------------------------------------- 1 | // A subsequent single page response from the '/v1/users' endpoint. 2 | // 3 | // This file is typically used in conjunction with v1/users?page_number=1&page_size=500.jsonc 4 | 5 | { 6 | "results": [ 7 | { 8 | "email": "carlos@connect.example", 9 | "username": "carlos12", 10 | "first_name": "Carlos", 11 | "last_name": "User", 12 | "user_role": "publisher", 13 | "created_time": "2019-09-09T15:24:32Z", 14 | "updated_time": "2022-03-02T20:25:06Z", 15 | "active_time": "2020-05-11T16:58:45Z", 16 | "confirmed": true, 17 | "locked": false, 18 | "guid": "20a79ce3-6e87-4522-9faf-be24228800a4" 19 | } 20 | ], 21 | "current_page": 2, 22 | "total": 3 23 | } 24 | -------------------------------------------------------------------------------- /tests/posit/connect/__api__/variants/6627/render.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "jXhOhdm5OOSkGhJw", 3 | "output": [ 4 | "Building static content...", 5 | "Launching static content..." 6 | ], 7 | "finished": true, 8 | "code": 1, 9 | "error": "Unable to render: Rendering exited abnormally: exit status 1", 10 | "last": 2, 11 | "result": null 12 | } 13 | -------------------------------------------------------------------------------- /tests/posit/connect/api.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pyjson5 as json 4 | 5 | 6 | def load_mock(path: str): 7 | """ 8 | Load mock data from a file. 9 | 10 | Reads a JSON or JSONC (JSON with Comments) file and returns the parsed data. 11 | 12 | It's primarily used for loading mock data for tests. 13 | 14 | The file names for mock data should match the query path that they represent. 15 | 16 | Parameters 17 | ---------- 18 | path : str 19 | The relative path to the JSONC file. 20 | 21 | Returns 22 | ------- 23 | dict | list 24 | The parsed data from the JSONC file. 25 | 26 | Examples 27 | -------- 28 | >>> data = load_mock("v1/example.json") 29 | >>> data = load_mock("v1/example.jsonc") 30 | """ 31 | return json.loads((Path(__file__).parent / "__api__" / path).read_text()) 32 | 33 | 34 | def load_mock_dict(path: str) -> dict: 35 | result = load_mock(path) 36 | assert isinstance(result, dict) 37 | return result 38 | 39 | 40 | def load_mock_list(path: str) -> list: 41 | result = load_mock(path) 42 | assert isinstance(result, list) 43 | return result 44 | 45 | 46 | def get_path(path: str) -> Path: 47 | return Path(__file__).parent / "__api__" / path 48 | -------------------------------------------------------------------------------- /tests/posit/connect/external/test_snowflake.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | import responses 4 | 5 | from posit.connect import Client 6 | from posit.connect.external.snowflake import PositAuthenticator 7 | 8 | 9 | def register_mocks(): 10 | responses.post( 11 | "https://connect.example/__api__/v1/oauth/integrations/credentials", 12 | match=[ 13 | responses.matchers.urlencoded_params_matcher( 14 | { 15 | "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", 16 | "subject_token_type": "urn:posit:connect:user-session-token", 17 | "subject_token": "cit", 18 | }, 19 | ), 20 | ], 21 | json={ 22 | "access_token": "dynamic-viewer-access-token", 23 | "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", 24 | "token_type": "Bearer", 25 | }, 26 | ) 27 | 28 | 29 | class TestPositAuthenticator: 30 | @responses.activate 31 | @patch.dict("os.environ", {"RSTUDIO_PRODUCT": "CONNECT"}) 32 | def test_posit_authenticator(self): 33 | register_mocks() 34 | 35 | client = Client(api_key="12345", url="https://connect.example/") 36 | client._ctx.version = None 37 | auth = PositAuthenticator( 38 | local_authenticator="SNOWFLAKE", 39 | user_session_token="cit", 40 | client=client, 41 | ) 42 | assert auth.authenticator == "oauth" 43 | assert auth.token == "dynamic-viewer-access-token" 44 | 45 | def test_posit_authenticator_fallback(self): 46 | # local_authenticator is used when the content is running locally 47 | client = Client(api_key="12345", url="https://connect.example/") 48 | client._ctx.version = None 49 | auth = PositAuthenticator( 50 | local_authenticator="SNOWFLAKE", 51 | user_session_token="cit", 52 | client=client, 53 | ) 54 | assert auth.authenticator == "SNOWFLAKE" 55 | assert auth.token is None 56 | -------------------------------------------------------------------------------- /tests/posit/connect/metrics/test_hits.py: -------------------------------------------------------------------------------- 1 | """Tests for the hits metrics module.""" 2 | 3 | import pytest 4 | import responses 5 | from responses import matchers 6 | 7 | from posit import connect 8 | 9 | from ..api import load_mock 10 | 11 | 12 | class TestHitsFetch: 13 | @responses.activate 14 | def test_fetch(self): 15 | # Set up mock response 16 | mock_get = responses.get( 17 | "https://connect.example/__api__/v1/instrumentation/content/hits", 18 | json=load_mock("v1/instrumentation/content/hits.json"), 19 | ) 20 | 21 | # Create client with required version for hits API 22 | c = connect.Client("https://connect.example", "12345") 23 | c._ctx.version = "2025.04.0" 24 | 25 | # Fetch hits 26 | hits = list(c.metrics.hits.fetch()) 27 | 28 | # Verify request was made 29 | assert mock_get.call_count == 1 30 | 31 | # Verify results 32 | assert len(hits) == 2 33 | assert hits[0]["id"] == 1001 34 | assert hits[0]["content_guid"] == "bd1d2285-6c80-49af-8a83-a200effe3cb3" 35 | assert hits[0]["timestamp"] == "2025-05-01T10:00:00-05:00" 36 | assert hits[0]["data"]["path"] == "/dashboard" 37 | 38 | @responses.activate 39 | def test_fetch_with_params(self): 40 | # Set up mock response 41 | mock_get = responses.get( 42 | "https://connect.example/__api__/v1/instrumentation/content/hits", 43 | json=load_mock("v1/instrumentation/content/hits.json"), 44 | match=[ 45 | matchers.query_param_matcher( 46 | { 47 | "from": "2025-05-01T00:00:00Z", 48 | "to": "2025-05-02T00:00:00Z", 49 | } 50 | ), 51 | ], 52 | ) 53 | 54 | # Create client with required version for hits API 55 | c = connect.Client("https://connect.example", "12345") 56 | c._ctx.version = "2025.04.0" 57 | 58 | # Fetch hits with parameters 59 | hits = list( 60 | c.metrics.hits.fetch(**{"from": "2025-05-01T00:00:00Z", "to": "2025-05-02T00:00:00Z"}) 61 | ) 62 | 63 | # Verify request was made with proper parameters 64 | assert mock_get.call_count == 1 65 | 66 | # Verify results 67 | assert len(hits) == 2 68 | 69 | 70 | class TestHitsFindBy: 71 | @responses.activate 72 | def test_find_by(self): 73 | # Set up mock response 74 | mock_get = responses.get( 75 | "https://connect.example/__api__/v1/instrumentation/content/hits", 76 | json=load_mock("v1/instrumentation/content/hits.json"), 77 | ) 78 | 79 | # Create client with required version for hits API 80 | c = connect.Client("https://connect.example", "12345") 81 | c._ctx.version = "2025.04.0" 82 | 83 | # Find hits by content_guid 84 | hit = c.metrics.hits.find_by(content_guid="bd1d2285-6c80-49af-8a83-a200effe3cb3") 85 | 86 | # Verify request was made 87 | assert mock_get.call_count == 1 88 | 89 | # Verify results 90 | assert hit is not None 91 | assert hit["id"] == 1001 92 | assert hit["content_guid"] == "bd1d2285-6c80-49af-8a83-a200effe3cb3" 93 | 94 | @responses.activate 95 | def test_find_by_not_found(self): 96 | # Set up mock response 97 | mock_get = responses.get( 98 | "https://connect.example/__api__/v1/instrumentation/content/hits", 99 | json=load_mock("v1/instrumentation/content/hits.json"), 100 | ) 101 | 102 | # Create client with required version for hits API 103 | c = connect.Client("https://connect.example", "12345") 104 | c._ctx.version = "2025.04.0" 105 | 106 | # Try to find hit with non-existent content_guid 107 | hit = c.metrics.hits.find_by(content_guid="non-existent-guid") 108 | 109 | # Verify request was made 110 | assert mock_get.call_count == 1 111 | 112 | # Verify no result was found 113 | assert hit is None 114 | 115 | 116 | class TestHitsVersionRequirement: 117 | @responses.activate 118 | def test_version_requirement(self): 119 | # Create client with version that's too old 120 | c = connect.Client("https://connect.example", "12345") 121 | c._ctx.version = "2024.04.0" 122 | 123 | with pytest.raises(RuntimeError): 124 | h = c.metrics.hits 125 | -------------------------------------------------------------------------------- /tests/posit/connect/metrics/test_rename_params.py: -------------------------------------------------------------------------------- 1 | from posit.connect.metrics.rename_params import rename_params 2 | 3 | 4 | class TestRenameParams: 5 | def test_start_to_from(self): 6 | params = {"start": ...} 7 | params = rename_params(params) 8 | assert "start" not in params 9 | assert "from" in params 10 | 11 | def test_end_to_to(self): 12 | params = {"end": ...} 13 | params = rename_params(params) 14 | assert "end" not in params 15 | assert "to" in params 16 | -------------------------------------------------------------------------------- /tests/posit/connect/metrics/test_shiny_usage.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import responses 4 | from responses import matchers 5 | 6 | from posit import connect 7 | from posit.connect.metrics import shiny_usage 8 | 9 | from ..api import load_mock, load_mock_dict 10 | 11 | 12 | class TestShinyUsageEventAttributes: 13 | @classmethod 14 | def setup_class(cls): 15 | results = load_mock_dict("v1/instrumentation/shiny/usage?limit=500.json")["results"] 16 | assert isinstance(results, list) 17 | cls.event = shiny_usage.ShinyUsageEvent( 18 | mock.Mock(), 19 | **results[0], 20 | ) 21 | 22 | def test_content_guid(self): 23 | assert self.event.content_guid == "bd1d2285-6c80-49af-8a83-a200effe3cb3" 24 | 25 | def test_user_guid(self): 26 | assert self.event.user_guid == "08e3a41d-1f8e-47f2-8855-f05ea3b0d4b2" 27 | 28 | def test_started(self): 29 | assert self.event.started == "2018-09-15T18:00:00-05:00" 30 | 31 | def test_ended(self): 32 | assert self.event.ended == "2018-09-15T18:01:00-05:00" 33 | 34 | def test_data_version(self): 35 | assert self.event.data_version == 1 36 | 37 | 38 | class TestShinyUsageFind: 39 | @responses.activate 40 | def test(self): 41 | # behavior 42 | mock_get = [ 43 | responses.get( 44 | "https://connect.example/__api__/v1/instrumentation/shiny/usage", 45 | json=load_mock("v1/instrumentation/shiny/usage?limit=500.json"), 46 | match=[ 47 | matchers.query_param_matcher( 48 | { 49 | "limit": 500, 50 | }, 51 | ), 52 | ], 53 | ), 54 | responses.get( 55 | "https://connect.example/__api__/v1/instrumentation/shiny/usage", 56 | json=load_mock("v1/instrumentation/shiny/usage?limit=500&next=23948901087.json"), 57 | match=[ 58 | matchers.query_param_matcher( 59 | { 60 | "next": "23948901087", 61 | "limit": 500, 62 | }, 63 | ), 64 | ], 65 | ), 66 | ] 67 | 68 | # setup 69 | c = connect.Client("https://connect.example", "12345") 70 | 71 | # invoke 72 | events = shiny_usage.ShinyUsage(c._ctx).find() 73 | 74 | # assert 75 | assert mock_get[0].call_count == 1 76 | assert mock_get[1].call_count == 1 77 | assert len(events) == 1 78 | 79 | 80 | class TestShinyUsageFindOne: 81 | @responses.activate 82 | def test(self): 83 | # behavior 84 | mock_get = [ 85 | responses.get( 86 | "https://connect.example/__api__/v1/instrumentation/shiny/usage", 87 | json=load_mock("v1/instrumentation/shiny/usage?limit=500.json"), 88 | match=[ 89 | matchers.query_param_matcher( 90 | { 91 | "limit": 500, 92 | }, 93 | ), 94 | ], 95 | ), 96 | responses.get( 97 | "https://connect.example/__api__/v1/instrumentation/shiny/usage", 98 | json=load_mock("v1/instrumentation/shiny/usage?limit=500&next=23948901087.json"), 99 | match=[ 100 | matchers.query_param_matcher( 101 | { 102 | "next": "23948901087", 103 | "limit": 500, 104 | }, 105 | ), 106 | ], 107 | ), 108 | ] 109 | 110 | # setup 111 | c = connect.Client("https://connect.example", "12345") 112 | 113 | # invoke 114 | event = shiny_usage.ShinyUsage(c._ctx).find_one() 115 | 116 | # assert 117 | assert mock_get[0].call_count == 1 118 | assert mock_get[1].call_count == 0 119 | assert event 120 | assert event.content_guid == "bd1d2285-6c80-49af-8a83-a200effe3cb3" 121 | -------------------------------------------------------------------------------- /tests/posit/connect/metrics/test_visits.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import responses 4 | from responses import matchers 5 | 6 | from posit import connect 7 | from posit.connect.metrics import visits 8 | 9 | from ..api import load_mock, load_mock_dict 10 | 11 | 12 | class TestVisitAttributes: 13 | @classmethod 14 | def setup_class(cls): 15 | results = load_mock_dict("v1/instrumentation/content/visits?limit=500.json")["results"] 16 | assert isinstance(results, list) 17 | first_result_dict = results[0] 18 | assert isinstance(first_result_dict, dict) 19 | cls.visit = visits.VisitEvent( 20 | mock.Mock(), 21 | **first_result_dict, 22 | ) 23 | 24 | def test_content_guid(self): 25 | assert self.visit.content_guid == "bd1d2285-6c80-49af-8a83-a200effe3cb3" 26 | 27 | def test_user_guid(self): 28 | assert self.visit.user_guid == "08e3a41d-1f8e-47f2-8855-f05ea3b0d4b2" 29 | 30 | def test_variant_key(self): 31 | assert self.visit.variant_key == "HidI2Kwq" 32 | 33 | def test_rendering_id(self): 34 | assert self.visit.rendering_id == 7 35 | 36 | def test_bundle_id(self): 37 | assert self.visit.bundle_id == 33 38 | 39 | def test_time(self): 40 | assert self.visit.time == "2018-09-15T18:00:00-05:00" 41 | 42 | def test_data_version(self): 43 | assert self.visit.data_version == 1 44 | 45 | def test_path(self): 46 | assert self.visit.path == "/logs" 47 | 48 | 49 | class TestVisitsFind: 50 | @responses.activate 51 | def test(self): 52 | # behavior 53 | mock_get = [ 54 | responses.get( 55 | "https://connect.example/__api__/v1/instrumentation/content/visits", 56 | json=load_mock("v1/instrumentation/content/visits?limit=500.json"), 57 | match=[ 58 | matchers.query_param_matcher( 59 | { 60 | "limit": 500, 61 | }, 62 | ), 63 | ], 64 | ), 65 | responses.get( 66 | "https://connect.example/__api__/v1/instrumentation/content/visits", 67 | json=load_mock( 68 | "v1/instrumentation/content/visits?limit=500&next=23948901087.json" 69 | ), 70 | match=[ 71 | matchers.query_param_matcher( 72 | { 73 | "next": "23948901087", 74 | "limit": 500, 75 | }, 76 | ), 77 | ], 78 | ), 79 | ] 80 | 81 | # setup 82 | c = connect.Client("https://connect.example", "12345") 83 | 84 | # invoke 85 | events = visits.Visits(c._ctx).find() 86 | 87 | # assert 88 | assert mock_get[0].call_count == 1 89 | assert mock_get[1].call_count == 1 90 | assert len(events) == 1 91 | 92 | 93 | class TestVisitsFindOne: 94 | @responses.activate 95 | def test(self): 96 | # behavior 97 | mock_get = [ 98 | responses.get( 99 | "https://connect.example/__api__/v1/instrumentation/content/visits", 100 | json=load_mock("v1/instrumentation/content/visits?limit=500.json"), 101 | match=[ 102 | matchers.query_param_matcher( 103 | { 104 | "limit": 500, 105 | }, 106 | ), 107 | ], 108 | ), 109 | responses.get( 110 | "https://connect.example/__api__/v1/instrumentation/content/visits", 111 | json=load_mock( 112 | "v1/instrumentation/content/visits?limit=500&next=23948901087.json" 113 | ), 114 | match=[ 115 | matchers.query_param_matcher( 116 | { 117 | "next": "23948901087", 118 | "limit": 500, 119 | }, 120 | ), 121 | ], 122 | ), 123 | ] 124 | 125 | # setup 126 | c = connect.Client("https://connect.example", "12345") 127 | 128 | # invoke 129 | event = visits.Visits(c._ctx).find_one() 130 | 131 | # assert 132 | assert mock_get[0].call_count == 1 133 | assert mock_get[1].call_count == 0 134 | assert event 135 | assert event.content_guid == "bd1d2285-6c80-49af-8a83-a200effe3cb3" 136 | -------------------------------------------------------------------------------- /tests/posit/connect/oauth/test_sessions.py: -------------------------------------------------------------------------------- 1 | import responses 2 | from responses import matchers 3 | 4 | from posit.connect.client import Client 5 | 6 | from ..api import load_mock 7 | 8 | 9 | class TestSessionDelete: 10 | @responses.activate 11 | def test(self): 12 | guid = "32c04dc6-0318-41b7-bc74-7e321b196f14" 13 | 14 | # behavior 15 | responses.get( 16 | f"https://connect.example/__api__/v1/oauth/sessions/{guid}", 17 | json=load_mock(f"v1/oauth/sessions/{guid}.json"), 18 | ) 19 | 20 | mock_delete = responses.delete(f"https://connect.example/__api__/v1/oauth/sessions/{guid}") 21 | 22 | # setup 23 | c = Client("https://connect.example", "12345") 24 | c._ctx.version = None 25 | session = c.oauth.sessions.get(guid) 26 | 27 | # invoke 28 | session.delete() 29 | 30 | # assert 31 | assert mock_delete.call_count == 1 32 | 33 | 34 | class TestSessionsFind: 35 | @responses.activate 36 | def test(self): 37 | # behavior 38 | mock_get = responses.get( 39 | "https://connect.example/__api__/v1/oauth/sessions", 40 | json=load_mock("v1/oauth/sessions.json"), 41 | ) 42 | 43 | # setup 44 | c = Client("https://connect.example", "12345") 45 | c._ctx.version = None 46 | 47 | # invoke 48 | sessions = c.oauth.sessions.find() 49 | 50 | # assert 51 | assert mock_get.call_count == 1 52 | assert len(sessions) == 3 53 | assert sessions[0]["id"] == "54" 54 | assert sessions[1]["id"] == "55" 55 | assert sessions[2]["id"] == "56" 56 | 57 | @responses.activate 58 | def test_params_all(self): 59 | # behavior 60 | mock_get = responses.get( 61 | "https://connect.example/__api__/v1/oauth/sessions", 62 | json=load_mock("v1/oauth/sessions.json"), 63 | match=[matchers.query_param_matcher({"all": True})], 64 | ) 65 | 66 | # setup 67 | c = Client("https://connect.example", "12345") 68 | c._ctx.version = None 69 | 70 | # invoke 71 | c.oauth.sessions.find(all=True) 72 | 73 | # assert 74 | assert mock_get.call_count == 1 75 | 76 | 77 | class TestSessionsGet: 78 | @responses.activate 79 | def test(self): 80 | guid = "32c04dc6-0318-41b7-bc74-7e321b196f14" 81 | 82 | # behavior 83 | mock_get = responses.get( 84 | f"https://connect.example/__api__/v1/oauth/sessions/{guid}", 85 | json=load_mock(f"v1/oauth/sessions/{guid}.json"), 86 | ) 87 | 88 | # setup 89 | c = Client("https://connect.example", "12345") 90 | c._ctx.version = None 91 | 92 | # invoke 93 | session = c.oauth.sessions.get(guid=guid) 94 | 95 | # assert 96 | assert mock_get.call_count == 1 97 | assert session["guid"] == guid 98 | -------------------------------------------------------------------------------- /tests/posit/connect/test_auth.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock, Mock, patch 2 | 3 | from posit.connect.auth import Auth 4 | 5 | 6 | class TestAuth: 7 | @patch("posit.connect.auth.Config") 8 | def test_auth_headers(self, Config: MagicMock): 9 | config = Config.return_value 10 | config.api_key = "foobar" 11 | auth = Auth(config=config) 12 | r = Mock() 13 | r.headers = {} 14 | auth(r) 15 | assert r.headers == {"Authorization": f"Key {config.api_key}"} 16 | -------------------------------------------------------------------------------- /tests/posit/connect/test_config.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | import pytest 4 | 5 | from posit.connect.config import Config, _get_api_key, _get_url 6 | 7 | 8 | @patch.dict("os.environ", {"CONNECT_API_KEY": "foobar"}) 9 | def test_get_api_key(): 10 | api_key = _get_api_key() 11 | assert api_key == "foobar" 12 | 13 | 14 | @patch.dict("os.environ", {"CONNECT_API_KEY": ""}) 15 | def test_get_api_key_empty(): 16 | with pytest.raises(ValueError): 17 | _get_api_key() 18 | 19 | 20 | @patch.dict("os.environ", clear=True) 21 | def test_get_api_key_miss(): 22 | with pytest.raises(ValueError): 23 | _get_api_key() 24 | 25 | 26 | @patch.dict("os.environ", {"CONNECT_SERVER": "http://foo.bar"}) 27 | def test_get_url(): 28 | url = _get_url() 29 | assert url == "http://foo.bar" 30 | 31 | 32 | @patch.dict("os.environ", {"CONNECT_SERVER": ""}) 33 | def test_get_url_empty(): 34 | with pytest.raises(ValueError): 35 | _get_url() 36 | 37 | 38 | @patch.dict("os.environ", clear=True) 39 | def test_get_url_miss(): 40 | with pytest.raises(ValueError): 41 | _get_url() 42 | 43 | 44 | def test_init(): 45 | api_key = "foobar" 46 | url = "http://foo.bar/__api__" 47 | config = Config(api_key=api_key, url=url) 48 | assert config.api_key == api_key 49 | assert config.url == url 50 | -------------------------------------------------------------------------------- /tests/posit/connect/test_context.py: -------------------------------------------------------------------------------- 1 | from email.contentmanager import ContentManager 2 | from unittest.mock import MagicMock, Mock 3 | 4 | import pytest 5 | import responses 6 | 7 | from posit.connect.client import Client 8 | from posit.connect.context import Context, requires 9 | 10 | 11 | class TestRequires: 12 | def test_version_unsupported(self): 13 | class Stub(ContentManager): 14 | def __init__(self, ctx): 15 | self._ctx = ctx 16 | 17 | @requires("1.0.0") 18 | def fail(self): 19 | pass 20 | 21 | ctx = MagicMock() 22 | ctx.version = "0.0.0" 23 | instance = Stub(ctx) 24 | 25 | with pytest.raises(RuntimeError): 26 | instance.fail() 27 | 28 | def test_version_supported(self): 29 | class Stub(ContentManager): 30 | def __init__(self, ctx): 31 | self._ctx = ctx 32 | 33 | @requires("1.0.0") 34 | def success(self): 35 | pass 36 | 37 | ctx = MagicMock() 38 | ctx.version = "1.0.0" 39 | instance = Stub(ctx) 40 | 41 | instance.success() 42 | 43 | def test_version_missing(self): 44 | class Stub(ContentManager): 45 | def __init__(self, ctx): 46 | self._ctx = ctx 47 | 48 | @requires("1.0.0") 49 | def success(self): 50 | pass 51 | 52 | ctx = MagicMock() 53 | ctx.version = None 54 | instance = Stub(ctx) 55 | 56 | instance.success() 57 | 58 | 59 | class TestContextVersion: 60 | @responses.activate 61 | def test_unknown(self): 62 | responses.get( 63 | "http://connect.example/__api__/server_settings", 64 | json={}, 65 | ) 66 | 67 | c = Client("http://connect.example", "12345") 68 | ctx = c._ctx 69 | 70 | assert ctx.version is None 71 | 72 | @responses.activate 73 | def test_known(self): 74 | responses.get( 75 | "http://connect.example/__api__/server_settings", 76 | json={"version": "2024.09.24"}, 77 | ) 78 | 79 | c = Client("http://connect.example", "12345") 80 | ctx = c._ctx 81 | 82 | assert ctx.version == "2024.09.24" 83 | 84 | def test_setter(self): 85 | ctx = Context(Mock()) 86 | ctx.version = "2024.09.24" 87 | assert ctx.version == "2024.09.24" 88 | -------------------------------------------------------------------------------- /tests/posit/connect/test_environments.py: -------------------------------------------------------------------------------- 1 | import responses 2 | 3 | from posit.connect.client import Client 4 | 5 | from .api import load_mock 6 | 7 | 8 | class TestEnvironmentsFind: 9 | @responses.activate 10 | def test(self): 11 | responses.get( 12 | "https://connect.example/__api__/v1/environments/25438b83-ea6d-4839-ae8e-53c52ac5f9ce", 13 | json=load_mock( 14 | "v1/environments/25438b83-ea6d-4839-ae8e-53c52ac5f9ce.json", 15 | ), 16 | ) 17 | c = Client("https://connect.example", "12345") 18 | c._ctx.version = None 19 | environment = c.environments.find("25438b83-ea6d-4839-ae8e-53c52ac5f9ce") 20 | assert environment["id"] == "314" 21 | 22 | 23 | class TestEnvironmentsFindBy: 24 | @responses.activate 25 | def test(self): 26 | responses.get( 27 | "https://connect.example/__api__/v1/environments", 28 | json=load_mock( 29 | "v1/environments.json", 30 | ), 31 | ) 32 | c = Client("https://connect.example", "12345") 33 | c._ctx.version = None 34 | environment = c.environments.find_by(id="314") 35 | assert environment 36 | assert environment["id"] == "314" 37 | 38 | 39 | class TestEnvironmentsCreate: 40 | @responses.activate 41 | def test(self): 42 | responses.post( 43 | "https://connect.example/__api__/v1/environments", 44 | json=load_mock( 45 | "v1/environments/25438b83-ea6d-4839-ae8e-53c52ac5f9ce.json", 46 | ), 47 | ) 48 | c = Client("https://connect.example", "12345") 49 | c._ctx.version = None 50 | environment = c.environments.create( 51 | title="Project Alpha (R 4.1.1, Python 3.10)", name="", cluster_name="" 52 | ) 53 | assert environment 54 | assert environment["id"] == "314" 55 | 56 | 57 | class TestEnvironmentDestroy: 58 | @responses.activate 59 | def test(self): 60 | responses.get( 61 | "https://connect.example/__api__/v1/environments/25438b83-ea6d-4839-ae8e-53c52ac5f9ce", 62 | json=load_mock( 63 | "v1/environments/25438b83-ea6d-4839-ae8e-53c52ac5f9ce.json", 64 | ), 65 | ) 66 | 67 | mock_delete = responses.delete( 68 | "https://connect.example/__api__/v1/environments/25438b83-ea6d-4839-ae8e-53c52ac5f9ce", 69 | ) 70 | 71 | c = Client("https://connect.example", "12345") 72 | c._ctx.version = None 73 | environment = c.environments.find("25438b83-ea6d-4839-ae8e-53c52ac5f9ce") 74 | assert environment["id"] == "314" 75 | 76 | environment.destroy() 77 | assert mock_delete.call_count == 1 78 | 79 | 80 | class TestEnvironmentUpdate: 81 | @responses.activate 82 | def test(self): 83 | responses.get( 84 | "https://connect.example/__api__/v1/environments/25438b83-ea6d-4839-ae8e-53c52ac5f9ce", 85 | json=load_mock( 86 | "v1/environments/25438b83-ea6d-4839-ae8e-53c52ac5f9ce.json", 87 | ), 88 | ) 89 | 90 | mock_put = responses.put( 91 | "https://connect.example/__api__/v1/environments/25438b83-ea6d-4839-ae8e-53c52ac5f9ce", 92 | json=load_mock( 93 | "v1/environments/25438b83-ea6d-4839-ae8e-53c52ac5f9ce.json", 94 | ), 95 | ) 96 | 97 | c = Client("https://connect.example", "12345") 98 | c._ctx.version = None 99 | environment = c.environments.find("25438b83-ea6d-4839-ae8e-53c52ac5f9ce") 100 | assert environment["id"] == "314" 101 | 102 | environment.update(title="test") 103 | assert mock_put.call_count == 1 104 | -------------------------------------------------------------------------------- /tests/posit/connect/test_errors.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from posit.connect.errors import ClientError 4 | 5 | 6 | def test(): 7 | error_code = 0 8 | error_message = "error" 9 | http_status = 404 10 | http_message = "Error" 11 | with pytest.raises( 12 | ClientError, 13 | match='{"error_code": 0, "error_message": "error", "http_status": 404, "http_message": "Error", "payload": null}', 14 | ): 15 | raise ClientError( 16 | error_code=error_code, 17 | error_message=error_message, 18 | http_status=http_status, 19 | http_message=http_message, 20 | ) 21 | 22 | 23 | def test_payload_is_str(): 24 | error_code = 0 25 | error_message = "error" 26 | http_status = 404 27 | http_message = "Error" 28 | payload = "This is an error payload" 29 | with pytest.raises( 30 | ClientError, 31 | match='{"error_code": 0, "error_message": "error", "http_status": 404, "http_message": "Error", "payload": "This is an error payload"}', 32 | ): 33 | raise ClientError( 34 | error_code=error_code, 35 | error_message=error_message, 36 | http_status=http_status, 37 | http_message=http_message, 38 | payload=payload, 39 | ) 40 | 41 | 42 | def test_payload_is_dict(): 43 | error_code = 0 44 | error_message = "error" 45 | http_status = 404 46 | http_message = "Error" 47 | payload = {"message": "This is an error payload"} 48 | with pytest.raises( 49 | ClientError, 50 | match='{"error_code": 0, "error_message": "error", "http_status": 404, "http_message": "Error", "payload": {"message": "This is an error payload"}}', 51 | ): 52 | raise ClientError( 53 | error_code=error_code, 54 | error_message=error_message, 55 | http_status=http_status, 56 | http_message=http_message, 57 | payload=payload, 58 | ) 59 | -------------------------------------------------------------------------------- /tests/posit/connect/test_groups.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | from unittest.mock import Mock 3 | 4 | import pytest 5 | import responses 6 | 7 | from posit.connect.client import Client 8 | from posit.connect.context import Context 9 | from posit.connect.groups import Group 10 | from posit.connect.users import User 11 | 12 | from .api import load_mock_dict 13 | 14 | session = Mock() 15 | url = Mock() 16 | 17 | 18 | class TestGroupAttributes: 19 | @classmethod 20 | def setup_class(cls): 21 | guid = "6f300623-1e0c-48e6-a473-ddf630c0c0c3" 22 | fake_item = load_mock_dict(f"v1/groups/{guid}.json") 23 | cls.item = Group(mock.Mock(), **fake_item) 24 | 25 | def test_guid(self): 26 | assert self.item["guid"] == "6f300623-1e0c-48e6-a473-ddf630c0c0c3" 27 | 28 | def test_name(self): 29 | assert self.item["name"] == "Friends" 30 | 31 | def test_owner_guid(self): 32 | assert self.item["owner_guid"] == "20a79ce3-6e87-4522-9faf-be24228800a4" 33 | 34 | 35 | class TestGroupMembers: 36 | @classmethod 37 | def setup_class(cls): 38 | cls.client = Client("https://connect.example", "12345") 39 | guid = "6f300623-1e0c-48e6-a473-ddf630c0c0c3" 40 | fake_item = load_mock_dict(f"v1/groups/{guid}.json") 41 | ctx = Context(cls.client) 42 | cls.group = Group(ctx, **fake_item) 43 | 44 | @responses.activate 45 | def test_members_count(self): 46 | responses.get( 47 | f"https://connect.example/__api__/v1/groups/{self.group['guid']}/members", 48 | json=load_mock_dict(f"v1/groups/{self.group['guid']}/members.json"), 49 | ) 50 | group_members = self.group.members 51 | 52 | assert group_members.count() == 2 53 | 54 | @responses.activate 55 | def test_members_find(self): 56 | responses.get( 57 | f"https://connect.example/__api__/v1/groups/{self.group['guid']}/members", 58 | json=load_mock_dict(f"v1/groups/{self.group['guid']}/members.json"), 59 | ) 60 | 61 | group_users = self.group.members.find() 62 | assert len(group_users) == 2 63 | for user in group_users: 64 | assert isinstance(user, User) 65 | 66 | @responses.activate 67 | def test_members_add(self): 68 | user_guid = "user-guid" 69 | responses.post( 70 | f"https://connect.example/__api__/v1/groups/{self.group['guid']}/members", 71 | json=[], # No need to return anything 72 | ) 73 | 74 | user = User(self.client._ctx, guid=user_guid) 75 | self.group.members.add(user) 76 | self.group.members.add(user_guid=user["guid"]) 77 | 78 | with pytest.raises(TypeError): 79 | self.group.members.add( 80 | "not-a-user", # pyright: ignore[reportArgumentType] 81 | ) 82 | with pytest.raises(TypeError): 83 | self.group.members.add(group_guid=42) # pyright: ignore[reportCallIssue] 84 | with pytest.raises(ValueError): 85 | self.group.members.add(user, user_guid=user["guid"]) # pyright: ignore[reportCallIssue] 86 | with pytest.raises(ValueError): 87 | self.group.members.add(user_guid="") 88 | 89 | @responses.activate 90 | def test_members_delete(self): 91 | user_guid = "user-guid" 92 | responses.get( 93 | f"https://connect.example/__api__/v1/groups/{self.group['guid']}", 94 | json=dict(self.group), 95 | ) 96 | responses.delete( 97 | f"https://connect.example/__api__/v1/groups/{self.group['guid']}/members/{user_guid}", 98 | json=[], # No need to return anything 99 | ) 100 | 101 | user = User(self.client._ctx, guid=user_guid) 102 | 103 | self.group.members.delete(user) 104 | self.group.members.delete(user_guid=user["guid"]) 105 | 106 | with pytest.raises(TypeError): 107 | self.group.members.delete( 108 | "not-a-user", # pyright: ignore[reportArgumentType] 109 | ) 110 | with pytest.raises(TypeError): 111 | self.group.members.delete(group_guid=42) # pyright: ignore[reportCallIssue] 112 | with pytest.raises(ValueError): 113 | self.group.members.delete(user, user_guid=user["guid"]) # pyright: ignore[reportCallIssue] 114 | 115 | with pytest.raises(ValueError): 116 | self.group.members.delete(user_guid="") 117 | -------------------------------------------------------------------------------- /tests/posit/connect/test_hooks.py: -------------------------------------------------------------------------------- 1 | import io 2 | from unittest.mock import Mock 3 | 4 | import pytest 5 | import responses 6 | from requests import HTTPError, JSONDecodeError, Response 7 | 8 | from posit.connect import Client 9 | from posit.connect.errors import ClientError 10 | from posit.connect.hooks import handle_errors 11 | 12 | 13 | def test_success(): 14 | response = Mock() 15 | response.status_code = 200 16 | assert handle_errors(response) == response 17 | 18 | 19 | def test_client_error(): 20 | response = Mock() 21 | response.status_code = 400 22 | response.json = Mock(return_value={"code": 0, "error": "foobar"}) 23 | with pytest.raises(ClientError): 24 | handle_errors(response) 25 | 26 | 27 | def test_client_error_without_payload(): 28 | class StatusException(Exception): 29 | pass 30 | 31 | response = Mock() 32 | response.status_code = 404 33 | response.json = Mock(side_effect=JSONDecodeError("Test code", "Test msg", 0)) 34 | response.raise_for_status = Mock(side_effect=StatusException()) 35 | with pytest.raises(StatusException): 36 | handle_errors(response) 37 | 38 | 39 | def test_200(): 40 | response = Response() 41 | response.status_code = 200 42 | assert handle_errors(response) == response 43 | 44 | 45 | def test_response_client_error_with_plaintext_payload(): 46 | response = Response() 47 | response.status_code = 404 48 | response.raw = io.BytesIO(b"Plain text 404 Not Found") 49 | with pytest.raises(HTTPError): 50 | handle_errors(response) 51 | 52 | 53 | def test_response_client_error_with_json_payload(): 54 | response = Response() 55 | response.status_code = 400 56 | response.raw = io.BytesIO(b'{"code":0,"error":"foobar"}') 57 | with pytest.raises( 58 | ClientError, 59 | match='{"error_code": 0, "error_message": "foobar", "http_status": 400, "http_message": "Bad Request", "payload": null}', 60 | ): 61 | handle_errors(response) 62 | 63 | 64 | def test_response_client_error_without_payload(): 65 | response = Response() 66 | response.status_code = 404 67 | response.raw = io.BytesIO(b"Plain text 404 Not Found") 68 | with pytest.raises(HTTPError): 69 | handle_errors(response) 70 | 71 | 72 | @responses.activate 73 | def test_deprecation_warning(): 74 | responses.get( 75 | "https://connect.example/__api__/v0", 76 | headers={"X-Deprecated-Endpoint": "v1/"}, 77 | ) 78 | c = Client("https://connect.example", "12345") 79 | 80 | with pytest.warns(DeprecationWarning): 81 | c.get("v0") 82 | -------------------------------------------------------------------------------- /tests/posit/connect/test_internal_code.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | 5 | here = Path(__file__).resolve().parent 6 | root_dir = here.parent.parent.parent 7 | 8 | tests_dir = root_dir / "tests" 9 | src_dir = root_dir / "src" 10 | integration_tests_dir = tests_dir / "integration" / "tests" 11 | 12 | 13 | @pytest.mark.parametrize("path", [tests_dir, src_dir, integration_tests_dir]) 14 | def test_no_from_typing_imports(path: Path): 15 | for python_file in path.rglob("*.py"): 16 | file_txt = python_file.read_text() 17 | if "\nfrom typing import" in file_txt: 18 | raise ValueError( 19 | f"Found `from typing import` in {python_file.relative_to(root_dir)}. Please replace the import with `typing_extensions`." 20 | ) 21 | -------------------------------------------------------------------------------- /tests/posit/connect/test_jobs.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import responses 3 | from requests.exceptions import HTTPError 4 | from typing_extensions import TYPE_CHECKING 5 | 6 | from posit.connect.client import Client 7 | 8 | from .api import load_mock 9 | 10 | if TYPE_CHECKING: 11 | from posit.connect.jobs import Job, Jobs 12 | 13 | 14 | class TestJobsMixin: 15 | @responses.activate 16 | def test(self): 17 | responses.get( 18 | "https://connect.example/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066", 19 | json=load_mock("v1/content/f2f37341-e21d-3d80-c698-a935ad614066.json"), 20 | ) 21 | 22 | responses.get( 23 | "https://connect.example/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs", 24 | json=load_mock("v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs.json"), 25 | ) 26 | 27 | c = Client("https://connect.example", "12345") 28 | content = c.content.get("f2f37341-e21d-3d80-c698-a935ad614066") 29 | jobs: Jobs = content.jobs 30 | assert len(jobs) == 1 31 | 32 | 33 | class TestJobsFind: 34 | @responses.activate 35 | def test(self): 36 | responses.get( 37 | "https://connect.example/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066", 38 | json=load_mock("v1/content/f2f37341-e21d-3d80-c698-a935ad614066.json"), 39 | ) 40 | 41 | responses.get( 42 | "https://connect.example/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs/tHawGvHZTosJA2Dx", 43 | json=load_mock( 44 | "v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs/tHawGvHZTosJA2Dx.json", 45 | ), 46 | ) 47 | 48 | c = Client("https://connect.example", "12345") 49 | content = c.content.get("f2f37341-e21d-3d80-c698-a935ad614066") 50 | 51 | job: Job = content.jobs.find("tHawGvHZTosJA2Dx") 52 | assert job["key"] == "tHawGvHZTosJA2Dx" 53 | 54 | @responses.activate 55 | def test_miss(self): 56 | responses.get( 57 | "https://connect.example/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066", 58 | json=load_mock("v1/content/f2f37341-e21d-3d80-c698-a935ad614066.json"), 59 | ) 60 | 61 | responses.get( 62 | "https://connect.example/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs/not-found", 63 | status=404, 64 | ) 65 | 66 | c = Client("https://connect.example", "12345") 67 | content = c.content.get("f2f37341-e21d-3d80-c698-a935ad614066") 68 | 69 | with pytest.raises(HTTPError): 70 | content.jobs.find("not-found") 71 | 72 | 73 | class TestJobsFindBy: 74 | @responses.activate 75 | def test(self): 76 | responses.get( 77 | "https://connect.example/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066", 78 | json=load_mock("v1/content/f2f37341-e21d-3d80-c698-a935ad614066.json"), 79 | ) 80 | 81 | responses.get( 82 | "https://connect.example/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs", 83 | json=load_mock("v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs.json"), 84 | ) 85 | 86 | c = Client("https://connect.example", "12345") 87 | content = c.content.get("f2f37341-e21d-3d80-c698-a935ad614066") 88 | 89 | job = content.jobs.find_by(key="tHawGvHZTosJA2Dx") 90 | assert job 91 | assert job["key"] == "tHawGvHZTosJA2Dx" 92 | 93 | 94 | class TestJobDestory: 95 | @responses.activate 96 | def test(self): 97 | responses.get( 98 | "https://connect.example/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066", 99 | json=load_mock("v1/content/f2f37341-e21d-3d80-c698-a935ad614066.json"), 100 | ) 101 | 102 | responses.get( 103 | "https://connect.example/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs/tHawGvHZTosJA2Dx", 104 | json=load_mock( 105 | "v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs/tHawGvHZTosJA2Dx.json", 106 | ), 107 | ) 108 | 109 | responses.delete( 110 | "https://connect.example/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs/tHawGvHZTosJA2Dx", 111 | ) 112 | 113 | c = Client("https://connect.example", "12345") 114 | content = c.content.get("f2f37341-e21d-3d80-c698-a935ad614066") 115 | 116 | job = content.jobs.find("tHawGvHZTosJA2Dx") 117 | job.destroy() 118 | -------------------------------------------------------------------------------- /tests/posit/connect/test_packages.py: -------------------------------------------------------------------------------- 1 | import responses 2 | 3 | from posit.connect.client import Client 4 | 5 | from .api import load_mock 6 | 7 | 8 | class TestPackagesFindBy: 9 | @responses.activate 10 | def test(self): 11 | mock_get = responses.get( 12 | "https://connect.example/__api__/v1/packages", 13 | json=load_mock("v1/packages.json"), 14 | ) 15 | 16 | c = Client("https://connect.example", "12345") 17 | c._ctx.version = None 18 | 19 | package = c.packages.find_by(name="posit") 20 | assert package 21 | assert package["name"] == "posit" 22 | assert mock_get.call_count == 1 23 | -------------------------------------------------------------------------------- /tests/posit/connect/test_resources.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | from unittest import mock 3 | from unittest.mock import Mock 4 | 5 | from typing_extensions import Optional 6 | 7 | from posit.connect.resources import BaseResource 8 | 9 | config = Mock() 10 | session = Mock() 11 | 12 | 13 | class FakeResource(BaseResource): 14 | @property 15 | def foo(self) -> Optional[str]: 16 | return self.get("foo") 17 | 18 | 19 | class TestResource: 20 | def test_init(self): 21 | p = mock.Mock() 22 | k = "foo" 23 | v = "bar" 24 | d = {k: v} 25 | r = FakeResource(p, **d) 26 | assert r._ctx == p 27 | 28 | def test__getitem__(self): 29 | warnings.filterwarnings("ignore", category=FutureWarning) 30 | k = "foo" 31 | v = "bar" 32 | d = {k: v} 33 | r = FakeResource(mock.Mock(), **d) 34 | assert r.__getitem__(k) == d.__getitem__(k) 35 | assert r[k] == d[k] 36 | 37 | def test__setitem__(self): 38 | warnings.filterwarnings("ignore", category=FutureWarning) 39 | k = "foo" 40 | v1 = "bar" 41 | v2 = "baz" 42 | d = {k: v1} 43 | r = FakeResource(mock.Mock(), **d) 44 | assert r[k] == v1 45 | r[k] = v2 46 | assert r[k] == v2 47 | 48 | def test__delitem__(self): 49 | warnings.filterwarnings("ignore", category=FutureWarning) 50 | k = "foo" 51 | v = "bar" 52 | d = {k: v} 53 | r = FakeResource(mock.Mock(), **d) 54 | assert k in r 55 | assert r[k] == v 56 | del r[k] 57 | assert k not in r 58 | 59 | def test_foo(self): 60 | k = "foo" 61 | v = "bar" 62 | d = {k: v} 63 | r = FakeResource(mock.Mock(), **d) 64 | assert r.foo == v 65 | -------------------------------------------------------------------------------- /tests/posit/connect/test_system.py: -------------------------------------------------------------------------------- 1 | import responses 2 | 3 | from posit.connect.client import Client 4 | from posit.connect.system import SystemRuntimeCache 5 | from posit.connect.tasks import Task 6 | 7 | from .api import load_mock_dict 8 | 9 | 10 | class TestSystemCacheRuntime: 11 | @responses.activate 12 | def test_runtime_caches(self): 13 | # behavior 14 | mock_get_runtimes = responses.get( 15 | "https://connect.example/__api__/v1/system/caches/runtime", 16 | json=load_mock_dict("v1/system/caches/runtime.json"), 17 | ) 18 | mock_delete = responses.delete( 19 | "https://connect.example/__api__/v1/system/caches/runtime", 20 | json={"task_id": "12345"}, 21 | ) 22 | mock_task = responses.get( 23 | "https://connect.example/__api__/v1/tasks/12345", 24 | json={"task_id": "12345"}, 25 | ) 26 | 27 | # setup 28 | client = Client(api_key="12345", url="https://connect.example") 29 | 30 | # invoke 31 | runtimes = client.system.caches.runtime.find() 32 | 33 | for runtime in runtimes: 34 | assert isinstance(runtime, SystemRuntimeCache) 35 | task = runtime.destroy() 36 | assert isinstance(task, Task) 37 | 38 | first_runtime = runtimes[0] 39 | task = first_runtime.destroy() 40 | assert isinstance(task, Task) 41 | task = client.system.caches.runtime.destroy( 42 | language=first_runtime["language"], 43 | version=first_runtime["version"], 44 | image_name=first_runtime["image_name"], 45 | ) 46 | assert isinstance(task, Task) 47 | 48 | # assert 49 | assert mock_get_runtimes.call_count == 1 50 | assert mock_delete.call_count == 3 51 | assert mock_task.call_count == len(runtimes) + 2 52 | -------------------------------------------------------------------------------- /tests/posit/connect/test_urls.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from posit.connect import urls 4 | 5 | 6 | class TestCreate: 7 | def test(self): 8 | url = "http://example.com/__api__" 9 | assert urls.Url(url) == url 10 | 11 | def test_append_path(self): 12 | assert urls.Url("http://example.com/") == "http://example.com/__api__" 13 | 14 | def test_missing_scheme(self): 15 | with pytest.raises(ValueError): 16 | urls.Url("example.com") 17 | 18 | def test_missing_netloc(self): 19 | with pytest.raises(ValueError): 20 | urls.Url("http://") 21 | 22 | 23 | class TestAppend: 24 | def test(self): 25 | url = "http://example.com/__api__" 26 | url = urls.Url(url) 27 | assert url + "path" == "http://example.com/__api__/path" 28 | 29 | def test_slash_prefix(self): 30 | url = "http://example.com/__api__" 31 | url = urls.Url(url) 32 | assert url + "/path" == "http://example.com/__api__/path" 33 | 34 | def test_slash_suffix(self): 35 | url = "http://example.com/__api__" 36 | url = urls.Url(url) 37 | assert url + "path/" == "http://example.com/__api__/path" 38 | -------------------------------------------------------------------------------- /tests/posit/connect/test_vanities.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | import responses 4 | from responses.matchers import json_params_matcher 5 | 6 | from posit import connect 7 | from posit.connect.vanities import Vanities, Vanity, VanityMixin 8 | 9 | 10 | class TestVanityDestroy: 11 | @responses.activate 12 | def test_destroy_sends_delete_request(self): 13 | content_guid = "8ce6eaca-60af-4c2f-93a0-f5f3cddf5ee5" 14 | base_url = "https://connect.example/__api__" 15 | endpoint = f"{base_url}/v1/content/{content_guid}/vanity" 16 | mock_delete = responses.delete(endpoint) 17 | 18 | c = connect.Client("https://connect.example", "12345") 19 | vanity = Vanity(c._ctx, content_guid=content_guid, path=Mock(), created_time=Mock()) 20 | 21 | vanity.destroy() 22 | 23 | assert mock_delete.call_count == 1 24 | 25 | @responses.activate 26 | def test_destroy_calls_after_destroy_callback(self): 27 | content_guid = "8ce6eaca-60af-4c2f-93a0-f5f3cddf5ee5" 28 | base_url = "https://connect.example/__api__" 29 | endpoint = f"{base_url}/v1/content/{content_guid}/vanity" 30 | responses.delete(endpoint) 31 | 32 | c = connect.Client("https://connect.example", "12345") 33 | after_destroy = Mock() 34 | vanity = Vanity( 35 | c._ctx, 36 | after_destroy=after_destroy, 37 | content_guid=content_guid, 38 | path=Mock(), 39 | created_time=Mock(), 40 | ) 41 | 42 | vanity.destroy() 43 | 44 | assert after_destroy.call_count == 1 45 | 46 | 47 | class TestVanitiesAll: 48 | @responses.activate 49 | def test_all_sends_get_request(self): 50 | base_url = "https://connect.example/__api__" 51 | endpoint = f"{base_url}/v1/vanities" 52 | mock_get = responses.get(endpoint, json=[]) 53 | 54 | c = connect.Client("https://connect.example", "12345") 55 | vanities = Vanities(c._ctx) 56 | 57 | vanities.all() 58 | 59 | assert mock_get.call_count == 1 60 | 61 | 62 | class TestVanityMixin: 63 | @responses.activate 64 | def test_vanity_getter_returns_vanity(self): 65 | guid = "8ce6eaca-60af-4c2f-93a0-f5f3cddf5ee5" 66 | base_url = "https://connect.example/__api__" 67 | endpoint = f"{base_url}/v1/content/{guid}/vanity" 68 | mock_get = responses.get(endpoint, json={"content_guid": guid, "path": "my-dashboard"}) 69 | 70 | c = connect.Client("https://connect.example", "12345") 71 | content = VanityMixin(c._ctx, guid=guid) 72 | 73 | assert content.vanity == "my-dashboard" 74 | assert mock_get.call_count == 1 75 | 76 | @responses.activate 77 | def test_vanity_setter_with_string(self): 78 | guid = "8ce6eaca-60af-4c2f-93a0-f5f3cddf5ee5" 79 | base_url = "https://connect.example/__api__" 80 | endpoint = f"{base_url}/v1/content/{guid}/vanity" 81 | path = "example" 82 | mock_put = responses.put( 83 | endpoint, 84 | json={"content_guid": guid, "path": path}, 85 | match=[json_params_matcher({"path": path})], 86 | ) 87 | 88 | c = connect.Client("https://connect.example", "12345") 89 | content = VanityMixin(c._ctx, guid=guid) 90 | content.vanity = path 91 | assert content.vanity == path 92 | 93 | assert mock_put.call_count == 1 94 | 95 | @responses.activate 96 | def test_vanity_deleter(self): 97 | guid = "8ce6eaca-60af-4c2f-93a0-f5f3cddf5ee5" 98 | base_url = "https://connect.example/__api__" 99 | endpoint = f"{base_url}/v1/content/{guid}/vanity" 100 | mock_delete = responses.delete(endpoint) 101 | 102 | c = connect.Client("https://connect.example", "12345") 103 | content = VanityMixin(c._ctx, guid=guid) 104 | content._vanity = Vanity(c._ctx, path=Mock(), content_guid=guid, created_time=Mock()) 105 | del content.vanity 106 | 107 | assert content._vanity is None 108 | assert mock_delete.call_count == 1 109 | -------------------------------------------------------------------------------- /tests/posit/workbench/external/test_databricks.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | from posit.workbench.external.databricks import ( 6 | POSIT_WORKBENCH_AUTH_TYPE, 7 | WorkbenchStrategy, 8 | ) 9 | 10 | try: 11 | from databricks.sdk.core import Config 12 | except ImportError: 13 | pytestmark = pytest.mark.skipif(True, reason="requires the Databricks SDK") 14 | 15 | 16 | class TestPositCredentialsHelpers: 17 | def test_workbench_strategy(self): 18 | # default will attempt to load the workbench profile 19 | with pytest.raises(ValueError, match="profile=workbench"): 20 | WorkbenchStrategy() 21 | 22 | # providing a Config is allowed 23 | cs = WorkbenchStrategy( 24 | config=Config(host="https://databricks.com/workspace", token="token") # pyright: ignore[reportPossiblyUnboundVariable] 25 | ) 26 | assert cs.auth_type() == POSIT_WORKBENCH_AUTH_TYPE 27 | cp = cs() 28 | 29 | # token from the Config is passed through to the auth header 30 | assert cp() == {"Authorization": "Bearer token"} 31 | -------------------------------------------------------------------------------- /vars.mk: -------------------------------------------------------------------------------- 1 | # Makefile variables file. 2 | # 3 | # Variables shared across project Makefiles via 'include vars.mk'. 4 | # 5 | # - ./Makefile 6 | # - ./docs/Makefile 7 | # - ./integration/Makefile 8 | 9 | # Shell settings 10 | SHELL := /bin/bash 11 | 12 | # Environment settings 13 | ENV ?= dev 14 | 15 | # Project settings 16 | PROJECT_NAME := posit-sdk 17 | 18 | # Python settings 19 | PYTHON ?= $(shell command -v python || command -v python3) 20 | UV ?= uv 21 | # uv defaults virtual environment to `$VIRTUAL_ENV` if set; otherwise .venv 22 | VIRTUAL_ENV ?= .venv 23 | 24 | UV_LOCK := uv.lock 25 | --------------------------------------------------------------------------------