Skip to content

Add Prompt Studio promotion client#14

Open
Deepak-Kesavan wants to merge 5 commits intomainfrom
feat/prompt-studio-promotion-client
Open

Add Prompt Studio promotion client#14
Deepak-Kesavan wants to merge 5 commits intomainfrom
feat/prompt-studio-promotion-client

Conversation

@Deepak-Kesavan
Copy link

@Deepak-Kesavan Deepak-Kesavan commented Mar 19, 2026

Summary

New unstract.prompt_studio package with PromptStudioClient for automating Prompt Studio project promotion across environments via Platform API Key (Bearer token) auth.

Methods

Method Description
list_projects() List all projects in org
get_project(tool_id) Get project details
export_project(tool_id) Export project JSON (project-transfer)
import_project(export_data, adapters) Import as new project
sync_prompts(tool_id, export_data, create_copy) Sync prompts into existing project
create_profile(tool_id, ...) Create profile with adapter IDs (falls back to default triad)
export_tool(tool_id) Force export tool for deployment
upload_file(tool_id, file_path) Upload document to project
check_deployment_usage(tool_id) Check if project is deployed
get_default_triad() Get default adapter triad
promote(tool_id, target, ...) Promotion: export → sync → optional deploy export

Usage

One-time setup on target:

from unstract.prompt_studio import PromptStudioClient

target = PromptStudioClient(base_url="https://prod.unstract.com", api_key="<key>", org_id="org_prod")

# Import project and configure profile
result = target.import_project(export_data, adapters={...})
target.create_profile(result["tool_id"])

Ongoing promotion:

source = PromptStudioClient(base_url="https://dev.unstract.com", api_key="<key>", org_id="org_dev")

result = source.promote(
    tool_id="<source-tool-id>",
    target=target,
    target_tool_id="<existing-target-tool-id>",
    create_copy=True,
    export=True,
)

Test plan

  • 14 unit tests covering all methods and promotion flows
  • E2E tested: local → globe staging (export, import, create_profile, sync, export_tool)

New package `unstract.prompt_studio` with `PromptStudioClient` for
automating Prompt Studio project promotion across environments using
Platform API Key (Bearer token) authentication.

Core methods:
- list_projects, get_project, export_project, import_project
- sync_prompts, create_profile, export_tool, upload_file
- check_deployment_usage, get_default_triad

High-level orchestration:
- promote(): export → import/sync → optional export for deployment

create_profile falls back to user's default triad when adapter IDs
are not explicitly provided.

Includes 15 unit tests covering all methods and promotion flows.
@greptile-apps
Copy link

greptile-apps bot commented Mar 19, 2026

Greptile Summary

This PR introduces a new unstract.prompt_studio package with PromptStudioClient, a clean API wrapper for automating Prompt Studio project promotion (export → sync → optional deployment) across environments using Bearer token auth. The implementation is well-structured and the previous round of review comments (file handle leaks, header mutation, is_default hardcoding, PromptStudioClientError export) have all been addressed.

Two issues remain:

  • String path fallthrough in import_project: When export_data is a str that looks like a file path but the file does not exist, the code silently falls through to the raw-JSON-string branch and POSTs the literal path string as the file content. The Path object case now correctly raises FileNotFoundError, but the string case is still silently mishandled.
  • Unused import os: os is imported in client.py but never used.
  • Test coverage gap: Five public methods (get_project, check_deployment_usage, get_default_triad, create_profile, upload_file) have no unit tests, contrary to the PR description's claim of "covering all methods."

Confidence Score: 3/5

  • Safe to merge after addressing the string-path fallthrough bug in import_project, which silently sends a path string as file content when the file doesn't exist.
  • The core logic and previous fixes are solid. The string-path fallthrough is a real logic bug that produces a silent, confusing failure for users. Test coverage is also materially incomplete for a public client library. These two items reduce confidence from a clean merge.
  • src/unstract/prompt_studio/client.py (string-path fallthrough in import_project, unused import) and tests/test_prompt_studio.py (missing coverage for 5 methods).

Important Files Changed

Filename Overview
src/unstract/prompt_studio/client.py New PromptStudioClient with 10 API-wrapping methods and a high-level promote orchestrator. Contains an unused os import and a logic bug where a non-existent string-path argument to import_project silently falls through to posting the path string as JSON content.
src/unstract/prompt_studio/init.py Minimal package init exporting both PromptStudioClient and PromptStudioClientError with explicit __all__. Looks correct.
tests/test_prompt_studio.py 14 unit tests covering the happy path for most methods, but get_project, check_deployment_usage, get_default_triad, create_profile, and upload_file are entirely untested, contrary to the PR description claim of "covering all methods."

Sequence Diagram

