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.
- {refkit-0.0.2 → refkit-0.0.3}/PKG-INFO +58 -14
- {refkit-0.0.2 → refkit-0.0.3}/README.md +56 -12
- {refkit-0.0.2 → refkit-0.0.3}/pyproject.toml +2 -2
- {refkit-0.0.2 → refkit-0.0.3}/src/refkit/__init__.py +39 -3
- {refkit-0.0.2 → refkit-0.0.3}/src/refkit/__init__.pyi +23 -0
- refkit-0.0.3/tests/test_properties.py +50 -0
- {refkit-0.0.2 → refkit-0.0.3}/tests/test_public_api.py +183 -70
- {refkit-0.0.2 → refkit-0.0.3}/tests/test_pyodide.py +9 -1
- refkit-0.0.3/tests/test_tidy_options.py +152 -0
- {refkit-0.0.2 → refkit-0.0.3}/tests/test_type_contracts.py +12 -3
- {refkit-0.0.2 → refkit-0.0.3}/.gitignore +0 -0
- {refkit-0.0.2 → refkit-0.0.3}/LICENSE +0 -0
- {refkit-0.0.2 → refkit-0.0.3}/NOTICE +0 -0
- {refkit-0.0.2 → refkit-0.0.3}/src/refkit/py.typed +0 -0
- {refkit-0.0.2 → refkit-0.0.3}/tests/conftest.py +0 -0
- {refkit-0.0.2 → refkit-0.0.3}/tests/fixtures/basic.bib +0 -0
- {refkit-0.0.2 → refkit-0.0.3}/tests/fixtures/hayagriva-rich.yaml +0 -0
- {refkit-0.0.2 → refkit-0.0.3}/tests/fixtures/parent.yaml +0 -0
- {refkit-0.0.2 → refkit-0.0.3}/tests/fixtures/parent.yml +0 -0
- {refkit-0.0.2 → refkit-0.0.3}/tests/fixtures/raw-duplicates.bib +0 -0
- {refkit-0.0.2 → refkit-0.0.3}/tests/fixtures/raw.bib +0 -0
- {refkit-0.0.2 → refkit-0.0.3}/tests/fixtures/refkit-note.csl +0 -0
- {refkit-0.0.2 → refkit-0.0.3}/tests/fixtures/typst-biblatex.bib +0 -0
- {refkit-0.0.2 → refkit-0.0.3}/tests/fixtures/typst-raw.bib +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: refkit
|
|
3
|
-
Version: 0.0.
|
|
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.
|
|
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
|
|
34
|
+
`refkit-core` contains the Rust/PyO3 extension as `refkit_core._refkit_core`, including PyEmscripten wheels for Pyodide.
|
|
35
35
|
|
|
36
|
-
|
|
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
|
|
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
|
-
|
|
285
|
+
Run every workspace gate from the repository root:
|
|
238
286
|
|
|
239
287
|
```bash
|
|
240
|
-
make
|
|
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
|
|
12
|
+
`refkit-core` contains the Rust/PyO3 extension as `refkit_core._refkit_core`, including PyEmscripten wheels for Pyodide.
|
|
13
13
|
|
|
14
|
-
|
|
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
|
|
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
|
-
|
|
263
|
+
Run every workspace gate from the repository root:
|
|
216
264
|
|
|
217
265
|
```bash
|
|
218
|
-
make
|
|
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.
|
|
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.
|
|
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__ ==
|
|
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__}
|
|
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
|
|
206
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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) ==
|
|
861
|
-
assert
|
|
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"]
|
|
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[
|
|
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[
|
|
1377
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1426
|
-
|
|
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
|
|
1432
|
-
assert [field.value for field in
|
|
1433
|
-
|
|
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
|
-
|
|
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", "
|
|
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
|
|
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
|
|
37
|
-
assert
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|