Metadata-Version: 2.5
Name: phrappy
Version: 1.1.0
Summary: Typed client for Phrase TMS (Memsource) generated from OpenAPI.
Project-URL: Homepage, https://github.com/kuhnemann/phrappy
Author: Henrik Kühnemann
License: MIT
Keywords: api,client,memsource,phrase,tms
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: httpx>=0.28.1
Requires-Dist: pydantic>=2
Provides-Extra: codegen
Requires-Dist: datamodel-code-generator>=0.26; extra == 'codegen'
Requires-Dist: diff-match-patch>=20241021; extra == 'codegen'
Requires-Dist: jinja2>=3.1; extra == 'codegen'
Requires-Dist: pyyaml>=6; extra == 'codegen'
Provides-Extra: dev
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest-httpx>=0.30; extra == 'dev'
Requires-Dist: pytest-recording>=0.13.1; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: python-dotenv>=1.1.0; extra == 'dev'
Requires-Dist: ruff==0.15.11; extra == 'dev'
Description-Content-Type: text/markdown

# phrappy 
[![PyPI Downloads](https://static.pepy.tech/personalized-badge/phrappy?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/phrappy)

Typed, batteries-included Python client for **Phrase TMS (Memsource)** generated from the public OpenAPI spec. Comes with both sync and async clients, fully equipped with first-class Pydantic v2 models.

The build process is fully automated and project release is planned to follow the Phrase TMS bi-weekly release cadence.

> This project is **not** an official Phrase/Memsource SDK. Official documentation can be found at [developers.phrase.com](https://developers.phrase.com/en/api/tms/latest/introduction)

---

## Installation

```bash
pip install phrappy
```

**Requirements:** Python ≥ 3.10

---

## Quickstart

### 1) Authenticate to get a token
Either use your authentication method of choice directly to get a token.
```python
from phrappy import Phrappy
from phrappy.models import LoginV3DtoV3

pp = Phrappy()
login_response = pp.authentication.login_v3(login_v3_dto_v3=LoginV3DtoV3(
    userName="your_name",
    password="<password>"
))
token = login_response.token
pp.close()
```
All typed methods also accept dict inputs that are then validated under the hood. For example:
```python
from phrappy import Phrappy


pp = Phrappy()
login_response = pp.authentication.login_v3(login_v3_dto_v3={
    "userName":"your_name",
    "password":"<password>"
})
token = login_response.token
pp.close()
```

Or use the convenience method for authenticating and getting a Phrappy instance that carries its token. 
```python
from phrappy import Phrappy

pp = Phrappy.from_creds(username="name@example.com", password="…")
me = pp.authentication.who_am_i()
print(me.user.uid)
pp.close()
```

For a 2-factor-enabled account, or to log in as a specific user of an identity:

```python
pp = Phrappy.from_creds(
    username="name@example.com",
    password="…",
    code="123456",          # 2FA verification code
    user_uid="…",           # defaults to the identity's default user
)
```

Using a context manager closes the underlying HTTP client automatically:
```python
from phrappy import Phrappy

with Phrappy(token="<YOUR_TOKEN>") as pp:
    me = pp.authentication.who_am_i()
    print(me.user.userName)
```

### 2) Or authenticate with OAuth 2.0

Phrase TMS supports a standard OAuth 2.0 authorization code grant alongside token auth, and its access tokens go out as `Bearer` rather than `ApiToken`. Pass `auth_scheme="Bearer"` with the access token your flow obtained:

```python
from phrappy import Phrappy

pp = Phrappy(token="<OAUTH_ACCESS_TOKEN>", auth_scheme="Bearer")
me = pp.authentication.who_am_i()
print(me.user.userName)
pp.close()
```

A token that already carries its scheme is used as given, so `Phrappy(token="Bearer …")` works without configuring anything:

```python
from phrappy import Phrappy

with Phrappy(token="Bearer <OAUTH_ACCESS_TOKEN>") as pp:
    me = pp.authentication.who_am_i()
    print(me.user.uid)
```

phrappy does not perform the authorization redirect or the code exchange — obtaining the access token is your application's job. Register the app under **Settings → Integrations → Registered OAuth Apps** to get a Client ID, then use `https://cloud.memsource.com/web/oauth/authorize` and `https://cloud.memsource.com/web/oauth/token`. Phrase's flow is a public-client grant validated by redirect URI alone: no client secret and no PKCE. OAuth login is not supported for users belonging to more than one organization.


### 3) Async usage
```python
import asyncio
from phrappy import AsyncPhrappy

async def main():
    async with AsyncPhrappy(token="<YOUR_TOKEN>") as app:
        me = await app.authentication.who_am_i()
        print(me.user.userName)

asyncio.run(main())
```

---

## Examples

### Create a project and upload a job (multipart)
```python
from pathlib import Path
from phrappy import Phrappy, cdh_generator
from phrappy.models import CreateProjectV3DtoV3, JobCreateRequestDtoV1

with Phrappy(token="<YOUR_TOKEN>") as pp:
    proj = pp.project.create_project_v3(
        create_project_v3_dto_v3=CreateProjectV3DtoV3(
            name="Demo", sourceLang="en", targetLangs=["sv"]
        )
    )

    p = Path("example.txt"); p.write_text("Hello from phrappy")
    jobs = pp.job.create_job(
        project_uid=proj.uid,
        content_disposition=cdh_generator(p.name),
        file_bytes=p.read_bytes(),
        memsource=JobCreateRequestDtoV1(targetLangs=proj.targetLangs),
    )
    print([j.uid for j in jobs.jobs or []])
```

### List your assigned projects
```python
me = pp.authentication.who_am_i()
page = pp.project.list_assigned_projects(user_uid=me.user.uid, target_lang=["sv"])  # typed page model
for item in page.content or []:
    print(item.name, item.status)
```

---

## API design

- Typed models everywhere! Inputs/outputs are Pydantic v2 models generated from the OpenAPI. You can pass either a model instance **or** a `dict` for body/header parameters; the client will validate and coerce.
- **All generated operations are keyword-only.** Parameter order is derived from the spec and shifts when Phrase changes which fields are required, so positional calls would silently rebind. Passing arguments by name makes that impossible.
- Rich method docstrings based on operation descriptions and typing information. 
- Every operation exists in both `Phrappy` and `AsyncPhrappy` under the same tag-based namespaces.
- Built on `httpx` with httpx.Client/httpx.AsyncClient under the hood.  
- **Response models are lenient by default**: unexpected fields in a Phrase TMS response are accepted and retained on the model (Pydantic `extra="allow"`) rather than raising `ValidationError`. This shields production callers from benign upstream drift when the API response diverges from the documented schema. Request models stay strict so caller typos fail fast.
- Set `PHRAPPY_STRICT=1` in the environment **before** importing `phrappy` to flip response models to `extra="forbid"` for CI drift detection. The setting is read once at import time; per-call toggling is not supported.

> If you find a mismatch between the API behavior and the generated models, please open an issue with the request/response payloads (redacted) and the package version.

---

## Configuration

- Defaults to `https://cloud.memsource.com/web`. Override via `Phrappy(base_url=...)` or `AsyncPhrappy(base_url=...)`. Profiles in the US data centre need `https://us.cloud.memsource.com/web`.
- Pass `timeout=` (seconds) to the client constructor. Per-request timeouts are also supported on `make_request` if you wrap custom calls.
- `auth_scheme=` sets the `Authorization` scheme used for a bare token. Defaults to `"ApiToken"`; pass `"Bearer"` for OAuth 2.0 access tokens. A token that already starts with a recognised scheme is sent unchanged, and the same normalisation applies to a per-call `phrase_token=` override.

---

## Testing

The test suite has three layers:

1. **Offline fixture tests** — captured Phrase TMS response bodies replayed through the generated Pydantic response models. Runs by default, no network, fast.
2. **Drift detection** — same fixtures re-run under `PHRAPPY_STRICT=1` to surface upstream schema divergence (unexpected fields, etc.) that lenient mode would silently tolerate.
3. **Live tests** — end-to-end against a real Phrase TMS account. Creates and deletes real resources, costs a handful of words per run.

```bash
# 1) offline suite (default — no credentials needed)
pytest -m "not live and not destructive" -q

# 2) drift detection — re-runs the offline suite under PHRAPPY_STRICT=1
scripts/run-strict-tests.sh

# 3) live tests — will create and delete assets in your account!
export PHRAPPY_TOKEN='ApiToken ...'
pytest -m live -q
```

Strict mode is gated by `tests/test_phrappy/fixtures/_drift_allowlist.json`,
a hand-maintained list of fixture paths with known upstream schema drift
(fields Phrase returns that the OpenAPI spec doesn't declare). The strict
suite asserts both directions:

- Fixtures on the allowlist MUST raise `ValidationError` in strict mode.
  If upstream fixes one, the test fails so the allowlist stays honest.
- Fixtures not on the allowlist MUST validate cleanly. A new drifter
  fails loudly instead of being lost in the noise.

Env vars used by the live tests and the fixture-capture helper:
- `PHRAPPY_TOKEN` **or** (`PHRAPPY_USER` and `PHRAPPY_PASSWORD`)
- `PHRAPPY_BASE_URL` (optional)

Fixtures under `tests/test_phrappy/fixtures/` are captured by
`scripts/capture_fixtures.py` (see its `--dry-run` output for what it
would capture). New fixtures are redacted of user-identifying fields
before they are committed.

---

## Releasing

Releases are automated by `.github/workflows/release.yml`. The short version:

1. Bump `__version__` in `src/phrappy/_meta.py`.
2. Add a `### X.Y.Z` section to the `## Release notes` below.
3. Commit to `main`, run `pytest -m "not live and not destructive"` locally.
4. `git tag -a vX.Y.Z -m "phrappy vX.Y.Z" && git push origin main && git push origin vX.Y.Z`.

The tagged push triggers regen + tests + `python -m build` + PyPI publish (OIDC trusted publishing) + sync to `kuhnemann/phrappy` + GitHub Release. Full runbook and the one-time credential setup are in `tasks/issue-3/RELEASE.md` on the builder repo.

---

## Roadmap

- Complete the test suite
- Streaming uploads/downloads
- Convenience functions for AsyncJob interactions
- Toggle for type validation / raw dict input/output


---

## Release notes
### 1.1.0

**Phrase TMS spec refresh (2026-08-18).** The generated client now includes the
latest documented surface, notably user-profile reads/updates, full due-date
scheme CRUD, connector authentication-page and connection-test operations, and
job clone/reimport additions. It also incorporates new and expanded response
models across the API. `TranslationExportEntry.exportWhen` is now required by
the upstream schema, and the QA setting is named `forbiddenStrings`.

Existing `ConnectorTypeEnum` remains available: the generator normalization
preserves that public name when the new connector-auth response reuses the same
enum values.

**Testing and verification.** Live tests are now organised by API tag, and new
synthetic request/response contract tests exercise every generated operation in
the `importsettings`, `analysis`, and `term_base` tags in both sync and async
clients (59 operations, 118 exercises). The final verification run passed 900
offline tests and 91 live/destructive tests; 12 live skips document unavailable
tenant features or an upstream `501` endpoint.

The live suite now covers **287 of 557 generated operations (51.5%) across 46
of 53 tags**. This is measured from the union of direct live-test calls, the
read-only sweep, owned-resource settings round trips, and metadata CRUD specs.

### 1.0.0

This release renames roughly 715 model classes, removes a tag, and makes every generated operation keyword-only. The library itself is the most thoroughly verified it has been: the live suite grew from 8 tests to 257 and now executes **50.6% of the API surface** — 275 of 544 operations across 45 of 52 tags — against a real Phrase account, up from 11.2%.

**OAuth 2.0 access tokens now work.** Phrase declares two security schemes for every operation — `ApiToken` and `OAuth2` — but the client hardcoded the `ApiToken` prefix, so the second was unreachable: an OAuth token came out as `ApiToken <oauth-token>`, which does not authenticate. The `Authorization` scheme is now a property of the client:

```python
pp = Phrappy(token="<OAUTH_ACCESS_TOKEN>", auth_scheme="Bearer")
```

`auth_scheme` defaults to `"ApiToken"`, so nothing existing changes, and a token that already carries a recognised scheme is passed through untouched. phrappy does not perform the authorization redirect or the code exchange; obtaining the token remains your application's job. Whether Phrase issues refresh tokens is not documented in the spec or the help centre, so no refresh handling is included yet.

- A per-call `phrase_token=` override is now given a scheme too. It was previously sent verbatim, so a bare token passed per call produced an `Authorization` header with no scheme at all while the same token on the constructor worked.
- `from_creds` gains optional keyword-only `code` (2-factor verification) and `user_uid`. Omitted values never reach the request body.
- `from_creds` now raises `PhrappyError` when a login response carries no token, instead of returning a client with `token = None` that failed later with unexplained 401s.
- Sync `make_request` raises `PhrappyError("Request retries exhausted")` after three rate-limited attempts. It previously returned `None`, so callers hit `AttributeError` on `None.json()`. The async client already behaved correctly.

**Breaking — models renamed.** Models refreshed against the Phrase TMS spec as of 2026-08-04. Phrase re-keyed most component schemas in that release, appending the endpoint's API version, and phrappy's class names follow the vendor's schema names. Roughly 715 model classes were renamed; most gained a version suffix:

| 0.4.0 | 1.0.0 |
| --- | --- |
| `JobCreateRequestDto` | `JobCreateRequestDtoV1` |
| `CreateProjectV3Dto` | `CreateProjectV3DtoV3` |
| `PatchProjectDto` | `PatchProjectDtoV1` |
| `AddTargetLangDto` | `RequestBodyForAddingTargetLanguagesToAProject` |
| `UserCreateDtoLinguist` | `UserCreateDtoV3Linguist` |

`UidReference`, `JobPartDeleteReferences`, `ProjectDtoV2` and other already-versioned names are unchanged. If an import breaks, try the same name with a `V1`/`V2`/`V3` suffix first.

**Breaking — SCIM removed.** Phrase removed all six `/scim` endpoints, so `phrappy.tags.scim` / `SCIMOperations` no longer exist. The `Project Reference File` tag was renamed to `Reference File`: use `client.reference_file` instead of `client.project_reference_file` (same operations).

**Breaking — every generated operation is now keyword-only.** `client.project.get_project(uid)` must become `client.project.get_project(project_uid=uid)`.

Parameter order is generated from the spec, and the generator puts required parameters first — so when Phrase makes a request body required, that body moves ahead of the path parameters and silently changes the meaning of a positional call. That is not hypothetical: in this release `update_custom_file_type`, `patch_project`, `edit_project_v3`, `set_project_status`, `clone_project`, `updates_segmentation_rule` and others all reordered, and a call like `patch_project(uid, dto)` would have bound the uid to the *body* and serialized the DTO into the URL, producing a confusing runtime error far from the cause.

Keyword-only arguments make that impossible: a stale call now fails immediately with a clear `TypeError` instead of misbinding. Every call site must be updated once; in exchange, future spec refreshes can never silently rebind your arguments. `Phrappy(...)` and `from_creds(...)` are unaffected — those are hand-written and their signatures are stable.

- New `quality_profile` tag (`evaluate`), plus `project_template.set_machine_translate_settings_for_project_template` and `segmentation_rules.replace_segmentation_rule_file`.
- Generator hardening: rendered tag modules and generated models now derive class names from one source of truth, stale tag modules are deleted on regen instead of lingering as broken orphans, and discriminator matching is version-agnostic so a re-keyed schema family no longer breaks the build.

**Where the API and its spec disagree.** Running half the surface against a real account surfaced a set of behaviours the spec does not describe. None of these are phrappy bugs and none can be fixed in the client — they are documented here because a caller reading the generated signatures would reasonably expect otherwise, and would get a confusing runtime error instead.

*Types the API does not honour.* Some `string/date-time` fields arrive as Java Instant objects (handled). `NetRateScheme.id` is typed `string` but arrives as an integer. `ProviderReference.id` is typed `string` while the user record it refers to types the same value as an integer, so an assignment payload has to stringify it.

*Operations that do not exist.* `POST /transMemories/{uid}/searchContent` is published in the spec, generated into the client, and answers `501 NOT_IMPLEMENTED`. There is no way to tell from the spec that the operation is absent.

*Requests the spec under-specifies.* `POST /projects/{p}/jobs/{j}/termBases/searchByJob` marks its request body optional while the body's `query` field is required. Codegen honours the spec, so the parameter defaults to `None` and omitting it is legal by every static measure the client has — and the server answers **500**, not 400. Assume a body is required whenever its schema declares a required field, regardless of what `requestBody` says.

*Preconditions invisible from the signature.* `search_segment_by_job`, `search_by_job3`, `wild_card_search_by_job3` and `search_terms_by_job_v2` search what the **project** has assigned, not something named in the request. With nothing assigned they answer `404 Not selected translation memory` / `TermBaseNotSelected`. Separately, `create_job` returns as soon as the upload is accepted, not when the file has been imported; editing too soon gives `400 JOB_NOT_READY`. Poll `get_part(...).imported` rather than sleeping.

*Two reference shapes that look alike.* Most references are `UidReference` (`{"uid": ...}`), but `AddWorkflowStepsDtoV1.workflowSteps` and `SetTermBaseDtoV1.readTermBases` take `IdReference` (`{"id": ...}`). Objects carry both, so the wrong one is easy to reach for — a project's term base assignment needs the term base's `id` while the translation memory assignment beside it needs the memory's `uid`.

*Read and write shapes are not symmetric.* Of 17 name-matched GET/PUT settings pairs, only 8 round-trip. Eight share **no field at all** between the read and write models — including project term bases, translation memories and MT settings — because the read form describes current state while the write form describes an assignment. `get_project_template_qa_settings` shares the field `checks` but not its type, and writing the read form back produces 196 validation errors. Read-modify-write is wrong for these nine; construct the write model directly.

*A documented fallback that does not hold.* `UserCreateDtoV3Submitter.automationWidgets` is optional, and the spec states that default widgets are assigned when the request names none. On a tenant with no default configured the create is refused outright, and the API publishes **no operation that lists automation widgets** — the id is only visible in the web UI.

*Eventual consistency.* An insert into a translation memory is searchable immediately; a `clear_trans_memory_v2` is not reflected in search straight away. Poll rather than asserting on the next call.

**Worth knowing about webhooks.** Phrase does not sign webhook payloads. `secretTokenType` offers only `AUTHORIZATION` or `X_MEMSOURCE_TOKEN`, both of which send your secret as a plain header — so a receiver verifies by comparing a header value, never by computing a digest over the body. The secret is replayable by anyone who observes one delivery, and delivery is not evidence the body is untampered. Treat webhook bodies as untrusted input and re-fetch anything security-relevant through the API. Verified against a live endpoint, not inferred from the docs.

### 0.4.0
- Response models are now lenient by default (`extra="allow"`); request models stay strict. Set `PHRAPPY_STRICT=1` before importing to flip response models to `extra="forbid"` for CI drift detection.
- Models refreshed against Phrase TMS spec as of 2026-04-21 (12 bi-weekly releases of drift caught up). New `language_assets` tag module. Manifest grew 401 → 409 operations. ~130 new hoisted enum classes in `phrappy.models`.
- Release pipeline rewritten: tag-and-push releases via GitHub Actions, OIDC trusted publishing to PyPI, no host-local `copy_to_public.py`.
- Offline fixture-based test harness (295 captured response fixtures) with a drift allowlist that turns `PHRAPPY_STRICT=1` into a CI regression gate.

### 0.3.0 
- Models and operations as of Phrase TMS v25.21 per 28/10 2025. Fixed alias handling. 

### 0.2.0
- Improved naming of enums that are hoisted from schema inline anonymous declarations.  

### 0.1.0
- Complete rewrite of build pipeline with fully automated and repeatable builds.
- Support for polymorph input and output schemas. 
- Slight change in API surface due to schema naming normalization.
- Added context manager support.
- Minimal test suite implemented.

---

## License

MIT
