pytypehintstore 0.0.4__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.
Files changed (33) hide show
  1. pytypehintstore-0.0.4/.github/dependabot.yml +6 -0
  2. pytypehintstore-0.0.4/.github/workflows/ci.yml +33 -0
  3. pytypehintstore-0.0.4/.github/workflows/publish.yml +24 -0
  4. pytypehintstore-0.0.4/.gitignore +19 -0
  5. pytypehintstore-0.0.4/CHANGELOG.md +93 -0
  6. pytypehintstore-0.0.4/CIERRE.md +275 -0
  7. pytypehintstore-0.0.4/INFORME.md +293 -0
  8. pytypehintstore-0.0.4/LICENSE +21 -0
  9. pytypehintstore-0.0.4/PKG-INFO +369 -0
  10. pytypehintstore-0.0.4/README.md +341 -0
  11. pytypehintstore-0.0.4/example.py +55 -0
  12. pytypehintstore-0.0.4/pyproject.toml +51 -0
  13. pytypehintstore-0.0.4/src/pytypehintstore/__init__.py +11 -0
  14. pytypehintstore-0.0.4/src/pytypehintstore/codec.py +249 -0
  15. pytypehintstore-0.0.4/src/pytypehintstore/errors.py +19 -0
  16. pytypehintstore-0.0.4/src/pytypehintstore/fingerprint.py +80 -0
  17. pytypehintstore-0.0.4/src/pytypehintstore/lockfile.py +199 -0
  18. pytypehintstore-0.0.4/src/pytypehintstore/py.typed +0 -0
  19. pytypehintstore-0.0.4/src/pytypehintstore/store.py +421 -0
  20. pytypehintstore-0.0.4/tests/conftest.py +270 -0
  21. pytypehintstore-0.0.4/tests/shared.py +34 -0
  22. pytypehintstore-0.0.4/tests/test_boundary.py +67 -0
  23. pytypehintstore-0.0.4/tests/test_codec.py +896 -0
  24. pytypehintstore-0.0.4/tests/test_identity.py +226 -0
  25. pytypehintstore-0.0.4/tests/test_lockfile.py +476 -0
  26. pytypehintstore-0.0.4/tests/test_persistence.py +648 -0
  27. pytypehintstore-0.0.4/tests/test_store.py +482 -0
  28. pytypehintstore-0.0.4/tests/test_stress_depth.py +801 -0
  29. pytypehintstore-0.0.4/tests/test_stress_generated.py +1452 -0
  30. pytypehintstore-0.0.4/tests/test_stress_hostile_file.py +759 -0
  31. pytypehintstore-0.0.4/tests/test_stress_unions.py +916 -0
  32. pytypehintstore-0.0.4/tests/test_stress_values.py +939 -0
  33. pytypehintstore-0.0.4/tests/test_writer.py +615 -0
