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.
@@ -6,6 +6,7 @@ __pycache__/
6
6
  .env
7
7
  .venv/
8
8
  .codegraph/
9
+ .omc/
9
10
  typekit2/__version__.py
10
11
 
11
12
  # C extensions
@@ -59,3 +60,4 @@ coverage.xml
59
60
 
60
61
  # Sphinx documentation
61
62
  docs/_build/
63
+ /.omc
@@ -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: 1.0.0
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/typekit-python
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
- Mutating commands are explicit:
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
- Mutating commands are explicit:
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: 31 tests passed with 95.67% branch-aware coverage.
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/typekit-python"
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, "client", property(lambda _: fake))
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 client(self) -> Typekit:
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.client.list_kits()
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.client.get_kit(kit_id, published=published)
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.client.get_font_family(family)
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.client.get_font_variations(family)
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.client.list_libraries()
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.client.get_library(library, page=page, per_page=per_page)
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.client.create_kit(
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.client.update_kit(
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.client.remove_kit(kit_id)
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.client.publish_kit(kit_id)
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.client.add_font(kit_id, family, parsed, subset)
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.client.remove_font(kit_id, family)
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__":
@@ -1,3 +1,3 @@
1
1
  # this_file: typekit2/__version__.py
2
2
  # Generated by hatch-vcs. Do not edit or commit.
3
- __version__ = version = '1.0.0'
3
+ __version__ = version = '2.0.1'
@@ -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": self.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