|
| 1 | +# CLAUDE.md — NetLicensing Python Client |
| 2 | + |
| 3 | +This document gives Claude Code everything it needs to navigate, modify, and extend this codebase efficiently. |
| 4 | + |
| 5 | +--- |
| 6 | + |
| 7 | +## Project at a Glance |
| 8 | + |
| 9 | +| Item | Value | |
| 10 | +|---|---| |
| 11 | +| Package name | `netlicensing-client` | |
| 12 | +| PyPI import name | `import netlicensing` | |
| 13 | +| Python requirement | `>= 3.11` | |
| 14 | +| Runtime dependencies | `httpx >= 0.25, < 1` · `pydantic >= 2.5, < 3` | |
| 15 | +| Project layout | `src/` layout — library lives in `src/netlicensing/` | |
| 16 | +| Entry point | `src/netlicensing/__init__.py` re-exports everything public | |
| 17 | +| Type checking | mypy strict mode (`pyproject.toml` `[tool.mypy]`) | |
| 18 | +| Formatting | black + isort, line length 120 | |
| 19 | + |
| 20 | +--- |
| 21 | + |
| 22 | +## Repository Layout |
| 23 | + |
| 24 | +``` |
| 25 | +src/netlicensing/ |
| 26 | +├── __init__.py # All public symbols re-exported here |
| 27 | +├── client.py # NetLicensingClient — the main class |
| 28 | +├── config.py # NetLicensingConfig (frozen dataclass) + env-var helpers |
| 29 | +├── exceptions.py # Full exception hierarchy |
| 30 | +├── py.typed # PEP 561 marker — do not delete |
| 31 | +├── models/ |
| 32 | +│ ├── __init__.py # Re-exports all entity models, enums, Page |
| 33 | +│ ├── base.py # NetLicensingModel base, serialize_form(), FormValue |
| 34 | +│ ├── entities.py # All entity models + all enums |
| 35 | +│ ├── pagination.py # Page[T] generic model |
| 36 | +│ └── response.py # NetLicensingResponse envelope parser + helpers |
| 37 | +└── services/ |
| 38 | + ├── __init__.py # Re-exports all service classes |
| 39 | + ├── base.py # ResourceService[ModelT] generic CRUD base |
| 40 | + ├── helpers.py # encode_filter(), merge_payload(), clean_params() |
| 41 | + ├── bundle.py # BundleService — extra: obtain() |
| 42 | + ├── license.py # LicenseService |
| 43 | + ├── license_template.py # LicenseTemplateService |
| 44 | + ├── licensee.py # LicenseeService — extra: validate(), transfer() |
| 45 | + ├── notification.py # NotificationService |
| 46 | + ├── payment_method.py # PaymentMethodService |
| 47 | + ├── product.py # ProductService |
| 48 | + ├── product_module.py # ProductModuleService |
| 49 | + ├── token.py # TokenService — extra: create_shop_token(), create_api_key_token() |
| 50 | + ├── transaction.py # TransactionService |
| 51 | + ├── utility.py # UtilityService — list_countries(), list_license_types(), list_licensing_models() |
| 52 | + └── validation.py # ValidationService — thin delegate to licensees.validate() |
| 53 | +
|
| 54 | +tests/ |
| 55 | +├── conftest.py # Shared fixtures: make_client, envelope helpers, form_body |
| 56 | +├── test_client.py # NetLicensingClient unit tests |
| 57 | +├── test_internals.py # Fine-grained coverage: config, serialize_form, response parsing, helpers |
| 58 | +├── test_models.py # Model + enum unit tests |
| 59 | +├── test_services.py # Service method tests (23 tests, covers all services) |
| 60 | +└── test_live_demo.py # Skipped unless NETLICENSING_LIVE_DEMO=1 |
| 61 | +
|
| 62 | +demo/ |
| 63 | +└── app.py # CLI demo: `validate` and `shop-token` subcommands |
| 64 | +``` |
| 65 | + |
| 66 | +--- |
| 67 | + |
| 68 | +## Development Setup |
| 69 | + |
| 70 | +```bash |
| 71 | +pip install -e ".[dev]" # editable install with all dev deps |
| 72 | +pytest # run tests + coverage |
| 73 | +mypy # type-check |
| 74 | +python -m build # build wheel + sdist |
| 75 | +twine check dist/* # verify package metadata |
| 76 | +``` |
| 77 | + |
| 78 | +--- |
| 79 | + |
| 80 | +## Running Tests |
| 81 | + |
| 82 | +```bash |
| 83 | +pytest # all tests (101 pass, 1 skipped) |
| 84 | +pytest tests/test_services.py # only service tests |
| 85 | +pytest -k "token" # filter by name |
| 86 | +pytest --no-cov # skip coverage (faster) |
| 87 | +NETLICENSING_LIVE_DEMO=1 pytest tests/test_live_demo.py # live API tests |
| 88 | +``` |
| 89 | + |
| 90 | +Coverage is reported automatically (`--cov=src/netlicensing --cov-report=term-missing`). |
| 91 | +Target: 99%+ (only 3 genuinely unreachable lines are excluded with `# pragma: no cover`). |
| 92 | + |
| 93 | +--- |
| 94 | + |
| 95 | +## Core Architecture |
| 96 | + |
| 97 | +### 1. `NetLicensingClient` (`client.py`) |
| 98 | + |
| 99 | +The single entry point. Instantiated directly by consumers: |
| 100 | + |
| 101 | +```python |
| 102 | +from netlicensing import NetLicensingClient |
| 103 | +client = NetLicensingClient(api_key="...", base_url="https://...") |
| 104 | +``` |
| 105 | + |
| 106 | +Key points: |
| 107 | +- Wraps `httpx.Client` — **synchronous only** |
| 108 | +- Auth is HTTP Basic: API key → `BasicAuth("apiKey", key)`, username/password → `BasicAuth(username, password)` |
| 109 | +- All 12 services are lazy-installed as attributes in `_install_services()` |
| 110 | +- `_request(method, path, *, params, data, json, headers, expected_status)` is the **single HTTP gateway** — all service code calls this |
| 111 | +- Retry loop: max `config.retries + 1` attempts; backs off with `config.retry_backoff * 2^attempt` seconds; retries on timeout, network errors, and `{408, 429, 500, 502, 503, 504}` for idempotent methods only (`GET`, `HEAD`, `OPTIONS`) |
| 112 | +- `_handle_response()` raises on non-2xx AND on 2xx responses that contain ERROR-level infos in the envelope |
| 113 | +- `NetLicensing` is an alias for `NetLicensingClient` (backwards compat) |
| 114 | +- Shortcuts: `client.validate(...)`, `client.get_licensee(...)`, `client.delete_licensee(...)` |
| 115 | + |
| 116 | +### 2. Configuration (`config.py`) |
| 117 | + |
| 118 | +`NetLicensingConfig` is a **frozen dataclass** (`frozen=True, slots=True`). Never mutate it; create a new one. |
| 119 | + |
| 120 | +Environment variables (all optional): |
| 121 | + |
| 122 | +| Env var | Config field | Default | |
| 123 | +|---|---|---| |
| 124 | +| `NETLICENSING_API_KEY` | `api_key` | `None` | |
| 125 | +| `NETLICENSING_USERNAME` | `username` | `None` | |
| 126 | +| `NETLICENSING_PASSWORD` | `password` | `None` | |
| 127 | +| `NETLICENSING_VENDOR_NUMBER` | `vendor_number` | `None` | |
| 128 | +| `NETLICENSING_BASE_URL` | `base_url` | `https://go.netlicensing.io/core/v2/rest` | |
| 129 | +| `NETLICENSING_TIMEOUT` | `timeout` | `30.0` | |
| 130 | +| `NETLICENSING_CONNECT_TIMEOUT` | `connect_timeout` | `10.0` | |
| 131 | +| `NETLICENSING_RETRIES` | `retries` | `2` | |
| 132 | +| `NETLICENSING_RETRY_BACKOFF` | `retry_backoff` | `0.25` | |
| 133 | +| `NETLICENSING_VERIFY` | `verify` | `True` | |
| 134 | + |
| 135 | +`config.has_auth` → `True` if `api_key` is set, or both `username` + `password` are set. |
| 136 | + |
| 137 | +### 3. Exception Hierarchy (`exceptions.py`) |
| 138 | + |
| 139 | +``` |
| 140 | +NetLicensingError |
| 141 | +├── NetLicensingNetworkError # DNS, connection refused, etc. |
| 142 | +│ └── NetLicensingTimeoutError # httpx.TimeoutException |
| 143 | +├── NetLicensingHTTPError # Non-2xx HTTP or API-level error info |
| 144 | +│ └── NetLicensingAuthError # 401 / 403 |
| 145 | +└── NetLicensingValidationError # Client-side parse / missing item |
| 146 | +``` |
| 147 | + |
| 148 | +`NetLicensingHTTPError` carries: `status_code`, `payload`, `method`, `url`, `request_id`. |
| 149 | + |
| 150 | +### 4. Models (`models/`) |
| 151 | + |
| 152 | +**`NetLicensingModel`** (base for all entity models): |
| 153 | +- Pydantic `BaseModel` with `extra="allow"` — custom (vendor-defined) properties survive round-trips via `model_extra` |
| 154 | +- `populate_by_name=True` — use either Python name (`licensee_number`) or API alias (`licenseeNumber`) |
| 155 | +- `to_form()` method — serializes the model for `application/x-www-form-urlencoded` POST bodies |
| 156 | + |
| 157 | +**`serialize_form(data, *, exclude)`** (`models/base.py`): |
| 158 | +- Converts a `NetLicensingModel` or plain `dict` to `dict[str, str | list[str]]` |
| 159 | +- Handles `Enum`, `bool`, `datetime`, `date`, `Decimal`, `Sequence`, JSON fallback |
| 160 | +- `FormValue = str | list[str]` type alias |
| 161 | + |
| 162 | +**`Page[T]`** (`models/pagination.py`): |
| 163 | +- Generic model: `items`, `page_number`, `items_number`, `total_pages`, `total_items`, `has_next` |
| 164 | +- Supports `__iter__`, `__len__`, `__bool__` |
| 165 | + |
| 166 | +**`NetLicensingResponse`** (`models/response.py`): |
| 167 | +Parses the NetLicensing JSON envelope: |
| 168 | +```json |
| 169 | +{ |
| 170 | + "infos": {"info": [{"id": "...", "type": "ERROR", "value": "..."}]}, |
| 171 | + "items": { |
| 172 | + "pagenumber": "0", "itemsnumber": "1", "totalpages": "1", "totalitems": "1", "hasnext": "false", |
| 173 | + "item": [{"type": "Product", "property": [{"name": "number", "value": "P-1"}], "list": []}] |
| 174 | + }, |
| 175 | + "ttl": 1440 |
| 176 | +} |
| 177 | +``` |
| 178 | + |
| 179 | +Key helpers: |
| 180 | +- `item_to_dict(item)` — flattens `[{"name": k, "value": v}]` property list into `{"k": v}` dict; also handles nested `list` entries |
| 181 | +- `_cast_value(v)` — coerces `"true"`/`"false"`/`"null"` strings and valid JSON strings to native Python |
| 182 | +- `model_from_response(payload, model, item_type)` — raises `NetLicensingValidationError` if no matching items |
| 183 | +- `page_from_response(payload, model, item_type)` — returns `Page[ModelT]` |
| 184 | +- `validation_from_response(payload)` — returns `ValidationResult` |
| 185 | + |
| 186 | +### 5. Services (`services/`) |
| 187 | + |
| 188 | +**`ResourceService[ModelT]`** (`services/base.py`) — generic base for all resource services: |
| 189 | + |
| 190 | +| Method | HTTP | Path | |
| 191 | +|---|---|---| |
| 192 | +| `get(number)` | GET | `/{endpoint}/{number}` | |
| 193 | +| `list(filter, *, params, **query)` | GET | `/{endpoint}` | |
| 194 | +| `iterate(filter, ...)` | GET | `/{endpoint}` (yields items, no multi-page) | |
| 195 | +| `_create_resource(resource, **props)` | POST | `/{endpoint}` | |
| 196 | +| `_update_resource(number, resource, **props)` | POST | `/{endpoint}/{number}` | |
| 197 | +| `delete(number, *, force_cascade)` | DELETE | `/{endpoint}/{number}` | |
| 198 | + |
| 199 | +All concrete services call `_create_resource` / `_update_resource` from their `create()` / `update()` methods after mapping Python kwargs to API camelCase field names. |
| 200 | + |
| 201 | +**Service → endpoint → model mapping:** |
| 202 | + |
| 203 | +| `client.` attribute | Endpoint | Model | |
| 204 | +|---|---|---| |
| 205 | +| `products` | `product` | `Product` | |
| 206 | +| `product_modules` | `productmodule` | `ProductModule` | |
| 207 | +| `licensees` | `licensee` | `Licensee` | |
| 208 | +| `license_templates` | `licensetemplate` | `LicenseTemplate` | |
| 209 | +| `licenses` | `license` | `License` | |
| 210 | +| `tokens` | `token` | `Token` | |
| 211 | +| `transactions` | `transaction` | `Transaction` | |
| 212 | +| `payment_methods` | `paymentmethod` | `PaymentMethod` | |
| 213 | +| `bundles` | `bundle` | `Bundle` | |
| 214 | +| `notifications` | `notification` | `Notification` | |
| 215 | +| `utility` | — | various | |
| 216 | +| `validation` | — | delegates to `licensees.validate()` | |
| 217 | + |
| 218 | +**Service helpers** (`services/helpers.py`): |
| 219 | +- `encode_filter(filter_)` — `{"active": True}` → `"active=True"` (semicolon-separated) |
| 220 | +- `merge_payload(resource, **properties)` — calls `resource.to_form()` then merges serialized kwargs |
| 221 | +- `clean_params(params, **extra)` — merges dict + kwargs into serialized form dict |
| 222 | + |
| 223 | +--- |
| 224 | + |
| 225 | +## Adding a New Service |
| 226 | + |
| 227 | +1. Create `src/netlicensing/services/myresource.py`: |
| 228 | + ```python |
| 229 | + from netlicensing.models import MyModel # or add to entities.py first |
| 230 | + from netlicensing.services.base import ResourceService |
| 231 | + |
| 232 | + class MyResourceService(ResourceService[MyModel]): |
| 233 | + endpoint = "myresource" |
| 234 | + item_type = "MyResource" |
| 235 | + model = MyModel |
| 236 | + |
| 237 | + def create(self, *, number: str | None = None, **props: Any) -> MyModel: |
| 238 | + return self._create_resource(None, number=number, **props) |
| 239 | + |
| 240 | + def update(self, number: str, **props: Any) -> MyModel: |
| 241 | + return self._update_resource(number, None, **props) |
| 242 | + ``` |
| 243 | + |
| 244 | +2. Export from `src/netlicensing/services/__init__.py`. |
| 245 | + |
| 246 | +3. Install in `client.py` `_install_services()`: `self.myresources = MyResourceService(self)`. |
| 247 | + |
| 248 | +4. Export `MyResourceService` + `MyModel` from `src/netlicensing/__init__.py` and add to `__all__`. |
| 249 | + |
| 250 | +5. Add model to `src/netlicensing/models/__init__.py`. |
| 251 | + |
| 252 | +--- |
| 253 | + |
| 254 | +## Adding a New Entity Model |
| 255 | + |
| 256 | +Add to `src/netlicensing/models/entities.py`: |
| 257 | + |
| 258 | +```python |
| 259 | +class MyEntity(NetLicensingModel): |
| 260 | + active: bool | None = None |
| 261 | + number: str | None = None |
| 262 | + name: str | None = None |
| 263 | + some_field: str | None = Field(default=None, alias="someField") # camelCase API name |
| 264 | + |
| 265 | + def to_form(self, *, exclude: set[str] | None = None) -> dict[str, FormValue]: |
| 266 | + # override only if you need to exclude read-only fields or transform values |
| 267 | + return serialize_form(self, exclude=(exclude or set()) | {"read_only_field"}) |
| 268 | +``` |
| 269 | + |
| 270 | +Rules: |
| 271 | +- All fields `| None = None` (partial updates are the norm) |
| 272 | +- Use `Field(alias="camelCase")` for any API field that differs from the Python name |
| 273 | +- `in_use` is always excluded from `to_form()` — it's a read-only computed field |
| 274 | +- Enums: subclass `_StrEnum` so `str(enum_value)` gives the raw string |
| 275 | + |
| 276 | +--- |
| 277 | + |
| 278 | +## Writing Tests |
| 279 | + |
| 280 | +All tests use `httpx.MockTransport` — **no real network calls**. |
| 281 | + |
| 282 | +### Fixtures (`conftest.py`) |
| 283 | + |
| 284 | +```python |
| 285 | +# Build a client wired to a custom handler |
| 286 | +def test_something(make_client, requests_seen): |
| 287 | + def handler(request: httpx.Request) -> httpx.Response: |
| 288 | + return httpx.Response(200, json=envelope("Product", {"number": "P-1"}), request=request) |
| 289 | + |
| 290 | + client = make_client(handler) |
| 291 | + product = client.products.get("P-1") |
| 292 | + assert product.number == "P-1" |
| 293 | + assert requests_seen[0].method == "GET" |
| 294 | +``` |
| 295 | + |
| 296 | +### Envelope helpers |
| 297 | + |
| 298 | +```python |
| 299 | +# Single-item response |
| 300 | +envelope("Product", {"number": "P-1", "active": True}) |
| 301 | + |
| 302 | +# Paginated response |
| 303 | +page_envelope("Licensee", [{"number": "C-1"}, {"number": "C-2"}]) |
| 304 | + |
| 305 | +# Validation response |
| 306 | +validation_envelope(valid=True) # includes one ProductModuleValidation item |
| 307 | + |
| 308 | +# Parse form body sent in a POST |
| 309 | +form_body(requests_seen[0]) # -> {"number": "P-1", "active": "true"} |
| 310 | +``` |
| 311 | + |
| 312 | +### Common patterns |
| 313 | + |
| 314 | +```python |
| 315 | +# Test a 4xx error raises NetLicensingHTTPError |
| 316 | +with pytest.raises(NetLicensingHTTPError) as exc_info: |
| 317 | + ... |
| 318 | +assert exc_info.value.status_code == 404 |
| 319 | + |
| 320 | +# Test an auth error |
| 321 | +with pytest.raises(NetLicensingAuthError): |
| 322 | + ... |
| 323 | + |
| 324 | +# Test retry behaviour (use retry_backoff=0 in make_client — it already does) |
| 325 | +call_count = 0 |
| 326 | +def handler(request): |
| 327 | + nonlocal call_count |
| 328 | + call_count += 1 |
| 329 | + if call_count < 3: |
| 330 | + return httpx.Response(503, request=request) |
| 331 | + return httpx.Response(200, json=envelope(...), request=request) |
| 332 | + |
| 333 | +# Test sleep is called |
| 334 | +with unittest.mock.patch("netlicensing.client.time.sleep") as mock_sleep: |
| 335 | + ... |
| 336 | + assert mock_sleep.call_count == 2 |
| 337 | +``` |
| 338 | + |
| 339 | +--- |
| 340 | + |
| 341 | +## API Field Name Conventions |
| 342 | + |
| 343 | +NetLicensing REST API uses camelCase in JSON/form bodies. Pydantic aliases map between camelCase (API) and snake_case (Python): |
| 344 | + |
| 345 | +| Python | API | |
| 346 | +|---|---| |
| 347 | +| `licensee_number` | `licenseeNumber` | |
| 348 | +| `product_number` | `productNumber` | |
| 349 | +| `product_module_number` | `productModuleNumber` | |
| 350 | +| `license_template_number` | `licenseTemplateNumber` | |
| 351 | +| `licensee_auto_create` | `licenseeAutoCreate` | |
| 352 | +| `marked_for_transfer` | `markedForTransfer` | |
| 353 | +| `start_date` | `startDate` | |
| 354 | +| `date_created` | `dateCreated` | |
| 355 | +| `token_type` | `tokenType` | |
| 356 | +| `api_key_role` | `apiKeyRole` | |
| 357 | +| `shop_url` | `shopURL` | |
| 358 | +| `success_url` | `successURL` | |
| 359 | + |
| 360 | +Always use the Python snake_case name in service method kwargs; these are translated to camelCase when passed to `_create_resource`/`_update_resource`. |
| 361 | + |
| 362 | +--- |
| 363 | + |
| 364 | +## Common Gotchas |
| 365 | + |
| 366 | +1. **`to_form()` must exclude read-only fields** — `in_use`, `shop_url`, `vendor_number`, etc. are returned by the API but must not be sent back. Always add them to the `exclude` set. |
| 367 | + |
| 368 | +2. **`model_extra` for custom properties** — NetLicensing supports vendor-defined custom properties. They are stored in `model_extra` after parsing and included in `to_form()` automatically via `extra="allow"`. |
| 369 | + |
| 370 | +3. **`filter` parameter encoding** — `list({"active": True})` serialises the filter as `"active=True"` (Python `True`, not lowercase). This is intentional — the API accepts it. The test `assert requests_seen[1].url.params["filter"] == "active=True"` reflects this. |
| 371 | + |
| 372 | +4. **`Transaction.date_created`/`date_closed` have `AliasChoices`** — the API returns both `dateCreated` and `datecreated` (lowercase) depending on the endpoint. Both are handled. |
| 373 | + |
| 374 | +5. **`Bundle.license_template_numbers` serialises as a comma-joined string** — `"LT-1,LT-2"` not a list, because that's what the API expects. The `to_form()` override handles this. |
| 375 | + |
| 376 | +6. **`Notification.events` serialises as comma-joined string** — same pattern as bundles. |
| 377 | + |
| 378 | +7. **`ValidationParameters.to_form()`** is the most complex serializer — it inlines `licensee_properties` at the top level and emits `productModuleNumber{i}` / `{key}{i}` indexed fields for `product_module_parameters`. |
| 379 | + |
| 380 | +8. **Retry only on idempotent methods** — status-code-based retry (`408`, `429`, `5xx`) applies only to `GET`, `HEAD`, `OPTIONS`. Network/timeout errors retry on all methods. |
| 381 | + |
| 382 | +9. **`_parse_payload()` tries JSON even without `Content-Type: application/json`** — some NetLicensing error responses omit the content type header; the fallback attempt ensures they're still parsed. |
| 383 | + |
| 384 | +10. **`py.typed` must not be deleted** — it's a PEP 561 marker. Without it, mypy reports `Package 'netlicensing' cannot be type checked due to missing py.typed marker`. |
| 385 | + |
| 386 | +--- |
| 387 | + |
| 388 | +## CI / GitHub Actions |
| 389 | + |
| 390 | +| Workflow | Trigger | What it does | |
| 391 | +|---|---|---| |
| 392 | +| `netlicensing-python-ci.yml` | push / PR to master | lint (mypy), test matrix (3.11–3.14), build + twine check, Codecov upload | |
| 393 | +| `netlicensing-publish-pypi.yml` | release published | test → build → publish to PyPI via OIDC Trusted Publisher | |
| 394 | + |
| 395 | +All workflows use `FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true` to silence Node.js 20 deprecation warnings (remove once action versions ship native Node 24 support). |
| 396 | + |
| 397 | +Both workflows require `permissions: contents: read` at the workflow level (CodeQL requirement). The publish job additionally needs `id-token: write` for OIDC. |
| 398 | + |
0 commit comments