texsmith 0.0.2.dev0__py3-none-any.whl
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.
- texsmith/__init__.py +107 -0
- texsmith/_alias.py +59 -0
- texsmith/adapters/__init__.py +6 -0
- texsmith/adapters/docker.py +258 -0
- texsmith/adapters/handlers/__init__.py +3 -0
- texsmith/adapters/handlers/_assets.py +363 -0
- texsmith/adapters/handlers/_helpers.py +62 -0
- texsmith/adapters/handlers/_mermaid.py +122 -0
- texsmith/adapters/handlers/admonitions.py +267 -0
- texsmith/adapters/handlers/basic.py +228 -0
- texsmith/adapters/handlers/blocks.py +851 -0
- texsmith/adapters/handlers/code.py +309 -0
- texsmith/adapters/handlers/inline.py +937 -0
- texsmith/adapters/handlers/links.py +239 -0
- texsmith/adapters/handlers/media.py +380 -0
- texsmith/adapters/latex/__init__.py +10 -0
- texsmith/adapters/latex/engines/__init__.py +726 -0
- texsmith/adapters/latex/engines/latex/__init__.py +26 -0
- texsmith/adapters/latex/engines/latex/log.py +720 -0
- texsmith/adapters/latex/engines/latex/runner.py +28 -0
- texsmith/adapters/latex/engines/tectonic/__init__.py +38 -0
- texsmith/adapters/latex/formatter.py +297 -0
- texsmith/adapters/latex/latexmk.py +148 -0
- texsmith/adapters/latex/partials/acronym.tex +1 -0
- texsmith/adapters/latex/partials/add.tex +1 -0
- texsmith/adapters/latex/partials/addition.tex +1 -0
- texsmith/adapters/latex/partials/blockquote.tex +3 -0
- texsmith/adapters/latex/partials/callout.tex +3 -0
- texsmith/adapters/latex/partials/choices.tex +5 -0
- texsmith/adapters/latex/partials/citation.tex +1 -0
- texsmith/adapters/latex/partials/codeblock.tex +7 -0
- texsmith/adapters/latex/partials/codeblock_listings.tex +5 -0
- texsmith/adapters/latex/partials/codeblock_pygments.tex +5 -0
- texsmith/adapters/latex/partials/codeblock_verbatim.tex +12 -0
- texsmith/adapters/latex/partials/codeinline.tex +1 -0
- texsmith/adapters/latex/partials/codeinlinett.tex +1 -0
- texsmith/adapters/latex/partials/comment.tex +1 -0
- texsmith/adapters/latex/partials/del.tex +1 -0
- texsmith/adapters/latex/partials/deletion.tex +1 -0
- texsmith/adapters/latex/partials/description_list.tex +5 -0
- texsmith/adapters/latex/partials/enquote.tex +1 -0
- texsmith/adapters/latex/partials/epigraph.tex +1 -0
- texsmith/adapters/latex/partials/exercises_solutions.tex +7 -0
- texsmith/adapters/latex/partials/figure.tex +33 -0
- texsmith/adapters/latex/partials/figure_tcolorbox.tex +15 -0
- texsmith/adapters/latex/partials/footnote.tex +3 -0
- texsmith/adapters/latex/partials/glossary.tex +1 -0
- texsmith/adapters/latex/partials/heading.tex +14 -0
- texsmith/adapters/latex/partials/highlight.tex +25 -0
- texsmith/adapters/latex/partials/horizontal_rule.tex +1 -0
- texsmith/adapters/latex/partials/href.tex +1 -0
- texsmith/adapters/latex/partials/icon.tex +1 -0
- texsmith/adapters/latex/partials/include.tex +1 -0
- texsmith/adapters/latex/partials/index.tex +1 -0
- texsmith/adapters/latex/partials/italic.tex +1 -0
- texsmith/adapters/latex/partials/keystroke.tex +46 -0
- texsmith/adapters/latex/partials/label.tex +1 -0
- texsmith/adapters/latex/partials/list_acronyms.tex +5 -0
- texsmith/adapters/latex/partials/list_glossary.tex +8 -0
- texsmith/adapters/latex/partials/multicolumn.tex +3 -0
- texsmith/adapters/latex/partials/ordered_list.tex +5 -0
- texsmith/adapters/latex/partials/pagestyle.tex +1 -0
- texsmith/adapters/latex/partials/ref.tex +1 -0
- texsmith/adapters/latex/partials/regex.tex +1 -0
- texsmith/adapters/latex/partials/smallcaps.tex +1 -0
- texsmith/adapters/latex/partials/strikethrough.tex +1 -0
- texsmith/adapters/latex/partials/strong.tex +1 -0
- texsmith/adapters/latex/partials/subscript.tex +1 -0
- texsmith/adapters/latex/partials/substitution.tex +1 -0
- texsmith/adapters/latex/partials/superscript.tex +1 -0
- texsmith/adapters/latex/partials/tabbed.tex +3 -0
- texsmith/adapters/latex/partials/table.tex +48 -0
- texsmith/adapters/latex/partials/underline.tex +1 -0
- texsmith/adapters/latex/partials/unordered_list.tex +5 -0
- texsmith/adapters/latex/partials/url.tex +1 -0
- texsmith/adapters/latex/pygments.py +98 -0
- texsmith/adapters/latex/pyxindy.py +64 -0
- texsmith/adapters/latex/renderer.py +220 -0
- texsmith/adapters/latex/tectonic.py +366 -0
- texsmith/adapters/latex/utils.py +86 -0
- texsmith/adapters/markdown/__init__.py +342 -0
- texsmith/adapters/plugins/__init__.py +8 -0
- texsmith/adapters/plugins/material.py +174 -0
- texsmith/adapters/plugins/snippet.py +1734 -0
- texsmith/adapters/transformers/__init__.py +122 -0
- texsmith/adapters/transformers/base.py +140 -0
- texsmith/adapters/transformers/strategies.py +1456 -0
- texsmith/adapters/transformers/utils.py +59 -0
- texsmith/api/__init__.py +80 -0
- texsmith/api/_utils.py +56 -0
- texsmith/api/document.py +645 -0
- texsmith/api/pipeline.py +229 -0
- texsmith/api/service.py +648 -0
- texsmith/api/templates.py +287 -0
- texsmith/core/__init__.py +6 -0
- texsmith/core/bibliography/__init__.py +57 -0
- texsmith/core/bibliography/collection.py +354 -0
- texsmith/core/bibliography/doi.py +194 -0
- texsmith/core/bibliography/issues.py +15 -0
- texsmith/core/bibliography/parsing.py +45 -0
- texsmith/core/callouts.py +99 -0
- texsmith/core/config.py +191 -0
- texsmith/core/context.py +242 -0
- texsmith/core/conversion/__init__.py +103 -0
- texsmith/core/conversion/core.py +780 -0
- texsmith/core/conversion/debug.py +87 -0
- texsmith/core/conversion/inputs.py +474 -0
- texsmith/core/conversion/renderer.py +578 -0
- texsmith/core/conversion/templates.py +646 -0
- texsmith/core/conversion_contexts.py +95 -0
- texsmith/core/diagnostics.py +118 -0
- texsmith/core/exceptions.py +41 -0
- texsmith/core/fonts/__init__.py +3 -0
- texsmith/core/fragments/__init__.py +716 -0
- texsmith/core/fragments/base.py +117 -0
- texsmith/core/metadata.py +280 -0
- texsmith/core/mustache.py +86 -0
- texsmith/core/partials.py +20 -0
- texsmith/core/rules.py +394 -0
- texsmith/core/templates/__init__.py +58 -0
- texsmith/core/templates/base.py +343 -0
- texsmith/core/templates/builtins.py +68 -0
- texsmith/core/templates/context_usage.py +137 -0
- texsmith/core/templates/loader.py +303 -0
- texsmith/core/templates/manifest.py +861 -0
- texsmith/core/templates/runtime.py +347 -0
- texsmith/core/templates/text.py +14 -0
- texsmith/core/templates/wrapper.py +340 -0
- texsmith/core/user_dir.py +179 -0
- texsmith/devtools.py +28 -0
- texsmith/extensions/__init__.py +162 -0
- texsmith/extensions/index/__init__.py +21 -0
- texsmith/extensions/index/markdown.py +94 -0
- texsmith/extensions/index/mkdocs_plugin.py +136 -0
- texsmith/extensions/index/registry.py +57 -0
- texsmith/extensions/index/renderer.py +183 -0
- texsmith/extensions/index/templates/index.tex +1 -0
- texsmith/extensions/latex_raw.py +115 -0
- texsmith/extensions/latex_text.py +117 -0
- texsmith/extensions/mermaid.py +267 -0
- texsmith/extensions/missing_footnotes.py +125 -0
- texsmith/extensions/multi_citations.py +54 -0
- texsmith/extensions/progressbar/__init__.py +9 -0
- texsmith/extensions/progressbar/markdown.py +212 -0
- texsmith/extensions/progressbar/renderer.py +117 -0
- texsmith/extensions/smallcaps.py +48 -0
- texsmith/extensions/texlogos/__init__.py +10 -0
- texsmith/extensions/texlogos/markdown.py +236 -0
- texsmith/extensions/texlogos/renderer.py +81 -0
- texsmith/extensions/texlogos/specs.py +66 -0
- texsmith/fonts/__init__.py +57 -0
- texsmith/fonts/cache.py +46 -0
- texsmith/fonts/constants.py +60 -0
- texsmith/fonts/coverage.py +275 -0
- texsmith/fonts/downloader.py +103 -0
- texsmith/fonts/fallback.py +438 -0
- texsmith/fonts/html_scripts.py +168 -0
- texsmith/fonts/logging.py +127 -0
- texsmith/fonts/pipeline.py +338 -0
- texsmith/fonts/scripts.py +493 -0
- texsmith/fonts/ucharclasses.py +164 -0
- texsmith/fragments/__init__.py +11 -0
- texsmith/fragments/bibliography/__init__.py +65 -0
- texsmith/fragments/bibliography/fragment.toml +3 -0
- texsmith/fragments/bibliography/ts-bibliography-backmatter.jinja.tex +35 -0
- texsmith/fragments/bibliography/ts-bibliography.jinja.tex +10 -0
- texsmith/fragments/callouts/__init__.py +88 -0
- texsmith/fragments/callouts/fragment.toml +3 -0
- texsmith/fragments/callouts/ts-callouts.jinja.sty +129 -0
- texsmith/fragments/code/__init__.py +82 -0
- texsmith/fragments/code/fragment.toml +3 -0
- texsmith/fragments/code/ts-code.jinja.sty +140 -0
- texsmith/fragments/extra/__init__.py +180 -0
- texsmith/fragments/extra/fragment.toml +3 -0
- texsmith/fragments/extra/ts-extra.jinja.tex +13 -0
- texsmith/fragments/fonts/__init__.py +880 -0
- texsmith/fragments/fonts/fragment.toml +3 -0
- texsmith/fragments/fonts/ts-fonts.jinja.sty +292 -0
- texsmith/fragments/frame/__init__.py +170 -0
- texsmith/fragments/frame/fragment.toml +3 -0
- texsmith/fragments/frame/ts-frame.tex.jinja +49 -0
- texsmith/fragments/geometry/__init__.py +169 -0
- texsmith/fragments/geometry/fragment.toml +3 -0
- texsmith/fragments/geometry/paper.py +526 -0
- texsmith/fragments/geometry/ts_geometry.tex.jinja +53 -0
- texsmith/fragments/glossary/__init__.py +67 -0
- texsmith/fragments/glossary/fragment.toml +3 -0
- texsmith/fragments/glossary/ts-glossary-backmatter.jinja.tex +5 -0
- texsmith/fragments/glossary/ts-glossary.jinja.sty +28 -0
- texsmith/fragments/index/__init__.py +66 -0
- texsmith/fragments/index/fragment.toml +3 -0
- texsmith/fragments/index/ts-index-backmatter.jinja.tex +3 -0
- texsmith/fragments/index/ts-index.jinja.sty +12 -0
- texsmith/fragments/keystrokes/__init__.py +69 -0
- texsmith/fragments/keystrokes/fragment.toml +3 -0
- texsmith/fragments/keystrokes/ts-keystrokes.jinja.sty +20 -0
- texsmith/fragments/todolist/__init__.py +71 -0
- texsmith/fragments/todolist/fragment.toml +3 -0
- texsmith/fragments/todolist/ts-todolist.jinja.sty +21 -0
- texsmith/fragments/typesetting/__init__.py +214 -0
- texsmith/fragments/typesetting/fragment.toml +3 -0
- texsmith/fragments/typesetting/ts-typesetting.tex.jinja +81 -0
- texsmith/index.py +26 -0
- texsmith/plugins/__init__.py +14 -0
- texsmith/progressbar.py +8 -0
- texsmith/quotes.py +40 -0
- texsmith/smart_dashes.py +66 -0
- texsmith/templates/__init__.py +3 -0
- texsmith/templates/article/README.md +33 -0
- texsmith/templates/article/__init__.py +281 -0
- texsmith/templates/article/template/manifest.toml +125 -0
- texsmith/templates/article/template/mermaid-config.json +18 -0
- texsmith/templates/article/template/template.tex +74 -0
- texsmith/templates/book/README.md +26 -0
- texsmith/templates/book/__init__.py +75 -0
- texsmith/templates/book/overrides/codeblock.tex +7 -0
- texsmith/templates/book/overrides/codeinline.tex +1 -0
- texsmith/templates/book/template/fixtoc.sty +81 -0
- texsmith/templates/book/template/manifest.toml +198 -0
- texsmith/templates/book/template/template.tex +314 -0
- texsmith/templates/common/__init__.py +1 -0
- texsmith/templates/common/latexmkrc +46 -0
- texsmith/templates/letter/README.md +59 -0
- texsmith/templates/letter/__init__.py +495 -0
- texsmith/templates/letter/demo.md +31 -0
- texsmith/templates/letter/fonts/modernline bold.otf +0 -0
- texsmith/templates/letter/fonts/modernline.otf +0 -0
- texsmith/templates/letter/manifest.toml +197 -0
- texsmith/templates/letter/template/callouts.jinja.sty +290 -0
- texsmith/templates/letter/template/template.tex +123 -0
- texsmith/templates/snippet/README.md +21 -0
- texsmith/templates/snippet/__init__.py +80 -0
- texsmith/templates/snippet/template/manifest.toml +66 -0
- texsmith/templates/snippet/template/template.tex +47 -0
- texsmith/texlogos.py +15 -0
- texsmith/ui/__init__.py +6 -0
- texsmith/ui/cli/__init__.py +22 -0
- texsmith/ui/cli/_options.py +338 -0
- texsmith/ui/cli/app.py +65 -0
- texsmith/ui/cli/bibliography.py +300 -0
- texsmith/ui/cli/commands/__init__.py +14 -0
- texsmith/ui/cli/commands/render.py +1128 -0
- texsmith/ui/cli/commands/templates.py +397 -0
- texsmith/ui/cli/diagnostics.py +36 -0
- texsmith/ui/cli/presenter.py +663 -0
- texsmith/ui/cli/state.py +263 -0
- texsmith/ui/cli/utils.py +235 -0
- texsmith-0.0.2.dev0.dist-info/METADATA +187 -0
- texsmith-0.0.2.dev0.dist-info/RECORD +252 -0
- texsmith-0.0.2.dev0.dist-info/WHEEL +4 -0
- texsmith-0.0.2.dev0.dist-info/entry_points.txt +22 -0
- texsmith-0.0.2.dev0.dist-info/licenses/LICENSE.md +21 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
"""Aggregation utilities for BibTeX references."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
6
|
+
import copy
|
|
7
|
+
import html
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import re
|
|
10
|
+
from typing import Any, cast
|
|
11
|
+
|
|
12
|
+
from pybtex.database import BibliographyData, Entry, Person
|
|
13
|
+
from pybtex.database.input import bibtex
|
|
14
|
+
from pybtex.exceptions import PybtexError
|
|
15
|
+
|
|
16
|
+
from .issues import BibliographyIssue
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BibliographyCollection:
|
|
20
|
+
"""Aggregate references from one or more BibTeX sources."""
|
|
21
|
+
|
|
22
|
+
def __init__(self) -> None:
|
|
23
|
+
self._entries: dict[str, Entry] = {}
|
|
24
|
+
self._sources: dict[str, set[Path]] = {}
|
|
25
|
+
self._issues: list[BibliographyIssue] = []
|
|
26
|
+
self._file_entry_counts: dict[Path, int] = {}
|
|
27
|
+
self._file_order: list[Path] = []
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def issues(self) -> Sequence[BibliographyIssue]:
|
|
31
|
+
"""Return the list of issues discovered while loading references."""
|
|
32
|
+
return tuple(self._issues)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def file_stats(self) -> Sequence[tuple[Path, int]]:
|
|
36
|
+
"""Return (file, entry_count) pairs in the order files were processed."""
|
|
37
|
+
return tuple((path, self._file_entry_counts.get(path, 0)) for path in self._file_order)
|
|
38
|
+
|
|
39
|
+
def load_files(self, files: Iterable[Path | str]) -> None:
|
|
40
|
+
"""Load BibTeX entries from one or more files."""
|
|
41
|
+
for file_path in files:
|
|
42
|
+
self._load_file(Path(file_path))
|
|
43
|
+
|
|
44
|
+
def _load_file(self, file_path: Path) -> None:
|
|
45
|
+
file_path = file_path.resolve()
|
|
46
|
+
self._file_order.append(file_path)
|
|
47
|
+
parser = bibtex.Parser()
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
data = parser.parse_file(str(file_path))
|
|
51
|
+
except (OSError, PybtexError) as exc:
|
|
52
|
+
self._issues.append(
|
|
53
|
+
BibliographyIssue(
|
|
54
|
+
message=f"Failed to parse '{file_path}': {exc}",
|
|
55
|
+
key=None,
|
|
56
|
+
source=file_path,
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
self._file_entry_counts[file_path] = 0
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
entry_count = len(data.entries)
|
|
63
|
+
self._file_entry_counts[file_path] = entry_count
|
|
64
|
+
if entry_count == 0:
|
|
65
|
+
self._issues.append(
|
|
66
|
+
BibliographyIssue(
|
|
67
|
+
message="No references found in file.",
|
|
68
|
+
key=None,
|
|
69
|
+
source=file_path,
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
self._merge_entries(data, file_path)
|
|
74
|
+
|
|
75
|
+
def load_data(
|
|
76
|
+
self,
|
|
77
|
+
data: BibliographyData,
|
|
78
|
+
*,
|
|
79
|
+
source: Path | str | None = None,
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Merge pre-parsed bibliography data into the collection."""
|
|
82
|
+
source_path = self._resolve_source_path(source)
|
|
83
|
+
entry_count = len(data.entries)
|
|
84
|
+
self._file_entry_counts[source_path] = (
|
|
85
|
+
self._file_entry_counts.get(source_path, 0) + entry_count
|
|
86
|
+
)
|
|
87
|
+
if source_path not in self._file_order:
|
|
88
|
+
self._file_order.append(source_path)
|
|
89
|
+
|
|
90
|
+
if entry_count == 0:
|
|
91
|
+
self._issues.append(
|
|
92
|
+
BibliographyIssue(
|
|
93
|
+
message="No references found in inline bibliography data.",
|
|
94
|
+
key=None,
|
|
95
|
+
source=source_path,
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
self._merge_entries(data, source_path)
|
|
101
|
+
|
|
102
|
+
def _resolve_source_path(self, source: Path | str | None) -> Path:
|
|
103
|
+
if source is None:
|
|
104
|
+
return Path("inline-bibliography.bib")
|
|
105
|
+
if isinstance(source, Path):
|
|
106
|
+
return source
|
|
107
|
+
return Path(source)
|
|
108
|
+
|
|
109
|
+
def _merge_entries(self, data: BibliographyData, source: Path) -> None:
|
|
110
|
+
for key, entry in data.entries.items():
|
|
111
|
+
self._sanitize_entry(entry)
|
|
112
|
+
existing = self._entries.get(key)
|
|
113
|
+
|
|
114
|
+
if existing is None:
|
|
115
|
+
self._entries[key] = entry
|
|
116
|
+
self._sources[key] = {source}
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
if not self._entries_equivalent(existing, entry):
|
|
120
|
+
self._issues.append(
|
|
121
|
+
BibliographyIssue(
|
|
122
|
+
message=(
|
|
123
|
+
"Duplicate entry conflicts with an existing "
|
|
124
|
+
"reference; ignoring the newer definition."
|
|
125
|
+
),
|
|
126
|
+
key=key,
|
|
127
|
+
source=source,
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
self._sources[key].add(source)
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
# Matching duplicates still originate from multiple sources.
|
|
134
|
+
self._sources[key].add(source)
|
|
135
|
+
|
|
136
|
+
def _sanitize_entry(self, entry: Entry) -> None:
|
|
137
|
+
for field_name, value in list(entry.fields.items()):
|
|
138
|
+
if field_name.lower() == "month" and isinstance(value, str):
|
|
139
|
+
normalised_month = _normalise_month_field(value)
|
|
140
|
+
if normalised_month is not None and normalised_month != value:
|
|
141
|
+
entry.fields[field_name] = normalised_month
|
|
142
|
+
continue
|
|
143
|
+
if not isinstance(value, str):
|
|
144
|
+
continue
|
|
145
|
+
sanitized = _sanitize_field_text(value, field=field_name)
|
|
146
|
+
if sanitized != value:
|
|
147
|
+
entry.fields[field_name] = sanitized
|
|
148
|
+
|
|
149
|
+
def find(self, reference_key: str) -> dict[str, Any] | None:
|
|
150
|
+
"""Return the portable representation of a specific reference."""
|
|
151
|
+
entry = self._entries.get(reference_key)
|
|
152
|
+
if entry is None:
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
return self._portable_entry(reference_key, entry, self._sources[reference_key])
|
|
156
|
+
|
|
157
|
+
def list_references(self) -> list[dict[str, Any]]:
|
|
158
|
+
"""Return all references as portable dictionaries sorted by key."""
|
|
159
|
+
portable: list[dict[str, Any]] = []
|
|
160
|
+
for key in sorted(self._entries):
|
|
161
|
+
portable.append(self._portable_entry(key, self._entries[key], self._sources[key]))
|
|
162
|
+
return portable
|
|
163
|
+
|
|
164
|
+
def to_dict(self) -> dict[str, dict[str, Any]]:
|
|
165
|
+
"""Return a dictionary keyed by reference identifiers."""
|
|
166
|
+
return {
|
|
167
|
+
key: self._portable_entry(key, entry, self._sources[key])
|
|
168
|
+
for key, entry in self._entries.items()
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
def to_bibliography_data(self, *, keys: Iterable[str] | None = None) -> BibliographyData:
|
|
172
|
+
"""Return a BibliographyData object scoped to the selected keys."""
|
|
173
|
+
if keys is None:
|
|
174
|
+
entries = dict(self._entries)
|
|
175
|
+
else:
|
|
176
|
+
selected = {key for key in keys if key in self._entries}
|
|
177
|
+
entries = {key: self._entries[key] for key in selected}
|
|
178
|
+
return BibliographyData(entries=entries)
|
|
179
|
+
|
|
180
|
+
def write_bibtex(self, target: Path | str, *, keys: Iterable[str] | None = None) -> None:
|
|
181
|
+
"""Persist the bibliography to a BibTeX file."""
|
|
182
|
+
path = Path(target)
|
|
183
|
+
data = self.to_bibliography_data(keys=keys)
|
|
184
|
+
raw_text = data.to_string("bibtex")
|
|
185
|
+
sanitized_lines = []
|
|
186
|
+
for line in raw_text.splitlines():
|
|
187
|
+
stripped = line.lstrip().lower()
|
|
188
|
+
if stripped.startswith("url =") or stripped.startswith("doi ="):
|
|
189
|
+
line = line.replace(r"\_", "_")
|
|
190
|
+
sanitized_lines.append(line)
|
|
191
|
+
payload = "\n".join(sanitized_lines).rstrip() + "\n"
|
|
192
|
+
try:
|
|
193
|
+
existing = path.read_text(encoding="utf-8")
|
|
194
|
+
except FileNotFoundError:
|
|
195
|
+
existing = None
|
|
196
|
+
except OSError:
|
|
197
|
+
existing = None
|
|
198
|
+
if existing == payload:
|
|
199
|
+
return
|
|
200
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
201
|
+
path.write_text(payload, encoding="utf-8")
|
|
202
|
+
|
|
203
|
+
def clone(self) -> BibliographyCollection:
|
|
204
|
+
"""Return a deep copy of the collection without reparsing sources."""
|
|
205
|
+
cloned = BibliographyCollection()
|
|
206
|
+
cloned._entries = copy.deepcopy(self._entries)
|
|
207
|
+
cloned._sources = copy.deepcopy(self._sources)
|
|
208
|
+
cloned._issues = list(self._issues)
|
|
209
|
+
cloned._file_entry_counts = dict(self._file_entry_counts)
|
|
210
|
+
cloned._file_order = list(self._file_order)
|
|
211
|
+
return cloned
|
|
212
|
+
|
|
213
|
+
def _entries_equivalent(self, first: Entry, second: Entry) -> bool:
|
|
214
|
+
return self._entry_signature(first) == self._entry_signature(second)
|
|
215
|
+
|
|
216
|
+
def _entry_signature(self, entry: Entry) -> dict[str, Any]:
|
|
217
|
+
fields = {str(field_name): value for field_name, value in _iter_mapping_items(entry.fields)}
|
|
218
|
+
persons_payload: dict[str, list[tuple[tuple[str, ...], ...]]] = {}
|
|
219
|
+
for role_name, persons in _iter_mapping_items(entry.persons):
|
|
220
|
+
if not isinstance(persons, Iterable):
|
|
221
|
+
persons_payload[str(role_name)] = []
|
|
222
|
+
continue
|
|
223
|
+
relevant_people = [
|
|
224
|
+
self._person_signature(person) for person in persons if isinstance(person, Person)
|
|
225
|
+
]
|
|
226
|
+
persons_payload[str(role_name)] = relevant_people
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
"type": entry.type,
|
|
230
|
+
"fields": fields,
|
|
231
|
+
"persons": persons_payload,
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
def _portable_entry(self, key: str, entry: Entry, sources: set[Path]) -> dict[str, Any]:
|
|
235
|
+
fields = {str(field_name): value for field_name, value in _iter_mapping_items(entry.fields)}
|
|
236
|
+
persons_payload: dict[str, list[dict[str, Any]]] = {}
|
|
237
|
+
for role_name, persons in _iter_mapping_items(entry.persons):
|
|
238
|
+
if not isinstance(persons, Iterable):
|
|
239
|
+
persons_payload[str(role_name)] = []
|
|
240
|
+
continue
|
|
241
|
+
people_payload = [
|
|
242
|
+
self._person_payload(person) for person in persons if isinstance(person, Person)
|
|
243
|
+
]
|
|
244
|
+
persons_payload[str(role_name)] = people_payload
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
"key": key,
|
|
248
|
+
"type": entry.type,
|
|
249
|
+
"fields": fields,
|
|
250
|
+
"persons": persons_payload,
|
|
251
|
+
"source_files": sorted(str(path) for path in sources),
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
def _person_signature(self, person: Person) -> tuple[tuple[str, ...], ...]:
|
|
255
|
+
signature: list[tuple[str, ...]] = []
|
|
256
|
+
for attribute in (
|
|
257
|
+
"first_names",
|
|
258
|
+
"middle_names",
|
|
259
|
+
"prelast_names",
|
|
260
|
+
"last_names",
|
|
261
|
+
"lineage_names",
|
|
262
|
+
):
|
|
263
|
+
value = getattr(person, attribute, ())
|
|
264
|
+
signature.append(tuple(str(part) for part in value))
|
|
265
|
+
return tuple(signature)
|
|
266
|
+
|
|
267
|
+
def _person_payload(self, person: Person) -> dict[str, Any]:
|
|
268
|
+
payload: dict[str, Any] = {}
|
|
269
|
+
for attribute, key in (
|
|
270
|
+
("first_names", "first"),
|
|
271
|
+
("middle_names", "middle"),
|
|
272
|
+
("prelast_names", "prelast"),
|
|
273
|
+
("last_names", "last"),
|
|
274
|
+
("lineage_names", "lineage"),
|
|
275
|
+
):
|
|
276
|
+
value = getattr(person, attribute, ())
|
|
277
|
+
payload[key] = [str(part) for part in value]
|
|
278
|
+
|
|
279
|
+
payload["text"] = str(person)
|
|
280
|
+
return payload
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
_HTML_TAG_RE = re.compile(r"<[^>]+?>")
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _sanitize_field_text(value: str, *, field: str | None = None) -> str:
|
|
287
|
+
"""Strip lightweight HTML markup and unescape entities from bibliography fields."""
|
|
288
|
+
if "<" in value and ">" in value:
|
|
289
|
+
value = _HTML_TAG_RE.sub("", value)
|
|
290
|
+
value = html.unescape(value)
|
|
291
|
+
if field and field.lower() in {"url", "doi"}:
|
|
292
|
+
value = value.replace(r"\_", "_")
|
|
293
|
+
return value
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
_MONTH_NAME_TO_INT: dict[str, int] = {
|
|
297
|
+
"jan": 1,
|
|
298
|
+
"january": 1,
|
|
299
|
+
"feb": 2,
|
|
300
|
+
"february": 2,
|
|
301
|
+
"mar": 3,
|
|
302
|
+
"march": 3,
|
|
303
|
+
"apr": 4,
|
|
304
|
+
"april": 4,
|
|
305
|
+
"may": 5,
|
|
306
|
+
"jun": 6,
|
|
307
|
+
"june": 6,
|
|
308
|
+
"jul": 7,
|
|
309
|
+
"july": 7,
|
|
310
|
+
"aug": 8,
|
|
311
|
+
"august": 8,
|
|
312
|
+
"sep": 9,
|
|
313
|
+
"sept": 9,
|
|
314
|
+
"september": 9,
|
|
315
|
+
"oct": 10,
|
|
316
|
+
"october": 10,
|
|
317
|
+
"nov": 11,
|
|
318
|
+
"november": 11,
|
|
319
|
+
"dec": 12,
|
|
320
|
+
"december": 12,
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _normalise_month_field(value: str) -> str | None:
|
|
325
|
+
"""Convert month names/abbreviations to their integer representation."""
|
|
326
|
+
candidate = value.strip().strip("{}\"'").lower()
|
|
327
|
+
if not candidate:
|
|
328
|
+
return None
|
|
329
|
+
|
|
330
|
+
if candidate.isdigit():
|
|
331
|
+
try:
|
|
332
|
+
month_int = int(candidate)
|
|
333
|
+
except ValueError:
|
|
334
|
+
return None
|
|
335
|
+
if 1 <= month_int <= 12:
|
|
336
|
+
return f"{month_int:02d}"
|
|
337
|
+
return None
|
|
338
|
+
|
|
339
|
+
month_int = _MONTH_NAME_TO_INT.get(candidate)
|
|
340
|
+
if month_int is None:
|
|
341
|
+
return None
|
|
342
|
+
return f"{month_int:02d}"
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _iter_mapping_items(value: object) -> Iterable[tuple[object, object]]:
|
|
346
|
+
if isinstance(value, Mapping):
|
|
347
|
+
yield from value.items()
|
|
348
|
+
return
|
|
349
|
+
|
|
350
|
+
items = getattr(value, "items", None)
|
|
351
|
+
if callable(items):
|
|
352
|
+
result = items()
|
|
353
|
+
if isinstance(result, Iterable):
|
|
354
|
+
yield from cast(Iterable[tuple[object, object]], result)
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Helpers for resolving DOIs to BibTeX payloads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, MutableMapping
|
|
6
|
+
from hashlib import sha256
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from threading import Lock
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
13
|
+
from requests import Session as RequestsSession # type: ignore[import]
|
|
14
|
+
else:
|
|
15
|
+
RequestsSession = Any # type: ignore[misc]
|
|
16
|
+
|
|
17
|
+
try: # pragma: no cover - optional dependency
|
|
18
|
+
import requests # type: ignore[import]
|
|
19
|
+
except ImportError: # pragma: no cover - optional dependency
|
|
20
|
+
requests = None # type: ignore[assignment]
|
|
21
|
+
|
|
22
|
+
from texsmith.core.user_dir import get_user_dir
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class DoiLookupError(Exception):
|
|
26
|
+
"""Raised when resolving a DOI to a BibTeX payload fails."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def normalise_doi(value: str) -> str:
|
|
30
|
+
"""Return a canonical representation for DOI strings."""
|
|
31
|
+
if not isinstance(value, str):
|
|
32
|
+
raise DoiLookupError("DOI must be provided as a string.")
|
|
33
|
+
candidate = value.strip()
|
|
34
|
+
if not candidate:
|
|
35
|
+
raise DoiLookupError("DOI value is empty.")
|
|
36
|
+
|
|
37
|
+
lowered = candidate.lower()
|
|
38
|
+
for prefix in (
|
|
39
|
+
"https://doi.org/",
|
|
40
|
+
"http://doi.org/",
|
|
41
|
+
"https://dx.doi.org/",
|
|
42
|
+
"http://dx.doi.org/",
|
|
43
|
+
):
|
|
44
|
+
if lowered.startswith(prefix):
|
|
45
|
+
candidate = candidate[len(prefix) :]
|
|
46
|
+
break
|
|
47
|
+
|
|
48
|
+
candidate = candidate.strip()
|
|
49
|
+
if candidate.lower().startswith("doi:"):
|
|
50
|
+
candidate = candidate.split(":", 1)[1]
|
|
51
|
+
|
|
52
|
+
candidate = candidate.strip().strip("/")
|
|
53
|
+
if not candidate:
|
|
54
|
+
raise DoiLookupError("DOI value is empty.")
|
|
55
|
+
return candidate
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class DoiBibliographyFetcher:
|
|
59
|
+
"""Retrieve BibTeX entries for DOIs using content negotiation fallbacks."""
|
|
60
|
+
|
|
61
|
+
_DEFAULT_USER_AGENT = "texsmith-bibliography-fetcher"
|
|
62
|
+
_BIBTEX_ACCEPT = "application/x-bibtex"
|
|
63
|
+
_CACHE_NAMESPACE = "bibliography"
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
*,
|
|
68
|
+
session: RequestsSession | None = None,
|
|
69
|
+
timeout: float = 10.0,
|
|
70
|
+
user_agent: str | None = None,
|
|
71
|
+
cache: MutableMapping[str, str] | None = None,
|
|
72
|
+
cache_dir: Path | None = None,
|
|
73
|
+
enable_cache: bool = True,
|
|
74
|
+
) -> None:
|
|
75
|
+
self._session_lock = Lock()
|
|
76
|
+
self._session: RequestsSession | None = session
|
|
77
|
+
self._timeout = timeout
|
|
78
|
+
self._user_agent = user_agent or self._DEFAULT_USER_AGENT
|
|
79
|
+
self._cache: MutableMapping[str, str] = cache or {}
|
|
80
|
+
self._enable_cache = enable_cache
|
|
81
|
+
resolved_cache_dir = self._resolve_cache_dir(cache_dir) if enable_cache else None
|
|
82
|
+
if session is not None and cache_dir is None:
|
|
83
|
+
resolved_cache_dir = None
|
|
84
|
+
self._cache_dir = resolved_cache_dir if enable_cache else None
|
|
85
|
+
|
|
86
|
+
if self._cache_dir is not None:
|
|
87
|
+
self._cache_dir.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
|
|
89
|
+
def fetch(self, value: str) -> str:
|
|
90
|
+
"""Return the BibTeX payload for a DOI, trying multiple providers."""
|
|
91
|
+
if requests is None:
|
|
92
|
+
msg = (
|
|
93
|
+
"Python 'requests' dependency is required to resolve DOIs. "
|
|
94
|
+
"Install it via 'pip install requests'."
|
|
95
|
+
)
|
|
96
|
+
raise DoiLookupError(msg)
|
|
97
|
+
|
|
98
|
+
doi = self._normalise(value)
|
|
99
|
+
cached = self._read_cache(doi)
|
|
100
|
+
if cached is not None:
|
|
101
|
+
return cached
|
|
102
|
+
|
|
103
|
+
attempts: list[str] = []
|
|
104
|
+
client = self._ensure_session()
|
|
105
|
+
for url, headers in self._candidate_requests(doi):
|
|
106
|
+
try:
|
|
107
|
+
response = client.get(url, headers=headers, timeout=self._timeout)
|
|
108
|
+
except requests.RequestException as exc:
|
|
109
|
+
attempts.append(f"{url}: {exc}")
|
|
110
|
+
continue
|
|
111
|
+
if response.status_code >= 400:
|
|
112
|
+
attempts.append(f"{url}: HTTP {response.status_code}")
|
|
113
|
+
continue
|
|
114
|
+
content = response.text.strip()
|
|
115
|
+
if content:
|
|
116
|
+
self._write_cache(doi, content)
|
|
117
|
+
return content
|
|
118
|
+
attempts.append(f"{url}: empty response")
|
|
119
|
+
detail = "; ".join(attempts) if attempts else "no responses"
|
|
120
|
+
raise DoiLookupError(f"Unable to resolve DOI '{doi}': {detail}")
|
|
121
|
+
|
|
122
|
+
def _normalise(self, value: str) -> str:
|
|
123
|
+
return normalise_doi(value)
|
|
124
|
+
|
|
125
|
+
def _candidate_requests(self, doi: str) -> Iterable[tuple[str, dict[str, str]]]:
|
|
126
|
+
base_headers = {"User-Agent": self._user_agent}
|
|
127
|
+
|
|
128
|
+
yield (
|
|
129
|
+
f"https://doi.org/{doi}",
|
|
130
|
+
{**base_headers, "Accept": self._BIBTEX_ACCEPT},
|
|
131
|
+
)
|
|
132
|
+
yield (
|
|
133
|
+
f"https://dx.doi.org/{doi}",
|
|
134
|
+
{**base_headers, "Accept": self._BIBTEX_ACCEPT},
|
|
135
|
+
)
|
|
136
|
+
yield (
|
|
137
|
+
f"https://api.crossref.org/works/{doi}/transform/application/x-bibtex",
|
|
138
|
+
dict(base_headers),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# ------------------------------------------------------------------ caching
|
|
142
|
+
|
|
143
|
+
def _read_cache(self, doi: str) -> str | None:
|
|
144
|
+
if not self._enable_cache:
|
|
145
|
+
return None
|
|
146
|
+
if doi in self._cache:
|
|
147
|
+
return self._cache[doi]
|
|
148
|
+
path = self._disk_cache_path(doi)
|
|
149
|
+
if path is None or not path.exists():
|
|
150
|
+
return None
|
|
151
|
+
try:
|
|
152
|
+
payload = path.read_text(encoding="utf-8")
|
|
153
|
+
except OSError:
|
|
154
|
+
return None
|
|
155
|
+
self._cache[doi] = payload
|
|
156
|
+
return payload
|
|
157
|
+
|
|
158
|
+
def _write_cache(self, doi: str, payload: str) -> None:
|
|
159
|
+
if not self._enable_cache:
|
|
160
|
+
return
|
|
161
|
+
self._cache[doi] = payload
|
|
162
|
+
path = self._disk_cache_path(doi)
|
|
163
|
+
if path is None:
|
|
164
|
+
return
|
|
165
|
+
try:
|
|
166
|
+
path.write_text(payload, encoding="utf-8")
|
|
167
|
+
except OSError:
|
|
168
|
+
# Disk caches should never break the primary workflow.
|
|
169
|
+
return
|
|
170
|
+
|
|
171
|
+
def _disk_cache_path(self, doi: str) -> Path | None:
|
|
172
|
+
if self._cache_dir is None:
|
|
173
|
+
return None
|
|
174
|
+
digest = sha256(doi.encode("utf-8")).hexdigest()
|
|
175
|
+
return self._cache_dir / f"{digest}.bib"
|
|
176
|
+
|
|
177
|
+
def _resolve_cache_dir(self, override: Path | None) -> Path | None:
|
|
178
|
+
if override is not None:
|
|
179
|
+
return override
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
return get_user_dir().cache_dir(self._CACHE_NAMESPACE)
|
|
183
|
+
except OSError:
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
# ------------------------------------------------------------------ requests
|
|
187
|
+
|
|
188
|
+
def _ensure_session(self) -> RequestsSession:
|
|
189
|
+
if self._session is not None:
|
|
190
|
+
return self._session
|
|
191
|
+
with self._session_lock:
|
|
192
|
+
if self._session is None:
|
|
193
|
+
self._session = requests.Session()
|
|
194
|
+
return self._session
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Shared data structures for bibliography processing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(slots=True)
|
|
10
|
+
class BibliographyIssue:
|
|
11
|
+
"""Represents a problem encountered while loading bibliography entries."""
|
|
12
|
+
|
|
13
|
+
message: str
|
|
14
|
+
key: str | None = None
|
|
15
|
+
source: Path | None = None
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Parsing helpers for bibliography payloads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
|
|
7
|
+
from pybtex.database import BibliographyData, Entry, Person
|
|
8
|
+
from pybtex.database.input import bibtex
|
|
9
|
+
from pybtex.exceptions import PybtexError
|
|
10
|
+
|
|
11
|
+
from texsmith.core.conversion.inputs import InlineBibliographyEntry
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def bibliography_data_from_string(payload: str, key: str) -> BibliographyData:
|
|
15
|
+
"""Parse a BibTeX payload and scope it to a specific reference key."""
|
|
16
|
+
parser = bibtex.Parser()
|
|
17
|
+
try:
|
|
18
|
+
parsed = parser.parse_stream(io.StringIO(payload))
|
|
19
|
+
except (OSError, PybtexError) as exc:
|
|
20
|
+
raise PybtexError(f"Failed to parse inline bibliography payload: {exc}") from exc
|
|
21
|
+
|
|
22
|
+
entries = list(parsed.entries.items())
|
|
23
|
+
if not entries:
|
|
24
|
+
raise PybtexError("Inline bibliography payload does not contain an entry.")
|
|
25
|
+
if len(entries) > 1:
|
|
26
|
+
raise PybtexError("Inline bibliography payload must contain a single entry.")
|
|
27
|
+
|
|
28
|
+
_, entry = entries[0]
|
|
29
|
+
return BibliographyData(entries={key: entry})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def bibliography_data_from_inline_entry(
|
|
33
|
+
key: str,
|
|
34
|
+
entry: InlineBibliographyEntry,
|
|
35
|
+
) -> BibliographyData:
|
|
36
|
+
"""Create a BibliographyData instance from a manual inline entry."""
|
|
37
|
+
if not entry.is_manual or not entry.entry_type:
|
|
38
|
+
raise ValueError("Inline entry must define a manual type before conversion.")
|
|
39
|
+
|
|
40
|
+
persons_payload = {
|
|
41
|
+
role: [Person(name) for name in names] for role, names in entry.persons.items() if names
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
bib_entry = Entry(entry.entry_type, fields=dict(entry.fields), persons=persons_payload)
|
|
45
|
+
return BibliographyData(entries={key: bib_entry})
|