mirror of
https://github.com/unslothai/unsloth.git
synced 2026-08-25 08:42:25 +00:00
* Studio: pin, archive and delete for generated images and clips Adds a 3 dots menu to the Images and Video pages, in the preview toolbar and on filmstrip tiles on hover. Pinned items sort to the front of the gallery, archived items move to their own section under Settings > Data, alongside archived chats. Flags are stored server side in a .flags.json beside the media, not in the PNG chunk or the clip sidecar, since they describe how a result is filed rather than how it was made. Ordering and filtering run before pagination, so pinning something on a later page still brings it to the front and archived items never eat a page slot. Clear all now spares archived items, which is the point of archiving. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: address review on gallery pin/archive Fail closed in clear(): an unreadable flag store read as "nothing is archived", so a default clear would have deleted the archive it promises to spare. It now refuses with a 503 and touches nothing. Forget the completed-job record when a clip is archived, or the mount resume path merges the snapshot straight back onto the strip. Tell the media pages when their shelf changed from Settings. Both stay mounted and only load on mount, so a restore left the strip stale. Also: surface flag write failures instead of reporting a save that never landed, sort merged generations instead of prepending them ahead of pins, resync the loaded window after an unpin so the promoted item is not skipped, walk past the pinned group when proving a lost generation landed, keep Show more reachable after emptying an archive page, and budget archived thumbnails through BlobUrlCache. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: second review pass on gallery pin/archive Fix the gallery route test double, which only accepted `valid` and so broke six diffusion route tests the moment the listing gained `archived`. My miss: I ran the gallery tests, not the suite. Close two more ways clear() could still delete an archived file. A single malformed entry was filtered out silently, reading as "not archived", so the store now loses its trust on any bad value while still returning the entries it can read, since listing should degrade rather than refuse. And clear() decided from a snapshot before unlinking, so an archive landing in that window was deleted anyway after its PATCH reported success; both sides now hold the same lock, with set_flags taking it across its ownership check and write. Stop re-sorting the pinned group on the client. The backend orders it by pin time, which the client never learns, so sorting by created_at silently rearranged pins whenever an unrelated merge ran. Load archived thumbnails from the viewport. Eviction dropped ids the loading effect never reconsidered, so evicted rows stayed blank; only visible rows are fetched now, and only off-screen ones are evicted. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: third review pass on gallery pin/archive Stop treating an unknown pinned row as proof a lost generation landed. A saved record is never pinned, so with more pins than the client had loaded the probe read an unfamiliar pin as evidence and reported a submission that never reached the server as a finished run. Pinned rows are skipped outright now; the first unpinned row alone decides. Release an archived thumbnail when its row is restored or deleted. The row's element unmounts without the observer reporting it, so its id stayed in the visible set and shielded its blob from every later prune, walking the cache past its budget one restore at a time. * Studio: fourth review pass on gallery pin/archive Do not let an unconvertible pin time break the listing. JSON integers are unbounded, so a hand-edited pinned_at of a few hundred digits overflowed float() inside the sort key and turned every gallery GET into a 500, in a store whose whole contract is to degrade to no flags rather than raise. Unusable values now read as unpinned, NaN and infinity included, and the reported pinned flag goes through the same conversion the sort uses. Require a baseline before calling an unpinned row new. With a loaded window that is entirely pinned and more pages behind it, every unpinned row is unfamiliar just for being unloaded, so the probe read the first historical one as proof and suppressed a real submission failure. The window can judge only if it already held an unpinned record or was the whole gallery; otherwise the probe claims nothing and the caller reports the error. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: fifth review pass on gallery pin/archive - A write now repairs a store with a malformed entry instead of merging it back, which had left every later clear() refused. - Deleting an archived item no longer tells the gallery page to reload; only a restore changes that shelf. - Both pages resync the loaded window on that event rather than cutting back to the first page. - Pin toggles are serialized per item, and a failed request only rolls back while it is still the latest intent. - Show more pages from the live row count and retries when a restore or delete shifts the list mid request. * Studio: sixth review pass on gallery pin/archive - Flag entries are validated field by field, so an archived value that is not a boolean, or an unusable pin time, costs the store its trust instead of reading as active and letting clear() delete the file. - A write sanitizes those fields rather than dropping the entry, so a bad pin time cannot take an archive flag with it. - Archived thumbnails release their requested id when a fetch is abandoned, else the row stayed blank for good. - The post-unpin resync is fenced behind a strip epoch, so a snapshot taken before a newer pin, archive or delete is discarded rather than applied. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: seventh review pass on gallery pin/archive - A repair resolves an unreadable archived flag to archived instead of dropping it, so a damaged store cannot be rewritten into one where clear() deletes the file. - Pin writes share one queue per gallery, so the server stamps pinned_at in click order across different items. - Merged generations and appended pages bump the strip epoch, so a resync in flight cannot overwrite them. - Archived thumbnails keep a fetch that outlived its effect run, and a failed one retries with a backoff and a cap. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: eighth review pass on gallery pin/archive - A filesystem that refuses flock no longer fails the call on unlock, which reported an error for a PATCH or clear whose work had already landed. - The archived dialog's mounted flag is set on effect setup, so StrictMode's replay in development stops discarding every thumbnail. - The restore subscriber's resync is fenced like the unpin one, so a merged generation is not overwritten by an older snapshot. * Studio: ninth review pass on gallery pin/archive The lost-generation probe took its ids from before the POST but read the window half in the catch. Scrolling while the request was in flight paged in historical unpinned records, turning a window that must refuse to judge into one that judged, so a submission that never reached the server was reported as a finished run. The whole baseline is now captured before the request. * Studio: tenth review pass on gallery pin/archive - A resync now separates a local change, which drops the snapshot, from the window growing off the server, which only makes it refetch. Discarding on an appended page left an unpin's promoted image missing, which is what the resync exists to prevent. - Only the most recently started resync may apply, so two quick restores no longer let the older snapshot drop the newer one. - Archived thumbnails prune against the current visibility set rather than the one their effect run captured. - A successful thumbnail load clears the row's failure history, so a later transient failure is not counted past the retry cap. * Studio: eleventh review pass on gallery pin/archive Keep a store rebuilt from illegible contents untrusted, so an unrelated pin cannot launder an unreadable archive into an active one that the next clear deletes. Re-read the gallery page offset after the response on both media pages, so an archive landing mid request cannot push a record across the page boundary and out of every page. * Studio: twelfth review pass on gallery pin/archive Replace an unreadable flag store once clear(include_archived = True) has removed every item we own, so the escape hatch actually restores the gallery instead of leaving every later clear refusing. Restore a failed unpin to its old place among the pins rather than promoting it to the front, and deduplicate a finished generation against records a concurrent resync already loaded. * Studio: thirteenth review pass on gallery pin/archive Fence the first-page gallery load against local flag changes on both media pages. Both render from the module cache while that GET runs, so their tiles are actionable for the whole window, and a response snapshotted before a pin or an archive replaced the strip with the state the user had just moved off. * Studio: fix the gallery flag store on Windows Stamp a pin strictly ahead of every stamp already in the store rather than trusting the wall clock. Windows advances time.time() in ~16 ms steps, so two pins a click apart landed on the same value and the pinned group lost the order the client serializes its PATCHes to preserve. Drive the unlock-failure test through whichever primitive the platform uses. It imported fcntl unconditionally, which does not exist on Windows, so it errored there instead of exercising the branch that runs. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: fourteenth review pass on gallery pin/archive Fence gallery pagination against a mutation that is merely in flight. The server shortens the shelf when it processes an archive or a delete, while the local count only moves when that response gets back, so a page read inside the round trip found the shortened shelf at an offset the count still agreed with and skipped the record that crossed the boundary. Both media pages bump the strip epoch when the request starts and fetchNextPage now requires that token to hold as well as the count. The archived list counts its mutation on entry for the same reason. * Studio: fifteenth review pass on gallery pin/archive Keep the monotonic pin stamp finite. A store already holding the largest finite float nudged to infinity, which json wrote and the reader then refused, so the pin just reported as saved read back as unset and took the store's trust with it. Honour the archived thumbnail retry cap in the effect as well as the scheduler: a failure clears the requested set, so any later run refetched a permanently missing file without limit. Ask the resync loop for the remaining window rather than a whole page, so a 51 row window stops fetching 100 and rendering the difference. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: sixteenth review pass on gallery pin/archive Refuse a gallery page while a shelf mutation is still in flight. The epoch is an edge, so a page that started after an archive bumped it and landed before the row was dropped saw both the epoch and the count hold still across a shelf the server had already shortened, and skipped the record that crossed the boundary. Refuse a terminal video progress record for a clip archived while that poll was in flight. Forgetting the backend record cannot revoke a response already on the wire, so the clip returned to the active strip carrying archived false. Key the archived media view by shelf, so a Show more still awaiting the old kind cannot append its rows to the new one and drive restore and delete through the wrong media API. * Studio: seventeenth review pass on gallery pin/archive Advance the strip generation before a restore resync, so a load or a page already in flight is discarded rather than landing on top of the restored window. Track pin clicks by attempt rather than by target state. Pin, unpin, pin before the first settles stores the same boolean twice, so the first attempt's failure rolled back the third attempt's optimistic pin and the queued pin then succeeded with the strip showing unpinned. Prune archived thumbnails when rows leave the viewport, not only after a successful fetch. At the end of a shelf nothing fetches again, so the budget was never re-checked. * Studio: tighten the gallery pin/archive comments Post-convergence pass over the comments this branch added: same intent, fewer lines. Comments and docstrings only, no code change. --------- Co-authored-by: shimmyshimmer <shimmyshimmer@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
382 lines
15 KiB
Python
382 lines
15 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""Unit tests for the gallery pin/archive flag store: patch semantics, the fail-safe read,
|
|
atomic writes and orphan pruning."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import math
|
|
import os
|
|
|
|
import pytest
|
|
|
|
import core.inference.gallery_flags as flags
|
|
|
|
|
|
@pytest.fixture
|
|
def gdir(tmp_path):
|
|
d = tmp_path / "images"
|
|
d.mkdir()
|
|
return d
|
|
|
|
|
|
def _store(directory):
|
|
return directory / ".flags.json"
|
|
|
|
|
|
def test_unknown_id_reads_as_no_flags(gdir):
|
|
items = flags.read(gdir)
|
|
assert items == {}
|
|
assert flags.flags_for(items, "nope") == {"pinned": False, "archived": False}
|
|
assert flags.is_archived(items, "nope") is False
|
|
|
|
|
|
def test_missing_store_is_not_created_by_reading(gdir):
|
|
flags.read(gdir)
|
|
assert not _store(gdir).exists()
|
|
|
|
|
|
def test_set_and_read_back_each_flag(gdir):
|
|
assert flags.set_flags(gdir, "a", pinned = True) == {"pinned": True, "archived": False}
|
|
assert flags.set_flags(gdir, "b", archived = True) == {"pinned": False, "archived": True}
|
|
items = flags.read(gdir)
|
|
assert flags.flags_for(items, "a") == {"pinned": True, "archived": False}
|
|
assert flags.is_archived(items, "b") is True
|
|
|
|
|
|
def test_none_leaves_the_other_flag_alone(gdir):
|
|
flags.set_flags(gdir, "a", pinned = True, archived = True)
|
|
# Patch only `archived`; the pin must survive.
|
|
assert flags.set_flags(gdir, "a", archived = False) == {"pinned": True, "archived": False}
|
|
|
|
|
|
def test_toggling_everything_off_removes_the_entry(gdir):
|
|
flags.set_flags(gdir, "a", pinned = True)
|
|
flags.set_flags(gdir, "a", pinned = False)
|
|
# No residue: an id back at its defaults should not keep a row.
|
|
assert flags.read(gdir) == {}
|
|
|
|
|
|
def test_pin_rank_orders_most_recently_pinned_first(gdir):
|
|
flags.set_flags(gdir, "first", pinned = True)
|
|
flags.set_flags(gdir, "second", pinned = True)
|
|
items = flags.read(gdir)
|
|
assert flags.pin_rank(items, "second") > flags.pin_rank(items, "first")
|
|
# An unpinned id must sort behind every pinned one.
|
|
assert flags.pin_rank(items, "unpinned") == float("-inf")
|
|
|
|
|
|
def test_a_coarse_clock_still_orders_two_pins(gdir, monkeypatch):
|
|
# Windows advances time.time() in ~16 ms steps, so two pins a click apart read the same clock
|
|
# and the group loses the order the client serializes its PATCHes to preserve.
|
|
import time as _time
|
|
|
|
monkeypatch.setattr(_time, "time", lambda: 1000.0)
|
|
flags.set_flags(gdir, "first", pinned = True)
|
|
flags.set_flags(gdir, "second", pinned = True)
|
|
flags.set_flags(gdir, "third", pinned = True)
|
|
items = flags.read(gdir)
|
|
ranks = [flags.pin_rank(items, i) for i in ("first", "second", "third")]
|
|
assert ranks[0] < ranks[1] < ranks[2], ranks
|
|
# Re-pinning an already pinned id still moves it to the front of the group.
|
|
flags.set_flags(gdir, "first", pinned = True)
|
|
items = flags.read(gdir)
|
|
assert flags.pin_rank(items, "first") > flags.pin_rank(items, "third")
|
|
|
|
|
|
def test_a_pin_never_stores_a_non_finite_timestamp(gdir):
|
|
# The nudge must not manufacture the value _pinned_at refuses: the largest finite float nudges
|
|
# to infinity, which reads back unpinned and leaves the store untrusted after a 200.
|
|
import sys
|
|
|
|
_store(gdir).write_text(
|
|
json.dumps({"version": 1, "items": {"huge": {"pinned_at": sys.float_info.max}}}),
|
|
encoding = "utf-8",
|
|
)
|
|
assert flags.set_flags(gdir, "a", pinned = True) == {"pinned": True, "archived": False}
|
|
items = flags.read_trusted(gdir) # must not raise: the store is still readable
|
|
assert flags.flags_for(items, "a")["pinned"] is True
|
|
assert math.isfinite(flags.pin_rank(items, "a"))
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"raw",
|
|
[
|
|
"not json at all",
|
|
"[]", # right type, wrong shape
|
|
'{"version": 1, "items": []}', # items must be a mapping
|
|
'{"version": 99, "items": {"a": {}}}', # unknown schema version
|
|
],
|
|
)
|
|
def test_a_corrupt_store_degrades_to_no_flags(gdir, raw):
|
|
# Losing a pin beats refusing to list the gallery, so every unreadable store reads empty.
|
|
_store(gdir).write_text(raw, encoding = "utf-8")
|
|
assert flags.read(gdir) == {}
|
|
|
|
|
|
def test_a_corrupt_store_is_overwritten_by_the_next_write(gdir):
|
|
_store(gdir).write_text("garbage", encoding = "utf-8")
|
|
flags.set_flags(gdir, "a", pinned = True)
|
|
items = flags.read(gdir)
|
|
assert set(items) == {"a"}
|
|
assert flags.flags_for(items, "a") == {"pinned": True, "archived": False}
|
|
|
|
|
|
def test_a_non_dict_entry_reads_as_no_flags(gdir):
|
|
_store(gdir).write_text(
|
|
json.dumps({"version": 1, "items": {"a": "hand edited"}}), encoding = "utf-8"
|
|
)
|
|
items = flags.read(gdir)
|
|
assert flags.flags_for(items, "a") == {"pinned": False, "archived": False}
|
|
|
|
|
|
def test_forget_prunes_only_the_named_ids(gdir):
|
|
flags.set_flags(gdir, "keep", pinned = True)
|
|
flags.set_flags(gdir, "drop", archived = True)
|
|
flags.forget(gdir, ["drop", "never-existed"])
|
|
items = flags.read(gdir)
|
|
assert set(items) == {"keep"}
|
|
|
|
|
|
def test_forget_on_an_empty_store_writes_nothing(gdir):
|
|
flags.forget(gdir, ["a"])
|
|
assert not _store(gdir).exists()
|
|
|
|
|
|
def test_writes_leave_no_temp_files_behind(gdir):
|
|
flags.set_flags(gdir, "a", pinned = True)
|
|
flags.forget(gdir, ["a"])
|
|
# The tmp is renamed into place, so only the store (and its lock) may remain.
|
|
leftovers = {p.name for p in gdir.iterdir()} - {".flags.json", ".flags.json.lock"}
|
|
assert leftovers == set()
|
|
|
|
|
|
def test_read_trusted_raises_on_a_corrupt_store(gdir):
|
|
_store(gdir).write_text("garbage", encoding = "utf-8")
|
|
with pytest.raises(flags.FlagsUnavailable):
|
|
flags.read_trusted(gdir)
|
|
|
|
|
|
def test_read_trusted_accepts_a_missing_store(gdir):
|
|
# No store yet genuinely means nothing is flagged, which is safe to act on.
|
|
assert flags.read_trusted(gdir) == {}
|
|
|
|
|
|
def test_set_flags_raises_when_the_store_cannot_be_written(gdir, monkeypatch):
|
|
def _boom(*a, **k):
|
|
raise OSError("read-only filesystem")
|
|
|
|
monkeypatch.setattr(flags.os, "replace", _boom)
|
|
with pytest.raises(OSError):
|
|
flags.set_flags(gdir, "a", pinned = True)
|
|
# The failed write leaves no temp behind.
|
|
assert [p.name for p in gdir.iterdir() if p.name.startswith(".flags.json.tmp")] == []
|
|
|
|
|
|
def test_forget_stays_best_effort_when_the_store_cannot_be_written(gdir, monkeypatch):
|
|
flags.set_flags(gdir, "a", pinned = True)
|
|
real = flags.os.replace
|
|
monkeypatch.setattr(flags.os, "replace", lambda *a, **k: (_ for _ in ()).throw(OSError("nope")))
|
|
# The media is already deleted by this point, so a stale row must not raise into the caller.
|
|
flags.forget(gdir, ["a"])
|
|
monkeypatch.setattr(flags.os, "replace", real)
|
|
|
|
|
|
def test_a_corrupt_store_is_replaced_rather_than_blocking_new_flags(gdir):
|
|
# Refusing here would leave the user unable to pin anything until they hand-fixed the file.
|
|
_store(gdir).write_text("[]", encoding = "utf-8")
|
|
flags.set_flags(gdir, "a", archived = True)
|
|
assert flags.is_archived(flags.read(gdir), "a") is True
|
|
|
|
|
|
def test_a_store_rebuilt_from_illegible_contents_stays_untrusted(gdir):
|
|
# The write must not be blocked, but the file it leaves is not evidence: the old contents were
|
|
# never read. Trusting it let an unrelated pin hand every archived image to the next clear().
|
|
_store(gdir).write_text("[]", encoding = "utf-8")
|
|
flags.set_flags(gdir, "a", archived = True)
|
|
with pytest.raises(flags.FlagsUnavailable):
|
|
flags.read_trusted(gdir)
|
|
# And it stays that way across further writes, rather than being laundered clean by the next one.
|
|
flags.set_flags(gdir, "b", pinned = True)
|
|
with pytest.raises(flags.FlagsUnavailable):
|
|
flags.read_trusted(gdir)
|
|
|
|
|
|
def test_a_malformed_entry_taints_the_whole_store_for_trusted_reads(gdir):
|
|
# One bad value would otherwise be filtered out silently, reading as "this id is not archived",
|
|
# which is enough for clear() to delete an archived file.
|
|
_store(gdir).write_text(
|
|
json.dumps({"version": 1, "items": {"ok": {"archived": True}, "bad": "corrupt"}}),
|
|
encoding = "utf-8",
|
|
)
|
|
with pytest.raises(flags.FlagsUnavailable):
|
|
flags.read_trusted(gdir)
|
|
# The fail-safe reader still degrades quietly, so listing keeps working.
|
|
assert flags.read(gdir) == {"ok": {"archived": True}}
|
|
|
|
|
|
def test_exclusive_serializes_against_set_flags(gdir):
|
|
# clear() decides from a snapshot then unlinks; an archive landing in that window must wait,
|
|
# not slip in and leave the file deleted after its PATCH reported success.
|
|
import threading
|
|
|
|
started = threading.Event()
|
|
landed = threading.Event()
|
|
|
|
def _archive():
|
|
started.set()
|
|
flags.set_flags(gdir, "a", archived = True)
|
|
landed.set()
|
|
|
|
with flags.exclusive(gdir):
|
|
worker = threading.Thread(target = _archive)
|
|
worker.start()
|
|
started.wait(timeout = 5)
|
|
# Held: the writer cannot land while the section is open.
|
|
assert not landed.wait(timeout = 0.5)
|
|
worker.join(timeout = 5)
|
|
assert landed.is_set()
|
|
assert flags.is_archived(flags.read(gdir), "a") is True
|
|
|
|
|
|
def test_forget_locked_does_not_deadlock_inside_exclusive(gdir):
|
|
# The cross-process lock is per descriptor, so a nested forget() would block on the lock its
|
|
# own caller holds. clear() uses forget_locked for exactly this reason.
|
|
flags.set_flags(gdir, "a", pinned = True)
|
|
with flags.exclusive(gdir):
|
|
flags.forget_locked(gdir, ["a"])
|
|
assert flags.read(gdir) == {}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"pinned_at",
|
|
[
|
|
10**400, # JSON ints are unbounded; this overflows float()
|
|
-(10**400),
|
|
float("nan"),
|
|
float("inf"),
|
|
"2026-01-01", # wrong type entirely
|
|
True, # bool is an int subclass, but not a timestamp
|
|
],
|
|
)
|
|
def test_an_unusable_pin_time_reads_as_unpinned_instead_of_raising(gdir, pinned_at):
|
|
# These are read on every listing, and the store's contract is to degrade rather than raise.
|
|
_store(gdir).write_text(
|
|
json.dumps({"version": 1, "items": {"a": {"pinned_at": pinned_at}}}), encoding = "utf-8"
|
|
)
|
|
items = flags.read(gdir)
|
|
assert flags.pin_rank(items, "a") == float("-inf")
|
|
assert flags.flags_for(items, "a")["pinned"] is False
|
|
|
|
|
|
def test_an_unusable_pin_time_does_not_hide_the_archived_flag(gdir):
|
|
_store(gdir).write_text(
|
|
json.dumps({"version": 1, "items": {"a": {"pinned_at": 10**400, "archived": True}}}),
|
|
encoding = "utf-8",
|
|
)
|
|
assert flags.is_archived(flags.read(gdir), "a") is True
|
|
|
|
|
|
def test_a_write_repairs_a_store_with_a_malformed_entry(gdir):
|
|
# Merging the bad entry back would leave every later clear() refused until someone fixed the
|
|
# file by hand, which is the opposite of what a pin action should cost the user.
|
|
_store(gdir).write_text(
|
|
json.dumps({"version": 1, "items": {"good": {"archived": True}, "bad": "corrupt"}}),
|
|
encoding = "utf-8",
|
|
)
|
|
flags.set_flags(gdir, "new", pinned = True)
|
|
# Trusted again, so a default clear is no longer blocked.
|
|
items = flags.read_trusted(gdir)
|
|
assert set(items) == {"good", "bad", "new"}
|
|
# The readable flags survived the repair.
|
|
assert flags.is_archived(items, "good") is True
|
|
assert flags.flags_for(items, "new")["pinned"] is True
|
|
# The unreadable one is kept on the archive shelf rather than handed to the next clear().
|
|
assert flags.is_archived(items, "bad") is True
|
|
|
|
|
|
@pytest.mark.parametrize("archived", [None, 1, "yes", []])
|
|
def test_a_non_bool_archived_is_refused_rather_than_read_as_active(gdir, archived):
|
|
# Every reader turns a non-bool into "not archived", which is what clear() deletes on, so the
|
|
# store has to refuse instead of handing the file over.
|
|
_store(gdir).write_text(
|
|
json.dumps({"version": 1, "items": {"a": {"archived": archived}}}), encoding = "utf-8"
|
|
)
|
|
with pytest.raises(flags.FlagsUnavailable):
|
|
flags.read_trusted(gdir)
|
|
|
|
|
|
def test_an_unusable_pin_time_also_costs_the_store_its_trust(gdir):
|
|
_store(gdir).write_text(
|
|
json.dumps({"version": 1, "items": {"a": {"pinned_at": 10**400}}}), encoding = "utf-8"
|
|
)
|
|
with pytest.raises(flags.FlagsUnavailable):
|
|
flags.read_trusted(gdir)
|
|
|
|
|
|
def test_a_write_repairs_a_bad_field_without_dropping_the_archive(gdir):
|
|
# Dropping the whole entry over its pin time would hand an archived item to the next clear().
|
|
_store(gdir).write_text(
|
|
json.dumps({"version": 1, "items": {"a": {"pinned_at": 10**400, "archived": True}}}),
|
|
encoding = "utf-8",
|
|
)
|
|
flags.set_flags(gdir, "b", pinned = True)
|
|
items = flags.read_trusted(gdir)
|
|
assert flags.is_archived(items, "a") is True
|
|
assert flags.flags_for(items, "a")["pinned"] is False
|
|
|
|
|
|
def test_a_write_never_repairs_an_archive_into_an_active_item(gdir):
|
|
# Dropping the unreadable flag would leave the store trusted and the item active, so the next
|
|
# default clear() would delete a file that was on the archive shelf. Resolve it the safe way.
|
|
_store(gdir).write_text(
|
|
json.dumps({"version": 1, "items": {"a": {"archived": None}}}), encoding = "utf-8"
|
|
)
|
|
flags.set_flags(gdir, "b", pinned = True)
|
|
items = flags.read_trusted(gdir)
|
|
assert flags.is_archived(items, "a") is True
|
|
assert flags.flags_for(items, "b")["pinned"] is True
|
|
|
|
|
|
def test_an_absent_archived_key_is_not_treated_as_damage(gdir):
|
|
# Unarchiving removes the key, so absent means active and must stay active through a repair.
|
|
_store(gdir).write_text(
|
|
json.dumps({"version": 1, "items": {"a": {"pinned_at": 10**400}}}), encoding = "utf-8"
|
|
)
|
|
flags.set_flags(gdir, "b", pinned = True)
|
|
assert flags.is_archived(flags.read_trusted(gdir), "a") is False
|
|
|
|
|
|
def test_archived_false_is_a_shape_we_write_and_stays_trusted(gdir):
|
|
_store(gdir).write_text(
|
|
json.dumps({"version": 1, "items": {"a": {"archived": False, "pinned_at": 1.0}}}),
|
|
encoding = "utf-8",
|
|
)
|
|
items = flags.read_trusted(gdir)
|
|
assert flags.flags_for(items, "a") == {"pinned": True, "archived": False}
|
|
|
|
|
|
def test_a_filesystem_that_cannot_lock_still_completes_the_write(gdir, monkeypatch):
|
|
# Some network filesystems refuse to lock. Acquisition already tolerated that, but the matching
|
|
# unlock did not, so the store was written and the call still raised, failing a PATCH whose
|
|
# work had landed (and, through clear(), one that had already deleted files).
|
|
# Whichever primitive this platform uses: fcntl does not exist on Windows, and importing it
|
|
# unconditionally failed the test there rather than exercising the branch that runs.
|
|
if os.name == "nt":
|
|
import msvcrt as locking
|
|
primitive = "locking"
|
|
else:
|
|
import fcntl as locking
|
|
primitive = "flock"
|
|
|
|
def _unsupported(*_args):
|
|
raise OSError(45, "Operation not supported")
|
|
|
|
monkeypatch.setattr(locking, primitive, _unsupported)
|
|
assert flags.set_flags(gdir, "a", pinned = True) == {"pinned": True, "archived": False}
|
|
flags.forget(gdir, ["a"])
|
|
with flags.exclusive(gdir):
|
|
pass
|
|
assert flags.read(gdir) == {}
|