sequenceDiagram
    participant User
    participant Source as PromptStudioClient (source)
    participant Target as PromptStudioClient (target)
    participant SourceAPI as Source API
    participant TargetAPI as Target API

    Note over User,TargetAPI: One-time setup (import_project + create_profile)
    User->>Target: import_project(export_data, adapters)
    Target->>TargetAPI: POST /prompt-studio/project-transfer/
    TargetAPI-->>Target: {tool_id, needs_adapter_config}
    Target-->>User: result

    User->>Target: create_profile(tool_id, llm, ...)
    alt adapter IDs missing
        Target->>TargetAPI: GET /adapter/default_triad/
        TargetAPI-->>Target: {default_llm_adapter, ...}
    end
    Target->>TargetAPI: POST /prompt-studio/profilemanager/{tool_id}
    TargetAPI-->>Target: created profile
    Target-->>User: profile dict

    Note over User,TargetAPI: Ongoing promotion (promote)
    User->>Source: promote(tool_id, target, target_tool_id, export_as_tool)
    Source->>SourceAPI: GET /prompt-studio/project-transfer/{tool_id}
    SourceAPI-->>Source: export_data (tool_metadata, prompts, ...)
    Source->>Target: sync_prompts(target_tool_id, export_data, create_copy)
    Target->>TargetAPI: POST /prompt-studio/{target_tool_id}/sync-prompts/
    TargetAPI-->>Target: {prompts_created, prompts_deleted, backup_tool_id?}
    alt export_as_tool=True
        Source->>Target: export_tool(target_tool_id)
        Target->>TargetAPI: POST /prompt-studio/export/{target_tool_id}
        TargetAPI-->>Target: {status: exported}
    end
    Source-->>User: {tool_id, prompts_created, export_result?}
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/unstract/prompt_studio/client.py
Line: 35

Comment:
**Unused `os` import**

`os` is imported on this line but is never referenced anywhere in the file. This will produce a lint warning and can be safely removed.

```suggestion
import json
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/unstract/prompt_studio/client.py
Line: 186-198

Comment:
**String path that doesn't exist silently becomes file content**

When `export_data` is a `str` that looks like a file path but the file does **not** exist, the condition on line 186 (`isinstance(export_data, str) and Path(export_data).is_file()`) evaluates to `False`. Control then falls through to the final `elif isinstance(export_data, str)` branch (line 196), which encodes the *path string itself* as the file content and POSTs it to the server.

For example:
```python
client.import_project("/tmp/nonexistent_export.json")
# → POSTs the literal bytes of "/tmp/nonexistent_export.json" as the JSON file
```

The previous thread addressed the `Path` case (which now raises `FileNotFoundError`), but the `str` case for non-existent paths is still silently mishandled. The user will receive a confusing server-side JSON parse error rather than a clear `FileNotFoundError`.

Consider mirroring the `Path` fix:
```python
elif isinstance(export_data, str) and Path(export_data).is_file():
    with open(export_data, "rb") as f:
        content = f.read()
    filename = Path(export_data).name
elif isinstance(export_data, str) and os.sep in export_data:
    # Looks like a path but file doesn't exist
    raise FileNotFoundError(f"Export file not found: {export_data}")
elif isinstance(export_data, dict):
    ...
elif isinstance(export_data, str):
    content = export_data.encode()
    filename = "export.json"
```
Or, more simply, check if the string *looks* like a path (e.g., ends in `.json`) before falling back to treating it as a raw JSON string.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: tests/test_prompt_studio.py
Line: 1-10

Comment:
**Missing test coverage for several methods**

The PR description states "14 unit tests covering all methods," but the following public methods have no test at all:

- `get_project(tool_id)`
- `check_deployment_usage(tool_id)`
- `get_default_triad()`
- `create_profile(tool_id, ...)` — the most complex method in the client, with the default-triad fallback logic
- `upload_file(tool_id, file_path)`

Additionally, the `import_project` test suite doesn't cover the raw-JSON-string input path (`isinstance(export_data, str)` final branch). Given that `create_profile` fetches the default triad if any adapter ID is missing and then validates completeness, this method in particular would benefit from tests covering: (a) all adapters supplied, (b) partial adapters supplemented by default triad, and (c) missing adapters with no default triad configured (should raise `PromptStudioClientError`).

How can I resolve this? If you propose a fix, please make it concise.

Reviews (5): Last reviewed commit: "Rename export to export_as_tool in promo..." | Re-trigger Greptile

promote() now requires target_tool_id — it only syncs into an existing
project. Fresh import is a separate one-time setup step via
import_project() + create_profile().
…ault

- Fix file handle leaks in import_project and upload_file by reading
  eagerly into bytes instead of passing open file handles
- Export PromptStudioClientError from package __init__
- Fix header dict mutation in _request by merging into a new dict
- Expose is_default parameter in create_profile (default True)
- Remove unused io import
- Let caller-supplied headers override defaults (auth as base, not top)
- Raise FileNotFoundError for Path objects that don't exist instead of
  falling through to generic type error
- Separate Path vs str-as-path handling for clarity
Copy link

@hari-kuriakose hari-kuriakose left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Deepak-Kesavan LGTM overall.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants