refkit 0.0.2__tar.gz → 0.0.3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: refkit
3
- Version: 0.0.2
3
+ Version: 0.0.3
4
4
  Summary: Fast Python citation parsing, rendering, and BibTeX editing backed by Rust
5
5
  Author-email: Péter Ferenc Gyarmati <dev.petergy@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -17,12 +17,12 @@ Classifier: Programming Language :: Python :: 3.14
17
17
  Classifier: Programming Language :: Rust
18
18
  Classifier: Typing :: Typed
19
19
  Requires-Python: <3.15,>=3.11
20
- Requires-Dist: refkit-core==0.0.2
20
+ Requires-Dist: refkit-core==0.0.3
21
21
  Description-Content-Type: text/markdown
22
22
 
23
23
  # refkit
24
24
 
25
- `refkit` reads BibTeX, BibLaTeX, and Hayagriva YAML, renders CSL citations, and edits raw BibTeX documents from Python.
25
+ `refkit` reads BibTeX, BibLaTeX, and Hayagriva YAML, renders CSL citations, formats BibTeX, and edits raw BibTeX documents from Python.
26
26
 
27
27
  ## Install
28
28
 
@@ -31,9 +31,9 @@ pip install refkit
31
31
  ```
32
32
 
33
33
  `refkit` is pure Python and depends on the exact matching `refkit-core` release.
34
- `refkit-core` contains the Rust/PyO3 extension as `refkit_core._refkit_core`, including PyEmscripten wheels for the Python 3.14 Pyodide runtime.
34
+ `refkit-core` contains the Rust/PyO3 extension as `refkit_core._refkit_core`, including PyEmscripten wheels for Pyodide.
35
35
 
36
- `refkit` supports CPython 3.11 through 3.14. Native wheels from `refkit-core` use the Python 3.11 stable ABI.
36
+ The supported Python versions and native wheel ABI are declared in package metadata and release workflows.
37
37
 
38
38
  ## Render A Citation
39
39
 
@@ -95,6 +95,53 @@ rk.full_bibliography("refs.bib", style="chicago-author-date").html
95
95
 
96
96
  Use `Library.parse_bibtex`, `Library.parse_yaml`, and `Document` when the bibliography source is already in memory or when several citations share the same library and style.
97
97
 
98
+ ## Format BibTeX
99
+
100
+ `tidy_bibtex` formats BibTeX text and returns `TidyResult` with the formatted source, warnings, and entry count.
101
+
102
+ ```python
103
+ import refkit as rk
104
+
105
+ result = rk.tidy_bibtex(
106
+ """
107
+ @ARTICLE {doe2024,
108
+ pages={6-13},
109
+ year={2024},}
110
+ """
111
+ )
112
+
113
+ print(result.bibtex)
114
+ print(result.count)
115
+ ```
116
+
117
+ Use `TidyOptions` for formatting choices:
118
+
119
+ ```python
120
+ options = rk.TidyOptions(sort_fields=True, wrap=88)
121
+ result = rk.tidy_bibtex(source, options=options)
122
+ ```
123
+
124
+ Warnings are structured objects:
125
+
126
+ ```python
127
+ for warning in result.warnings:
128
+ print(warning.code, warning.rule, warning.message)
129
+ ```
130
+
131
+ Raw edit flows can render the current document state before formatting:
132
+
133
+ ```python
134
+ raw = rk.BibDocument.read("refs.bib")
135
+ raw.entries["doe2024"].fields["title"].value = "Corrected title"
136
+ result = raw.tidy(options=rk.TidyOptions(sort_fields=True))
137
+ ```
138
+
139
+ Use `tidy_file` when the input is on disk. It writes a file when `output` is supplied.
140
+
141
+ ```python
142
+ rk.tidy_file("refs.bib", output="refs.tidy.bib")
143
+ ```
144
+
98
145
  ## Capabilities
99
146
 
100
147
  | Capability | Python surface |
@@ -104,6 +151,7 @@ Use `Library.parse_bibtex`, `Library.parse_yaml`, and `Document` when the biblio
104
151
  | Render bibliographies | `Document.cited_bibliography`, `Document.full_bibliography`, `full_bibliography` |
105
152
  | Load styles and locales | `Style.load`, `Style.from_path`, `Style.from_xml`, `Locale.load` |
106
153
  | Inspect entries | mapping access, `keys`, `get`, `get_many`, `select`, `project`, `to_dicts` |
154
+ | Format BibTeX | `tidy_bibtex`, `tidy_file`, `TidyOptions`, `TidyResult` |
107
155
  | Edit raw BibTeX | `BibDocument.read`, `BibDocument.parse`, field assignment, `write` |
108
156
  | Inspect rendered output | `Rendered.text`, `Rendered.html`, `Rendered.tree` |
109
157
 
@@ -167,7 +215,7 @@ for entry in library.select("article > periodical[volume]"):
167
215
 
168
216
  ## Edit Raw BibTeX
169
217
 
170
- `BibDocument` preserves the raw `.bib` structure that normalized rendering does not need: comments, preambles, string definitions, failed blocks, order, and source spans.
218
+ `BibDocument` preserves raw `.bib` comments, preambles, string definitions, failed blocks, order, and source spans.
171
219
 
172
220
  ```python
173
221
  raw = rk.BibDocument.read("refs.bib")
