Skip to content

Commit 49496fa

Browse files
committed
Add CLAUDE.md file
1 parent 985ad37 commit 49496fa

1 file changed

Lines changed: 398 additions & 0 deletions

File tree

CLAUDE.md

Lines changed: 398 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,398 @@
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

Comments
 (0)