Metadata-Version: 2.4
Name: polarion-rest-client
Version: 25.12.3
Summary: Polarion REST API client, wrappers & helpers
Author-Email: emesika <elimesika@gmail.com>
License-Expression: MIT
Requires-Python: >=3.10
Requires-Dist: attrs>=23.1.0
Requires-Dist: httpx>=0.23.0
Requires-Dist: pydantic>=2.0.0
Requires-Dist: python-dateutil>=2.9.0
Requires-Dist: beautifulsoup4<5,>=4.12
Requires-Dist: pandas<3,>=2.0
Requires-Dist: truststore>=0.10.4
Description-Content-Type: text/markdown

# Polarion REST API Client

[![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/)
[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE)
[![PDM](https://img.shields.io/badge/pdm-managed-blueviolet)](https://pdm.fming.dev/)

A Python client for the [Polarion ALM](https://www.siemens.com/polarion) REST API, combining an **auto-generated low-level client** (from the upstream OpenAPI spec) with **high-level resource wrappers** for common operations.

Both **synchronous** and **asynchronous** usage are supported out of the box.

---

## Features

- **Auto-generated client** from the Polarion OpenAPI specification via [`openapi-python-client`](https://github.com/openapi-generators/openapi-python-client)
- **High-level resource wrappers** with full CRUD support for Projects, Work Items, Work Item Attachments, Work Item Comments, Work Item Approvals, Work Item Work Records, Linked Work Items, Externally Linked Work Items, Documents, Document Parts, Document Comments, Document Attachments, and Document Table extraction
- **Sync and Async** — every operation works with both `PolarionClient` (sync) and `PolarionAsyncClient` (async)
- **Automatic pagination** — fetch all pages transparently with `page_size=-1` or iterate lazily with `.iter()`
- **Batch operations** — bulk create, update, and delete work items in a single request
- **Typed error hierarchy** — `JSONAPIError` subclasses (`Unauthorized`, `Forbidden`, `NotFound`, `Conflict`, `ServerError`) for precise exception handling
- **Environment-based configuration** — configure via environment variables or `.env` files (with `python-dotenv` support)
- **SSL via truststore** — uses the OS certificate store by default for enterprise environments

---

## Project Structure

```text
.
├── codegen/                                # OpenAPI specs + generation config
│   ├── polarion-openapi.json               # upstream Polarion REST spec (input)
│   ├── polarion-openapi-clean.json         # cleaned spec (derived)
│   └── client-config.yaml                  # openapi-python-client config
├── scripts/                                # helper scripts
│   ├── clean_rest_spec.py                  # applies Polarion-specific fixes to the spec
│   ├── rest_json_validator.py              # JSON / OpenAPI validation
│   ├── regenerate_polarion_rest_client.sh  # end-to-end: clean → validate → generate → copy
│   └── set_version.py                      # set package version in pyproject.toml
├── src/
│   └── polarion_rest_client/               # installable package
│       ├── __init__.py                     # public API surface
│       ├── client.py                       # PolarionClient & PolarionAsyncClient
│       ├── session.py                      # get_env_vars() environment loader
│       ├── error.py                        # typed exception hierarchy
│       ├── resource.py                     # PolarionResource base class
│       ├── paging.py                       # generic pagination utilities
│       ├── project.py                      # Project resource
│       ├── workitem.py                     # WorkItem resource
│       ├── workitem_attachment.py          # WorkItemAttachment resource
│       ├── workitem_comment.py            # WorkItemComment resource
│       ├── workitem_approval.py          # WorkItemApproval resource
│       ├── workitem_workrecord.py        # WorkItemWorkRecord resource
│       ├── workitem_linked.py            # WorkItemLinked resource
│       ├── workitem_externally_linked.py # WorkItemExternallyLinked resource
│       ├── document.py                     # Document resource
│       ├── document_part.py                # DocumentPart resource
│       ├── document_comment.py             # DocumentComment resource
│       ├── document_attachment.py          # DocumentAttachment resource
│       ├── document_table_utils.py         # HTML table extraction helpers
│       └── openapi/                        # auto-generated client (committed)
├── tests/                                  # unit + integration tests
├── examples/                               # runnable usage examples
│   ├── common.py                           # shared helpers for examples
│   ├── workitem/                           # work item examples
│   └── document/                           # document examples
├── pyproject.toml                          # PDM / PEP 621 metadata
├── CHANGELOG.md
├── LICENSE
└── README.md
```

---

## Requirements

- Python **3.10+**
- [PDM](https://pdm.fming.dev/latest/) (`pipx install pdm` recommended)

---

## Installation

### From PyPI

```bash
pip install polarion-rest-client
```

### From Source

```bash
git clone https://gitlab.com/elimesika-group/polarion-rest-client.git
cd polarion-rest-client
pdm install --dev
```

---

## Configuration

The client reads configuration from environment variables. Set them in your shell or place them in a `.env` file (requires `python-dotenv`).

### Required Variables

| Variable | Description |
|---|---|
| `POLARION_URL` | Base URL of the Polarion server (e.g., `https://polarion.example.com`) |

### Authentication (one of the following)

| Variable | Description |
|---|---|
| `POLARION_TOKEN` | Personal access token (preferred) |
| `POLARION_USERNAME` + `POLARION_PASSWORD` | Basic authentication credentials |

### Optional Variables

| Variable | Default | Description |
|---|---|---|
| `POLARION_TOKEN_PREFIX` | `Bearer` | Authorization header prefix for token auth |
| `POLARION_VERIFY_SSL` | `true` | Set to `false` to disable SSL verification |
| `POLARION_TIMEOUT` | `30.0` | Request timeout in seconds |

### Example `.env` File

```bash
POLARION_URL=https://polarion.example.com
POLARION_TOKEN=your-personal-access-token
POLARION_VERIFY_SSL=true
POLARION_TIMEOUT=30.0
```

---

## Quick Start

### Synchronous

```python
import polarion_rest_client as prc
from polarion_rest_client.project import Project

pc = prc.PolarionClient(**prc.get_env_vars())

project_api = Project(pc)
project = project_api.get("my-project-id")
print(project)
```

### Asynchronous

```python
import asyncio
import polarion_rest_client as prc
from polarion_rest_client.project import Project

async def main():
    async with prc.PolarionAsyncClient(**prc.get_env_vars()) as pc:
        project_api = Project(pc)
        project = await project_api.get("my-project-id")
        print(project)

asyncio.run(main())
```

---

## High-Level Resources

Each resource wrapper accepts either a `PolarionClient` (sync) or `PolarionAsyncClient` (async) and provides a consistent API.

### Project

```python
from polarion_rest_client.project import Project

api = Project(pc)

api.list()                                     # list all projects
api.get("project-id")                          # get a single project
api.create("new-project", tracker_prefix="NP") # create (async job, waits by default)
api.patch("project-id", name="New Name")       # update attributes
api.delete("project-id")                       # delete (async job)
api.exists("project-id")                       # check existence (returns bool)
api.list_templates()                           # list project templates
```

### WorkItem

```python
from polarion_rest_client.workitem import WorkItem

api = WorkItem(pc)

api.create("proj", wi_type="task", title="My Task")
api.get("proj", "WI-001")
api.update("proj", "WI-001", title="Updated Title")
api.delete("proj", ["WI-001", "WI-002"])

# Pagination
items = api.list("proj", page_size=50)             # single page
all_items = api.list("proj", page_size=-1)          # fetch all pages
for item in api.iter("proj", page_size=100):        # lazy iteration
    print(item["id"])

# Batch operations
api.update_many("proj", [
    {"id": "WI-001", "attributes": {"severity": "critical"}},
    {"id": "WI-002", "attributes": {"severity": "major"}},
])
api.update_many_same_attrs("proj", ["WI-001", "WI-002"],
    attributes={"status": "approved"})

# Search
api.find_by_title("proj", "My Task")

# Global (cross-project) operations
all_items = api.list_all(page_size=50)
for item in api.iter_all(page_size=100):
    print(item["id"])
api.update_all([{"id": "proj/WI-001", "attributes": {"severity": "critical"}}])
api.delete_all([{"id": "proj/WI-001"}])

# Enum options & workflow actions
api.get_available_enum_options("proj", "WI-001", "status")
api.get_available_enum_options_for_type("proj", "status", wi_type="task")
api.get_current_enum_options("proj", "WI-001", "status")
api.get_workflow_actions("proj", "WI-001")

# Relationships (generic JSON:API)
api.get_relationships("proj", "WI-001", "categories")
api.create_relationships("proj", "WI-001", "categories",
    [{"type": "categories", "id": "proj/cat-1"}])
api.update_relationships("proj", "WI-001", "categories",
    [{"type": "categories", "id": "proj/cat-2"}])
api.delete_relationships("proj", "WI-001", "categories",
    [{"type": "categories", "id": "proj/cat-2"}])

# Move to/from document
api.move_to_document("proj", "WI-001", target_document="proj/space/doc-name")
api.move_from_document("proj", "WI-001")

# Test parameter definitions
api.list_test_parameter_definitions("proj", "WI-001")
api.get_test_parameter_definition("proj", "WI-001", "param-id")
```

### WorkItemAttachment

```python
from polarion_rest_client.workitem_attachment import WorkItemAttachment

api = WorkItemAttachment(pc)

api.list("proj", "WI-001")
api.get("proj", "WI-001", "attachment-id")
api.get_content("proj", "WI-001", "attachment-id")          # returns bytes
api.create("proj", "WI-001",
    file_data=b"...", file_name="diagram.png", mime_type="image/png")
api.update("proj", "WI-001", "attachment-id", title="New Title")
api.delete("proj", "WI-001", "attachment-id")
for att in api.iter("proj", "WI-001"):
    print(att["id"])
```

### WorkItemComment

```python
from polarion_rest_client.workitem_comment import WorkItemComment

api = WorkItemComment(pc)

api.list("proj", "WI-001")
api.get("proj", "WI-001", "comment-id")
api.create("proj", "WI-001", text="Review needed")
api.update("proj", "WI-001", "comment-id", resolved=True)
api.reply("proj", "WI-001", "comment-id", text="Done")
api.resolve("proj", "WI-001", "comment-id")
api.unresolve("proj", "WI-001", "comment-id")
for c in api.iter("proj", "WI-001"):
    print(c["id"])
```

### WorkItemApproval

```python
from polarion_rest_client.workitem_approval import WorkItemApproval

api = WorkItemApproval(pc)

api.list("proj", "WI-001")
api.get("proj", "WI-001", "user-id")
api.create("proj", "WI-001", user_id="user-id")
api.update("proj", "WI-001", "user-id", status="approved")
api.approve("proj", "WI-001", "user-id")
api.disapprove("proj", "WI-001", "user-id")
api.reset("proj", "WI-001", "user-id")
api.delete("proj", "WI-001", "user-id")
for a in api.iter("proj", "WI-001"):
    print(a["id"], a.get("attributes", {}).get("status"))
```

### WorkItemWorkRecord

```python
from polarion_rest_client.workitem_workrecord import WorkItemWorkRecord

api = WorkItemWorkRecord(pc)

api.list("proj", "WI-001")
api.get("proj", "WI-001", "record-id")
api.create("proj", "WI-001", user_id="user-id", time_spent="2h", date="2026-04-26", comment="Design work")
api.delete("proj", "WI-001", "record-id")
for r in api.iter("proj", "WI-001"):
    print(r["id"], r.get("attributes", {}).get("timeSpent"))
```

### WorkItemLinked

```python
from polarion_rest_client.workitem_linked import WorkItemLinked

api = WorkItemLinked(pc)

api.list("proj", "WI-001")
api.get("proj", "WI-001", role_id="relates_to", target_project_id="proj", linked_work_item_id="WI-002")
api.create("proj", "WI-001", target_project_id="proj", target_work_item_id="WI-002", role="relates_to")
api.update("proj", "WI-001", "relates_to", "proj", "WI-002", suspect=True)
api.delete("proj", "WI-001", "relates_to", "proj", "WI-002")
for lk in api.iter("proj", "WI-001"):
    print(lk["id"], lk.get("attributes", {}).get("role"))

# Backlinks (incoming links, new in 25.12)
api.list_backlinks("proj", "WI-002")
api.create_backlink("proj", "WI-002",
                    source_project_id="proj",
                    source_work_item_id="WI-003",
                    role="relates_to")
for bl in api.iter_backlinks("proj", "WI-002"):
    print(bl["id"])
```

### WorkItemExternallyLinked

```python
from polarion_rest_client.workitem_externally_linked import WorkItemExternallyLinked

api = WorkItemExternallyLinked(pc)

api.list("proj", "WI-001")
api.get("proj", "WI-001", role_id="relates_to", hostname="polarion.example.com",
        target_project_id="remote-proj", linked_work_item_id="WI-002")
api.create("proj", "WI-001",
    work_item_uri="https://polarion.example.com/polarion/rest/v1/#/project/remote-proj/workitem?id=WI-002",
    role="relates_to")
api.delete("proj", "WI-001", role_id="relates_to", hostname="polarion.example.com",
           target_project_id="remote-proj", linked_work_item_id="WI-002")
for lk in api.iter("proj", "WI-001"):
    print(lk["id"], lk.get("attributes", {}).get("workItemURI"))
```

### Document

```python
from polarion_rest_client.document import Document

api = Document(pc)

# CRUD
api.create("proj", "space", module_name="my-doc", title="My Document")
api.get("proj", "space", "my-doc")
api.update("proj", "space", "my-doc", title="Updated Title")

# Listing (new in 25.12)
api.list("proj", "space", page_size=50)           # space-scoped
api.list_project("proj", page_size=50)             # project-scoped (all spaces)
api.list_all(page_size=50)                         # cross-project
for doc in api.iter("proj", "space"):              # space-scoped iterator
    print(doc["id"])
for doc in api.iter_project("proj"):               # project-scoped iterator
    print(doc["id"])
for doc in api.iter_all():                         # cross-project iterator
    print(doc["id"])

# Search by title
api.find_by_title("proj", "space", "My Document", limit=5)

# Actions
api.branch("proj", "space", "my-doc", target_project_id="other-proj")
api.copy("proj", "space", "my-doc", target_document_name="my-doc-copy")
api.merge_from_master("proj", "space", "my-doc")
api.merge_to_master("proj", "space", "my-doc")
```

### DocumentPart

```python
from polarion_rest_client.document_part import DocumentPart

api = DocumentPart(pc)

api.list("proj", "space", "my-doc", page_size=-1)
api.get("proj", "space", "my-doc", "part-id", fields_parts="@all")
api.create("proj", "space", "my-doc", part_type="workitem", work_item_id="WI-001")
api.create("proj", "space", "my-doc", part_type="workitem",
           work_item_id="WI-002", layout=1)  # layout attr (new in 25.12)
for part in api.iter("proj", "space", "my-doc"):
    print(part["id"])

# Move a part (new in 25.12) — parent is required with before/after
api.move("proj", "space", "my-doc", "workitem_WI-002",
         parent="proj/space/my-doc/workitem_WI-001")
api.move("proj", "space", "my-doc", "workitem_WI-003",
         after="proj/space/my-doc/workitem_WI-002",
         parent="proj/space/my-doc/workitem_WI-001")
```

### DocumentComment

```python
from polarion_rest_client.document_comment import DocumentComment

api = DocumentComment(pc)

api.list("proj", "space", "my-doc")
api.get("proj", "space", "my-doc", "comment-id")
api.create("proj", "space", "my-doc", text="Review needed")
api.update("proj", "space", "my-doc", "comment-id", resolved=True)
api.reply("proj", "space", "my-doc", "comment-id", text="Done")
api.resolve("proj", "space", "my-doc", "comment-id")
api.unresolve("proj", "space", "my-doc", "comment-id")
```

### DocumentAttachment

```python
from polarion_rest_client.document_attachment import DocumentAttachment

api = DocumentAttachment(pc)

api.list("proj", "space", "my-doc")
api.get("proj", "space", "my-doc", "attachment-id")
api.create("proj", "space", "my-doc",
    file_data=b"...", file_name="diagram.png", mime_type="image/png")
api.update("proj", "space", "my-doc", "attachment-id", title="Updated Title")
content = api.get_content("proj", "space", "my-doc", "attachment-id")
```

### Document Table Extraction

Extract structured data from HTML tables embedded in Polarion documents into pandas DataFrames.

```python
from polarion_rest_client.document_table_utils import (
    extract_document_tables_by_columns,
)

df = extract_document_tables_by_columns(
    pc, "proj", "space", "my-doc",
    expected_columns=["Name", "Version", "Status"],
)
print(df)
```

---

## Error Handling

The client provides a typed exception hierarchy rooted in `PolarionError`:

```
PolarionError
├── HTTPStatusError          # non-2xx without JSON:API errors payload
└── JSONAPIError             # JSON:API errors[] present
    ├── Unauthorized         # 401
    ├── Forbidden            # 403
    ├── NotFound             # 404
    ├── Conflict             # 409
    └── ServerError          # 5xx
```

```python
from polarion_rest_client import NotFound, Unauthorized

try:
    project_api.get("nonexistent")
except NotFound as e:
    print(f"Not found: {e.detail}")
except Unauthorized:
    print("Check your credentials")
```

---

## Examples

The `examples/` directory contains runnable scripts demonstrating common workflows. Set the following environment variables before running:

| Variable | Description |
|---|---|
| `POLARION_TEST_PROJECT_ID` | Project ID for examples |
| `POLARION_TEST_SPACE_ID` | Space ID (defaults to `_default`) |
| `POLARION_TEST_WI_TYPE` | Work item type (defaults to `task`) |

```bash
# Work item CRUD
python examples/workitem/workitem_CRUD.py

# Document CRUD
python examples/document/document_CRUD.py

# Document attachment CRUD
python examples/document/document_attachment_CRUD.py

# Document comment CRUD
python examples/document/document_comment_CRUD.py

# Pagination
python examples/workitem/workitem_paging.py
python examples/document/document_paging.py

# Batch updates
python examples/workitem/workitem_batch_update.py

# Work item attachment CRUD
python examples/workitem/workitem_attachment_CRUD.py

# Work item comment CRUD
python examples/workitem/workitem_comment_CRUD.py

# Work item approval CRUD
python examples/workitem/workitem_approval_CRUD.py

# Work item work record CRUD
python examples/workitem/workitem_workrecord_CRUD.py

# Linked work items CRUD
python examples/workitem/workitem_linked_CRUD.py

# Externally linked work items CRUD
python examples/workitem/workitem_externally_linked_CRUD.py

# Advanced work item operations (enum options, workflow, move, global list)
python examples/workitem/workitem_advanced.py

# Table extraction
python examples/document/document_tables.py
```

---

## Regeneration Workflow

The generated client can be rebuilt from an updated OpenAPI specification.

1. Place or update the upstream spec:

```text
codegen/polarion-openapi.json
```

2. Run the pipeline (clean, validate, generate, copy):

```bash
pdm run regenerate-client
```

3. Verify the import:

```bash
python -c "import polarion_rest_client.openapi as pkg; print('OK:', pkg.__name__)"
```

The generated package under `src/polarion_rest_client/openapi/` is committed so that consumers can use the client without running the generator.

---

## Bumping to a New Polarion Version (Developer Guide)

When a new Polarion server version is released and a new OpenAPI specification becomes available, follow this end-to-end procedure to update the client.

### Prerequisites

- Access to the new Polarion REST API JSON specification (exported from the target server)
- A working development environment (`pdm install --dev`)
- Access to a Polarion test server running the **new** version (for integration tests)

### Step-by-Step Procedure

#### 1. Obtain the new OpenAPI specification

Export the REST API JSON from the new Polarion server (typically available at `https://<server>/polarion/rest/v1/openapi.json`) and place it at:

```bash
cp /path/to/new-spec.json codegen/polarion-openapi.json
```

#### 2. Clean the specification

The upstream spec contains known quirks that must be patched before code generation. Run the cleaner:

```bash
python scripts/clean_rest_spec.py \
    codegen/polarion-openapi.json \
    codegen/polarion-openapi-clean.json
```

The cleaner applies the following deterministic fixes:

| Fix | What it does |
|---|---|
| `octet_stream_upload` | Normalizes `application/octet-stream` upload schemas to `string/binary` |
| `wildcard_error_responses` | Expands vendor `4XX-5XX` entries into concrete 4xx/5xx responses |
| `missing_downloads_items` | Adds missing `items` for array schemas in `jobsSingle*Response` |
| `error_source_nullability` | Marks error source objects as nullable |
| `expand_reference_only_components` | Wraps bare `$ref` component schemas in `allOf` for the generator |

Review the output. If the new spec introduces **new** quirks that break generation, add a fixer function in `scripts/clean_rest_spec.py`, register it in `apply_all_fixes()`, and document it in the table above.

#### 3. Validate the cleaned specification

```bash
python scripts/rest_json_validator.py codegen/polarion-openapi-clean.json
```

This checks JSON syntax and validates the spec against the OpenAPI schema. Fix any errors before proceeding.

#### 4. Regenerate the client

```bash
pdm run regenerate-client
```

This runs the full pipeline (clean, validate, generate, copy into `src/polarion_rest_client/openapi/`). Alternatively, steps 2-4 are equivalent to this single command.

#### 5. Check for breaking changes in the generated code

After regeneration, verify that the high-level wrappers still compile and function correctly:

```bash
python -c "import polarion_rest_client.openapi as pkg; print('OK:', pkg.__name__)"
```

Common issues to watch for:

| Symptom | Likely cause | Resolution |
|---|---|---|
| `ImportError` in a resource module | Endpoint or model was renamed/removed in the new spec | Update the import paths in the affected wrapper (e.g., `project.py`, `workitem.py`) |
| `TypeError` on a wrapper method call | Generated function signature changed (new/renamed params) | Run `resolve_page_params()` diagnostics; update keyword arguments in the wrapper |
| New endpoints available | Polarion added new REST resources | Consider adding new high-level wrappers under `src/polarion_rest_client/` |
| Generator warnings about unsupported schemas | New spec patterns the cleaner doesn't handle yet | Add a new fixer in `scripts/clean_rest_spec.py` |

#### 6. Update the package version

Bump the version to reflect the new Polarion server version. The format is `XX.YY.ZZ` where `XX.YY` matches the Polarion version and `ZZ` starts at `01` for a new server release:

```bash
pdm run python scripts/set_version.py XX.YY.01
```

For example, if upgrading to Polarion 25.12:

```bash
pdm run python scripts/set_version.py 25.12.01
```

#### 7. Run the test suite

```bash
# Unit tests (no server required)
pdm run pytest -m unit

# Integration tests (requires POLARION_* env vars pointing to the new server)
pdm run pytest -m integration
```

Fix any failures caused by API changes before proceeding.

#### 8. Verify the examples

The `examples/` directory contains runnable scripts that exercise the high-level wrappers against a real server. Run them all to confirm that the new spec hasn't broken any workflows:

```bash
# Ensure env vars point to the new Polarion server
export POLARION_URL=https://polarion-new.example.com
export POLARION_TOKEN=...
export POLARION_TEST_PROJECT_ID=my-test-project

# Run the full example suite
bash examples/run_all.sh
```

If any example fails, investigate whether the issue is in the generated client (step 5) or the high-level wrapper, and fix accordingly.

#### 9. Update documentation

- Update `CHANGELOG.md` with the new version and a summary of changes
- If new high-level resources were added, update the **High-Level Resources** section of this README
- If new environment variables or configuration options were introduced, update the **Configuration** section

#### 10. Build and verify

```bash
pdm run pre_build
pdm build
```

Sanity-check the wheel:

```bash
pip install dist/polarion_rest_client-*.whl
python -c "import polarion_rest_client; print(polarion_rest_client.__version__)"
```

#### 11. Commit and release

```bash
git add -A
git commit -m "chore(release): bump to Polarion XX.YY (vXX.YY.01)"
git tag -a vXX.YY.01 -m "Polarion XX.YY, patch 01"
git push origin HEAD && git push origin vXX.YY.01
```

Then follow the [Releases and Publishing](#releases-and-publishing) section to publish to PyPI.

### Custom Templates (Advanced)

You can override the Jinja templates used by `openapi-python-client` to customize the generated code (naming conventions, default headers, timeouts, docstrings, etc.).

1. Place overrides under `codegen/custom_templates/` using the **same relative paths** as the upstream templates.
2. The regeneration script automatically passes `--custom-template-path` when this directory exists.

To discover the upstream template paths:

```bash
python -c "
import importlib.resources as r, openapi_python_client as opc
print((r.files(opc) / 'templates').as_posix())
"
```

Copy only the files you want to override; the rest fall back to defaults.

---

## Versioning

Package versions follow the format **`XX.YY.ZZ`**:

| Segment | Meaning |
|---|---|
| `XX.YY` | Polarion server version (e.g., `25.06`) |
| `ZZ` | Client patch number (e.g., `08`) |

PEP 440 normalizes `25.06.08` to `25.6.8` on PyPI. Git tags use the human-friendly form (`v25.06.08`).

Set the version with:

```bash
pdm run python scripts/set_version.py 25.06.08
```

---

## Tests

Tests are parameterized to run in both sync and async modes.

```bash
pdm run pytest
```

Test markers:

- `unit` — fast tests, no external services
- `integration` — tests that hit a real Polarion server

```bash
pdm run pytest -m unit
pdm run pytest -m integration
```

---

## Contributing

1. Create a feature branch:

```bash
git checkout -b feature/my-feature
```

2. Run the full pipeline before committing:

```bash
pdm run pre_build
```

3. Run tests:

```bash
pdm run pytest
```

4. Lint:

```bash
flake8 . --exclude ./venv --max-line-length=120
```

5. Commit following [Conventional Commits](https://www.conventionalcommits.org/) and open a Merge Request with a clear description, issue references, and test evidence.

> Avoid manual edits to files under `src/polarion_rest_client/openapi/` — they are regenerated.

---

## Releases and Publishing

### Quick Release Checklist

1. Update `CHANGELOG.md`
2. Set version: `pdm run python scripts/set_version.py XX.YY.ZZ`
3. Regenerate and build:

```bash
pdm run pre_build
pdm build
```

4. Dry-run on TestPyPI:

```bash
pdm publish -r testpypi --username __token__ --password "$TESTPYPI_TOKEN"
```

5. Verify from TestPyPI:

```bash
pip install -i https://test.pypi.org/simple/ \
    --extra-index-url https://pypi.org/simple \
    polarion-rest-client==<version>
```

6. Publish to PyPI:

```bash
pdm publish --username __token__ --password "$PYPI_TOKEN"
```

7. Tag and push:

```bash
git tag -a vXX.YY.ZZ -m "Polarion XX.YY, patch ZZ"
git push origin vXX.YY.ZZ
```

### Token Setup (One-Time)

| Registry | Registration | Token Scope |
|---|---|---|
| [TestPyPI](https://test.pypi.org/account/register/) | Required for dry-run | `TESTPYPI_TOKEN` — Entire account (first upload) |
| [PyPI](https://pypi.org/account/register/) | Required for release | `PYPI_TOKEN` — Entire account, then project-scoped |

> Never commit tokens. Pass them via environment variables. After your first PyPI release, create a project-scoped token and revoke the wide one.

### Troubleshooting

| Error | Fix |
|---|---|
| "File already exists" / 400 | Bump patch version, rebuild, retry |
| 403 "user isn't allowed to upload" | Use an **Entire account** token for first upload |
| Name conflict | Change `[project].name` in `pyproject.toml` |
| Packaging issues | `rm -rf dist && pdm run pre_build && pdm build` |

---

## License

Licensed under the [MIT License](./LICENSE).
