Share the checks and the job builders of the orchestrator

One function checks a whole number of a request, and one checks the
metadata field names. save_settings takes the settings by name. A rebuild
job and a story from an epub each have one builder.
This commit is contained in:
tim
2026-10-03 08:09:41 +02:00
parent 89709f875a
commit 2ada3b3d7e
3 changed files with 98 additions and 95 deletions
+82 -80
View File
@@ -66,6 +66,25 @@ def default_locked_fields() -> list[str]:
return [f for f in config.DEFAULT_LOCKED_FIELDS if f in METADATA_FIELDS] return [f for f in config.DEFAULT_LOCKED_FIELDS if f in METADATA_FIELDS]
def whole_number(value: Any, name: str, most: int | None = None, negative: bool = False) -> int:
"""A value of a request as a whole number. A wrong value is an error of the request."""
try:
number = int(value)
except (TypeError, ValueError):
raise ValueError(f"{name} must be a whole number") from None
if number < 0 and not negative:
raise ValueError(f"{name} cannot be negative")
if most is not None and number > most:
raise ValueError(f"{name} cannot be more than {most}")
return number
def _check_fields(fields: Any) -> None:
bad = [f for f in fields if f not in METADATA_FIELDS]
if bad:
raise ValueError(f"Unknown metadata fields: {bad}")
# Queueing # Queueing
@@ -104,9 +123,7 @@ def queue_story(
raise ValueError("Use the library import for this mode") raise ValueError("Use the library import for this mode")
if mode_enum == DownloadMode.RECHECK and not chapter_ids: if mode_enum == DownloadMode.RECHECK and not chapter_ids:
raise ValueError("Select at least one chapter to recheck") raise ValueError("Select at least one chapter to recheck")
bad = [f for f in force_fields or [] if f not in METADATA_FIELDS] _check_fields(force_fields or [])
if bad:
raise ValueError(f"Unknown metadata fields: {bad}")
job = Job( job = Job(
mode=mode_enum, mode=mode_enum,
story_id=story_id, story_id=story_id,
@@ -118,6 +135,10 @@ def queue_story(
return job_queue.add(job).to_dict() return job_queue.add(job).to_dict()
def _queue_rebuild(story: dict[str, Any]) -> dict[str, Any]:
return job_queue.add(Job(mode=DownloadMode.REBUILD, story_id=story["id"], title=story["title"])).to_dict()
def queue_import(books: list[dict[str, Any]], check: bool = True) -> list[dict[str, Any]]: def queue_import(books: list[dict[str, Any]], check: bool = True) -> list[dict[str, Any]]:
"""Queue imports of library books, given as {"id": ..., "uuid": ..., "title": ...}.""" """Queue imports of library books, given as {"id": ..., "uuid": ..., "title": ...}."""
jobs = [] jobs = []
@@ -136,10 +157,15 @@ def queue_import(books: list[dict[str, Any]], check: bool = True) -> list[dict[s
return jobs return jobs
def _epub_story_url(path: Path) -> tuple[str, str] | None:
"""The story URL and the site of an epub from FanFicFare, or None."""
return next((n for n in map(fff.normalize_url, epub_urls(path)) if n), None)
def queue_epub_file(path: Path, filename: str, story_id: int | None = None, check: bool = True) -> dict[str, Any]: def queue_epub_file(path: Path, filename: str, story_id: int | None = None, check: bool = True) -> dict[str, Any]:
"""Queue an import of an uploaded epub. A file without a story URL, or of another story, fails at once.""" """Queue an import of an uploaded epub. A file without a story URL, or of another story, fails at once."""
try: try:
normal = next((n for n in map(fff.normalize_url, epub_urls(path)) if n), None) normal = _epub_story_url(path)
if not normal: if not normal:
raise StoryError(f"{filename} has no supported story URL. Only epubs from FanFicFare can be imported.") raise StoryError(f"{filename} has no supported story URL. Only epubs from FanFicFare can be imported.")
story = store.get_story_by_url(normal[0]) story = store.get_story_by_url(normal[0])
@@ -266,15 +292,11 @@ def update_story_settings(
"""Change the settings that are given. A recheck_days of 0 turns the full rechecks off.""" """Change the settings that are given. A recheck_days of 0 turns the full rechecks off."""
story = _require_story(story_id) story = _require_story(story_id)
if recheck_days is not None: if recheck_days is not None:
days = int(recheck_days) days = whole_number(recheck_days, "The number of days")
if days < 0:
raise ValueError("The number of days cannot be negative")
store.set_setting(story_id, "recheck_days", days or None) store.set_setting(story_id, "recheck_days", days or None)
changes: dict[str, Any] = {} changes: dict[str, Any] = {}
if locked_fields is not None: if locked_fields is not None:
bad = [f for f in locked_fields if f not in METADATA_FIELDS] _check_fields(locked_fields)
if bad:
raise ValueError(f"Unknown metadata fields: {bad}")
changes["locked_fields"] = sorted(set(locked_fields)) changes["locked_fields"] = sorted(set(locked_fields))
# An unlocked field takes the site value again. # An unlocked field takes the site value again.
unlocked = set(story["locked_fields"]) - set(locked_fields) unlocked = set(story["locked_fields"]) - set(locked_fields)
@@ -317,20 +339,18 @@ def edit_metadata(story_id: int, values: dict[str, Any]) -> dict[str, Any]:
build uses the values. build uses the values.
""" """
story = _require_story(story_id) story = _require_story(story_id)
bad = [f for f in values if f not in METADATA_FIELDS] _check_fields(values)
if bad:
raise ValueError(f"Unknown metadata fields: {bad}")
pinned = dict(story["pinned"]) pinned = dict(story["pinned"])
for field, value in values.items(): for field, value in values.items():
if value is None: if value is None:
pinned.pop(field, None) pinned.pop(field, None)
else: else:
pinned[field] = _edited_value(field, value) pinned[field] = _edited_value(field, value)
title = _display_title(story, pinned, story["site_values"].get("title") or story["title"]) title = _display_title(pinned, story["site_values"].get("title") or story["title"])
store.update_story(story_id, pinned=pinned, title=title) store.update_story(story_id, pinned=pinned, title=title)
queued = None queued = None
if story["metahtml"] and (story["calibre_id"] or story["ingested_at"]): if story["metahtml"] and (story["calibre_id"] or story["ingested_at"]):
queued = job_queue.add(Job(mode=DownloadMode.REBUILD, story_id=story_id, title=title)).to_dict() queued = _queue_rebuild({**story, "title": title})
return {"story": story_view(story_id), "job": queued} return {"story": story_view(story_id), "job": queued}
@@ -364,58 +384,38 @@ def get_settings() -> dict[str, Any]:
} }
def _validate_job_list_value(value: Any, name: str, maximum: int) -> int: # The settings that save_settings takes.
try: SETTINGS = ("recent_chapters", "personal_ini", "link_books", "hold_new_links", "pause_uploads", *JOB_LIST_DEFAULTS)
n = int(value)
except (TypeError, ValueError) as e:
raise ValueError(f"{name} must be a whole number") from e
if n < 0:
raise ValueError(f"{name} cannot be negative")
if n > maximum:
raise ValueError(f"{name} cannot be more than {maximum}")
return n
def save_settings( def save_settings(**changes: Any) -> dict[str, Any]:
recent_chapters: Any = None, """Change the settings that are given, by the names in SETTINGS. A value of None changes nothing.
personal_ini: str | None = None,
hold_new_links: bool | None = None,
pause_uploads: bool | None = None,
story_jobs_hours: Any = None,
story_jobs_count: Any = None,
main_jobs_hours: Any = None,
main_jobs_count: Any = None,
link_books: bool | None = None,
) -> dict[str, Any]:
"""Change the settings that are given. Return all settings, and FanFicFare's warnings about personal.ini.
Every value is checked before the first one is stored, so a bad value changes nothing. Returns all settings, and FanFicFare's warnings about personal.ini. Every value is checked before the first
one is stored, so a bad value changes nothing.
""" """
unknown = set(changes) - set(SETTINGS)
if unknown:
raise ValueError(f"Unknown settings: {sorted(unknown)}")
changes = {name: value for name, value in changes.items() if value is not None}
link_books, personal_ini = changes.get("link_books"), changes.get("personal_ini")
if link_books is not None and not isinstance(link_books, bool): if link_books is not None and not isinstance(link_books, bool):
raise ValueError("Link books to CWA library must be true or false") raise ValueError("Link books to CWA library must be true or false")
numbers: dict[str, int] = {} numbers: dict[str, int] = {}
if recent_chapters is not None: if "recent_chapters" in changes:
try: numbers["recent_chapters"] = whole_number(changes["recent_chapters"], "The number of recent chapters")
numbers["recent_chapters"] = int(recent_chapters) for name in JOB_LIST_DEFAULTS:
except (TypeError, ValueError) as e: if name in changes:
raise ValueError("The number of recent chapters must be a whole number") from e most = config.JOB_LIST_MAX_HOURS if name.endswith("hours") else config.JOB_LIST_MAX_COUNT
if numbers["recent_chapters"] < 0: numbers[name] = whole_number(changes[name], name, most)
raise ValueError("The number of recent chapters cannot be negative")
for name, value, maximum in (
("story_jobs_hours", story_jobs_hours, config.JOB_LIST_MAX_HOURS),
("story_jobs_count", story_jobs_count, config.JOB_LIST_MAX_COUNT),
("main_jobs_hours", main_jobs_hours, config.JOB_LIST_MAX_HOURS),
("main_jobs_count", main_jobs_count, config.JOB_LIST_MAX_COUNT),
):
if value is not None:
numbers[name] = _validate_job_list_value(value, name, maximum)
warnings = fff.check_personal_ini(personal_ini) if personal_ini is not None else [] warnings = fff.check_personal_ini(personal_ini) if personal_ini is not None else []
released = False released = False
if link_books is not None and _linking_available(): if link_books is not None and _linking_available():
released = _link_books() != link_books released = _link_books() != link_books
store.set_setting(None, "link_books", link_books) store.set_setting(None, "link_books", link_books)
for name, value in (("hold_new_links", hold_new_links), ("pause_uploads", pause_uploads)): for name in ("hold_new_links", "pause_uploads"):
value = changes.get(name)
if value is None or (name == "pause_uploads" and config.INGEST_DIR is None): if value is None or (name == "pause_uploads" and config.INGEST_DIR is None):
continue continue
released = released or (bool(store.get_setting(None, name)) and not value) released = released or (bool(store.get_setting(None, name)) and not value)
@@ -508,7 +508,7 @@ def set_chapter_revision(chapter_id: int, rev: int) -> dict[str, Any]:
if not chapter or store.revision_html(chapter_id, rev) is None: if not chapter or store.revision_html(chapter_id, rev) is None:
raise KeyError("No such chapter revision") raise KeyError("No such chapter revision")
store.update_chapter(chapter_id, current_rev=rev, changed_at=now()) store.update_chapter(chapter_id, current_rev=rev, changed_at=now())
return queue_story(chapter["story_id"], DownloadMode.REBUILD.value) return _queue_rebuild(_require_story(chapter["story_id"]))
def delete_revision(chapter_id: int, rev: int) -> list[dict[str, Any]]: def delete_revision(chapter_id: int, rev: int) -> list[dict[str, Any]]:
@@ -532,7 +532,7 @@ def set_chapters_included(story_id: int, chapter_ids: list[int], included: bool)
chapter = store.get_chapter(cid) chapter = store.get_chapter(cid)
if chapter and chapter["story_id"] == story_id: if chapter and chapter["story_id"] == story_id:
store.update_chapter(cid, included=int(included)) store.update_chapter(cid, included=int(included))
return queue_story(story_id, DownloadMode.REBUILD.value) return _queue_rebuild(_require_story(story_id))
def move_chapters(story_id: int, chapter_ids: list[int], after_id: int | None) -> dict[str, Any]: def move_chapters(story_id: int, chapter_ids: list[int], after_id: int | None) -> dict[str, Any]:
@@ -551,7 +551,7 @@ def move_chapters(story_id: int, chapter_ids: list[int], after_id: int | None) -
return rest[:at] + moving + rest[at:] return rest[:at] + moving + rest[at:]
store.reorder_chapters(story_id, arrange) store.reorder_chapters(story_id, arrange)
return queue_story(story_id, DownloadMode.REBUILD.value) return _queue_rebuild(_require_story(story_id))
def delete_chapters(story_id: int, chapter_ids: list[int]) -> dict[str, Any]: def delete_chapters(story_id: int, chapter_ids: list[int]) -> dict[str, Any]:
@@ -563,7 +563,7 @@ def delete_chapters(story_id: int, chapter_ids: list[int]) -> dict[str, Any]:
if len(deleting) == len(order): if len(deleting) == len(order):
raise ValueError("A story must keep at least one chapter. To remove the story, stop tracking it") raise ValueError("A story must keep at least one chapter. To remove the story, stop tracking it")
store.delete_chapters(story_id, deleting) store.delete_chapters(story_id, deleting)
return queue_story(story_id, DownloadMode.REBUILD.value) return _queue_rebuild(_require_story(story_id))
def link_story(story_id: int, book_uuid: str | None) -> None: def link_story(story_id: int, book_uuid: str | None) -> None:
@@ -596,7 +596,7 @@ def approve_upload(story_id: int, book_uuid: str | None) -> dict[str, Any] | Non
raise ValueError("The library link of this story changed. Reload the page, and check the link again.") raise ValueError("The library link of this story changed. Reload the page, and check the link again.")
store.set_setting(story_id, "unapproved_link", None) store.set_setting(story_id, "unapproved_link", None)
if _can_upload() and store.get_setting(story_id, "upload_held") and story["metahtml"]: if _can_upload() and store.get_setting(story_id, "upload_held") and story["metahtml"]:
return job_queue.add(Job(mode=DownloadMode.REBUILD, story_id=story_id, title=story["title"])).to_dict() return _queue_rebuild(story)
return None return None
@@ -627,7 +627,7 @@ def build_for_download(story_id: int) -> tuple[BinaryIO, str]:
"""The epub of a story for a browser download, as an open file. Bindery builds it only if the book changed """The epub of a story for a browser download, as an open file. Bindery builds it only if the book changed
after the last build.""" after the last build."""
story = _require_story(story_id) story = _require_story(story_id)
overrides = _epub_overrides(story, story["pinned"]) overrides = _epub_overrides(story["pinned"])
key = _build_key(story, overrides, story["site_values"]) key = _build_key(story, overrides, story["site_values"])
# The file is opened under the lock, so a new build cannot delete it first. # The file is opened under the lock, so a new build cannot delete it first.
with _build_lock: with _build_lock:
@@ -664,11 +664,11 @@ def _resolve_metadata(story: dict[str, Any], site: dict[str, Any], force_fields:
return pinned return pinned
def _epub_overrides(story: dict[str, Any], pinned: dict[str, Any]) -> dict[str, Any]: def _epub_overrides(pinned: dict[str, Any]) -> dict[str, Any]:
return {f: v for f, v in pinned.items() if f in METADATA_FIELDS} return {f: v for f, v in pinned.items() if f in METADATA_FIELDS}
def _display_title(story: dict[str, Any], pinned: dict[str, Any], site_title: str) -> str: def _display_title(pinned: dict[str, Any], site_title: str) -> str:
return pinned.get("title") or site_title return pinned.get("title") or site_title
@@ -864,7 +864,7 @@ def _release_held_uploads() -> None:
for story_id in store.story_settings("upload_held"): for story_id in store.story_settings("upload_held"):
story = store.get_story(story_id) story = store.get_story(story_id)
if story and story["metahtml"]: if story and story["metahtml"]:
job_queue.add(Job(mode=DownloadMode.REBUILD, story_id=story_id, title=story["title"])) _queue_rebuild(story)
def _hold_reason(story: dict[str, Any], book: dict[str, Any] | None) -> str | None: def _hold_reason(story: dict[str, Any], book: dict[str, Any] | None) -> str | None:
@@ -943,7 +943,7 @@ def adopt_build_keys() -> None:
story = _require_story(story_id) story = _require_story(story_id)
if not story["metahtml"]: if not story["metahtml"]:
continue continue
key = _build_key(story, _epub_overrides(story, story["pinned"]), story["site_values"]) key = _build_key(story, _epub_overrides(story["pinned"]), story["site_values"])
store.set_setting(story_id, "built_key", key) store.set_setting(story_id, "built_key", key)
if (story["calibre_id"] or story["ingested_at"]) and not held.get(story_id): if (story["calibre_id"] or story["ingested_at"]) and not held.get(story_id):
store.set_setting(story_id, "sent_key", key) store.set_setting(story_id, "sent_key", key)
@@ -997,6 +997,16 @@ def _import_epub(story_id: int, contents: fff.EpubContents) -> int:
return len(new) return len(new)
def _story_from_epub(
url: str, site: str, title: str, meta: dict[str, Any], book_id: int | None = None, book_uuid: str | None = None
) -> int:
"""Create a story with the metadata of its epub. The locked fields keep the values of the epub."""
locked = default_locked_fields()
story_id = store.create_story(url, site, title, " & ".join(meta["authors"]), locked, book_id, book_uuid)
store.update_story(story_id, pinned={f: _field_value(f, meta) for f in locked}, status="ok")
return story_id
def _run_import(job: Job) -> str: def _run_import(job: Job) -> str:
tracked = store.get_story_by_calibre_id(job.calibre_id) tracked = store.get_story_by_calibre_id(job.calibre_id)
# A tracked story without chapters is left from an import that stopped, so the import goes on. # A tracked story without chapters is left from an import that stopped, so the import goes on.
@@ -1009,7 +1019,7 @@ def _run_import(job: Job) -> str:
_progress(job, "Downloading epub from CWA") _progress(job, "Downloading epub from CWA")
epub = cwa().download_epub(source, config.TMP_DIR / f"import-{job.calibre_id}-{uuid.uuid4().hex[:8]}.epub") epub = cwa().download_epub(source, config.TMP_DIR / f"import-{job.calibre_id}-{uuid.uuid4().hex[:8]}.epub")
try: try:
normal = next((n for n in map(fff.normalize_url, epub_urls(epub)) if n), None) normal = _epub_story_url(epub)
if not normal: if not normal:
raise StoryError(f"{job.title} has no supported story URL") raise StoryError(f"{job.title} has no supported story URL")
meta = epub_metadata(epub) meta = epub_metadata(epub)
@@ -1021,12 +1031,8 @@ def _run_import(job: Job) -> str:
return f"Linked to tracked story {story['title']}" return f"Linked to tracked story {story['title']}"
contents = _read_epub(epub, title) contents = _read_epub(epub, title)
if story is None: if story is None:
locked = default_locked_fields() story_id = _story_from_epub(normal[0], normal[1], title, meta, job.calibre_id, book_uuid or None)
story_id = store.create_story( store.update_story(story_id, ingested_at=now())
normal[0], normal[1], title, " & ".join(meta["authors"]), locked, job.calibre_id, book_uuid or None
)
pinned = {f: _field_value(f, meta) for f in locked}
store.update_story(story_id, pinned=pinned, ingested_at=now(), status="ok")
story = _require_story(story_id) story = _require_story(story_id)
story_id = story["id"] story_id = story["id"]
job_queue.update(job, story_id=story_id) job_queue.update(job, story_id=story_id)
@@ -1047,11 +1053,7 @@ def _run_import_file(job: Job) -> str:
story = store.get_story(job.story_id) if job.story_id else store.get_story_by_url(job.url) story = store.get_story(job.story_id) if job.story_id else store.get_story_by_url(job.url)
if story is None: if story is None:
meta = epub_metadata(path) meta = epub_metadata(path)
locked = default_locked_fields() story_id = _story_from_epub(job.url, fff.normalize_url(job.url)[1], meta["title"] or job.url, meta)
story_id = store.create_story(
job.url, fff.normalize_url(job.url)[1], meta["title"] or job.url, " & ".join(meta["authors"]), locked
)
store.update_story(story_id, pinned={f: _field_value(f, meta) for f in locked}, status="ok")
_link_changed(story_id) _link_changed(story_id)
story = _require_story(story_id) story = _require_story(story_id)
job_queue.update(job, story_id=story["id"], title=story["title"]) job_queue.update(job, story_id=story["id"], title=story["title"])
@@ -1069,7 +1071,7 @@ def _run_import_file(job: Job) -> str:
Job(mode=DownloadMode.NEW, story_id=story["id"], title=story["title"], options={"build": bool(added)}) Job(mode=DownloadMode.NEW, story_id=story["id"], title=story["title"], options={"build": bool(added)})
) )
elif added and story["metahtml"]: elif added and story["metahtml"]:
job_queue.add(Job(mode=DownloadMode.REBUILD, story_id=story["id"], title=story["title"])) _queue_rebuild(story)
return message return message
@@ -1077,7 +1079,7 @@ def _run_rebuild(job: Job) -> str:
story = _require_story(job.story_id) story = _require_story(job.story_id)
job_queue.update(job, title=story["title"]) job_queue.update(job, title=story["title"])
pinned = _resolve_metadata(story, story["site_values"], []) pinned = _resolve_metadata(story, story["site_values"], [])
overrides = _epub_overrides(story, pinned) overrides = _epub_overrides(pinned)
key = _build_key(story, overrides, story["site_values"]) key = _build_key(story, overrides, story["site_values"])
force = bool(job.options.get("force")) force = bool(job.options.get("force"))
if not force and key == _done_key(story["id"]): if not force and key == _done_key(story["id"]):
@@ -1319,8 +1321,8 @@ def _run_site_job(job: Job) -> str:
# The book is built and sent only when its chapters, rules or metadata changed. A job that resumes, or that # The book is built and sent only when its chapters, rules or metadata changed. A job that resumes, or that
# follows a failed job, finds the changes of the earlier run through the key. # follows a failed job, finds the changes of the earlier run through the key.
pinned = _resolve_metadata(story, snap.calibre, job.force_fields) pinned = _resolve_metadata(story, snap.calibre, job.force_fields)
title = _display_title(story, pinned, snap.title) title = _display_title(pinned, snap.title)
overrides = _epub_overrides(story, pinned) overrides = _epub_overrides(pinned)
key = _build_key(story, overrides, snap.calibre) key = _build_key(story, overrides, snap.calibre)
checked: dict[str, Any] = {"pinned": pinned, "site_values": snap.calibre, "title": title, "last_checked": now()} checked: dict[str, Any] = {"pinned": pinned, "site_values": snap.calibre, "title": title, "last_checked": now()}
if key == _done_key(story_id): if key == _done_key(story_id):
+2 -15
View File
@@ -105,10 +105,7 @@ def _list(data: dict[str, Any], name: str) -> list[Any]:
def _int(value: Any, name: str) -> int: def _int(value: Any, name: str) -> int:
try: return orchestrator.whole_number(value, name, negative=True)
return int(value)
except (TypeError, ValueError):
raise ValueError(f"{name} must be a whole number") from None
def _ids(data: dict[str, Any], name: str = "chapter_ids") -> list[int]: def _ids(data: dict[str, Any], name: str = "chapter_ids") -> list[int]:
@@ -280,17 +277,7 @@ def settings() -> Any:
if request.method == "PUT": if request.method == "PUT":
_admin_only() _admin_only()
data = _json() data = _json()
return orchestrator.save_settings( return orchestrator.save_settings(**{name: data.get(name) for name in orchestrator.SETTINGS})
data.get("recent_chapters"),
data.get("personal_ini"),
data.get("hold_new_links"),
data.get("pause_uploads"),
data.get("story_jobs_hours"),
data.get("story_jobs_count"),
data.get("main_jobs_hours"),
data.get("main_jobs_count"),
link_books=data.get("link_books"),
)
settings = orchestrator.get_settings() settings = orchestrator.get_settings()
if not auth.is_admin(g.identity): if not auth.is_admin(g.identity):
del settings["personal_ini"] # it can hold passwords for story sites del settings["personal_ini"] # it can hold passwords for story sites
+14
View File
@@ -199,3 +199,17 @@ def test_a_story_change_does_not_ask_cwa_for_the_book(client, story, monkeypatch
assert response.json["recheck_days"] == 4 assert response.json["recheck_days"] == 4
assert "chapters" not in response.json assert "chapters" not in response.json
cwa.get_book.assert_not_called() cwa.get_book.assert_not_called()
def test_settings_are_saved_by_name(client):
with pytest.raises(ValueError, match="Unknown settings: \\['recent'\\]"):
o.save_settings(recent=3)
# The route passes only the known settings on.
response = client.put("/api/settings", json={"recent": 3, "recent_chapters": 4, "main_jobs_count": 9}, headers=H)
assert (response.status_code, response.json["recent_chapters"], response.json["main_jobs_count"]) == (200, 4, 9)
for data, error in [
({"recent_chapters": -1}, "The number of recent chapters cannot be negative"),
({"main_jobs_count": 501}, "main_jobs_count cannot be more than 500"),
({"story_jobs_hours": "x"}, "story_jobs_hours must be a whole number"),
]:
assert client.put("/api/settings", json=data, headers=H).json == {"error": error}