@@ -0,0 +1,6 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: "github-actions"
4
+ directory: "/"
5
+ schedule:
6
+ interval: "weekly"
@@ -0,0 +1,33 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [master]
6
+ pull_request:
7
+
8
+ concurrency:
9
+ group: ${{ github.workflow }}-${{ github.ref }}
10
+ cancel-in-progress: true
11
+
12
+ jobs:
13
+ check:
14
+ # Both, because the lock is the one part written twice: an open handle on
15
+ # Windows, an advisory flock on POSIX. A single runner would leave half of
16
+ # it untested.
17
+ runs-on: ${{ matrix.os }}
18
+ strategy:
19
+ fail-fast: false
20
+ matrix:
21
+ os: [ubuntu-latest, windows-latest]
22
+ python-version: ["3.11", "3.12", "3.13"]
23
+ steps:
24
+ - uses: actions/checkout@v7
25
+ - uses: actions/setup-python@v7
26
+ with:
27
+ python-version: ${{ matrix.python-version }}
28
+ - uses: astral-sh/setup-uv@v7
29
+ with:
30
+ enable-cache: true
31
+ - run: uv pip install --system -e ".[dev]"
32
+ - run: mypy src
33
+ - run: pytest
@@ -0,0 +1,24 @@
1
+ name: Publish
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ permissions:
11
+ id-token: write
12
+ steps:
13
+ - uses: actions/checkout@v7
14
+ - uses: actions/setup-python@v7
15
+ with:
16
+ python-version: "3.11"
17
+ - uses: astral-sh/setup-uv@v7
18
+ with:
19
+ enable-cache: true
20
+ - run: uv pip install --system -e ".[dev]"
21
+ - run: mypy src
22
+ - run: pytest
23
+ - run: uv build
24
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,19 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ build/
4
+ dist/
5
+ *.egg-info/
6
+ .venv/
7
+ venv/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .vscode/
11
+ .idea/
12
+
13
+ # What the example leaves behind: its directory, the store and the copies it
14
+ # rotates, plus a part file from a dump that never landed.
15
+ data/
16
+ *.json.lock
17
+ *.json.part
18
+ tasks.json
19
+ tasks.*.json
@@ -0,0 +1,93 @@
1
+ # Changelog
2
+
3
+ ## [0.0.4]
4
+
5
+ One library on both platforms: the lock now holds the same contract on POSIX
6
+ that it held on Windows, and the checker agrees on both.
7
+
8
+ - The lock is settled by the operating system on POSIX too. It was an open
9
+ handle on Windows — a file Windows will not unlink while it is open — and a
10
+ bare `O_EXCL` everywhere else, which POSIX does not defend: two processes
11
+ reaching for one orphaned lockfile could both unlink it and both create their
12
+ own, ending as two live owners of one file. POSIX now takes an advisory
13
+ `flock`, which the kernel drops when a process dies, and checks the inode
14
+ afterwards in case the file was replaced between the open and the lock.
15
+ - An orphan needs no stealing on POSIX: what makes it an orphan is that its
16
+ `flock` died with its owner. The behaviour a caller sees is unchanged — one
17
+ owner, orphans reclaimed, the same message naming the live PID.
18
+ - `release` unlinks before closing on POSIX and after closing on Windows, each
19
+ being the order that cannot hand a second owner the lock.
20
+ - `mypy` passes on both platforms. The Windows probe is declared under
21
+ `sys.platform` rather than `os.name`, which is the form a checker reads as a
22
+ platform guard, so `ctypes.WinDLL` is not looked for on Linux.
23
+ - CI runs the suite on `ubuntu-latest` and `windows-latest`, over 3.11, 3.12
24
+ and 3.13. Half of the lock only exists on one of them.
25
+ - New test: two processes reaching for one orphan at the same instant leave a
26
+ single owner. It is the property both halves exist to hold, and it runs on
27
+ both.
28
+ - One difference is left, and declared: with a lockfile nobody is holding, POSIX
29
+ trusts the `flock` and takes it over — so a recycled pid cannot lock a path
30
+ out there — while Windows trusts the pid written in it and refuses. Two live
31
+ stores behave identically on both.
32
+
33
+ ## [0.0.2]
34
+
35
+ Out of a stress campaign: 1150 generated schemas, four adversarial fronts, and
36
+ the suite from 200 tests to 1583.
37
+
38
+ - Fixed, data loss: `fingerprint` walked a schema as a tree when it is a graph —
39
+ the core compiles a class once and shares it between every field naming it —
40
+ so a class the core compiled in a millisecond could take minutes to
41
+ fingerprint, and `store_of` hung with no error and no output. Each dataclass
42
+ is now written once and referred back to. Schemas with the same class in two
43
+ fields change their fingerprint, and so their file name.
44
+ - Fixed, data loss: a lone surrogate was accepted by `add`, could never be
45
+ written to a UTF-8 file, and froze every later dump — the failure surfacing
46
+ only at `close()`, as an error from another library. Such a row is refused at
47
+ `add` with `SchemaValueError: cannot be written as UTF-8`, after the core has
48
+ had its say.
49
+ - Fixed, data loss: a union whose options share a transport name — an enum class
50
+ called `date` beside `date` itself — routed values to the wrong branch, and a
51
+ stored member came back as a plain string. The store now refuses the schema at
52
+ open, naming the field and the shared name, before the lockfile is taken. Real
53
+ collisions only: the same enum with nothing to collide with still works.
54
+ - A `$type`/`$value` wrapper is those two keys and nothing else. A key beside
55
+ them, or a `$value` that is itself a wrapper, travels intact for the core to
56
+ refuse instead of being read anyway and erased by the next dump.
57
+ - The format version is checked by type as well as by value, so a `1.0` or a
58
+ `true` in `v` is no longer read as version 1.
59
+ - Packaging: classifiers, keywords and project URLs for PyPI.
60
+
61
+ ## [0.0.1]
62
+
63
+ First release. Rows of one `pytypehint`-validated dataclass, kept in memory and
64
+ mirrored to a JSON file a person can open and edit.
65
+
66
+ - `store_of(cls, directory, *, debounce=2.0, keep=5)` opens the store of one
67
+ dataclass on a file named for the class and the fingerprint of its schema.
68
+ Reads answer from memory; writes mutate the dict at once and a writer thread
69
+ dumps the whole state `debounce` seconds after the burst of writes stops.
70
+ - `add` and `put` accept a row by making the round trip it will make anyway:
71
+ the instance is encoded to its transport form and rebuilt through the schema,
72
+ so what the store holds always came out of the constructor and always
73
+ survives the file. Validation failures are the core's own `SchemaTypeError`
74
+ and `SchemaValueError`, untouched; the store adds no second validation
75
+ system.
76
+ - The file carries the transport form — ISO text for a date or a time, the
77
+ member name for an enum, an object for a nested dataclass — so it stays
78
+ readable and editable. A row that cannot be read back names the file, the row
79
+ and the core's error.
80
+ - Dumps are atomic: `.part`, `fsync`, `os.replace`. A dump that fails leaves
81
+ nothing of itself behind, keeps the state unwritten and is retried; the
82
+ writer thread never dies of it. The previous file is rotated aside with a
83
+ nanosecond stamp and copies beyond `keep` are pruned.
84
+ - `close()` dumps what is pending, stops the writer and releases the lock. It is
85
+ idempotent, and a failure on the way down reaches every caller that was
86
+ closing the same store. An `atexit` hook closes a store the caller forgot, on
87
+ a normal interpreter exit.
88
+ - One process owns a store. The lockfile holds the owner's PID and stays open
89
+ for as long as the lock is held, so a second process is refused at startup
90
+ rather than corrupting the file later, and two processes racing for the same
91
+ orphaned lock cannot both end up owning it.
92
+ - Three exceptions, all subclasses of `StoreError`: `StoreLockedError`,
93
+ `StoreLoadError`, and `StoreError` itself for an operation on a closed store.
@@ -0,0 +1,275 @@
1
+ # Cierre de 0.0.2
2
+
3
+ Temporal, para revisión humana. No forma parte del repo publicable.
4
+
5
+ ```
6
+ $ python -m pytest -q
7
+ 1583 passed, 13 xfailed in 29.44s
8
+
9
+ $ python -m mypy # src
10
+ Success: no issues found in 6 source files
11
+ $ python -m mypy tests
12
+ Success: no issues found in 14 source files
13
+ ```
14
+
15
+ | | 0.0.1 | 0.0.2 |
16
+ |---|---:|---:|
17
+ | tests | 200 | **1583** (+13 xfail) |
18
+ | tiempo de suite en frío | 6,9 s | **29,4 s** (presupuesto 60 s) |
19
+ | líneas de `src/` | 861 | **928** |
20
+ | módulos | 5 | 6 |
21
+
22
+ ---
23
+
24
+ ## Fase 1 — el arreglo del enum, completo
25
+
26
+ ### `src/pytypehintstore/store.py`
27
+
28
+ Importa `List` y `Struct`, ya públicos y ya usados por el codec:
29
+
30
+ ```diff
31
+ -from pytypehint import SchemaTypeError, SchemaValueError, struct_of
32
+ +from pytypehint import List, SchemaTypeError, SchemaValueError, Struct, struct_of
33
+ ```
34
+
35
+ La guarda corre en `__init__`, después de derivar el nombre del archivo (para
36
+ poder citarlo) y **antes** de `folder.mkdir` y de `acquire`:
37
+
38
+ ```diff
39
+ self._path = folder / name
40
+ self._name = str(Path(directory) / name)
41
+ + self._ambiguous()
42
+ self._debounce = float(debounce)
43
+ ```
44
+
45
+ El método, junto a `_open`:
46
+
47
+ ```python
48
+ # Two options of one union that answer to the same transport name are
49
+ # indistinguishable in the file: the wrapper names an option and the reader
50
+ # takes the first that answers. The core allows the pair — an enum class
51
+ # called `date` competes with `date` itself, in different namespaces to it —
52
+ # so the store refuses at the door rather than routing a value to the wrong
53
+ # branch and handing back something nobody stored.
54
+ def _ambiguous(self) -> None:
55
+ found = _shared_name((self._schema,), (), set())
56
+
57
+ if found is None:
58
+ return
59
+
60
+ path, shared = found
61
+ where = ": ".join(path)
62
+ raise StoreError(
63
+ f"{self._name}: {where}: two options of a union share the transport "
64
+ f"name {shared!r}: rename one of the classes")
65
+ ```
66
+
67
+ Y el recorrido, a nivel de módulo (18 líneas):
68
+
69
+ ```python
70
+ # The first pair of options sharing a transport name, as (path, name), or None.
71
+ # `seen` keeps a recursive schema from being walked forever.
72
+ def _shared_name(shapes, path, seen):
73
+ names = set()
74
+
75
+ for shape in shapes:
76
+ # A Struct carries its $type inside the object and never competes for
77
+ # the wrapper's, so a dataclass and an enum may share one name — the
78
+ # core allows exactly that, and the codec keeps them apart.
79
+ if type(shape) is Struct:
80
+ continue
81
+
82
+ name = shape.option_id()
83
+
84
+ if name in names:
85
+ return path, name
86
+
87
+ names.add(name)
88
+
89
+ for shape in shapes:
90
+ if type(shape) is Struct:
91
+ if id(shape) in seen:
92
+ continue
93
+
94
+ seen.add(id(shape))
95
+
96
+ for field in shape.fields:
97
+ found = _shared_name(field.shape, (*path, field.name), seen)
98
+
99
+ if found is not None:
100
+ return found
101
+
102
+ elif type(shape) is List:
103
+ found = _shared_name(shape.item, path, seen)
104
+
105
+ if found is not None:
106
+ return found
107
+
108
+ return None
109
+ ```
110
+
111
+ **La exclusión de `Struct` no estaba en el prompt y es necesaria.** Sin ella la
112
+ guarda se pasaba de estricta y rompía un test verde de la suite anterior
113
+ (`test_codec.py::test_an_enum_sharing_its_name_with_a_dataclass_survives_as_a_member`):
114
+ un dataclass y un enum homónimos **funcionan correctamente**, porque un `Struct`
115
+ lleva su `$type` dentro del objeto y nunca compite por el del wrapper — es
116
+ exactamente lo que arregló la auditoría anterior. La guarda mira solo a las
117
+ opciones que pueden viajar envueltas.
118
+
119
+ Cero privados del core: `option_id()`, `fields`, `field.shape` y `List.item` son
120
+ públicos, y `tests/test_boundary.py` sigue en verde sobre los seis módulos.
121
+
122
+ ### Comportamiento, verificado a mano
123
+
124
+ | caso | resultado |
125
+ |---|---|
126
+ | `date \| EnumNamedDate` | `RECHAZA — when: two options of a union share the transport name 'date': rename one of the classes` |
127
+ | el mismo enum **sin** `date` al lado | **ABRE**, y guarda sus miembros |
128
+ | anidado a 3 niveles | `RECHAZA — outer: inner: when: …` (el path completo) |
129
+ | dentro de `list[...]` | `RECHAZA — items: …` |
130
+ | dos enums homónimos | lo rechaza **el core** al compilar (`duplicate discriminator name(s)`) |
131
+ | uniones normales (`str\|date`, `list[str]\|list[int]`, `Enum\|None`) | **ABREN** |
132
+
133
+ ---
134
+
135
+ ## Estado final de los 19 xfail
136
+
137
+ **Seis eran del enum: ninguno queda.** Convertidos en tests del rechazo:
138
+
139
+ | antes (xfail) | ahora |
140
+ |---|---|
141
+ | `unions::test_an_enum_named_str_keeps_its_member_through_the_store` | `test_an_enum_sharing_a_transport_name_with_a_scalar_is_refused_at_open` — mensaje completo por igualdad |
142
+ | `unions::test_a_date_survives_an_enum_named_date_with_an_iso_member` | `test_a_date_beside_an_enum_named_date_is_refused_either_way_round[enum first]` |
143
+ | `unions::test_an_enum_member_named_like_an_iso_date_survives_a_date_option` | el mismo, `[date first]` |
144
+ | `generated::test_enum_named_like_a_scalar_loses_the_member` (codec puro) | `test_an_enum_named_like_a_scalar_is_why_the_store_checks_the_names` — **adaptado, no borrado**: pinta las dos mitades, lo que el codec haría y el rechazo que lo impide, así que quitar la guarda deja un test diciendo qué vuelve en su lugar |
145
+ | `generated::test_enum_named_like_a_scalar_corrupts_a_stored_row` | `test_a_schema_the_codec_could_not_name_never_becomes_a_store` |
146
+ | `generated::test_enum_named_like_date_loses_the_member` | `test_an_enum_named_like_date_is_refused_the_same_way` |
147
+
148
+ Además, dos tests que **pasaban** documentando la mitad superviviente de la
149
+ colisión (`test_an_enum_named_str_takes_a_plain_string_with_it`,
150
+ `test_the_same_collision_refuses_other_values_out_loud`) dejaron de tener
151
+ sentido —el store ya no abre ese schema— y se sustituyeron por
152
+ `test_a_refused_schema_leaves_nothing_behind` y
153
+ `test_an_enum_named_like_a_scalar_it_never_meets_is_fine` (el caso negativo).
154
+
155
+ **Los 13 restantes son todos el mismo límite declarado**, el de la unión de
156
+ listas enrutada por el tipo de los items, ahora con una línea apuntando al
157
+ README en su `reason`:
158
+
159
+ | test | qué fija |
160
+ |---|---|
161
+ | `generated::test_codec_round_trip[seed14, 48, 230, 374]` | los 4 de 500 schemas generados que lo tocan |
162
+ | `generated::test_the_transport_names_the_branch_the_core_would_choose[seed14, 48, 230, 374]` | el `$type` escrito no es la rama que `value_branch` elegiría |
163
+ | `generated::test_store_round_trip[seed14, 48]` | los mismos, a través de un store real |
164
+ | `generated::test_a_union_of_lists_routes_by_type_alone_on_length` | el caso mínimo: `[]` bajo `Annotated[list[str], Min(1)] \| list[int]` |
165
+ | `…_on_a_pattern` | lo mismo con `Pattern` |
166
+ | `…_on_choices` | lo mismo con `Choices` |
167
+
168
+ Ninguno quedó obsoleto por la fase 1: son un defecto distinto, en otra capa.
169
+
170
+ ---
171
+
172
+ ## Fase 2 — limpieza
173
+
174
+ - **Presupuesto: 29,4 s en frío**, menos de la mitad de los 60 s. Ningún test
175
+ necesitó `@pytest.mark.slow`, así que no se registró el marcador y el README
176
+ no necesita instrucciones aparte. Los cinco más lentos: 2,1 s (4 hilos contra
177
+ el escritor), 1,4 s (bisección del techo de recursión), 1,3 s (5000 filas),
178
+ 1,0 s (cobertura del generador), 1,0 s (medición del ritmo de reintento).
179
+ - **Código muerto: no había.** Un barrido AST sobre los 14 archivos de test
180
+ buscando funciones y clases definidas y nunca usadas no encontró ninguna.
181
+ - **mypy limpio en tests**, además de en src. Cinco avisos, todos por
182
+ construcciones deliberadas: tres `Enum("date", …)` guardados en variables con
183
+ otro nombre (es el bug que se está probando), un `v: str | ShadowStr` con una
184
+ variable como tipo, y una anotación que faltaba en `_LIST_VALUES`. Los cuatro
185
+ primeros llevan `# type: ignore` con el código exacto; el quinto se anotó.
186
+ `warn_unused_ignores` está activo, así que si el motivo desaparece, avisa.
187
+
188
+ ---
189
+
190
+ ## Fase 4 — empaquetado
191
+
192
+ ```
193
+ $ python -m build
194
+ Successfully built pytypehintstore-0.0.2.tar.gz and
195
+ pytypehintstore-0.0.2-py3-none-any.whl
196
+
197
+ $ unzip -l dist/pytypehintstore-0.0.2-py3-none-any.whl
198
+ pytypehintstore/__init__.py codec.py errors.py fingerprint.py
199
+ lockfile.py store.py py.typed
200
+ pytypehintstore-0.0.2.dist-info/{METADATA,RECORD,WHEEL,licenses/LICENSE}
201
+ ```
202
+
203
+ **El quick start del README, tal cual, contra el wheel en un venv limpio:**
204
+
205
+ ```
206
+ $ .venv/Scripts/pip install dist/pytypehintstore-0.0.2-py3-none-any.whl
207
+ pytypehint 0.0.7 · pytypehintstore 0.0.2
208
+
209
+ $ .venv/Scripts/python quickstart.py
210
+ path.name: Task.a50534e4.json ← el mismo nombre que documenta el README
211
+ get: Task(title='Buy milk', priority=<Priority.HIGH: 'high'>, done=False)
212
+ all: [(2, Task(title='Write the store', priority=<Priority.LOW: 'low'>, done=True))]
213
+ reopened: ids kept, next add gave 3
214
+ py.typed viaja: True
215
+ ```
216
+
217
+ `example.py` contra ese mismo wheel, tres ejecuciones seguidas: ids 1‑2, 3‑4,
218
+ 5‑6, las filas anteriores intactas y las copias rotadas de su familia. El
219
+ directorio acabó con las **dos** bases de datos conviviendo — la `Task` del quick
220
+ start (`a50534e4`) y la `Task` del ejemplo (`975cbdd0`), dos clases con el mismo
221
+ nombre y distinto contrato — que es el contrato hecho visible.
222
+
223
+ ---
224
+
225
+ ## Decisiones que tomé sin cobertura del prompt
226
+
227
+ 1. **La exclusión de `Struct` en la guarda** (arriba). Sin ella, sobre‑rechazo.
228
+ 2. **La dependencia se fija en `pytypehint>=0.0.7`**, la versión actual y contra
229
+ la que está probado todo. No bisecté qué versión anterior bastaría: habría
230
+ exigido instalar y correr la suite contra cada una, y el core es del mismo
231
+ autor y avanza junto.
232
+ 3. **Las URLs del `pyproject` asumen `github.com/offerrall/pytypehintstore`**,
233
+ por el patrón del resto del ecosistema. **Confírmalas antes de publicar**: si
234
+ el repo se llama de otro modo, son tres líneas.
235
+ 4. **La entrada 0.0.1 del CHANGELOG llevaba dos frases falsas** —la firma vieja
236
+ `store_of(cls, path, …)` y el remedio de `missing key(s)`, ambos retirados en
237
+ el rediseño— y las corregí en vez de dejarlas: esa versión nunca se publicó,
238
+ así que su entrada describe la librería, no un histórico.
239
+ 5. **`mypy` sigue configurado con `files = ["src"]`**. Los tests pasan si se le
240
+ pide explícitamente (`mypy tests`), pero no los añadí a la configuración: lo
241
+ que se publica es el paquete, y el CI ya corre `mypy src`.
242
+ 6. **El test del codec puro del enum se adaptó en vez de eliminarse.** El prompt
243
+ dejaba elegir; conservarlo documenta por qué existe la guarda, y falla con un
244
+ mensaje útil si alguien la quita.
245
+
246
+ ---
247
+
248
+ ## Lo que queda fuera, a sabiendas
249
+
250
+ - **El límite de la unión de listas** sigue abierto. Arreglarlo necesita el
251
+ router de valores del core, que es privado; la alternativa sería reimplementar
252
+ las restricciones en el codec, es decir, el segundo validador que el diseño
253
+ no quiere. Está en el README, en 13 xfail estrictos y en `INFORME.md`.
254
+ - ~~**POSIX.** La carrera de los dos dueños del lockfile está cerrada en Windows
255
+ porque un archivo abierto no se puede borrar; en POSIX sí.~~ **Cerrado en
256
+ 0.0.4**: POSIX toma un `flock` que el kernel suelta al morir el proceso, más
257
+ una comprobación de inodo. La suite corre ahora en `ubuntu-latest` y
258
+ `windows-latest`. Queda una diferencia declarada, con test por plataforma: un
259
+ lockfile que nadie sostiene lo toma POSIX (el `flock` es la evidencia) y lo
260
+ respeta Windows (el PID lo es).
261
+ - **El traceback compartido del segundo `close()`** (cosmético, ya declarado).
262
+ - **`INFORME.md`** se queda en el repo con el detalle de la campaña; dime si
263
+ prefieres que se vaya con este `CIERRE.md`.
264
+
265
+ ---
266
+
267
+ ## Veredicto
268
+
269
+ La propiedad se sostiene con una excepción, y la excepción es ruidosa: **si el
270
+ core compila el schema, el store lo persiste sin pérdida o falla a gritos** — en
271
+ `add`, o en `store_of` antes de tocar el disco. El único caso de corrupción
272
+ silenciosa que la campaña encontró está cerrado, y lo que queda es un rechazo
273
+ molesto y raro (4 de 500 schemas), no una pérdida.
274
+
275
+ Queda listo para `twine upload`. Ese botón es tuyo.