├── CODEOWNERS
├── src
└── posit
│ ├── workbench
│ ├── __init__.py
│ └── external
│ │ ├── __init__.py
│ │ └── databricks.py
│ ├── connect
│ ├── __init__.py
│ ├── metrics
│ │ ├── __init__.py
│ │ ├── metrics.py
│ │ ├── rename_params.py
│ │ ├── hits.py
│ │ └── shiny_usage.py
│ ├── oauth
│ │ ├── __init__.py
│ │ ├── types.py
│ │ └── sessions.py
│ ├── external
│ │ ├── __init__.py
│ │ └── snowflake.py
│ ├── me.py
│ ├── auth.py
│ ├── variants.py
│ ├── errors.py
│ ├── config.py
│ ├── context.py
│ ├── hooks.py
│ ├── _utils.py
│ ├── cursors.py
│ ├── urls.py
│ ├── paginator.py
│ ├── sessions.py
│ └── repository.py
│ └── __init__.py
├── integration
├── .gitignore
├── tests
│ └── posit
│ │ └── connect
│ │ ├── oauth
│ │ ├── __init__.py
│ │ └── test_integrations.py
│ │ ├── test_client.py
│ │ ├── __init__.py
│ │ ├── test_groups.py
│ │ ├── test_environments.py
│ │ ├── test_packages.py
│ │ ├── test_env.py
│ │ ├── test_jobs.py
│ │ ├── test_vanities.py
│ │ ├── test_system.py
│ │ ├── test_content_item_repository.py
│ │ ├── test_content_item_permissions.py
│ │ ├── test_content.py
│ │ └── test_bundles.py
├── resources
│ └── connect
│ │ └── bundles
│ │ ├── example-flask-minimal
│ │ ├── requirements.txt
│ │ ├── bundle.tar.gz
│ │ ├── hello.py
│ │ └── manifest.json
│ │ └── example-quarto-minimal
│ │ ├── styles.css
│ │ ├── .gitignore
│ │ ├── about.qmd
│ │ ├── bundle.tar.gz
│ │ ├── index.qmd
│ │ ├── _quarto.yml
│ │ └── manifest.json
└── Makefile
├── tests
└── posit
│ ├── connect
│ ├── __api__
│ │ ├── v1
│ │ │ ├── tags
│ │ │ │ ├── 3
│ │ │ │ │ └── content.json
│ │ │ │ ├── 33.json
│ │ │ │ ├── 3.json
│ │ │ │ ├── 29.json
│ │ │ │ └── 33-patched.json
│ │ │ ├── groups
│ │ │ │ ├── empty-group-guid
│ │ │ │ │ └── members.json
│ │ │ │ ├── 6f300623-1e0c-48e6-a473-ddf630c0c0c3.json
│ │ │ │ ├── groups.json
│ │ │ │ └── 6f300623-1e0c-48e6-a473-ddf630c0c0c3
│ │ │ │ │ └── members.json
│ │ │ ├── system
│ │ │ │ └── caches
│ │ │ │ │ └── runtime.json
│ │ │ ├── content
│ │ │ │ ├── f2f37341-e21d-3d80-c698-a935ad614066
│ │ │ │ │ ├── packages.json
│ │ │ │ │ ├── tag-add.json
│ │ │ │ │ ├── bundles
│ │ │ │ │ │ ├── 101
│ │ │ │ │ │ │ └── download
│ │ │ │ │ │ │ │ └── bundle.tar.gz
│ │ │ │ │ │ └── 101.json
│ │ │ │ │ ├── repository.json
│ │ │ │ │ ├── permissions
│ │ │ │ │ │ ├── 59.json
│ │ │ │ │ │ └── 94.json
│ │ │ │ │ ├── repository_patch.json
│ │ │ │ │ ├── tags.json
│ │ │ │ │ ├── permissions.json
│ │ │ │ │ ├── jobs
│ │ │ │ │ │ └── tHawGvHZTosJA2Dx.json
│ │ │ │ │ ├── oauth
│ │ │ │ │ │ └── integrations
│ │ │ │ │ │ │ └── associations.json
│ │ │ │ │ ├── jobs.json
│ │ │ │ │ └── bundles.json
│ │ │ │ └── f2f37341-e21d-3d80-c698-a935ad614066.json
│ │ │ ├── tags?name=academy.json
│ │ │ ├── packages.json
│ │ │ ├── tasks
│ │ │ │ └── jXhOhdm5OOSkGhJw.json
│ │ │ ├── groups.json
│ │ │ ├── instrumentation
│ │ │ │ ├── shiny
│ │ │ │ │ ├── usage?limit=500&next=23948901087.json
│ │ │ │ │ └── usage?limit=500.json
│ │ │ │ └── content
│ │ │ │ │ ├── visits?limit=500&next=23948901087.json
│ │ │ │ │ ├── hits.json
│ │ │ │ │ └── visits?limit=500.json
│ │ │ ├── oauth
│ │ │ │ ├── sessions
│ │ │ │ │ └── 32c04dc6-0318-41b7-bc74-7e321b196f14.json
│ │ │ │ ├── integrations
│ │ │ │ │ ├── 22644575-a27b-4118-ad06-e24459b05126
│ │ │ │ │ │ └── associations.json
│ │ │ │ │ └── 22644575-a27b-4118-ad06-e24459b05126.json
│ │ │ │ ├── sessions.json
│ │ │ │ └── integrations.json
│ │ │ ├── user.json
│ │ │ ├── users
│ │ │ │ ├── 20a79ce3-6e87-4522-9faf-be24228800a4.json
│ │ │ │ └── a1b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6.json
│ │ │ ├── environments
│ │ │ │ └── 25438b83-ea6d-4839-ae8e-53c52ac5f9ce.json
│ │ │ ├── environments.json
│ │ │ ├── users?page_number=2&page_size=500.jsonc
│ │ │ ├── tags?parent_id=3.json
│ │ │ ├── users?page_number=1&page_size=500.jsonc
│ │ │ └── content?owner_guid=20a79ce3-6e87-4522-9faf-be24228800a4.json
│ │ ├── variants
│ │ │ └── 6627
│ │ │ │ └── render.json
│ │ └── applications
│ │ │ └── f2f37341-e21d-3d80-c698-a935ad614066
│ │ │ └── variants.json
│ ├── test_auth.py
│ ├── metrics
│ │ ├── test_rename_params.py
│ │ ├── test_shiny_usage.py
│ │ ├── test_hits.py
│ │ └── test_visits.py
│ ├── test_packages.py
│ ├── test_internal_code.py
│ ├── test_urls.py
│ ├── api.py
│ ├── test_config.py
│ ├── test_system.py
│ ├── test_errors.py
│ ├── test_context.py
│ ├── test_hooks.py
│ ├── oauth
│ │ └── test_sessions.py
│ ├── test_environments.py
│ ├── test_vanities.py
│ ├── test_groups.py
│ ├── test_jobs.py
│ └── external
│ │ └── test_snowflake.py
│ └── workbench
│ └── external
│ └── test_databricks.py
├── examples
├── __init__.py
└── README.md
├── .vscode
├── extensions.json
├── launch.json
└── settings.json
├── .snyk
├── docs
├── .gitignore
├── index.qmd
├── installation.qmd
├── quickstart.qmd
├── Makefile
└── _quarto.yml
├── .coveragerc
├── .pre-commit-config.yaml
├── vars.mk
├── .github
└── workflows
│ ├── release.yaml
│ ├── coverage.yaml
│ ├── board.yaml
│ ├── conventional-commits.yaml
│ ├── site.yaml
│ ├── claude.yaml
│ └── ci.yaml
├── LICENSE
├── README.md
├── Makefile
├── CONTRIBUTING.md
└── .gitignore
/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * @tdstein
2 |
--------------------------------------------------------------------------------
/src/posit/workbench/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/integration/.gitignore:
--------------------------------------------------------------------------------
1 | logs
2 | reports
3 |
--------------------------------------------------------------------------------
/integration/tests/posit/connect/oauth/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/posit/connect/__api__/v1/tags/3/content.json:
--------------------------------------------------------------------------------
1 | []
2 |
--------------------------------------------------------------------------------
/src/posit/connect/__init__.py:
--------------------------------------------------------------------------------
1 | from .client import Client as Client
2 |
--------------------------------------------------------------------------------
/examples/__init__.py:
--------------------------------------------------------------------------------
1 | # This file fixes the mypy errors "duplicate module named app.py"
2 |
--------------------------------------------------------------------------------
/integration/resources/connect/bundles/example-flask-minimal/requirements.txt:
--------------------------------------------------------------------------------
1 | Flask==3.0.3
2 |
--------------------------------------------------------------------------------
/integration/resources/connect/bundles/example-quarto-minimal/styles.css:
--------------------------------------------------------------------------------
1 | /* css styles */
2 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "charliermarsh.ruff"
4 | ]
5 | }
6 |
--------------------------------------------------------------------------------
/integration/resources/connect/bundles/example-quarto-minimal/.gitignore:
--------------------------------------------------------------------------------
1 | /.quarto/
2 | _site/
3 |
--------------------------------------------------------------------------------
/.snyk:
--------------------------------------------------------------------------------
1 | # Snyk (https://snyk.io) policy file
2 | ---
3 | exclude:
4 | global:
5 | - "test_*.py"
6 |
--------------------------------------------------------------------------------
/docs/.gitignore:
--------------------------------------------------------------------------------
1 | _extensions/
2 | _inv/
3 | _site/
4 | .quarto/
5 | objects.json
6 | reference/
7 |
8 | /.quarto/
9 |
--------------------------------------------------------------------------------
/src/posit/connect/metrics/__init__.py:
--------------------------------------------------------------------------------
1 | """Metric resources."""
2 |
3 | from .metrics import Metrics as Metrics
4 |
--------------------------------------------------------------------------------
/integration/resources/connect/bundles/example-quarto-minimal/about.qmd:
--------------------------------------------------------------------------------
1 | ---
2 | title: "About"
3 | ---
4 |
5 | About this site
6 |
--------------------------------------------------------------------------------
/src/posit/__init__.py:
--------------------------------------------------------------------------------
1 | """The Posit SDK."""
2 |
3 | from . import connect as connect
4 | from . import workbench as workbench
5 |
--------------------------------------------------------------------------------
/tests/posit/connect/__api__/v1/groups/empty-group-guid/members.json:
--------------------------------------------------------------------------------
1 | {
2 | "results": [],
3 | "current_page": 1,
4 | "total": 0
5 | }
6 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/integration/resources/connect/bundles/example-flask-minimal/bundle.tar.gz:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/posit-dev/posit-sdk-py/HEAD/integration/resources/connect/bundles/example-flask-minimal/bundle.tar.gz
--------------------------------------------------------------------------------
/integration/resources/connect/bundles/example-quarto-minimal/bundle.tar.gz:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/posit-dev/posit-sdk-py/HEAD/integration/resources/connect/bundles/example-quarto-minimal/bundle.tar.gz
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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/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/content/f2f37341-e21d-3d80-c698-a935ad614066/bundles/101/download/bundle.tar.gz:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/posit-dev/posit-sdk-py/HEAD/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/bundles/101/download/bundle.tar.gz
--------------------------------------------------------------------------------
/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/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/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_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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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__/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/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/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/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/__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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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.group = cls.client.groups.create(name="Friends")
9 |
10 | @classmethod
11 | def teardown_class(cls):
12 | cls.group.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.group["guid"])
20 |
21 | def test_find(self):
22 | assert self.client.groups.find() == [self.group]
23 |
24 | def test_find_one(self):
25 | assert self.client.groups.find_one() == self.group
26 |
--------------------------------------------------------------------------------
/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/__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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/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 | "app_guid": "f2f37341-e21d-3d80-c698-a935ad614066",
12 | "oauth_integration_guid": "00000000-a27b-4118-ad06-e24459b05126",
13 | "oauth_integration_name": "another integration",
14 | "oauth_integration_description": "another description",
15 | "oauth_integration_template": "connect",
16 | "created_time": "2024-10-02T18:16:09Z"
17 | }
18 | ]
19 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/__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/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_default_workbench_strategy(self):
18 | # By default, the WorkbenchStrategy should use "workbench" profile.
19 | strategy = WorkbenchStrategy()
20 |
21 | with pytest.raises(ValueError, match="profile=workbench"):
22 | strategy()
23 |
24 | def test_workbench_strategy(self):
25 | # providing a Config is allowed
26 | cs = WorkbenchStrategy(
27 | config=Config(host="https://databricks.com/workspace", token="token") # pyright: ignore[reportPossiblyUnboundVariable]
28 | )
29 | assert cs.auth_type() == POSIT_WORKBENCH_AUTH_TYPE
30 | cp = cs()
31 |
32 | # token from the Config is passed through to the auth header
33 | assert cp() == {"Authorization": "Bearer token"}
34 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/src/posit/connect/oauth/types.py:
--------------------------------------------------------------------------------
1 | from enum import Enum
2 |
3 | GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"
4 |
5 |
6 | class OAuthTokenType(str, Enum):
7 | ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token"
8 | AWS_CREDENTIALS = "urn:ietf:params:aws:token-type:credentials"
9 | API_KEY = "urn:posit:connect:api-key"
10 | CONTENT_SESSION_TOKEN = "urn:posit:connect:content-session-token"
11 | USER_SESSION_TOKEN = "urn:posit:connect:user-session-token"
12 |
13 |
14 | class OAuthIntegrationAuthType(str, Enum):
15 | """OAuth integration authentication type."""
16 |
17 | VIEWER = "Viewer"
18 | SERVICE_ACCOUNT = "Service Account"
19 | VISITOR_API_KEY = "Visitor API Key"
20 |
21 |
22 | class OAuthIntegrationType(str, Enum):
23 | """OAuth integration type."""
24 |
25 | AWS = "aws"
26 | AZURE = "azure"
27 | AZURE_OPENAI = "azure-openai"
28 | CONNECT = "connect"
29 | CUSTOM = "custom"
30 | DATABRICKS = "databricks"
31 | GITHUB = "github"
32 | GOOGLE_BIGQUERY = "bigquery"
33 | GOOGLE_DRIVE = "drive"
34 | GOOGLE_SHEETS = "sheets"
35 | GOOGLE_VERTEX_AI = "vertex-ai"
36 | MSGRAPH = "msgraph"
37 | SALESFORCE = "salesforce"
38 | SHAREPOINT = "sharepoint"
39 | SNOWFLAKE = "snowflake"
40 |
--------------------------------------------------------------------------------
/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_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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/__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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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.11.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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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/__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 | "id": "5",
38 | "guid": "d32b3f4e-3f4a-4c5a-9f1e-8b6e2f7c9a2b",
39 | "created_time": "2025-07-15T20:35:11Z",
40 | "updated_time": "2025-07-15T20:35:11Z",
41 | "name": "Connect Visitor API Key",
42 | "description": "Allows access to Connect APIs on behalf of a Connect user.",
43 | "template": "connect",
44 | "config": {
45 | "scopes": "admin"
46 | }
47 | }
48 | ]
49 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | CONNECT_VERSION < version.parse("2024.05.0"),
15 | reason="Cache runtimes not implemented",
16 | )
17 | class TestSystem:
18 | @classmethod
19 | def setup_class(cls):
20 | cls.client = Client()
21 | cls.content = cls.client.content.create(name=cls.__name__)
22 | path = Path("../../../resources/connect/bundles/example-flask-minimal/bundle.tar.gz")
23 | path = (Path(__file__).parent / path).resolve()
24 | bundle = cls.content.bundles.create(str(path))
25 | task = bundle.deploy()
26 | task.wait_for()
27 |
28 | @classmethod
29 | def teardown_class(cls):
30 | cls.content.delete()
31 |
32 | def test_runtime_caches(self):
33 | runtimes = self.client.system.caches.runtime.find()
34 | assert len(runtimes) > 0
35 |
36 | def test_runtime_cache_destroy(self):
37 | # Find existing runtime caches
38 | runtimes: list[SystemRuntimeCache] = self.client.system.caches.runtime.find()
39 | assert len(runtimes) > 0
40 |
41 | # Get the first cache for testing
42 | cache = runtimes[0]
43 | assert isinstance(cache, SystemRuntimeCache)
44 |
45 | # Test dry run destroy (should return None and not actually destroy)
46 | result = cache.destroy(dry_run=True)
47 | assert result is None
48 |
49 | # Verify cache still exists after dry run
50 | runtimes_after_dry_run = self.client.system.caches.runtime.find()
51 | assert len(runtimes_after_dry_run) == len(runtimes)
52 |
53 | # Test actual destroy
54 | task: Task = cache.destroy()
55 | assert isinstance(task, Task)
56 | task.wait_for()
57 |
58 | # Verify cache was removed
59 | runtimes_after_destroy = self.client.system.caches.runtime.find()
60 | assert len(runtimes_after_destroy) == len(runtimes) - 1
61 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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_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(
81 | DeprecationWarning,
82 | match="https://connect.example/__api__/v0 is deprecated and will be removed in a future version of Connect. Please upgrade `posit-sdk` in order to use the new APIs.",
83 | ):
84 | c.get("v0")
85 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | include vars.mk
2 |
3 | .DEFAULT_GOAL := all
4 |
5 | .PHONY: build clean cov default dev 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 | docs: ensure-uv
39 | $(MAKE) -C ./docs
40 |
41 | $(VIRTUAL_ENV):
42 | $(UV) venv $(VIRTUAL_ENV)
43 | ensure-uv:
44 | @if ! command -v $(UV) >/dev/null; then \
45 | $(PYTHON) -m ensurepip && $(PYTHON) -m pip install "uv >= 0.4.27"; \
46 | fi
47 | @# Install virtual environment (before calling `uv pip install ...`)
48 | @$(MAKE) $(VIRTUAL_ENV) 1>/dev/null
49 | @# Be sure recent uv is installed
50 | @$(UV) pip install "uv >= 0.4.27" --quiet
51 |
52 | fmt: dev
53 | $(UV) run ruff check --fix
54 | $(UV) run ruff format
55 |
56 | install: build
57 | $(UV) pip install dist/*.whl
58 |
59 | it: $(UV_LOCK)
60 | $(MAKE) -C ./integration
61 |
62 | lint: dev
63 | $(UV) run ruff check
64 | $(UV) run pyright
65 |
66 | test: dev
67 | $(UV) run coverage run --source=src -m pytest tests
68 |
69 | uninstall: ensure-uv
70 | $(UV) pip uninstall $(PROJECT_NAME)
71 |
72 | version:
73 | @$(MAKE) ensure-uv &>/dev/null
74 | @$(UV) run --quiet --with "setuptools_scm" python -m setuptools_scm
75 |
76 | help:
77 | @echo "Makefile Targets"
78 | @echo " all Run dev, test, lint, and build"
79 | @echo " build Build the project"
80 | @echo " clean Clean up project artifacts"
81 | @echo " cov Generate a coverage report"
82 | @echo " cov-html Generate an HTML coverage report and open it"
83 | @echo " cov-xml Generate an XML coverage report"
84 | @echo " dev Install the project in editable mode"
85 | @echo " docs Build the documentation"
86 | @echo " ensure-uv Ensure 'uv' is installed"
87 | @echo " fmt Format the code"
88 | @echo " install Install the built project"
89 | @echo " it Run integration tests"
90 | @echo " lint Lint the code"
91 | @echo " test Run unit tests with coverage"
92 | @echo " uninstall Uninstall the project"
93 | @echo " version Display the project version"
94 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.github/workflows/claude.yaml:
--------------------------------------------------------------------------------
1 | name: Claude
2 | on:
3 | issues:
4 | types:
5 | - opened
6 | - assigned
7 | issue_comment:
8 | types:
9 | - created
10 | pull_request:
11 | paths:
12 | - .github/workflows/claude.yaml
13 | pull_request_review:
14 | types:
15 | - submitted
16 | pull_request_review_comment:
17 | types:
18 | - created
19 | concurrency:
20 | group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }}
21 | cancel-in-progress: false
22 | jobs:
23 | default:
24 | if: |
25 | (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
26 | (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
27 | (github.event_name == 'issues' && contains(github.event.issue.body, '@claude')) ||
28 | (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
29 | github.event_name == 'pull_request'
30 | runs-on: ubuntu-latest
31 | timeout-minutes: 5
32 | permissions:
33 | contents: read
34 | pull-requests: write
35 | issues: write
36 | id-token: write
37 | steps:
38 | - uses: actions/checkout@v5
39 | with:
40 | fetch-depth: 1
41 | - uses: aws-actions/configure-aws-credentials@v5
42 | with:
43 | role-to-assume: arn:aws:iam::440849847947:role/claude-code-gha
44 | role-session-name: gha-claude-code-action
45 | aws-region: us-east-2
46 | - id: generate-token
47 | uses: actions/create-github-app-token@v2
48 | with:
49 | app-id: 1129585
50 | private-key: ${{ secrets.POSIT_CONNECT_PROJECTS_PEM }}
51 | - uses: anthropics/claude-code-action@v1
52 | with:
53 | use_bedrock: true
54 | github_token: ${{ steps.generate-token.outputs.token }}
55 | branch_prefix: claude-
56 | additional_permissions: "actions: read"
57 | track_progress: true
58 | prompt: |
59 | You are a helpful AI assistant for code reviews and issue triage.
60 | Respond to comments and issues that mention you with relevant code suggestions or triage actions.
61 | If you cannot assist, politely inform the user. In your responses, don't be overly complimentary.
62 | Stick to the facts and provide actionable advice.
63 | claude_args: |
64 | --model us.anthropic.claude-sonnet-4-5-20250929-v1:0
65 | --fallback-model us.anthropic.claude-haiku-4-5-20251001-v1:0
66 | --allowedTools mcp__github__create_pull_request,mcp__github__create_issue,mcp__github__search_issues,mcp__github__update_issue,mcp__github__create_pending_pull_request_review,mcp__github__add_pull_request_review_comment_to_pending_review,mcp__github__submit_pending_pull_request_review,mcp__github__get_pull_request_diff
67 |
--------------------------------------------------------------------------------
/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/Makefile:
--------------------------------------------------------------------------------
1 | include ../vars.mk
2 |
3 | # pytest settings
4 | PYTEST_ARGS ?= "-s"
5 |
6 | .DEFAULT_GOAL := latest
7 |
8 | define GET_PORT
9 | $(UV) run -- python -c 'import socket; s=socket.socket(); s.bind(("", 0)); print(s.getsockname()[1]); s.close()'
10 | endef
11 |
12 | .PHONY: $(CONNECT_VERSIONS) \
13 | all \
14 | clean \
15 | latest \
16 | print-versions \
17 | help \
18 | test-%
19 |
20 | # Versions
21 | CONNECT_VERSIONS := \
22 | 2025.10.0 \
23 | 2025.09.1 \
24 | 2025.07.0 \
25 | 2025.06.0 \
26 | 2025.05.0 \
27 | 2025.04.0 \
28 | 2025.03.0 \
29 | 2025.02.0 \
30 | 2025.01.0 \
31 | 2024.12.0 \
32 | 2024.11.0 \
33 | 2024.09.0 \
34 | 2024.08.0 \
35 | 2024.06.0 \
36 | 2024.05.0 \
37 | 2024.04.1 \
38 | 2024.04.0 \
39 | 2024.03.0 \
40 | 2024.02.0 \
41 | 2024.01.0 \
42 | 2023.12.0 \
43 | 2023.10.0 \
44 | 2023.09.0 \
45 | 2023.07.0 \
46 | 2023.06.0 \
47 | 2023.05.0 \
48 | 2023.01.1 \
49 | 2023.01.0 \
50 | 2022.12.0 \
51 | 2022.11.0
52 |
53 | clean:
54 | rm -rf logs reports
55 | find . -type d -empty -delete
56 |
57 | # Run pytest for a specific version (assumes Connect is already running).
58 | test-%:
59 | @mkdir -p logs reports
60 | $(UV) run pytest $(PYTEST_ARGS) \
61 | --junitxml=reports/junit-$*.xml | tee logs/$*.log
62 |
63 | # Spin up Connect for a specific version and run tests.
64 | $(CONNECT_VERSIONS): %:
65 | PORT=$$($(GET_PORT)); \
66 | uv run --with https://github.com/posit-dev/with-connect.git \
67 | with-connect --version $* --port $$PORT \
68 | -- $(MAKE) test-$*
69 |
70 | # Run test suite against all Connect versions.
71 | all: $(CONNECT_VERSIONS:%=%)
72 |
73 | # Run test suite against latest Connect version.
74 | latest:
75 | $(MAKE) $(firstword $(CONNECT_VERSIONS))
76 |
77 | # Show available versions
78 | print-versions:
79 | @printf "%s\n" $(strip $(CONNECT_VERSIONS))
80 |
81 | # Show help message.
82 | help:
83 | @echo "Makefile Targets:"
84 | @echo " latest (default) Run test suite for latest Connect version."
85 | @echo " all Run test suite for all Connect versions."
86 | @echo " Run test suite for the specified Connect version. (e.g., make 2025.10.0)"
87 | @echo " clean Clean up logs and reports directories."
88 | @echo " print-versions Show the available Connect versions."
89 | @echo " help Show this help message."
90 | @echo
91 | @echo "Common Usage:"
92 | @echo " make Run test suite for latest Connect version (default)."
93 | @echo " make latest Run test suite for latest Connect version."
94 | @echo " make 2025.10.0 Run test suite for specific Connect version."
95 | @echo " make all Run test suite for all Connect versions."
96 | @echo " make -j 4 all Run test suite in parallel for all Connect versions."
97 | @echo
98 | @echo "Environment Variables:"
99 | @echo " PYTEST_ARGS Arguments to pass to pytest. Default: \"-s\""
100 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yaml:
--------------------------------------------------------------------------------
1 | name: CI
2 | on:
3 | pull_request:
4 | push:
5 | branches:
6 | - main
7 | concurrency:
8 | group: ${{ github.workflow }}-${{ github.ref }}
9 | cancel-in-progress: true
10 | jobs:
11 | lint:
12 | runs-on: ubuntu-latest
13 | steps:
14 | - uses: actions/checkout@v5
15 | - uses: astral-sh/setup-uv@v7
16 | - run: uv python install
17 | - run: make dev
18 | - run: make lint
19 | - run: make fmt
20 |
21 | test:
22 | runs-on: ubuntu-latest
23 | strategy:
24 | fail-fast: false
25 | matrix:
26 | python-version:
27 | - "3.8"
28 | - "3.9"
29 | - "3.10"
30 | - "3.11"
31 | - "3.12"
32 | - "3.13"
33 | steps:
34 | - uses: actions/checkout@v5
35 | - uses: astral-sh/setup-uv@v7
36 | - run: uv python install ${{ matrix.python-version }}
37 | - run: make dev
38 | - run: make test
39 |
40 | setup-integration-test:
41 | runs-on: ubuntu-latest
42 | outputs:
43 | versions: ${{ steps.versions.outputs.versions }}
44 | steps:
45 | - uses: actions/checkout@v4
46 | - id: versions
47 | working-directory: ./integration
48 | # 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"]).
49 | run: |
50 | versions=$(make print-versions | jq -c -Rs 'split("\n") | .[:-1]')
51 | echo "versions=$versions" >> "$GITHUB_OUTPUT"
52 |
53 | integration-test:
54 | runs-on: ubuntu-latest
55 | needs: setup-integration-test
56 | strategy:
57 | fail-fast: false
58 | matrix:
59 | CONNECT_VERSION: ${{ fromJson(needs.setup-integration-test.outputs.versions) }}
60 | steps:
61 | - uses: actions/checkout@v5
62 | - uses: astral-sh/setup-uv@v7
63 | - run: uv python install
64 | - name: Run integration tests
65 | uses: posit-dev/with-connect@main
66 | with:
67 | version: ${{ matrix.CONNECT_VERSION }}
68 | license: ${{ secrets.CONNECT_LICENSE }}
69 | command:
70 | make -C ./integration test-${{ matrix.CONNECT_VERSION }}
71 | - uses: actions/upload-artifact@v5
72 | if: always()
73 | with:
74 | name: ${{ matrix.CONNECT_VERSION }} - Integration Test Report
75 | path: integration/reports/*.xml
76 |
77 | integration-test-report:
78 | needs: integration-test
79 | runs-on: ubuntu-latest
80 | permissions:
81 | checks: write
82 | pull-requests: write
83 | if: always()
84 | steps:
85 | - uses: actions/download-artifact@v6
86 | with:
87 | path: artifacts
88 | - uses: EnricoMi/publish-unit-test-result-action@v2
89 | with:
90 | check_name: integration-test-results
91 | comment_mode: off
92 | files: "artifacts/**/*.xml"
93 | report_individual_runs: true
94 |
95 | build:
96 | runs-on: ubuntu-latest
97 | steps:
98 | - uses: actions/checkout@v5
99 | - uses: astral-sh/setup-uv@v7
100 | - run: uv python install
101 | - run: make dev
102 | - run: make build
103 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | item = self.client.content.get(self.content["guid"])
27 | # Check that essential fields match instead of exact equality
28 | for key in self.content:
29 | assert key in item
30 | assert item[key] == self.content[key]
31 | if CONNECT_VERSION >= version.parse("2024.06.0"):
32 | # get() always includes owner, tags, and vanity_url. Owner data is always present in
33 | # all content, tags and vanity_url are only present if explicitly set in the content.
34 | assert "owner" in item
35 | assert "tags" not in item
36 | assert "vanity_url" not in item
37 |
38 | def test_find(self):
39 | assert self.client.content.find()
40 |
41 | def test_find_by(self):
42 | assert self.client.content.find_by(name=self.content["name"]) == self.content
43 |
44 | def test_find_one(self):
45 | assert self.client.content.find_one()
46 |
47 | def test_content_item_owner(self):
48 | item = self.client.content.find_one(include=None)
49 | assert item
50 | owner = item.owner
51 | assert owner["guid"] == self.client.me["guid"]
52 |
53 | def test_content_item_owner_from_include(self):
54 | item = self.client.content.find_one(include="owner")
55 | assert item
56 | owner = item.owner
57 | assert owner["guid"] == self.client.me["guid"]
58 |
59 | @pytest.mark.skipif(
60 | CONNECT_VERSION <= version.parse("2024.04.1"),
61 | reason="Python 3.12 not available",
62 | )
63 | def test_restart(self):
64 | # create content
65 | content = self.client.content.create(name="example-flask-minimal")
66 | # create bundle
67 | path = Path("../../../resources/connect/bundles/example-flask-minimal/bundle.tar.gz")
68 | path = (Path(__file__).parent / path).resolve()
69 | bundle = content.bundles.create(str(path))
70 | # deploy bundle
71 | task = bundle.deploy()
72 | task.wait_for()
73 | # restart
74 | content.restart()
75 | # delete content
76 | content.delete()
77 |
78 | @pytest.mark.skipif(
79 | CONNECT_VERSION <= version.parse("2023.01.1"),
80 | reason="Quarto not available",
81 | )
82 | def test_render(self):
83 | # create content
84 | content = self.client.content.create(name="example-quarto-minimal")
85 | # create bundle
86 | path = Path("../../../resources/connect/bundles/example-quarto-minimal/bundle.tar.gz")
87 | path = (Path(__file__).parent / path).resolve()
88 | bundle = content.bundles.create(str(path))
89 | # deploy bundle
90 | task = bundle.deploy()
91 | task.wait_for()
92 | # render
93 | task = content.render()
94 | task.wait_for()
95 | # delete content
96 | content.delete()
97 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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
75 |
76 | def auth_type(self) -> str:
77 | return POSIT_WORKBENCH_AUTH_TYPE
78 |
79 | @property
80 | def _config(self) -> Config:
81 | """The Databricks SDK `Config` object used by this strategy.
82 |
83 | Returns
84 | -------
85 | Config
86 | The provided `Config` object, defaulting to a new `Config` with the profile set to "workbench" if not provided.
87 | """
88 | if self.__config is None:
89 | # Do not create this configuration object until it is needed.
90 | # This avoids failing if the 'workbench' profile is not defined in the user's
91 | # `~/.databrickscfg` file until this strategy is actually used.
92 | self.__config = Config(profile="workbench")
93 |
94 | return self.__config
95 |
96 | def __call__(self, *args, **kwargs) -> CredentialsProvider: # noqa: ARG002
97 | if self._config.token is None:
98 | raise ValueError("Missing value for field 'token' in Config.")
99 |
100 | return lambda: {"Authorization": f"Bearer {self._config.token}"}
101 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | content_session_token: Optional[str] = None,
73 | audience: Optional[str] = None,
74 | ):
75 | self._local_authenticator = local_authenticator
76 | self._client = client
77 | self._user_session_token = user_session_token
78 | self._content_session_token = content_session_token
79 | self._audience = audience
80 |
81 | @property
82 | def authenticator(self) -> Optional[str]:
83 | if is_local():
84 | return self._local_authenticator
85 | return "oauth"
86 |
87 | @property
88 | def token(self) -> Optional[str]:
89 | if is_local():
90 | return None
91 |
92 | # If a session token wasn't provided and we're running on Connect then we raise an exception.
93 | # user_session_token is required to impersonate the viewer.
94 | # content_session_token is required for service account access.
95 | if self._user_session_token is None and self._content_session_token is None:
96 | raise ValueError(
97 | "A user-session-token or content-session-token is required for authentication."
98 | )
99 |
100 | if self._client is None:
101 | self._client = Client()
102 |
103 | if self._user_session_token is not None:
104 | credentials = self._client.oauth.get_credentials(
105 | self._user_session_token,
106 | audience=self._audience,
107 | )
108 | else:
109 | credentials = self._client.oauth.get_content_credentials(
110 | self._content_session_token,
111 | audience=self._audience,
112 | )
113 |
114 | return credentials.get("access_token")
115 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/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_find_by(self):
64 | result = self.client.oauth.integrations.find_by(
65 | integration_type="custom",
66 | config={"auth_mode": "Confidential"},
67 | name="example integration",
68 | )
69 | assert result is not None
70 | assert result["guid"] == self.integration["guid"]
71 |
72 | result = self.client.oauth.integrations.find_by(
73 | integration_type="custom",
74 | config={"auth_mode": "Confidential"},
75 | name="nonexistent integration",
76 | )
77 | assert result is None
78 |
79 | def test_create_update_delete(self):
80 | # create a new integration
81 |
82 | integration = self.client.oauth.integrations.create(
83 | name="new integration",
84 | description="new integration description",
85 | template="custom",
86 | config={
87 | "auth_mode": "Confidential",
88 | "authorization_uri": "https://example.com/__tenand_id__/oauth2/v2.0/authorize",
89 | "client_id": "client_id",
90 | "client_secret": "client_secret",
91 | "scopes": "a b c",
92 | "token_endpoint_auth_method": "client_secret_post",
93 | "token_uri": "https://example.com/__tenant_id__/oauth2/v2.0/token",
94 | },
95 | )
96 |
97 | created = self.client.oauth.integrations.get(integration["guid"])
98 | assert created == integration
99 |
100 | all_integrations = self.client.oauth.integrations.find()
101 | assert len(all_integrations) == 3
102 |
103 | # update the new integration
104 |
105 | created.update(name="updated integration name")
106 | updated = self.client.oauth.integrations.get(integration["guid"])
107 | assert updated["name"] == "updated integration name"
108 |
109 | # delete the new integration
110 |
111 | created.delete()
112 | all_integrations_after_delete = self.client.oauth.integrations.find()
113 | assert len(all_integrations_after_delete) == 2
114 |
--------------------------------------------------------------------------------
/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/external/test_snowflake.py:
--------------------------------------------------------------------------------
1 | from unittest.mock import patch
2 |
3 | import pytest
4 | import responses
5 |
6 | from posit.connect import Client
7 | from posit.connect.external.snowflake import PositAuthenticator
8 |
9 |
10 | def register_mocks():
11 | responses.post(
12 | "https://connect.example/__api__/v1/oauth/integrations/credentials",
13 | match=[
14 | responses.matchers.urlencoded_params_matcher(
15 | {
16 | "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
17 | "subject_token_type": "urn:posit:connect:user-session-token",
18 | "subject_token": "cit",
19 | },
20 | ),
21 | ],
22 | json={
23 | "access_token": "dynamic-viewer-access-token",
24 | "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
25 | "token_type": "Bearer",
26 | },
27 | )
28 |
29 | responses.post(
30 | "https://connect.example/__api__/v1/oauth/integrations/credentials",
31 | match=[
32 | responses.matchers.urlencoded_params_matcher(
33 | {
34 | "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
35 | "subject_token_type": "urn:posit:connect:content-session-token",
36 | "subject_token": "content-token-123",
37 | },
38 | ),
39 | ],
40 | json={
41 | "access_token": "service-account-access-token",
42 | "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
43 | "token_type": "Bearer",
44 | },
45 | )
46 |
47 |
48 | class TestPositAuthenticator:
49 | @responses.activate
50 | @patch.dict("os.environ", {"RSTUDIO_PRODUCT": "CONNECT"})
51 | def test_posit_authenticator(self):
52 | register_mocks()
53 |
54 | client = Client(api_key="12345", url="https://connect.example/")
55 | client._ctx.version = None
56 | auth = PositAuthenticator(
57 | local_authenticator="SNOWFLAKE",
58 | user_session_token="cit",
59 | client=client,
60 | )
61 | assert auth.authenticator == "oauth"
62 | assert auth.token == "dynamic-viewer-access-token"
63 |
64 | @responses.activate
65 | @patch.dict("os.environ", {"RSTUDIO_PRODUCT": "CONNECT"})
66 | def test_posit_authenticator_content_token(self):
67 | register_mocks()
68 |
69 | client = Client(api_key="12345", url="https://connect.example/")
70 | client._ctx.version = None
71 | auth = PositAuthenticator(
72 | local_authenticator="SNOWFLAKE",
73 | content_session_token="content-token-123",
74 | client=client,
75 | )
76 | assert auth.authenticator == "oauth"
77 | assert auth.token == "service-account-access-token"
78 |
79 | def test_posit_authenticator_fallback(self):
80 | # local_authenticator is used when the content is running locally
81 | client = Client(api_key="12345", url="https://connect.example/")
82 | client._ctx.version = None
83 | auth = PositAuthenticator(
84 | local_authenticator="SNOWFLAKE",
85 | user_session_token="cit",
86 | client=client,
87 | )
88 | assert auth.authenticator == "SNOWFLAKE"
89 | assert auth.token is None
90 |
91 | def test_posit_authenticator_content_token_fallback(self):
92 | # local_authenticator is used when the content is running locally
93 | client = Client(api_key="12345", url="https://connect.example/")
94 | client._ctx.version = None
95 | auth = PositAuthenticator(
96 | local_authenticator="SNOWFLAKE",
97 | content_session_token="content-token-123",
98 | client=client,
99 | )
100 | assert auth.authenticator == "SNOWFLAKE"
101 | assert auth.token is None
102 |
103 | @patch.dict("os.environ", {"RSTUDIO_PRODUCT": "CONNECT"})
104 | def test_posit_authenticator_missing_tokens(self):
105 | # Should raise an error when running on Connect without any session token
106 | client = Client(api_key="12345", url="https://connect.example/")
107 | client._ctx.version = None
108 | auth = PositAuthenticator(
109 | local_authenticator="SNOWFLAKE",
110 | client=client,
111 | )
112 | assert auth.authenticator == "oauth"
113 |
114 | with pytest.raises(
115 | ValueError, match="A user-session-token or content-session-token is required"
116 | ):
117 | _ = auth.token
118 |
--------------------------------------------------------------------------------
/src/posit/connect/metrics/shiny_usage.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing_extensions import List, overload
4 |
5 | from ..cursors import CursorPaginator
6 | from ..resources import BaseResource, Resources
7 | from .rename_params import rename_params
8 |
9 |
10 | class ShinyUsageEvent(BaseResource):
11 | @property
12 | def content_guid(self) -> str:
13 | """The associated unique content identifier.
14 |
15 | Returns
16 | -------
17 | str
18 | """
19 | return self["content_guid"]
20 |
21 | @property
22 | def user_guid(self) -> str:
23 | """The associated unique user identifier.
24 |
25 | Returns
26 | -------
27 | str
28 | """
29 | return self["user_guid"]
30 |
31 | @property
32 | def started(self) -> str:
33 | """The started timestamp.
34 |
35 | Returns
36 | -------
37 | str
38 | """
39 | return self["started"]
40 |
41 | @property
42 | def ended(self) -> str:
43 | """The ended timestamp.
44 |
45 | Returns
46 | -------
47 | str
48 | """
49 | return self["ended"]
50 |
51 | @property
52 | def data_version(self) -> int:
53 | """The data version.
54 |
55 | Returns
56 | -------
57 | int
58 | """
59 | return self["data_version"]
60 |
61 |
62 | class ShinyUsage(Resources):
63 | @overload
64 | def find(
65 | self,
66 | *,
67 | content_guid: str = ...,
68 | min_data_version: int = ...,
69 | start: str = ...,
70 | end: str = ...,
71 | ) -> List[ShinyUsageEvent]:
72 | """Find usage.
73 |
74 | Parameters
75 | ----------
76 | content_guid : str, optional
77 | Filter by an associated unique content identifer, by default ...
78 | min_data_version : int, optional
79 | Filter by a minimum data version, by default ...
80 | start : str, optional
81 | Filter by the start time, by default ...
82 | end : str, optional
83 | Filter by the end time, by default ...
84 |
85 | Returns
86 | -------
87 | List[ShinyUsageEvent]
88 | """
89 |
90 | @overload
91 | def find(self, **kwargs) -> List[ShinyUsageEvent]:
92 | """Find usage.
93 |
94 | Returns
95 | -------
96 | List[ShinyUsageEvent]
97 | """
98 |
99 | def find(self, **kwargs) -> List[ShinyUsageEvent]:
100 | """Find usage.
101 |
102 | Returns
103 | -------
104 | List[ShinyUsageEvent]
105 | """
106 | params = rename_params(kwargs)
107 |
108 | path = "/v1/instrumentation/shiny/usage"
109 | paginator = CursorPaginator(self._ctx, path, params=params)
110 | results = paginator.fetch_results()
111 | return [
112 | ShinyUsageEvent(
113 | self._ctx,
114 | **result,
115 | )
116 | for result in results
117 | ]
118 |
119 | @overload
120 | def find_one(
121 | self,
122 | *,
123 | content_guid: str = ...,
124 | min_data_version: int = ...,
125 | start: str = ...,
126 | end: str = ...,
127 | ) -> ShinyUsageEvent | None:
128 | """Find a usage event.
129 |
130 | Parameters
131 | ----------
132 | content_guid : str, optional
133 | Filter by an associated unique content identifer, by default ...
134 | min_data_version : int, optional
135 | Filter by a minimum data version, by default ...
136 | start : str, optional
137 | Filter by the start time, by default ...
138 | end : str, optional
139 | Filter by the end time, by default ...
140 |
141 | Returns
142 | -------
143 | ShinyUsageEvent | None
144 | """
145 |
146 | @overload
147 | def find_one(self, **kwargs) -> ShinyUsageEvent | None:
148 | """Find a usage event.
149 |
150 | Returns
151 | -------
152 | ShinyUsageEvent | None
153 | """
154 |
155 | def find_one(self, **kwargs) -> ShinyUsageEvent | None:
156 | """Find a usage event.
157 |
158 | Returns
159 | -------
160 | ShinyUsageEvent | None
161 | """
162 | params = rename_params(kwargs)
163 | path = "/v1/instrumentation/shiny/usage"
164 | paginator = CursorPaginator(self._ctx, path, params=params)
165 | pages = paginator.fetch_pages()
166 | results = (result for page in pages for result in page.results)
167 | visits = (
168 | ShinyUsageEvent(
169 | self._ctx,
170 | **result,
171 | )
172 | for result in results
173 | )
174 | return next(visits, None)
175 |
--------------------------------------------------------------------------------