@@ -229,19 +277,15 @@ out = df.select(
229
277
  ## Development
230
278
 
231
279
  ```bash
232
- uv sync --all-packages --group dev
280
+ uv sync --locked --all-packages --group dev
233
281
  (cd packages/refkit-core && uv run maturin develop)
234
- uv run pytest packages/refkit/tests --no-cov
282
+ uv run --locked --all-packages --group dev python -m pytest packages/refkit/tests --no-cov
235
283
  ```
236
284
 
237
- The workspace also provides:
285
+ Run every workspace gate from the repository root:
238
286
 
239
287
  ```bash
240
- make lint
241
- make typecheck
242
- make test
243
- make rust
244
- make build
288
+ make check
245
289
  ```
246
290
 
247
291
  ## License
@@ -1,6 +1,6 @@
1
1
  # refkit
2
2
 
3
- `refkit` reads BibTeX, BibLaTeX, and Hayagriva YAML, renders CSL citations, and edits raw BibTeX documents from Python.
3
+ `refkit` reads BibTeX, BibLaTeX, and Hayagriva YAML, renders CSL citations, formats BibTeX, and edits raw BibTeX documents from Python.
4
4
 
5
5
  ## Install
6
6
 
@@ -9,9 +9,9 @@ pip install refkit
9
9
  ```
10
10
 
11
11
  `refkit` is pure Python and depends on the exact matching `refkit-core` release.
12
- `refkit-core` contains the Rust/PyO3 extension as `refkit_core._refkit_core`, including PyEmscripten wheels for the Python 3.14 Pyodide runtime.
12
+ `refkit-core` contains the Rust/PyO3 extension as `refkit_core._refkit_core`, including PyEmscripten wheels for Pyodide.
13
13
 
14
- `refkit` supports CPython 3.11 through 3.14. Native wheels from `refkit-core` use the Python 3.11 stable ABI.
14
+ The supported Python versions and native wheel ABI are declared in package metadata and release workflows.
15
15
 
16
16
  ## Render A Citation
17
17
 
@@ -73,6 +73,53 @@ rk.full_bibliography("refs.bib", style="chicago-author-date").html
73
73
 
74
74
  Use `Library.parse_bibtex`, `Library.parse_yaml`, and `Document` when the bibliography source is already in memory or when several citations share the same library and style.
75
75
 
76
+ ## Format BibTeX
77
+
78
+ `tidy_bibtex` formats BibTeX text and returns `TidyResult` with the formatted source, warnings, and entry count.
79
+
80
+ ```python
81
+ import refkit as rk
82
+
83
+ result = rk.tidy_bibtex(
84
+ """
85
+ @ARTICLE {doe2024,
86
+ pages={6-13},
87
+ year={2024},}
88
+ """
89
+ )
90
+
91
+ print(result.bibtex)
92
+ print(result.count)
93
+ ```
94
+
95
+ Use `TidyOptions` for formatting choices:
96
+
97
+ ```python
98
+ options = rk.TidyOptions(sort_fields=True, wrap=88)
99
+ result = rk.tidy_bibtex(source, options=options)
100
+ ```
101
+
102
+ Warnings are structured objects:
103
+
104
+ ```python
105
+ for warning in result.warnings:
106
+ print(warning.code, warning.rule, warning.message)
107
+ ```
108
+
109
+ Raw edit flows can render the current document state before formatting:
110
+
111
+ ```python
112
+ raw = rk.BibDocument.read("refs.bib")
113
+ raw.entries["doe2024"].fields["title"].value = "Corrected title"
114
+ result = raw.tidy(options=rk.TidyOptions(sort_fields=True))
115
+ ```
116
+
117
+ Use `tidy_file` when the input is on disk. It writes a file when `output` is supplied.
118
+
119
+ ```python
120
+ rk.tidy_file("refs.bib", output="refs.tidy.bib")
121
+ ```
122
+
76
123
  ## Capabilities
77
124
 
78
125
  | Capability | Python surface |
@@ -82,6 +129,7 @@ Use `Library.parse_bibtex`, `Library.parse_yaml`, and `Document` when the biblio
82
129
  | Render bibliographies | `Document.cited_bibliography`, `Document.full_bibliography`, `full_bibliography` |
83
130
  | Load styles and locales | `Style.load`, `Style.from_path`, `Style.from_xml`, `Locale.load` |
84
131
  | Inspect entries | mapping access, `keys`, `get`, `get_many`, `select`, `project`, `to_dicts` |
132
+ | Format BibTeX | `tidy_bibtex`, `tidy_file`, `TidyOptions`, `TidyResult` |
85
133
  | Edit raw BibTeX | `BibDocument.read`, `BibDocument.parse`, field assignment, `write` |
86
134
  | Inspect rendered output | `Rendered.text`, `Rendered.html`, `Rendered.tree` |
87
135
 
@@ -145,7 +193,7 @@ for entry in library.select("article > periodical[volume]"):
145
193
 
146
194
  ## Edit Raw BibTeX
147
195
 
148
- `BibDocument` preserves the raw `.bib` structure that normalized rendering does not need: comments, preambles, string definitions, failed blocks, order, and source spans.
196
+ `BibDocument` preserves raw `.bib` comments, preambles, string definitions, failed blocks, order, and source spans.
149
197
 
150
198
  ```python
151
199
  raw = rk.BibDocument.read("refs.bib")
@@ -207,19 +255,15 @@ out = df.select(
207
255
  ## Development
208
256
 
209
257
  ```bash
210
- uv sync --all-packages --group dev
258
+ uv sync --locked --all-packages --group dev
211
259
  (cd packages/refkit-core && uv run maturin develop)
212
- uv run pytest packages/refkit/tests --no-cov
260
+ uv run --locked --all-packages --group dev python -m pytest packages/refkit/tests --no-cov
213
261
  ```
214
262
 
215
- The workspace also provides:
263
+ Run every workspace gate from the repository root:
216
264
 
217
265
  ```bash
218
- make lint
219
- make typecheck
220
- make test
221
- make rust
222
- make build
266
+ make check
223
267
  ```
224
268
 
225
269
  ## License
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "refkit"
3
- version = "0.0.2"
3
+ version = "0.0.3"
4
4
  description = "Fast Python citation parsing, rendering, and BibTeX editing backed by Rust"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -22,7 +22,7 @@ classifiers = [
22
22
  "Typing :: Typed",
23
23
  ]
24
24
  dependencies = [
25
- "refkit-core==0.0.2",
25
+ "refkit-core==0.0.3",
26
26
  ]
27
27
 
28
28
  [build-system]
@@ -4,17 +4,17 @@ from __future__ import annotations
4
4
 
5
5
  from importlib.metadata import version as _metadata_version
6
6
  from os import PathLike
7
+ from pathlib import Path
7
8
 
8
9
  import refkit_core as _core
9
10
 
10
- _COMPATIBLE_REFKIT_CORE_VERSION = "0.0.2"
11
11
  __version__ = _metadata_version("refkit")
12
12
 
13
13
 
14
14
  def check_refkit_core_version() -> bool:
15
15
  """Return whether the installed `refkit-core` version matches `refkit`."""
16
16
 
17
- return _core.__version__ == _COMPATIBLE_REFKIT_CORE_VERSION
17
+ return _core.__version__ == __version__
18
18
 
19
19
 
20
20
  def _ensure_refkit_core_version() -> None:
@@ -22,7 +22,7 @@ def _ensure_refkit_core_version() -> None:
22
22
  return
23
23
  raise SystemError(
24
24
  f"The installed refkit-core version ({_core.__version__}) is incompatible "
25
- f"with refkit {__version__}, which requires {_COMPATIBLE_REFKIT_CORE_VERSION}. "
25
+ f"with refkit {__version__}. "
26
26
  "Install refkit and refkit-core from the same release."
27
27
  )
28
28
 
@@ -46,6 +46,11 @@ RefkitError = _core.RefkitError
46
46
  Rendered = _core.Rendered
47
47
  RenderedDocument = _core.RenderedDocument
48
48
  Style = _core.Style
49
+ TidyError = _core.TidyError
50
+ TidyOptions = _core.TidyOptions
51
+ TidyResult = _core.TidyResult
52
+ TidySyntaxError = _core.TidySyntaxError
53
+ TidyWarning = _core.TidyWarning
49
54
  build_info = _core.build_info
50
55
  build_mode = _core.build_mode
51
56
 
@@ -67,15 +72,46 @@ __all__ = [
67
72
  "Rendered",
68
73
  "RenderedDocument",
69
74
  "Style",
75
+ "TidyError",
76
+ "TidyOptions",
77
+ "TidyResult",
78
+ "TidySyntaxError",
79
+ "TidyWarning",
70
80
  "build_info",
71
81
  "build_mode",
72
82
  "cite",
73
83
  "full_bibliography",
84
+ "tidy_bibtex",
85
+ "tidy_file",
74
86
  "check_refkit_core_version",
75
87
  "__version__",
76
88
  ]
77
89
 
78
90
 
91
+ def tidy_bibtex(
92
+ source: str,
93
+ *,
94
+ options: TidyOptions | None = None,
95
+ ) -> TidyResult:
96
+ """Format BibTeX text and return the formatted source plus warnings."""
97
+
98
+ return _core.tidy_bibtex(source, options=options)
99
+
100
+
101
+ def tidy_file(
102
+ path: str | PathLike[str],
103
+ *,
104
+ output: str | PathLike[str] | None = None,
105
+ options: TidyOptions | None = None,
106
+ ) -> TidyResult:
107
+ """Read a BibTeX file, format it, and write the result when `output` is set."""
108
+
109
+ result = BibDocument.read(path).tidy(options=options)
110
+ if output is not None:
111
+ Path(output).write_text(result.bibtex, encoding="utf-8")
112
+ return result
113
+
114
+
79
115
  def cite(
80
116
  source: str | PathLike[str],
81
117
  citation: str | Cite | CitationGroup,
@@ -18,6 +18,11 @@ from refkit_core import (
18
18
  Rendered,
19
19
  RenderedDocument,
20
20
  Style,
21
+ TidyError,
22
+ TidyOptions,
23
+ TidyResult,
24
+ TidySyntaxError,
25
+ TidyWarning,
21
26
  build_info,
22
27
  build_mode,
23
28
  )
@@ -40,10 +45,17 @@ __all__ = [
40
45
  "Rendered",
41
46
  "RenderedDocument",
42
47
  "Style",
48
+ "TidyError",
49
+ "TidyOptions",
50
+ "TidyResult",
51
+ "TidySyntaxError",
52
+ "TidyWarning",
43
53
  "build_info",
44
54
  "build_mode",
45
55
  "cite",
46
56
  "full_bibliography",
57
+ "tidy_bibtex",
58
+ "tidy_file",
47
59
  "check_refkit_core_version",
48
60
  "__version__",
49
61
  ]
@@ -51,6 +63,17 @@ __all__ = [
51
63
  __version__: str
52
64
 
53
65
  def check_refkit_core_version() -> bool: ...
66
+ def tidy_bibtex(
67
+ source: str,
68
+ *,
69
+ options: TidyOptions | None = None,
70
+ ) -> TidyResult: ...
71
+ def tidy_file(
72
+ path: str | PathLike[str],
73
+ *,
74
+ output: str | PathLike[str] | None = None,
75
+ options: TidyOptions | None = None,
76
+ ) -> TidyResult: ...
54
77
  def cite(
55
78
  source: str | PathLike[str],
56
79
  citation: str | Cite | CitationGroup,
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ import string
4
+
5
+ import hypothesis.strategies as st
6
+ import pytest
7
+ from hypothesis import given, settings
8
+
9
+ import refkit as rk
10
+
11
+ KEYS = st.from_regex(r"[A-Za-z][A-Za-z0-9_-]{0,15}", fullmatch=True)
12
+ TEXT = st.text(alphabet=string.ascii_letters + string.digits + " ", min_size=1, max_size=32)
13
+ YEARS = st.integers(min_value=1000, max_value=9999)
14
+
15
+
16
+ def _entry(key: str, title: str, year: int) -> str:
17
+ return f"@article{{{key}, title={{{title}}}, year={{{year}}}}}"
18
+
19
+
20
+ @given(key=KEYS, title=TEXT, year=YEARS)
21
+ @settings(max_examples=75, deadline=None)
22
+ def test_tidy_bibtex_is_idempotent_for_valid_entries(key: str, title: str, year: int) -> None:
23
+ first = rk.tidy_bibtex(_entry(key, title, year)).bibtex
24
+ second = rk.tidy_bibtex(first).bibtex
25
+
26
+ assert second == first
27
+ assert rk.Library.parse_bibtex(first).keys() == [key]
28
+
29
+
30
+ @given(key=KEYS, title=TEXT, replacement=TEXT, year=YEARS)
31
+ @settings(max_examples=50, deadline=None)
32
+ def test_raw_title_edits_survive_serialization(
33
+ key: str,
34
+ title: str,
35
+ replacement: str,
36
+ year: int,
37
+ ) -> None:
38
+ document = rk.BibDocument.parse(_entry(key, title, year))
39
+ document.entries[key].fields["title"].value = replacement
40
+ serialized = document.to_bibtex()
41
+
42
+ assert replacement in serialized
43
+ assert rk.Library.parse_bibtex(serialized).keys() == [key]
44
+
45
+
46
+ @given(key=KEYS, title=TEXT)
47
+ @settings(max_examples=50, deadline=None)
48
+ def test_default_parser_rejects_unclosed_entries(key: str, title: str) -> None:
49
+ with pytest.raises(rk.RefkitError, match="parse error"):
50
+ rk.Library.parse_bibtex(f"@article{{{key}, title={{{title}")
@@ -200,10 +200,11 @@ def test_real_bibliography_fixture_parses_inspects_and_renders() -> None:
200
200
  assert len(library) == 12
201
201
  assert library.diagnostics == []
202
202
  assert len(raw.entries) == 12
203
- assert raw.comments[0].startswith("% Real BibTeX subset")
204
203
  title = rows["DeepResearchGym"]["title"]
205
- assert isinstance(title, str)
206
- assert title.startswith("DeepResearchGym")
204
+ assert (
205
+ title == "DeepResearchGym: A Free, Transparent, and Reproducible Evaluation Sandbox "
206
+ "for Deep Research"
207
+ )
207
208
  assert rows["DeepResearchGym"]["doi"] == "10.48550/ARXIV.2505.19253"
208
209
  assert rows["ijcai2019p684"]["volume"] is None
209
210
  assert _render_one(doc, "ijcai2019p684").text == "(Chen et al., 2019)"
@@ -253,12 +254,11 @@ def test_library_parse_accepts_source_strings_and_mapping_helpers() -> None:
253
254
  """,
254
255
  )
255
256
 
256
- assert library
257
- assert not library.is_empty()
258
257
  assert library.keys() == ["inline"]
259
- entry = library.get("inline")
260
- assert entry is not None
258
+ entry = cast(rk.Entry, library.get("inline"))
259
+ assert entry.key == "inline"
261
260
  assert entry.title == "Inline Source"
261
+ assert library["inline"].title == "Inline Source"
262
262
  assert library.get("missing") is None
263
263
  assert [entry.key for entry in library.get_many(["inline"])] == ["inline"]
264
264
  assert library.get_many(["inline"])[0].title == "Inline Source"
@@ -365,14 +365,17 @@ def test_refkit_import_reports_runtime_core_metadata() -> None:
365
365
  def test_refkit_import_rejects_mismatched_core_version(monkeypatch: pytest.MonkeyPatch) -> None:
366
366
  required_core_version = metadata.version("refkit-core")
367
367
  mismatched_core = ModuleType("refkit_core")
368
- cast(Any, mismatched_core).__version__ = f"{required_core_version}.mismatch"
368
+ mismatched_version = f"{required_core_version}.mismatch"
369
+ cast(Any, mismatched_core).__version__ = mismatched_version
369
370
 
370
371
  monkeypatch.setitem(sys.modules, "refkit_core", mismatched_core)
371
372
  monkeypatch.delitem(sys.modules, "refkit")
372
373
  monkeypatch.syspath_prepend(str(ROOT / "src"))
373
374
  try:
374
- with pytest.raises(SystemError, match=f"requires {required_core_version}"):
375
+ with pytest.raises(SystemError) as raised:
375
376
  importlib.import_module("refkit")
377
+ assert required_core_version in str(raised.value)
378
+ assert mismatched_version in str(raised.value)
376
379
  finally:
377
380
  sys.modules["refkit"] = rk
378
381
 
@@ -436,11 +439,16 @@ def test_bibliography_text_and_tree_include_second_field_labels() -> None:
436
439
  entry = cast(dict[str, Any], rendered.tree[0])
437
440
  first_field = cast(dict[str, Any], entry["first_field"])
438
441
 
439
- assert rendered.text.startswith("[1]")
442
+ assert rendered.text == (
443
+ "[1] J. Doe, “Refkit for Bibliographies,” Journal of Citation Systems, "
444
+ "vol. 12, pp. 1–20, 2024, doi: 10.1234/refkit.2024."
445
+ )
440
446
  assert entry["kind"] == "bibliography-entry"
441
447
  assert first_field["kind"] == "Element"
442
448
  assert first_field["meta"] == "CitationNumber"
443
- assert any(node.get("text") == "[1]" for node in _tree_nodes(first_field))
449
+ assert [
450
+ node.get("text") for node in _tree_nodes(first_field) if node.get("kind") == "Text"
451
+ ] == ["[1]"]
444
452
 
445
453
 
446
454
  def test_rendered_tree_exposes_documented_structured_keys() -> None:
@@ -454,7 +462,8 @@ def test_rendered_tree_exposes_documented_structured_keys() -> None:
454
462
  assert "children" in citation_tree[0]
455
463
  assert bibliography_tree[0]["kind"] == "bibliography-entry"
456
464
  assert bibliography_tree[0]["key"] == "doe2024"
457
- assert bibliography_tree[0]["first_field"] is not None
465
+ first_field = cast(dict[str, Any], bibliography_tree[0]["first_field"])
466
+ assert first_field["kind"] == "Element"
458
467
  assert bibliography_tree[0]["children"][0]["kind"] == "Element"
459
468
 
460
469
 
@@ -485,8 +494,7 @@ def test_library_reads_yaml_and_selects_parent_periodical() -> None:
485
494
  library = rk.Library.read(FIXTURES / "parent.yaml")
486
495
  matches = library.select("article > periodical[volume]")
487
496
 
488
- assert len(matches) == 1
489
- assert matches[0].key == "doe2024"
497
+ assert [entry.key for entry in matches] == ["doe2024"]
490
498
  assert matches[0].title == "Refkit for Bibliographies"
491
499
  assert matches[0].parents[0].title == "Journal of Citation Systems"
492
500
 
@@ -574,7 +582,7 @@ def test_style_and_locale_loaders_cover_supported_sources() -> None:
574
582
  assert bundled.title == "APA Style 7th edition"
575
583
  assert from_xml.id == "xml"
576
584
  assert from_xml.title == "Refkit Note Fixture"
577
- assert from_path.id.endswith("refkit-note.csl")
585
+ assert Path(from_path.id) == FIXTURES / "refkit-note.csl"
578
586
  assert from_path.title == "Refkit Note Fixture"
579
587
  assert locale.code == "en-US"
580
588
  assert "Doe" in _render_one(document, "doe2024").text
@@ -703,7 +711,8 @@ def test_library_non_strict_recovers_entry_after_malformed_at_line(tmp_path: Pat
703
711
  library = rk.Library.read(source, recovery="report")
704
712
 
705
713
  assert library.keys() == ["valid"]
706
- assert "ignored malformed BibTeX block" in library.diagnostics[0]
714
+ assert library.diagnostics
715
+ assert "ignored" in library.diagnostics[0]
707
716
 
708
717
 
709
718
  def test_library_non_strict_drops_closed_malformed_entries(tmp_path: Path) -> None:
@@ -748,7 +757,8 @@ def test_library_non_strict_drops_missing_separator_after_bare_value(tmp_path: P
748
757
  library = rk.Library.read(source, recovery="report")
749
758
 
750
759
  assert library.keys() == ["valid"]
751
- assert "ignored malformed BibTeX block" in library.diagnostics[0]
760
+ assert library.diagnostics
761
+ assert "ignored" in library.diagnostics[0]
752
762
 
753
763
 
754
764
  def test_library_non_strict_drops_missing_field_values(tmp_path: Path) -> None:
@@ -770,7 +780,8 @@ def test_library_non_strict_drops_missing_field_values(tmp_path: Path) -> None:
770
780
  library = rk.Library.read(source, recovery="report")
771
781
 
772
782
  assert library.keys() == ["valid"]
773
- assert "ignored malformed BibTeX block" in library.diagnostics[0]
783
+ assert library.diagnostics
784
+ assert "ignored" in library.diagnostics[0]
774
785
 
775
786
 
776
787
  def test_library_non_strict_drops_entries_missing_key_comma(tmp_path: Path) -> None:
@@ -792,7 +803,8 @@ def test_library_non_strict_drops_entries_missing_key_comma(tmp_path: Path) -> N
792
803
  library = rk.Library.read(source, recovery="report")
793
804
 
794
805
  assert library.keys() == ["valid"]
795
- assert "ignored malformed BibTeX block" in library.diagnostics[0]
806
+ assert library.diagnostics
807
+ assert "ignored" in library.diagnostics[0]
796
808
 
797
809
 
798
810
  def test_library_non_strict_drops_malformed_field_identifiers(tmp_path: Path) -> None:
@@ -814,7 +826,8 @@ def test_library_non_strict_drops_malformed_field_identifiers(tmp_path: Path) ->
814
826
  library = rk.Library.read(source, recovery="report")
815
827
 
816
828
  assert library.keys() == ["valid"]
817
- assert "ignored malformed BibTeX block" in library.diagnostics[0]
829
+ assert library.diagnostics
830
+ assert "ignored" in library.diagnostics[0]
818
831
 
819
832
 
820
833
  def test_library_non_strict_drops_malformed_unsafe_bare_values(tmp_path: Path) -> None:
@@ -857,8 +870,11 @@ def test_library_non_strict_drops_malformed_string_definitions(tmp_path: Path) -
857
870
  library = rk.Library.read(source, recovery="report")
858
871
 
859
872
  assert library.keys() == ["valid"]
860
- assert len(library.diagnostics) == 3
861
- assert all("ignored malformed BibTeX block" in item for item in library.diagnostics)
873
+ assert len(library.diagnostics) == 4
874
+ assert library.diagnostics[0].startswith("syntax recovery could not pre-filter BibTeX entries")
875
+ assert [
876
+ diagnostic.startswith("ignored string definition") for diagnostic in library.diagnostics[1:]
877
+ ] == [True, True, True]
862
878
 
863
879
 
864
880
  def test_library_recovery_ignores_invalid_typed_fields() -> None:
@@ -1316,16 +1332,13 @@ def test_raw_bib_document_edits_duplicate_occurrences_without_losing_raw_blocks(
1316
1332
 
1317
1333
  raw.write(output)
1318
1334
 
1319
- text = output.read_text(encoding="utf-8")
1320
- assert "% duplicate raw fixture" in text
1321
- assert '@preamble{"Duplicate fixture"}' in text
1322
- assert "@string{j = {Journal of Duplicate Contracts}}" in text
1323
- assert "raw prose outside entries" in text
1324
- assert "@broken{bad" in text
1325
- assert "Corrected Second Field" in text
1326
- assert "Corrected Duplicate Entry" in text
1327
-
1328
1335
  written = rk.BibDocument.read(output)
1336
+ assert written.comments == ["% duplicate raw fixture\n"]
1337
+ assert written.preamble == "Duplicate fixture"
1338
+ assert written.strings["j"] == "Journal of Duplicate Contracts"
1339
+ assert [block["raw"] for block in written.blocks if block["kind"] == "other"] == [
1340
+ "raw prose outside entries\n\n"
1341
+ ]
1329
1342
  written_first, written_second = written.entries.get_all("dup")
1330
1343
  assert [field.value for field in written_first.fields.get_all("title")] == [
1331
1344
  "First Title",
@@ -1333,7 +1346,7 @@ def test_raw_bib_document_edits_duplicate_occurrences_without_losing_raw_blocks(
1333
1346
  ]
1334
1347
  assert written_second.fields["title"].value == "Corrected Duplicate Entry"
1335
1348
  assert written.entries["later"].fields["title"].value == "Later Entry"
1336
- assert written.failed_blocks[0]["raw"].startswith("@broken{bad")
1349
+ assert written.failed_blocks[0]["raw"] == "@broken{bad,\n title = {No close}\n\n"
1337
1350
 
1338
1351
 
1339
1352
  def test_raw_bib_document_preserves_blocks_and_writes_field_edit(tmp_path: Path) -> None:
@@ -1341,7 +1354,7 @@ def test_raw_bib_document_preserves_blocks_and_writes_field_edit(tmp_path: Path)
1341
1354
  blocks = raw.blocks
1342
1355
  source = (FIXTURES / "raw.bib").read_text(encoding="utf-8")
1343
1356
 
1344
- assert raw.comments[0].startswith("% library comment")
1357
+ assert raw.comments == ["% library comment\n", "% trailing comment\n"]
1345
1358
  assert raw.preamble == "BibTeX preamble"
1346
1359
  assert raw.strings["jcs"] == "Journal of Citation Systems"
1347
1360
  assert blocks[0]["kind"] == "comment"
@@ -1358,14 +1371,11 @@ def test_raw_bib_document_preserves_blocks_and_writes_field_edit(tmp_path: Path)
1358
1371
  output = tmp_path / "updated.bib"
1359
1372
  raw.write(output)
1360
1373
 
1361
- text = output.read_text()
1362
- assert "% library comment" in text
1363
- assert "@preamble" in text
1364
- assert "@string" in text
1365
- assert "@broken" in text
1366
- assert "Corrected title" in text
1367
- assert "journal = jcs" in text
1368
1374
  written = rk.BibDocument.read(output)
1375
+ assert written.comments[:2] == ["% library comment\n", "% trailing comment\n"]
1376
+ assert written.preamble == "BibTeX preamble"
1377
+ assert written.strings["jcs"] == "Journal of Citation Systems"
1378
+ assert written.failed_blocks[0]["raw"] == "@broken{missing,\n title = {No close}\n"
1369
1379
  assert written.entries["doe2024"].fields["title"].value == "Corrected title"
1370
1380
  assert written.entries["doe2024"].fields["journal"].value == "jcs"
1371
1381
 
@@ -1373,28 +1383,33 @@ def test_raw_bib_document_preserves_blocks_and_writes_field_edit(tmp_path: Path)
1373
1383
  def test_raw_bib_document_preserves_typst_biblatex_blocks(tmp_path: Path) -> None:
1374
1384
  raw = rk.BibDocument.read(FIXTURES / "typst-raw.bib")
1375
1385
 
1376
- assert raw.comments[0].startswith("@comment")
1377
- assert any(comment.startswith("% Comments before") for comment in raw.comments)
1386
+ assert raw.comments == [
1387
+ "@comment{thisdoesntmatter,\n does = {not matter}\n}",
1388
+ "% Comments before the entry work\n",
1389
+ "% A comment after the entry\n",
1390
+ ]
1378
1391
  assert raw.strings["benchjournal"] == "Journal of Citation Benchmarks"
1379
1392
  assert raw.preamble == '"Reference " # "fixture"'
1380
1393
  assert raw.entries.unique_keys() == ["fischer2022equivalence", "roes2003belief"]
1381
1394
  assert raw.entries["roes2003belief"].span[0] < raw.entries["roes2003belief"].span[1]
1382
1395
  assert raw.failed_blocks[0]["kind"] == "failed"
1383
- assert "field author is missing '='" in raw.failed_blocks[0]["error"]
1396
+ assert raw.failed_blocks[0]["error"]
1384
1397
 
1385
1398
  raw.entries["roes2003belief"].fields["title"].value = "Edited belief title"
1386
1399
  output = tmp_path / "typst-raw-out.bib"
1387
1400
  raw.write(output)
1388
1401
 
1389
- text = output.read_text(encoding="utf-8")
1390
- assert "@comment{thisdoesntmatter" in text
1391
- assert "% Comments before the entry work" in text
1392
- assert "@string{benchjournal" in text
1393
- assert '@preamble{"Reference " # "fixture"}' in text
1394
- assert "Edited belief title" in text
1395
- assert "@inproceedings{conigliocorbalan" in text
1396
- assert "author {Marcelo Coniglio and Maria Corbalan}" in text
1397
1402
  written = rk.BibDocument.read(output)
1403
+ assert written.comments == raw.comments
1404
+ assert written.strings["benchjournal"] == "Journal of Citation Benchmarks"
1405
+ assert written.preamble == '"Reference " # "fixture"'
1406
+ assert written.failed_blocks[0]["raw"] == (
1407
+ "@inproceedings{conigliocorbalan,\n"
1408
+ " author {Marcelo Coniglio and Maria Corbalan},\n"
1409
+ " title = {Sequent Calculi for Nonsense Logics},\n"
1410
+ " year = {2012}\n"
1411
+ "}"
1412
+ )
1398
1413
  assert written.entries["roes2003belief"].fields["title"].value == "Edited belief title"
1399
1414
 
1400
1415
 
@@ -1412,35 +1427,26 @@ def test_raw_bib_document_parse_accepts_source_strings_and_mapping_helpers(
1412
1427
  )
1413
1428
 
1414
1429
  assert raw.comments == ["% inline comment\n"]
1415
- assert raw.entries
1416
- assert not raw.entries.is_empty()
1417
- assert "inline" in raw.entries
1418
- assert "missing" not in raw.entries
1419
- entry = raw.entries.get_unique("inline")
1420
- assert entry is not None
1430
+ assert raw.entries.unique_keys() == ["inline"]
1431
+ entry = raw.entries["inline"]
1421
1432
  assert entry.key == "inline"
1422
1433
  assert raw.entries.get_unique("missing") is None
1423
1434
  assert [entry.key for entry in raw.entries.occurrences()] == ["inline"]
1424
1435
  assert [entry.key for entry in raw.entries.get_all("inline")] == ["inline"]
1425
- assert raw.entries["inline"].fields
1426
- assert not raw.entries["inline"].fields.is_empty()
1427
- title = raw.entries["inline"].fields.get_unique("title")
1428
- assert title is not None
1436
+ assert entry.fields.unique_keys() == ["title"]
1437
+ title = entry.fields["title"]
1429
1438
  assert title.name == "title"
1430
1439
  assert title.value == "Inline Raw"
1431
- assert [field.name for field in raw.entries["inline"].fields.occurrences()] == ["title"]
1432
- assert [field.value for field in raw.entries["inline"].fields.get_all("title")] == [
1433
- "Inline Raw"
1434
- ]
1435
- assert "title" in raw.entries["inline"].fields
1436
- assert "missing" not in raw.entries["inline"].fields
1437
- assert raw.entries["inline"].fields.get_unique("missing") is None
1440
+ assert [field.name for field in entry.fields.occurrences()] == ["title"]
1441
+ assert [field.value for field in entry.fields.get_all("title")] == ["Inline Raw"]
1442
+ assert entry.fields.get_unique("missing") is None
1438
1443
  assert raw.failed_blocks
1439
1444
 
1440
1445
  output = tmp_path / "inline.bib"
1441
1446
  raw.write(output)
1442
1447
 
1443
- assert "@article{inline" in output.read_text(encoding="utf-8")
1448
+ written = rk.BibDocument.read(output)
1449
+ assert written.entries["inline"].fields["title"].value == "Inline Raw"
1444
1450
 
1445
1451
 
1446
1452
  def test_raw_bib_document_accepts_permissive_citation_keys() -> None:
@@ -1680,7 +1686,7 @@ def test_raw_bib_document_duplicate_fields_are_addressable_by_occurrence(tmp_pat
1680
1686
 
1681
1687
  entry = raw.entries["duplicate"]
1682
1688
  assert entry.fields.unique_keys() == ["title", "year"]
1683
- assert [field.name for field in entry.fields.occurrences()] == ["title", "title", "year"]
1689
+ assert [field.name for field in entry.fields.occurrences()] == ["title", "TITLE", "year"]
1684
1690
  titles = entry.fields.get_all("title")
1685
1691
  assert [field.value for field in titles] == ["First", "Second"]
1686
1692
 
@@ -1847,3 +1853,110 @@ def test_raw_bib_document_allows_no_space_comment_after_bare_value(tmp_path: Pat
1847
1853
 
1848
1854
  assert raw.failed_blocks == []
1849
1855
  assert raw.entries["commented"].fields["year"].value == "2024"
1856
+
1857
+
1858
+ def test_tidy_bibtex_formats_text_and_reports_count() -> None:
1859
+ result = rk.tidy_bibtex(
1860
+ """@ARTICLE {feinberg1983technique,
1861
+ number={1},
1862
+ pages={6-13},
1863
+ year={1983},}
1864
+ """
1865
+ )
1866
+
1867
+ assert result.count == 1
1868
+ assert result.warnings == []
1869
+ assert result.bibtex == (
1870
+ "@article{feinberg1983technique,\n"
1871
+ " number = {1},\n"
1872
+ " pages = {6--13},\n"
1873
+ " year = {1983}\n"
1874
+ "}\n"
1875
+ )
1876
+
1877
+
1878
+ def test_tidy_options_enable_default_field_sorting() -> None:
1879
+ result = rk.tidy_bibtex(
1880
+ """@article{doe2024,
1881
+ year={2024},
1882
+ title={Fast Citations},
1883
+ author={Doe, Jane}
1884
+ }
1885
+ """,
1886
+ options=rk.TidyOptions(sort_fields=True),
1887
+ )
1888
+
1889
+ assert result.bibtex.index("title") < result.bibtex.index("author")
1890
+ assert result.bibtex.index("author") < result.bibtex.index("year")
1891
+
1892
+
1893
+ def test_tidy_bibtex_returns_structured_warnings() -> None:
1894
+ result = rk.tidy_bibtex(
1895
+ """@article{
1896
+ title={Missing key}
1897
+ }
1898
+ """
1899
+ )
1900
+
1901
+ assert result.count == 1
1902
+ assert [(warning.code, warning.rule) for warning in result.warnings] == [("missing_key", None)]
1903
+ assert "citation key" in result.warnings[0].message
1904
+
1905
+
1906
+ def test_tidy_bibtex_raises_structured_syntax_error() -> None:
1907
+ with pytest.raises(rk.TidySyntaxError) as raised:
1908
+ rk.tidy_bibtex("@article{broken,\n title = {No close}\n")
1909
+
1910
+ err = raised.value
1911
+ assert err.line == 1
1912
+ assert err.column == 1
1913
+ assert err.byte == 0
1914
+ assert err.character == "@"
1915
+ assert err.message
1916
+
1917
+
1918
+ def test_bib_document_tidy_uses_current_raw_state() -> None:
1919
+ raw = rk.BibDocument.parse(
1920
+ """@article{doe2024,
1921
+ title={Old title},
1922
+ year={2024}
1923
+ }
1924
+ """
1925
+ )
1926
+ raw.entries["doe2024"].fields["title"].value = "Corrected title"
1927
+
1928
+ result = raw.tidy(options=rk.TidyOptions(sort_fields=True))
1929
+
1930
+ assert "Corrected title" in result.bibtex
1931
+ assert raw.to_bibtex().count("Corrected title") == 1
1932
+
1933
+
1934
+ def test_tidy_file_writes_output_when_requested(tmp_path: Path) -> None:
1935
+ source = tmp_path / "refs.bib"
1936
+ output = tmp_path / "refs.tidy.bib"
1937
+ source.write_text(
1938
+ """@ARTICLE {doe2024,
1939
+ pages={6-13},
1940
+ year={2024},}
1941
+ """,
1942
+ encoding="utf-8",
1943
+ )
1944
+
1945
+ result = rk.tidy_file(source, output=output)
1946
+
1947
+ assert output.read_text(encoding="utf-8") == result.bibtex
1948
+ assert "@article{doe2024" in result.bibtex
1949
+
1950
+
1951
+ def test_tidy_file_can_return_result_without_writing(tmp_path: Path) -> None:
1952
+ source = tmp_path / "refs.bib"
1953
+ original = """@ARTICLE {doe2024,
1954
+ pages={6-13},
1955
+ year={2024},}
1956
+ """
1957
+ source.write_text(original, encoding="utf-8")
1958
+
1959
+ result = rk.tidy_file(source)
1960
+
1961
+ assert source.read_text(encoding="utf-8") == original
1962
+ assert "@article{doe2024" in result.bibtex
@@ -31,6 +31,11 @@ CORE_EXPORTS = (
31
31
  "Rendered",
32
32
  "RenderedDocument",
33
33
  "Style",
34
+ "TidyError",
35
+ "TidyOptions",
36
+ "TidyResult",
37
+ "TidySyntaxError",
38
+ "TidyWarning",
34
39
  )
35
40
 
36
41
 
@@ -42,18 +47,21 @@ def _fake_core_module(name: str, *, version: str) -> ModuleType:
42
47
  dynamic_module.build_mode = "release"
43
48
  for export in CORE_EXPORTS:
44
49
  setattr(module, export, type(export, (), {}))
50
+ dynamic_module.tidy_bibtex = lambda source, *, options=None: source
51
+ dynamic_module._tidy_option_names = []
45
52
  return module
46
53
 
47
54
 
48
55
  def test_mock_pyodide_sets_platform_and_stub_modules(mock_pyodide: Any) -> None:
49
56
  micropip = ModuleType("micropip")
57
+ previous_micropip = sys.modules.get("micropip")
50
58
 
51
59
  with mock_pyodide(micropip=micropip):
52
60
  assert sys.platform == "emscripten"
53
61
  assert importlib.import_module("pyodide") is sys.modules["pyodide"]
54
62
  assert importlib.import_module("micropip") is micropip
55
63
 
56
- assert sys.modules.get("micropip") is not micropip
64
+ assert sys.modules.get("micropip") is previous_micropip
57
65
 
58
66
 
59
67
  def test_pyodide_env_fixture_sets_platform(pyodide_env: None) -> None:
@@ -0,0 +1,152 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import inspect
5
+ from pathlib import Path
6
+ from typing import Any, cast
7
+
8
+ import pytest
9
+
10
+ import refkit as rk
11
+ import refkit_core
12
+
13
+ WORKSPACE = Path(__file__).parents[2]
14
+ CORE_STUB = WORKSPACE / "refkit-core" / "src" / "refkit_core" / "_refkit_core.pyi"
15
+ EXPECTED_TIDY_OPTION_NAMES = (
16
+ "omit",
17
+ "curly",
18
+ "numeric",
19
+ "months",
20
+ "space",
21
+ "tab",
22
+ "align",
23
+ "blank_lines",
24
+ "sort",
25
+ "duplicates",
26
+ "merge",
27
+ "strip_enclosing_braces",
28
+ "drop_all_caps",
29
+ "escape",
30
+ "sort_fields",
31
+ "strip_comments",
32
+ "trailing_commas",
33
+ "encode_urls",
34
+ "tidy_comments",
35
+ "remove_empty_fields",
36
+ "remove_duplicate_fields",
37
+ "generate_keys",
38
+ "max_authors",
39
+ "lowercase",
40
+ "enclosing_braces",
41
+ "remove_braces",
42
+ "wrap",
43
+ )
44
+
45
+
46
+ def _stub_tidy_option_names() -> tuple[str, ...]:
47
+ module = ast.parse(CORE_STUB.read_text(encoding="utf-8"))
48
+ for node in module.body:
49
+ if isinstance(node, ast.ClassDef) and node.name == "TidyOptions":
50
+ init = next(
51
+ item
52
+ for item in node.body
53
+ if isinstance(item, ast.FunctionDef) and item.name == "__init__"
54
+ )
55
+ return tuple(arg.arg for arg in init.args.kwonlyargs)
56
+ raise AssertionError("TidyOptions stub not found")
57
+
58
+
59
+ def test_tidy_options_stub_lists_public_keywords() -> None:
60
+ assert _stub_tidy_option_names() == EXPECTED_TIDY_OPTION_NAMES
61
+
62
+
63
+ def test_tidy_options_native_allowlist_lists_public_keywords() -> None:
64
+ assert tuple(refkit_core._tidy_option_names) == EXPECTED_TIDY_OPTION_NAMES
65
+
66
+
67
+ def test_tidy_options_runtime_signature_lists_public_keywords() -> None:
68
+ signature = inspect.signature(rk.TidyOptions)
69
+
70
+ assert tuple(signature.parameters) == EXPECTED_TIDY_OPTION_NAMES
71
+ assert signature.parameters["space"].default == 2
72
+ assert signature.parameters["escape"].default is True
73
+ assert signature.parameters["sort_fields"].default is None
74
+
75
+
76
+ def test_tidy_options_reject_unknown_names() -> None:
77
+ options_type = cast(Any, rk.TidyOptions)
78
+ with pytest.raises(ValueError, match="unknown tidy option"):
79
+ options_type(unknown=True)
80
+
81
+
82
+ @pytest.mark.parametrize(
83
+ ("option", "value", "message"),
84
+ [
85
+ ("sort_fields", "title", "iterable of strings"),
86
+ ("duplicates", ["bogus"], "unknown duplicate rule"),
87
+ ("merge", "bogus", "unknown merge strategy"),
88
+ ("wrap", "wide", "integer"),
89
+ ("space", "two", "integer"),
90
+ ("space", True, "integer"),
91
+ ("max_authors", False, "integer"),
92
+ ],
93
+ )
94
+ def test_tidy_options_validate_representative_values(
95
+ option: str,
96
+ value: object,
97
+ message: str,
98
+ ) -> None:
99
+ options_type = cast(Any, rk.TidyOptions)
100
+ with pytest.raises((TypeError, ValueError), match=message):
101
+ options_type(**{option: value})
102
+
103
+
104
+ def test_tidy_options_default_toggles_forward_to_formatter() -> None:
105
+ source = """@article{doe2024,
106
+ year={2024},
107
+ title={Fast Citations},
108
+ author={Doe, Jane}
109
+ }
110
+ """
111
+
112
+ sorted_fields = rk.tidy_bibtex(source, options=rk.TidyOptions(sort_fields=True))
113
+ assert sorted_fields.bibtex.index("title") < sorted_fields.bibtex.index("author")
114
+
115
+ wrapped = rk.tidy_bibtex(
116
+ (
117
+ "@article{wide, title={One two three four five six seven eight nine "
118
+ "ten eleven twelve thirteen fourteen fifteen sixteen}}\n"
119
+ ),
120
+ options=rk.TidyOptions(wrap=True),
121
+ )
122
+ assert "\n One two" in wrapped.bibtex
123
+
124
+ generated = rk.tidy_bibtex(
125
+ "@article{old, author={Doe, Jane}, title={Fast Citations}, year={2024}}\n",
126
+ options=rk.TidyOptions(generate_keys=True),
127
+ )
128
+ assert "@article{doe2024fast," in generated.bibtex
129
+
130
+ duplicate = rk.tidy_bibtex(
131
+ """
132
+ @article{first, title={Same}, doi={10.1/example}, year={2024}}
133
+ @article{second, title={Same}, doi={10.1/example}, year={2025}}
134
+ """,
135
+ options=rk.TidyOptions(duplicates=["doi"], merge="first"),
136
+ )
137
+ assert [warning.rule for warning in duplicate.warnings] == ["doi"]
138
+ assert duplicate.count == 2
139
+ assert duplicate.bibtex.count("@article") == 1
140
+ assert duplicate.bibtex == (
141
+ "@article{first,\n"
142
+ " title = {Same},\n"
143
+ " doi = {10.1/example},\n"
144
+ " year = {2024}\n"
145
+ "}\n"
146
+ )
147
+
148
+
149
+ def test_tidy_options_constructor_rejects_positional_arguments() -> None:
150
+ options_type = cast(Any, rk.TidyOptions)
151
+ with pytest.raises(TypeError):
152
+ options_type(True)
@@ -19,6 +19,10 @@ def failed_block_errors(raw: rk.BibDocument) -> list[str]:
19
19
  return [block["error"] for block in raw.failed_blocks]
20
20
 
21
21
 
22
+ def tidy_warning_codes(result: rk.TidyResult) -> list[str]:
23
+ return [warning.code for warning in result.warnings]
24
+
25
+
22
26
  def duplicate_entry_titles(raw: rk.BibDocument, key: str) -> list[str]:
23
27
  return [
24
28
  field.value for entry in raw.entries.get_all(key) for field in entry.fields.get_all("title")
@@ -32,12 +36,17 @@ def test_type_checked_structured_return_samples() -> None:
32
36
 
33
37
  rendered = doc.render([rk.Citation("first", "doe2024")])
34
38
 
35
- assert rendered_tree_kinds(rendered["first"])
36
- assert all(start >= 0 for start in raw_block_starts(raw))
37
- assert all(error for error in failed_block_errors(raw))
39
+ assert rendered_tree_kinds(rendered["first"]) == ["Text", "Element", "Text"]
40
+ assert raw_block_starts(raw)[0] == 0
41
+ assert failed_block_errors(raw) == ["entry ended before closing delimiter"]
38
42
  duplicate_raw = rk.BibDocument.read(FIXTURES / "raw-duplicates.bib")
39
43
  assert duplicate_entry_titles(duplicate_raw, "dup") == [
40
44
  "First Title",
41
45
  "Second Title",
42
46
  "Duplicate Entry",
43
47
  ]
48
+ tidied = rk.BibDocument.parse("@article{typed, title={Typed Contract}, year={2024}}\n").tidy(
49
+ options=rk.TidyOptions(strip_comments=True)
50
+ )
51
+ assert isinstance(tidied.bibtex, str)
52
+ assert tidy_warning_codes(tidied) == []
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes