typekit2 1.0.0__tar.gz → 2.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {typekit2-1.0.0 → typekit2-2.0.1}/.gitignore +2 -0
- {typekit2-1.0.0 → typekit2-2.0.1}/CHANGELOG.md +9 -1
- {typekit2-1.0.0 → typekit2-2.0.1}/PKG-INFO +31 -3
- {typekit2-1.0.0 → typekit2-2.0.1}/PLAN.md +1 -1
- {typekit2-1.0.0 → typekit2-2.0.1}/README.md +29 -1
- {typekit2-1.0.0 → typekit2-2.0.1}/WORK.md +4 -1
- {typekit2-1.0.0 → typekit2-2.0.1}/docs/API.md +9 -2
- {typekit2-1.0.0 → typekit2-2.0.1}/pyproject.toml +1 -1
- {typekit2-1.0.0 → typekit2-2.0.1}/tests/test_cli.py +71 -1
- {typekit2-1.0.0 → typekit2-2.0.1}/tests/test_client.py +19 -0
- {typekit2-1.0.0 → typekit2-2.0.1}/tests/test_packaging.py +3 -0
- typekit2-2.0.1/tests/test_workflows.py +163 -0
- {typekit2-1.0.0 → typekit2-2.0.1}/typekit2/__main__.py +64 -17
- {typekit2-1.0.0 → typekit2-2.0.1}/typekit2/__version__.py +1 -1
- {typekit2-1.0.0 → typekit2-2.0.1}/typekit2/client.py +7 -3
- typekit2-2.0.1/typekit2/workflows.py +193 -0
- {typekit2-1.0.0 → typekit2-2.0.1}/.env.example +0 -0
- {typekit2-1.0.0 → typekit2-2.0.1}/DEPENDENCIES.md +0 -0
- {typekit2-1.0.0 → typekit2-2.0.1}/LICENSE +0 -0
- {typekit2-1.0.0 → typekit2-2.0.1}/TODO.md +0 -0
- {typekit2-1.0.0 → typekit2-2.0.1}/publish.sh +0 -0
- {typekit2-1.0.0 → typekit2-2.0.1}/test.sh +0 -0
- {typekit2-1.0.0 → typekit2-2.0.1}/typekit2/__init__.py +0 -0
- {typekit2-1.0.0 → typekit2-2.0.1}/typekit2/exceptions.py +0 -0
- {typekit2-1.0.0 → typekit2-2.0.1}/uv.lock +0 -0
|
@@ -6,8 +6,14 @@ this_file: CHANGELOG.md
|
|
|
6
6
|
|
|
7
7
|
## Unreleased
|
|
8
8
|
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Rewrote the `v1.0.0` and `v1.0.1` commits to strip a stray carriage return from the author identity, which GitHub Desktop could not parse.
|
|
12
|
+
- Ignored local `.omc/` agent state so `publish.sh` sees a clean tree.
|
|
13
|
+
|
|
9
14
|
### Changed
|
|
10
15
|
|
|
16
|
+
- Updated project links for the renamed `fontlaborg/typekit2-python` repository.
|
|
11
17
|
- Renamed the distribution and import package from `typekit` to `typekit2`.
|
|
12
18
|
- Replaced `setup.py` with standards-based `pyproject.toml` packaging.
|
|
13
19
|
- Migrated the client from Python 2 to typed Python 3.10+.
|
|
@@ -22,8 +28,10 @@ this_file: CHANGELOG.md
|
|
|
22
28
|
- Offline tests for authentication, transport, payloads, errors, parsing, and CLI behavior.
|
|
23
29
|
- Tag-derived semantic versions using `hatch-vcs`.
|
|
24
30
|
- `publish.sh` workflow using `uvx gitnextver`, `uv build`, and `uv publish`.
|
|
31
|
+
- Compact `kit-fonts` discovery and read-only `request-get` escape-hatch commands.
|
|
32
|
+
- Safe `plan-remove-fonts` and `remove-fonts` batch workflows with ID resolution, dry-run, full preservation checks, optional publish, and timeout reconciliation.
|
|
33
|
+
- Official Adobe API Token page in setup and API documentation.
|
|
25
34
|
|
|
26
35
|
### Removed
|
|
27
36
|
|
|
28
37
|
- Python 2-only code, live destructive tests, `setup.py`, and generated distutils manifest.
|
|
29
|
-
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: typekit2
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0.1
|
|
4
4
|
Summary: Modern Python client and CLI for the Adobe Fonts (Typekit) API
|
|
5
5
|
Project-URL: Documentation, https://fonts.adobe.com/docs/api
|
|
6
|
-
Project-URL: Repository, https://github.com/fontlaborg/
|
|
6
|
+
Project-URL: Repository, https://github.com/fontlaborg/typekit2-python
|
|
7
7
|
Author-email: Suchan Lee <lee.suchan@gmail.com>
|
|
8
8
|
License-Expression: MIT
|
|
9
9
|
License-File: LICENSE
|
|
@@ -51,6 +51,8 @@ cp .env.example .env
|
|
|
51
51
|
export TYPEKIT_API_KEY='your-token'
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
+
Create or copy the token from Adobe’s official [API Token page](https://fonts.adobe.com/account/tokens).
|
|
55
|
+
|
|
54
56
|
`python-dotenv` loads `.env` without overriding an existing environment variable. Never commit `.env`; it is ignored.
|
|
55
57
|
|
|
56
58
|
## Python API
|
|
@@ -104,9 +106,26 @@ typekit2 family pcpv
|
|
|
104
106
|
typekit2 variations pcpv
|
|
105
107
|
typekit2 libraries
|
|
106
108
|
typekit2 library full --page=1 --per-page=50
|
|
109
|
+
typekit2 kit-fonts gav0zux --matching=halyard
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Safe batch removal
|
|
113
|
+
|
|
114
|
+
Preview the exact slug/name/ID resolution and resulting family count without writing:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
typekit2 plan-remove-fonts gav0zux halyard-display,halyard-micro,halyard-text
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Apply the removal while proving every unrequested family ID remains, then publish:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
typekit2 remove-fonts gav0zux halyard-display,halyard-micro,halyard-text --publish=true
|
|
107
124
|
```
|
|
108
125
|
|
|
109
|
-
|
|
126
|
+
The result reports the resolved IDs, before/after counts, preservation check, and publish status. Unknown names abort the whole operation before its first write. `--ignore-missing=true` makes reruns idempotent; `--dry-run=true` provides another preview path. A timed-out DELETE is reconciled against the draft before continuing, and a timed-out publish is reconciled against the published kit rather than retried blindly.
|
|
127
|
+
|
|
128
|
+
Other mutating commands remain explicit:
|
|
110
129
|
|
|
111
130
|
```bash
|
|
112
131
|
typekit2 create-kit Example --domains=example.com,www.example.com
|
|
@@ -127,6 +146,13 @@ typekit2 create-kit Example \
|
|
|
127
146
|
|
|
128
147
|
Run `typekit2 --help` or `python -m typekit2 --help` for generated Fire help.
|
|
129
148
|
|
|
149
|
+
All successful command results are JSON. Errors exit nonzero through Fire and never include the API key. For an API read not yet covered by a high-level command, use the authenticated read-only escape hatch:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
typekit2 request-get families/pcpv
|
|
153
|
+
typekit2 request-get libraries/full --params='{"page":2,"per_page":25}'
|
|
154
|
+
```
|
|
155
|
+
|
|
130
156
|
## API behavior
|
|
131
157
|
|
|
132
158
|
- Requests use `https://typekit.com/api/v1/json`.
|
|
@@ -159,3 +185,5 @@ Set `PUBLISH_SKIP_UPLOAD=1` to exercise the Git release and artifact verificatio
|
|
|
159
185
|
## License and provenance
|
|
160
186
|
|
|
161
187
|
MIT. The project began as `typekit-python` by Suchan Lee; `typekit2` is its Python 3 modernization.
|
|
188
|
+
|
|
189
|
+
Repository: [github.com/fontlaborg/typekit2-python](https://github.com/fontlaborg/typekit2-python)
|
|
@@ -16,9 +16,9 @@ The modernization target is a maintained Python 3 package whose library, CLI, do
|
|
|
16
16
|
- Replace live account-mutating tests with deterministic request-level tests.
|
|
17
17
|
- Document Adobe endpoints, parameter semantics, installation, API, CLI, and releases.
|
|
18
18
|
- Add a guarded `gitnextver` and `uv publish` release workflow.
|
|
19
|
+
- Add compact kit-family discovery and verified multi-family removal/publish workflows.
|
|
19
20
|
|
|
20
21
|
## Future work
|
|
21
22
|
|
|
22
23
|
- Add opt-in live read-only smoke tests when a dedicated Adobe Fonts test account is available.
|
|
23
24
|
- Add API resources only when they appear in Adobe’s authoritative reference.
|
|
24
|
-
|
|
@@ -27,6 +27,8 @@ cp .env.example .env
|
|
|
27
27
|
export TYPEKIT_API_KEY='your-token'
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
+
Create or copy the token from Adobe’s official [API Token page](https://fonts.adobe.com/account/tokens).
|
|
31
|
+
|
|
30
32
|
`python-dotenv` loads `.env` without overriding an existing environment variable. Never commit `.env`; it is ignored.
|
|
31
33
|
|
|
32
34
|
## Python API
|
|
@@ -80,9 +82,26 @@ typekit2 family pcpv
|
|
|
80
82
|
typekit2 variations pcpv
|
|
81
83
|
typekit2 libraries
|
|
82
84
|
typekit2 library full --page=1 --per-page=50
|
|
85
|
+
typekit2 kit-fonts gav0zux --matching=halyard
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Safe batch removal
|
|
89
|
+
|
|
90
|
+
Preview the exact slug/name/ID resolution and resulting family count without writing:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
typekit2 plan-remove-fonts gav0zux halyard-display,halyard-micro,halyard-text
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Apply the removal while proving every unrequested family ID remains, then publish:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
typekit2 remove-fonts gav0zux halyard-display,halyard-micro,halyard-text --publish=true
|
|
83
100
|
```
|
|
84
101
|
|
|
85
|
-
|
|
102
|
+
The result reports the resolved IDs, before/after counts, preservation check, and publish status. Unknown names abort the whole operation before its first write. `--ignore-missing=true` makes reruns idempotent; `--dry-run=true` provides another preview path. A timed-out DELETE is reconciled against the draft before continuing, and a timed-out publish is reconciled against the published kit rather than retried blindly.
|
|
103
|
+
|
|
104
|
+
Other mutating commands remain explicit:
|
|
86
105
|
|
|
87
106
|
```bash
|
|
88
107
|
typekit2 create-kit Example --domains=example.com,www.example.com
|
|
@@ -103,6 +122,13 @@ typekit2 create-kit Example \
|
|
|
103
122
|
|
|
104
123
|
Run `typekit2 --help` or `python -m typekit2 --help` for generated Fire help.
|
|
105
124
|
|
|
125
|
+
All successful command results are JSON. Errors exit nonzero through Fire and never include the API key. For an API read not yet covered by a high-level command, use the authenticated read-only escape hatch:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
typekit2 request-get families/pcpv
|
|
129
|
+
typekit2 request-get libraries/full --params='{"page":2,"per_page":25}'
|
|
130
|
+
```
|
|
131
|
+
|
|
106
132
|
## API behavior
|
|
107
133
|
|
|
108
134
|
- Requests use `https://typekit.com/api/v1/json`.
|
|
@@ -135,3 +161,5 @@ Set `PUBLISH_SKIP_UPLOAD=1` to exercise the Git release and artifact verificatio
|
|
|
135
161
|
## License and provenance
|
|
136
162
|
|
|
137
163
|
MIT. The project began as `typekit-python` by Suchan Lee; `typekit2` is its Python 3 modernization.
|
|
164
|
+
|
|
165
|
+
Repository: [github.com/fontlaborg/typekit2-python](https://github.com/fontlaborg/typekit2-python)
|
|
@@ -12,11 +12,14 @@ this_file: WORK.md
|
|
|
12
12
|
- Renamed the package to `typekit2` and implemented the modern client and CLI.
|
|
13
13
|
- Added offline contract tests before implementation.
|
|
14
14
|
- Added tag-derived versioning and a guarded publish workflow.
|
|
15
|
+
- Updated the Git remote and package metadata for `fontlaborg/typekit2-python`.
|
|
16
|
+
- Added compact family discovery, batch-removal preview, verified batch removal, read-only raw GET, and publish-timeout reconciliation to the Fire CLI.
|
|
15
17
|
|
|
16
18
|
### Verification
|
|
17
19
|
|
|
18
20
|
- `ruff format --check` and `ruff check`: passed.
|
|
19
|
-
- Offline suite:
|
|
21
|
+
- Offline suite: 44 tests passed with 93.93% branch-aware coverage after the CLI workflow extension.
|
|
20
22
|
- Fresh `uv build --no-sources`: wheel and sdist built successfully; metadata names `typekit2`, the wheel contains `typekit2/__main__.py` and generated `typekit2/__version__.py`, and no legacy `typekit/` package is present.
|
|
21
23
|
- Installed-artifact smoke test from `/tmp`: both `typekit2 doctor` and `python -m typekit2 doctor` returned redacted JSON.
|
|
22
24
|
- Disposable release rehearsal: `publish.sh` advanced a local test repository from `v0.1.0` to `v0.1.1`, pushed matching branch/tag refs to a local bare remote, built matching artifacts, and skipped only PyPI upload under `PUBLISH_SKIP_UPLOAD=1`.
|
|
25
|
+
- Live read-only CLI checks listed the three Halyard variable families in `gav0zux` and produced a correct three-family removal preview without changing the kit.
|
|
@@ -8,7 +8,7 @@ The service and URL paths retain the Typekit name even though the product is now
|
|
|
8
8
|
|
|
9
9
|
## Authentication and transport
|
|
10
10
|
|
|
11
|
-
Adobe requires authenticated requests to use HTTPS. `typekit2` sends `TYPEKIT_API_KEY` as `X-Typekit-Token`, following the [authentication reference](https://fonts.adobe.com/docs/api/auth). It deliberately avoids the documented query-string fallback because URLs are commonly retained in logs and traces.
|
|
11
|
+
Create the credential on Adobe’s official [API Token page](https://fonts.adobe.com/account/tokens). Adobe requires authenticated requests to use HTTPS. `typekit2` sends `TYPEKIT_API_KEY` as `X-Typekit-Token`, following the [authentication reference](https://fonts.adobe.com/docs/api/auth). It deliberately avoids the documented query-string fallback because URLs are commonly retained in logs and traces.
|
|
12
12
|
|
|
13
13
|
Form bodies follow Adobe’s [parameter encoding](https://fonts.adobe.com/docs/api/parameters): repeated values use `name[]`, while family objects use keys such as `families[0][id]`.
|
|
14
14
|
|
|
@@ -30,6 +30,14 @@ Form bodies follow Adobe’s [parameter encoding](https://fonts.adobe.com/docs/a
|
|
|
30
30
|
| `list_libraries()` | `GET libraries` | Available font libraries |
|
|
31
31
|
| `get_library(...)` | `GET libraries/:library` | Supports `page` and `per_page` |
|
|
32
32
|
|
|
33
|
+
## Safe CLI workflows
|
|
34
|
+
|
|
35
|
+
- `kit-fonts` returns compact family records and supports substring filtering across IDs, slugs, and names.
|
|
36
|
+
- `plan-remove-fonts` resolves every requested ID, slug, or name before writing and returns the projected count.
|
|
37
|
+
- `remove-fonts` issues narrow family DELETEs, then compares the complete before/after ID sets to prove unrelated families remain.
|
|
38
|
+
- `remove-fonts --publish=true` publishes only after draft verification. Network errors are reconciled against current draft/published state because a mutating timeout has an indeterminate outcome.
|
|
39
|
+
- `request-get` is a read-only raw escape hatch; raw POST or DELETE calls are intentionally not exposed.
|
|
40
|
+
|
|
33
41
|
Reference pages: [kit manipulation](https://fonts.adobe.com/docs/api/kits), [font families](https://fonts.adobe.com/docs/api/v1/:format/families/:family), [libraries](https://fonts.adobe.com/docs/api/v1/:format/libraries), and [errors](https://fonts.adobe.com/docs/api/errors).
|
|
34
42
|
|
|
35
43
|
## Important semantics
|
|
@@ -42,4 +50,3 @@ Reference pages: [kit manipulation](https://fonts.adobe.com/docs/api/kits), [fon
|
|
|
42
50
|
- The legacy analytics option is deprecated by Adobe and is intentionally not exposed.
|
|
43
51
|
|
|
44
52
|
The upstream documentation is old in vocabulary but remains live and dated © 2026. `typekit2` documents only behavior supported by those current pages.
|
|
45
|
-
|
|
@@ -29,7 +29,7 @@ classifiers = [
|
|
|
29
29
|
|
|
30
30
|
[project.urls]
|
|
31
31
|
Documentation = "https://fonts.adobe.com/docs/api"
|
|
32
|
-
Repository = "https://github.com/fontlaborg/
|
|
32
|
+
Repository = "https://github.com/fontlaborg/typekit2-python"
|
|
33
33
|
|
|
34
34
|
[project.scripts]
|
|
35
35
|
typekit2 = "typekit2.__main__:main"
|
|
@@ -6,6 +6,7 @@ import json
|
|
|
6
6
|
|
|
7
7
|
import pytest
|
|
8
8
|
|
|
9
|
+
import typekit2.__main__ as cli_module
|
|
9
10
|
from typekit2.__main__ import TypekitCLI, _serialize, parse_csv, parse_families
|
|
10
11
|
|
|
11
12
|
|
|
@@ -65,7 +66,7 @@ class FakeClient:
|
|
|
65
66
|
|
|
66
67
|
def test_cli_commands_delegate_with_parsed_values(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
67
68
|
fake = FakeClient()
|
|
68
|
-
monkeypatch.setattr(TypekitCLI, "
|
|
69
|
+
monkeypatch.setattr(TypekitCLI, "_client", property(lambda _: fake))
|
|
69
70
|
cli = TypekitCLI()
|
|
70
71
|
|
|
71
72
|
assert cli.kits()["method"] == "list_kits"
|
|
@@ -83,3 +84,72 @@ def test_cli_commands_delegate_with_parsed_values(monkeypatch: pytest.MonkeyPatc
|
|
|
83
84
|
assert cli.publish_kit("abc")["method"] == "publish_kit"
|
|
84
85
|
assert cli.add_font("abc", "pcpv", "n4,i4")["args"][2] == ["n4", "i4"]
|
|
85
86
|
assert cli.remove_font("abc", "pcpv")["method"] == "remove_font"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_cli_uses_configured_timeout_and_exposes_batch_workflows(
|
|
90
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
91
|
+
) -> None:
|
|
92
|
+
fake = FakeClient()
|
|
93
|
+
monkeypatch.setattr("typekit2.__main__.Typekit", lambda timeout: (fake, timeout))
|
|
94
|
+
cli = TypekitCLI(timeout=75)
|
|
95
|
+
|
|
96
|
+
assert cli._client == (fake, 75)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_cli_batch_commands_parse_and_delegate(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
100
|
+
fake = FakeClient()
|
|
101
|
+
monkeypatch.setattr(TypekitCLI, "_client", property(lambda _: fake))
|
|
102
|
+
monkeypatch.setattr(
|
|
103
|
+
cli_module,
|
|
104
|
+
"kit_fonts",
|
|
105
|
+
lambda client, kit_id, **kwargs: {"client": client, "kit_id": kit_id, **kwargs},
|
|
106
|
+
)
|
|
107
|
+
monkeypatch.setattr(
|
|
108
|
+
cli_module,
|
|
109
|
+
"plan_remove_fonts",
|
|
110
|
+
lambda client, kit_id, families: {"families": families},
|
|
111
|
+
)
|
|
112
|
+
monkeypatch.setattr(
|
|
113
|
+
cli_module,
|
|
114
|
+
"remove_fonts",
|
|
115
|
+
lambda client, kit_id, families, **kwargs: {"families": families, **kwargs},
|
|
116
|
+
)
|
|
117
|
+
cli = TypekitCLI()
|
|
118
|
+
|
|
119
|
+
assert cli.kit_fonts("abc", matching="halyard")["matching"] == "halyard"
|
|
120
|
+
assert cli.plan_remove_fonts("abc", "one,two")["families"] == ["one", "two"]
|
|
121
|
+
result = cli.remove_fonts("abc", "one,two", publish=True, publish_timeout=90)
|
|
122
|
+
assert result["families"] == ["one", "two"]
|
|
123
|
+
assert result["publish"] is True
|
|
124
|
+
assert result["publish_timeout"] == 90
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_cli_read_only_escape_hatch_parses_json_params(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
128
|
+
fake = FakeClient()
|
|
129
|
+
monkeypatch.setattr(TypekitCLI, "_client", property(lambda _: fake))
|
|
130
|
+
cli = TypekitCLI()
|
|
131
|
+
|
|
132
|
+
result = cli.request_get("libraries/full", '{"page": 2}')
|
|
133
|
+
|
|
134
|
+
assert result["method"] == "request"
|
|
135
|
+
assert result["args"] == ("GET", "libraries/full")
|
|
136
|
+
assert result["kwargs"] == {"params": {"page": 2}}
|
|
137
|
+
with pytest.raises(ValueError, match="JSON object"):
|
|
138
|
+
cli.request_get("libraries/full", "[]")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_main_exposes_command_instance_without_public_client(
|
|
142
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
143
|
+
) -> None:
|
|
144
|
+
captured = {}
|
|
145
|
+
monkeypatch.setattr(
|
|
146
|
+
cli_module.fire,
|
|
147
|
+
"Fire",
|
|
148
|
+
lambda component, **kwargs: captured.update(component=component, kwargs=kwargs),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
cli_module.main()
|
|
152
|
+
|
|
153
|
+
assert isinstance(captured["component"], TypekitCLI)
|
|
154
|
+
assert not hasattr(captured["component"], "client")
|
|
155
|
+
assert captured["kwargs"]["name"] == "typekit2"
|
|
@@ -217,3 +217,22 @@ def test_resource_methods_use_documented_paths_and_escaped_ids() -> None:
|
|
|
217
217
|
"https://typekit.com/api/v1/json/kits/abc123",
|
|
218
218
|
]
|
|
219
219
|
assert session.calls[4]["data"] == [("subset", "all"), ("variations", "n4")]
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_request_and_publish_allow_per_call_timeout() -> None:
|
|
223
|
+
session = FakeSession(FakeResponse({"published": "now"}))
|
|
224
|
+
client = Typekit(api_key="secret", session=session, timeout=7.5)
|
|
225
|
+
|
|
226
|
+
client.publish_kit("abc123", timeout=120)
|
|
227
|
+
|
|
228
|
+
assert session.calls[0]["timeout"] == 120
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def test_request_rejects_nonpositive_per_call_timeout_before_network() -> None:
|
|
232
|
+
session = FakeSession(FakeResponse({}))
|
|
233
|
+
client = Typekit(api_key="secret", session=session)
|
|
234
|
+
|
|
235
|
+
with pytest.raises(ValueError, match="greater than zero"):
|
|
236
|
+
client.request("GET", "kits", timeout=0)
|
|
237
|
+
|
|
238
|
+
assert session.calls == []
|
|
@@ -17,6 +17,9 @@ def test_pyproject_uses_typekit2_hatch_vcs_and_fire_entry_point() -> None:
|
|
|
17
17
|
assert config["project"]["name"] == "typekit2"
|
|
18
18
|
assert config["project"]["dynamic"] == ["version"]
|
|
19
19
|
assert config["project"]["scripts"] == {"typekit2": "typekit2.__main__:main"}
|
|
20
|
+
assert config["project"]["urls"]["Repository"] == (
|
|
21
|
+
"https://github.com/fontlaborg/typekit2-python"
|
|
22
|
+
)
|
|
20
23
|
assert config["tool"]["hatch"]["version"]["source"] == "vcs"
|
|
21
24
|
assert (
|
|
22
25
|
config["tool"]["hatch"]["build"]["hooks"]["vcs"]["version-file"]
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# this_file: tests/test_workflows.py
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from typekit2.exceptions import TypekitAPIError
|
|
10
|
+
from typekit2.workflows import kit_fonts, plan_remove_fonts, remove_fonts
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def family(family_id: str, slug: str, name: str | None = None) -> dict:
|
|
14
|
+
return {
|
|
15
|
+
"id": family_id,
|
|
16
|
+
"slug": slug,
|
|
17
|
+
"name": name or slug.replace("-", " ").title(),
|
|
18
|
+
"subset": "default",
|
|
19
|
+
"variations": ["n4"],
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FakeWorkflowClient:
|
|
24
|
+
def __init__(self, families: list[dict]) -> None:
|
|
25
|
+
self.families = deepcopy(families)
|
|
26
|
+
self.published = deepcopy(families)
|
|
27
|
+
self.remove_calls: list[tuple[str, str]] = []
|
|
28
|
+
self.publish_calls: list[tuple[str, float | None]] = []
|
|
29
|
+
self.remove_timeout_ids: set[str] = set()
|
|
30
|
+
self.publish_timeout = False
|
|
31
|
+
self.add_unexpected_family = False
|
|
32
|
+
|
|
33
|
+
def get_kit(self, kit_id: str, *, published: bool = False) -> dict:
|
|
34
|
+
selected = self.published if published else self.families
|
|
35
|
+
return {"kit": {"id": kit_id, "families": deepcopy(selected)}}
|
|
36
|
+
|
|
37
|
+
def get_published_kit(self, kit_id: str) -> dict:
|
|
38
|
+
return self.get_kit(kit_id, published=True)
|
|
39
|
+
|
|
40
|
+
def remove_font(self, kit_id: str, family_id: str) -> dict:
|
|
41
|
+
self.remove_calls.append((kit_id, family_id))
|
|
42
|
+
self.families = [item for item in self.families if item["id"] != family_id]
|
|
43
|
+
if self.add_unexpected_family:
|
|
44
|
+
self.families.append(family("unexpected", "unexpected-font"))
|
|
45
|
+
self.add_unexpected_family = False
|
|
46
|
+
if family_id in self.remove_timeout_ids:
|
|
47
|
+
raise TypekitAPIError("request timed out")
|
|
48
|
+
return {"ok": True}
|
|
49
|
+
|
|
50
|
+
def publish_kit(self, kit_id: str, *, timeout: float | None = None) -> dict:
|
|
51
|
+
self.publish_calls.append((kit_id, timeout))
|
|
52
|
+
self.published = deepcopy(self.families)
|
|
53
|
+
if self.publish_timeout:
|
|
54
|
+
raise TypekitAPIError("request timed out")
|
|
55
|
+
return {"published": "now"}
|
|
56
|
+
|
|
57
|
+
def request(self, method: str, path: str, **kwargs) -> dict:
|
|
58
|
+
return {"method": method, "path": path, **kwargs}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
HALYARD = [
|
|
62
|
+
family("ykgy", "halyard-display"),
|
|
63
|
+
family("hfhh", "halyard-micro"),
|
|
64
|
+
family("gvck", "halyard-text"),
|
|
65
|
+
family("bqky", "halyard-display-variable"),
|
|
66
|
+
family("nqbl", "halyard-micro-variable"),
|
|
67
|
+
family("cnlf", "halyard-text-variable"),
|
|
68
|
+
family("other", "other-font"),
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_kit_fonts_returns_compact_sorted_filtered_records() -> None:
|
|
73
|
+
client = FakeWorkflowClient(HALYARD)
|
|
74
|
+
|
|
75
|
+
result = kit_fonts(client, "kit123", matching="variable")
|
|
76
|
+
|
|
77
|
+
assert result["kit_id"] == "kit123"
|
|
78
|
+
assert result["family_count"] == 3
|
|
79
|
+
assert [item["slug"] for item in result["families"]] == [
|
|
80
|
+
"halyard-display-variable",
|
|
81
|
+
"halyard-micro-variable",
|
|
82
|
+
"halyard-text-variable",
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_plan_remove_fonts_resolves_slugs_ids_and_names_without_writing() -> None:
|
|
87
|
+
client = FakeWorkflowClient(HALYARD)
|
|
88
|
+
|
|
89
|
+
result = plan_remove_fonts(client, "kit123", ["halyard-display", "hfhh", "Halyard Text"])
|
|
90
|
+
|
|
91
|
+
assert result["status"] == "preview"
|
|
92
|
+
assert [item["id"] for item in result["remove"]] == ["ykgy", "hfhh", "gvck"]
|
|
93
|
+
assert result["before_family_count"] == 7
|
|
94
|
+
assert result["after_family_count"] == 4
|
|
95
|
+
assert client.remove_calls == []
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_remove_fonts_rejects_missing_identifier_before_writing() -> None:
|
|
99
|
+
client = FakeWorkflowClient(HALYARD)
|
|
100
|
+
|
|
101
|
+
with pytest.raises(ValueError, match="not present"):
|
|
102
|
+
remove_fonts(client, "kit123", ["halyard-display", "typo"])
|
|
103
|
+
|
|
104
|
+
assert client.remove_calls == []
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_remove_fonts_preserves_every_unrequested_family_and_publishes() -> None:
|
|
108
|
+
client = FakeWorkflowClient(HALYARD)
|
|
109
|
+
|
|
110
|
+
result = remove_fonts(
|
|
111
|
+
client,
|
|
112
|
+
"kit123",
|
|
113
|
+
["halyard-display", "halyard-micro", "halyard-text"],
|
|
114
|
+
publish=True,
|
|
115
|
+
publish_timeout=120,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
assert result["status"] == "removed"
|
|
119
|
+
assert result["before_family_count"] == 7
|
|
120
|
+
assert result["after_family_count"] == 4
|
|
121
|
+
assert result["unrelated_families_preserved"] is True
|
|
122
|
+
assert result["publish"] == {"status": "accepted", "response": {"published": "now"}}
|
|
123
|
+
assert client.publish_calls == [("kit123", 120)]
|
|
124
|
+
assert {item["slug"] for item in client.families} == {
|
|
125
|
+
"halyard-display-variable",
|
|
126
|
+
"halyard-micro-variable",
|
|
127
|
+
"halyard-text-variable",
|
|
128
|
+
"other-font",
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_remove_fonts_reconciles_delete_and_publish_timeouts() -> None:
|
|
133
|
+
client = FakeWorkflowClient(HALYARD)
|
|
134
|
+
client.remove_timeout_ids = {"ykgy"}
|
|
135
|
+
client.publish_timeout = True
|
|
136
|
+
|
|
137
|
+
result = remove_fonts(client, "kit123", ["ykgy"], publish=True)
|
|
138
|
+
|
|
139
|
+
assert result["removed"][0]["id"] == "ykgy"
|
|
140
|
+
assert result["publish"]["status"] == "confirmed-after-error"
|
|
141
|
+
assert "timed out" in result["publish"]["error"]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_remove_fonts_can_preview_or_ignore_already_absent_families() -> None:
|
|
145
|
+
client = FakeWorkflowClient(HALYARD)
|
|
146
|
+
|
|
147
|
+
preview = remove_fonts(client, "kit123", ["halyard-display"], dry_run=True)
|
|
148
|
+
absent = remove_fonts(client, "kit123", ["missing"], ignore_missing=True)
|
|
149
|
+
|
|
150
|
+
assert preview["status"] == "preview"
|
|
151
|
+
assert absent["status"] == "no-changes"
|
|
152
|
+
assert absent["missing"] == ["missing"]
|
|
153
|
+
assert client.remove_calls == []
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_remove_fonts_fails_if_any_unrequested_family_changes() -> None:
|
|
157
|
+
client = FakeWorkflowClient(HALYARD)
|
|
158
|
+
client.add_unexpected_family = True
|
|
159
|
+
|
|
160
|
+
with pytest.raises(TypekitAPIError, match="unexpected kit changes") as error:
|
|
161
|
+
remove_fonts(client, "kit123", ["halyard-display"])
|
|
162
|
+
|
|
163
|
+
assert error.value.errors[0]["unexpectedly_added"] == ["unexpected"]
|
|
@@ -11,7 +11,8 @@ from typing import Any
|
|
|
11
11
|
import fire
|
|
12
12
|
from dotenv import load_dotenv
|
|
13
13
|
|
|
14
|
-
from .client import Typekit
|
|
14
|
+
from .client import DEFAULT_TIMEOUT, Typekit
|
|
15
|
+
from .workflows import kit_fonts, plan_remove_fonts, remove_fonts
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
def parse_csv(value: str | Sequence[str]) -> list[str]:
|
|
@@ -36,10 +37,16 @@ def parse_families(value: str | list[dict[str, Any]] | None) -> list[dict[str, A
|
|
|
36
37
|
class TypekitCLI:
|
|
37
38
|
"""Read and manage Adobe Fonts kits and font metadata."""
|
|
38
39
|
|
|
40
|
+
def __init__(self, timeout: float = DEFAULT_TIMEOUT) -> None:
|
|
41
|
+
"""Configure the default HTTP timeout in seconds."""
|
|
42
|
+
if timeout <= 0:
|
|
43
|
+
raise ValueError("timeout must be greater than zero")
|
|
44
|
+
self._timeout = timeout
|
|
45
|
+
|
|
39
46
|
@property
|
|
40
|
-
def
|
|
47
|
+
def _client(self) -> Typekit:
|
|
41
48
|
"""Create the API client lazily so help and doctor work without credentials."""
|
|
42
|
-
return Typekit()
|
|
49
|
+
return Typekit(timeout=self._timeout)
|
|
43
50
|
|
|
44
51
|
def doctor(self) -> dict[str, str]:
|
|
45
52
|
"""Check whether TYPEKIT_API_KEY is available without revealing it."""
|
|
@@ -52,27 +59,27 @@ class TypekitCLI:
|
|
|
52
59
|
|
|
53
60
|
def kits(self) -> list[dict[str, Any]]:
|
|
54
61
|
"""List kits owned by the authenticated user."""
|
|
55
|
-
return self.
|
|
62
|
+
return self._client.list_kits()
|
|
56
63
|
|
|
57
64
|
def kit(self, kit_id: str, published: bool = False) -> dict[str, Any]:
|
|
58
65
|
"""Get a draft kit or its published version."""
|
|
59
|
-
return self.
|
|
66
|
+
return self._client.get_kit(kit_id, published=published)
|
|
60
67
|
|
|
61
68
|
def family(self, family: str) -> dict[str, Any]:
|
|
62
69
|
"""Get a font family by ID or slug."""
|
|
63
|
-
return self.
|
|
70
|
+
return self._client.get_font_family(family)
|
|
64
71
|
|
|
65
72
|
def variations(self, family: str) -> list[str]:
|
|
66
73
|
"""List FVD variation codes for a font family."""
|
|
67
|
-
return self.
|
|
74
|
+
return self._client.get_font_variations(family)
|
|
68
75
|
|
|
69
76
|
def libraries(self) -> list[dict[str, Any]]:
|
|
70
77
|
"""List font libraries."""
|
|
71
|
-
return self.
|
|
78
|
+
return self._client.list_libraries()
|
|
72
79
|
|
|
73
80
|
def library(self, library: str, page: int = 1, per_page: int = 100) -> dict[str, Any]:
|
|
74
81
|
"""Get one paginated font library."""
|
|
75
|
-
return self.
|
|
82
|
+
return self._client.get_library(library, page=page, per_page=per_page)
|
|
76
83
|
|
|
77
84
|
def create_kit(
|
|
78
85
|
self,
|
|
@@ -82,7 +89,7 @@ class TypekitCLI:
|
|
|
82
89
|
segmented_css_names: bool | None = None,
|
|
83
90
|
) -> dict[str, Any]:
|
|
84
91
|
"""Create a draft kit; domains are CSV and families are a JSON list."""
|
|
85
|
-
return self.
|
|
92
|
+
return self._client.create_kit(
|
|
86
93
|
name,
|
|
87
94
|
parse_csv(domains),
|
|
88
95
|
parse_families(families),
|
|
@@ -98,7 +105,7 @@ class TypekitCLI:
|
|
|
98
105
|
segmented_css_names: bool | None = None,
|
|
99
106
|
) -> dict[str, Any]:
|
|
100
107
|
"""Update supplied draft-kit fields."""
|
|
101
|
-
return self.
|
|
108
|
+
return self._client.update_kit(
|
|
102
109
|
kit_id,
|
|
103
110
|
name=name,
|
|
104
111
|
domains=parse_csv(domains) if domains is not None else None,
|
|
@@ -108,11 +115,11 @@ class TypekitCLI:
|
|
|
108
115
|
|
|
109
116
|
def remove_kit(self, kit_id: str) -> dict[str, Any]:
|
|
110
117
|
"""Delete a kit. This is a live destructive action."""
|
|
111
|
-
return self.
|
|
118
|
+
return self._client.remove_kit(kit_id)
|
|
112
119
|
|
|
113
|
-
def publish_kit(self, kit_id: str) -> dict[str, Any]:
|
|
120
|
+
def publish_kit(self, kit_id: str, timeout: float = 120.0) -> dict[str, Any]:
|
|
114
121
|
"""Publish the current draft kit to the CDN."""
|
|
115
|
-
return self.
|
|
122
|
+
return self._client.publish_kit(kit_id, timeout=timeout)
|
|
116
123
|
|
|
117
124
|
def add_font(
|
|
118
125
|
self,
|
|
@@ -123,11 +130,51 @@ class TypekitCLI:
|
|
|
123
130
|
) -> dict[str, Any]:
|
|
124
131
|
"""Add or replace one font family in a draft kit."""
|
|
125
132
|
parsed = parse_csv(variations) if variations is not None else None
|
|
126
|
-
return self.
|
|
133
|
+
return self._client.add_font(kit_id, family, parsed, subset)
|
|
127
134
|
|
|
128
135
|
def remove_font(self, kit_id: str, family: str) -> dict[str, Any]:
|
|
129
136
|
"""Remove one font family from a draft kit."""
|
|
130
|
-
return self.
|
|
137
|
+
return self._client.remove_font(kit_id, family)
|
|
138
|
+
|
|
139
|
+
def kit_fonts(
|
|
140
|
+
self,
|
|
141
|
+
kit_id: str,
|
|
142
|
+
matching: str | None = None,
|
|
143
|
+
published: bool = False,
|
|
144
|
+
) -> dict[str, Any]:
|
|
145
|
+
"""List concise kit families, optionally filtered by ID, slug, or name."""
|
|
146
|
+
return kit_fonts(self._client, kit_id, matching=matching, published=published)
|
|
147
|
+
|
|
148
|
+
def plan_remove_fonts(self, kit_id: str, families: str) -> dict[str, Any]:
|
|
149
|
+
"""Preview a batch removal using comma-separated IDs, slugs, or names."""
|
|
150
|
+
return plan_remove_fonts(self._client, kit_id, parse_csv(families))
|
|
151
|
+
|
|
152
|
+
def remove_fonts(
|
|
153
|
+
self,
|
|
154
|
+
kit_id: str,
|
|
155
|
+
families: str,
|
|
156
|
+
publish: bool = False,
|
|
157
|
+
dry_run: bool = False,
|
|
158
|
+
ignore_missing: bool = False,
|
|
159
|
+
publish_timeout: float = 120.0,
|
|
160
|
+
) -> dict[str, Any]:
|
|
161
|
+
"""Remove several families, preserve all others, and optionally publish."""
|
|
162
|
+
return remove_fonts(
|
|
163
|
+
self._client,
|
|
164
|
+
kit_id,
|
|
165
|
+
parse_csv(families),
|
|
166
|
+
publish=publish,
|
|
167
|
+
dry_run=dry_run,
|
|
168
|
+
ignore_missing=ignore_missing,
|
|
169
|
+
publish_timeout=publish_timeout,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def request_get(self, path: str, params: str | None = None) -> dict[str, Any]:
|
|
173
|
+
"""Make an authenticated raw GET; params is an optional JSON object."""
|
|
174
|
+
parsed = json.loads(params) if params else None
|
|
175
|
+
if parsed is not None and not isinstance(parsed, dict):
|
|
176
|
+
raise ValueError("params must be a JSON object")
|
|
177
|
+
return self._client.request("GET", path, params=parsed)
|
|
131
178
|
|
|
132
179
|
|
|
133
180
|
def _serialize(value: Any) -> str:
|
|
@@ -137,7 +184,7 @@ def _serialize(value: Any) -> str:
|
|
|
137
184
|
|
|
138
185
|
def main() -> None:
|
|
139
186
|
"""Run the typekit2 CLI."""
|
|
140
|
-
fire.Fire(TypekitCLI, name="typekit2", serialize=_serialize)
|
|
187
|
+
fire.Fire(TypekitCLI(), name="typekit2", serialize=_serialize)
|
|
141
188
|
|
|
142
189
|
|
|
143
190
|
if __name__ == "__main__":
|
|
@@ -98,14 +98,18 @@ class Typekit:
|
|
|
98
98
|
*,
|
|
99
99
|
data: FormData | None = None,
|
|
100
100
|
params: Mapping[str, Any] | None = None,
|
|
101
|
+
timeout: float | None = None,
|
|
101
102
|
) -> dict[str, Any]:
|
|
102
103
|
"""Make an authenticated request and return the decoded JSON object."""
|
|
104
|
+
request_timeout = self.timeout if timeout is None else timeout
|
|
105
|
+
if request_timeout <= 0:
|
|
106
|
+
raise ValueError("timeout must be greater than zero")
|
|
103
107
|
kwargs: dict[str, Any] = {
|
|
104
108
|
"headers": {
|
|
105
109
|
"User-Agent": f"typekit2/{__version__}",
|
|
106
110
|
"X-Typekit-Token": self.api_key,
|
|
107
111
|
},
|
|
108
|
-
"timeout":
|
|
112
|
+
"timeout": request_timeout,
|
|
109
113
|
}
|
|
110
114
|
if data is not None:
|
|
111
115
|
kwargs["data"] = data
|
|
@@ -184,9 +188,9 @@ class Typekit:
|
|
|
184
188
|
"""Delete a kit."""
|
|
185
189
|
return self.request("DELETE", f"kits/{quote(kit_id, safe='')}")
|
|
186
190
|
|
|
187
|
-
def publish_kit(self, kit_id: str) -> dict[str, Any]:
|
|
191
|
+
def publish_kit(self, kit_id: str, *, timeout: float | None = None) -> dict[str, Any]:
|
|
188
192
|
"""Publish the current draft kit asynchronously."""
|
|
189
|
-
return self.request("POST", f"kits/{quote(kit_id, safe='')}/publish")
|
|
193
|
+
return self.request("POST", f"kits/{quote(kit_id, safe='')}/publish", timeout=timeout)
|
|
190
194
|
|
|
191
195
|
def get_font_family(self, family: str) -> dict[str, Any]:
|
|
192
196
|
"""Return a font family by ID or slug."""
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# this_file: typekit2/workflows.py
|
|
2
|
+
"""Safe, composable workflows built on the Adobe Fonts API client."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .exceptions import TypekitAPIError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _load_families(client: Any, kit_id: str, *, published: bool = False) -> list[dict[str, Any]]:
|
|
13
|
+
payload = client.get_published_kit(kit_id) if published else client.get_kit(kit_id)
|
|
14
|
+
families = payload.get("kit", {}).get("families")
|
|
15
|
+
if not isinstance(families, list) or not all(isinstance(item, dict) for item in families):
|
|
16
|
+
raise TypekitAPIError("Adobe Fonts returned an invalid kit family list")
|
|
17
|
+
return families
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _compact(family: dict[str, Any]) -> dict[str, Any]:
|
|
21
|
+
return {
|
|
22
|
+
key: family[key] for key in ("id", "slug", "name", "subset", "variations") if key in family
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _identifiers(values: str | Sequence[str]) -> list[str]:
|
|
27
|
+
items = values.split(",") if isinstance(values, str) else list(values)
|
|
28
|
+
parsed = [str(item).strip() for item in items if str(item).strip()]
|
|
29
|
+
if not parsed:
|
|
30
|
+
raise ValueError("families must contain at least one ID, slug, or name")
|
|
31
|
+
return parsed
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _resolve(
|
|
35
|
+
families: list[dict[str, Any]], identifiers: list[str]
|
|
36
|
+
) -> tuple[list[dict[str, Any]], list[str]]:
|
|
37
|
+
lookup: dict[str, dict[str, dict[str, Any]]] = {}
|
|
38
|
+
for family in families:
|
|
39
|
+
for field in ("id", "slug", "name"):
|
|
40
|
+
value = family.get(field)
|
|
41
|
+
if isinstance(value, str):
|
|
42
|
+
lookup.setdefault(value.casefold(), {})[str(family.get("id"))] = family
|
|
43
|
+
resolved: list[dict[str, Any]] = []
|
|
44
|
+
missing: list[str] = []
|
|
45
|
+
seen: set[str] = set()
|
|
46
|
+
for identifier in identifiers:
|
|
47
|
+
matches = list(lookup.get(identifier.casefold(), {}).values())
|
|
48
|
+
if not matches:
|
|
49
|
+
missing.append(identifier)
|
|
50
|
+
continue
|
|
51
|
+
if len(matches) > 1:
|
|
52
|
+
raise ValueError(f"family identifier is ambiguous: {identifier}")
|
|
53
|
+
family = matches[0]
|
|
54
|
+
family_id = str(family["id"])
|
|
55
|
+
if family_id not in seen:
|
|
56
|
+
resolved.append(family)
|
|
57
|
+
seen.add(family_id)
|
|
58
|
+
return resolved, missing
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _plan(
|
|
62
|
+
kit_id: str, families: list[dict[str, Any]], identifiers: list[str]
|
|
63
|
+
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
64
|
+
resolved, missing = _resolve(families, identifiers)
|
|
65
|
+
plan = {
|
|
66
|
+
"status": "preview",
|
|
67
|
+
"kit_id": kit_id,
|
|
68
|
+
"requested": identifiers,
|
|
69
|
+
"remove": [_compact(family) for family in resolved],
|
|
70
|
+
"missing": missing,
|
|
71
|
+
"before_family_count": len(families),
|
|
72
|
+
"after_family_count": len(families) - len(resolved),
|
|
73
|
+
}
|
|
74
|
+
return plan, resolved
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def kit_fonts(
|
|
78
|
+
client: Any,
|
|
79
|
+
kit_id: str,
|
|
80
|
+
*,
|
|
81
|
+
matching: str | None = None,
|
|
82
|
+
published: bool = False,
|
|
83
|
+
) -> dict[str, Any]:
|
|
84
|
+
"""Return a concise, optionally filtered family inventory for one kit."""
|
|
85
|
+
families = _load_families(client, kit_id, published=published)
|
|
86
|
+
if matching:
|
|
87
|
+
needle = matching.casefold()
|
|
88
|
+
families = [
|
|
89
|
+
family
|
|
90
|
+
for family in families
|
|
91
|
+
if any(
|
|
92
|
+
needle in str(family.get(field, "")).casefold() for field in ("id", "slug", "name")
|
|
93
|
+
)
|
|
94
|
+
]
|
|
95
|
+
compact = sorted(
|
|
96
|
+
(_compact(family) for family in families), key=lambda item: item.get("slug", "")
|
|
97
|
+
)
|
|
98
|
+
return {
|
|
99
|
+
"kit_id": kit_id,
|
|
100
|
+
"published": published,
|
|
101
|
+
"matching": matching,
|
|
102
|
+
"family_count": len(compact),
|
|
103
|
+
"families": compact,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def plan_remove_fonts(client: Any, kit_id: str, families: str | Sequence[str]) -> dict[str, Any]:
|
|
108
|
+
"""Resolve a batch removal by ID, slug, or name without changing the kit."""
|
|
109
|
+
current = _load_families(client, kit_id)
|
|
110
|
+
plan, _ = _plan(kit_id, current, _identifiers(families))
|
|
111
|
+
return plan
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _verify_removal(
|
|
115
|
+
before: list[dict[str, Any]],
|
|
116
|
+
after: list[dict[str, Any]],
|
|
117
|
+
removed: list[dict[str, Any]],
|
|
118
|
+
) -> None:
|
|
119
|
+
before_ids = {str(family["id"]) for family in before}
|
|
120
|
+
removed_ids = {str(family["id"]) for family in removed}
|
|
121
|
+
after_ids = {str(family["id"]) for family in after}
|
|
122
|
+
expected_ids = before_ids - removed_ids
|
|
123
|
+
if after_ids != expected_ids:
|
|
124
|
+
raise TypekitAPIError(
|
|
125
|
+
"Post-removal verification found unexpected kit changes",
|
|
126
|
+
errors=[
|
|
127
|
+
{
|
|
128
|
+
"still_present": sorted(removed_ids & after_ids),
|
|
129
|
+
"unexpectedly_removed": sorted(expected_ids - after_ids),
|
|
130
|
+
"unexpectedly_added": sorted(after_ids - expected_ids),
|
|
131
|
+
}
|
|
132
|
+
],
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _publish_verified(
|
|
137
|
+
client: Any, kit_id: str, after: list[dict[str, Any]], timeout: float
|
|
138
|
+
) -> dict[str, Any]:
|
|
139
|
+
try:
|
|
140
|
+
response = client.publish_kit(kit_id, timeout=timeout)
|
|
141
|
+
except TypekitAPIError as error:
|
|
142
|
+
published = _load_families(client, kit_id, published=True)
|
|
143
|
+
expected_ids = {str(family["id"]) for family in after}
|
|
144
|
+
published_ids = {str(family["id"]) for family in published}
|
|
145
|
+
if published_ids == expected_ids:
|
|
146
|
+
return {"status": "confirmed-after-error", "error": str(error)}
|
|
147
|
+
raise TypekitAPIError(
|
|
148
|
+
"Publish outcome is indeterminate; the published kit does not match the verified draft",
|
|
149
|
+
errors=[{"original_error": str(error)}],
|
|
150
|
+
) from error
|
|
151
|
+
return {"status": "accepted", "response": response}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def remove_fonts(
|
|
155
|
+
client: Any,
|
|
156
|
+
kit_id: str,
|
|
157
|
+
families: str | Sequence[str],
|
|
158
|
+
*,
|
|
159
|
+
publish: bool = False,
|
|
160
|
+
dry_run: bool = False,
|
|
161
|
+
ignore_missing: bool = False,
|
|
162
|
+
publish_timeout: float = 120.0,
|
|
163
|
+
) -> dict[str, Any]:
|
|
164
|
+
"""Remove several families while proving all unrequested families remain."""
|
|
165
|
+
before = _load_families(client, kit_id)
|
|
166
|
+
plan, resolved = _plan(kit_id, before, _identifiers(families))
|
|
167
|
+
if plan["missing"] and not ignore_missing:
|
|
168
|
+
missing = ", ".join(plan["missing"])
|
|
169
|
+
raise ValueError(f"families are not present in kit {kit_id}: {missing}")
|
|
170
|
+
if dry_run:
|
|
171
|
+
return plan
|
|
172
|
+
if not resolved:
|
|
173
|
+
return {**plan, "status": "no-changes", "publish": {"status": "skipped"}}
|
|
174
|
+
for family in resolved:
|
|
175
|
+
try:
|
|
176
|
+
client.remove_font(kit_id, str(family["id"]))
|
|
177
|
+
except TypekitAPIError:
|
|
178
|
+
current_ids = {str(item["id"]) for item in _load_families(client, kit_id)}
|
|
179
|
+
if str(family["id"]) in current_ids:
|
|
180
|
+
raise
|
|
181
|
+
after = _load_families(client, kit_id)
|
|
182
|
+
_verify_removal(before, after, resolved)
|
|
183
|
+
result = {
|
|
184
|
+
**plan,
|
|
185
|
+
"status": "removed",
|
|
186
|
+
"removed": [_compact(family) for family in resolved],
|
|
187
|
+
"after_family_count": len(after),
|
|
188
|
+
"unrelated_families_preserved": True,
|
|
189
|
+
"publish": {"status": "skipped"},
|
|
190
|
+
}
|
|
191
|
+
if publish:
|
|
192
|
+
result["publish"] = _publish_verified(client, kit_id, after, publish_timeout)
|
|
193
|
+
return result
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|