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.
Files changed (252) hide show
  1. texsmith/__init__.py +107 -0
  2. texsmith/_alias.py +59 -0
  3. texsmith/adapters/__init__.py +6 -0
  4. texsmith/adapters/docker.py +258 -0
  5. texsmith/adapters/handlers/__init__.py +3 -0
  6. texsmith/adapters/handlers/_assets.py +363 -0
  7. texsmith/adapters/handlers/_helpers.py +62 -0
  8. texsmith/adapters/handlers/_mermaid.py +122 -0
  9. texsmith/adapters/handlers/admonitions.py +267 -0
  10. texsmith/adapters/handlers/basic.py +228 -0
  11. texsmith/adapters/handlers/blocks.py +851 -0
  12. texsmith/adapters/handlers/code.py +309 -0
  13. texsmith/adapters/handlers/inline.py +937 -0
  14. texsmith/adapters/handlers/links.py +239 -0
  15. texsmith/adapters/handlers/media.py +380 -0
  16. texsmith/adapters/latex/__init__.py +10 -0
  17. texsmith/adapters/latex/engines/__init__.py +726 -0
  18. texsmith/adapters/latex/engines/latex/__init__.py +26 -0
  19. texsmith/adapters/latex/engines/latex/log.py +720 -0
  20. texsmith/adapters/latex/engines/latex/runner.py +28 -0
  21. texsmith/adapters/latex/engines/tectonic/__init__.py +38 -0
  22. texsmith/adapters/latex/formatter.py +297 -0
  23. texsmith/adapters/latex/latexmk.py +148 -0
  24. texsmith/adapters/latex/partials/acronym.tex +1 -0
  25. texsmith/adapters/latex/partials/add.tex +1 -0
  26. texsmith/adapters/latex/partials/addition.tex +1 -0
  27. texsmith/adapters/latex/partials/blockquote.tex +3 -0
  28. texsmith/adapters/latex/partials/callout.tex +3 -0
  29. texsmith/adapters/latex/partials/choices.tex +5 -0
  30. texsmith/adapters/latex/partials/citation.tex +1 -0
  31. texsmith/adapters/latex/partials/codeblock.tex +7 -0
  32. texsmith/adapters/latex/partials/codeblock_listings.tex +5 -0
  33. texsmith/adapters/latex/partials/codeblock_pygments.tex +5 -0
  34. texsmith/adapters/latex/partials/codeblock_verbatim.tex +12 -0
  35. texsmith/adapters/latex/partials/codeinline.tex +1 -0
  36. texsmith/adapters/latex/partials/codeinlinett.tex +1 -0
  37. texsmith/adapters/latex/partials/comment.tex +1 -0
  38. texsmith/adapters/latex/partials/del.tex +1 -0
  39. texsmith/adapters/latex/partials/deletion.tex +1 -0
  40. texsmith/adapters/latex/partials/description_list.tex +5 -0
  41. texsmith/adapters/latex/partials/enquote.tex +1 -0
  42. texsmith/adapters/latex/partials/epigraph.tex +1 -0
  43. texsmith/adapters/latex/partials/exercises_solutions.tex +7 -0
  44. texsmith/adapters/latex/partials/figure.tex +33 -0
  45. texsmith/adapters/latex/partials/figure_tcolorbox.tex +15 -0
  46. texsmith/adapters/latex/partials/footnote.tex +3 -0
  47. texsmith/adapters/latex/partials/glossary.tex +1 -0
  48. texsmith/adapters/latex/partials/heading.tex +14 -0
  49. texsmith/adapters/latex/partials/highlight.tex +25 -0
  50. texsmith/adapters/latex/partials/horizontal_rule.tex +1 -0
  51. texsmith/adapters/latex/partials/href.tex +1 -0
  52. texsmith/adapters/latex/partials/icon.tex +1 -0
  53. texsmith/adapters/latex/partials/include.tex +1 -0
  54. texsmith/adapters/latex/partials/index.tex +1 -0
  55. texsmith/adapters/latex/partials/italic.tex +1 -0
  56. texsmith/adapters/latex/partials/keystroke.tex +46 -0
  57. texsmith/adapters/latex/partials/label.tex +1 -0
  58. texsmith/adapters/latex/partials/list_acronyms.tex +5 -0
  59. texsmith/adapters/latex/partials/list_glossary.tex +8 -0
  60. texsmith/adapters/latex/partials/multicolumn.tex +3 -0
  61. texsmith/adapters/latex/partials/ordered_list.tex +5 -0
  62. texsmith/adapters/latex/partials/pagestyle.tex +1 -0
  63. texsmith/adapters/latex/partials/ref.tex +1 -0
  64. texsmith/adapters/latex/partials/regex.tex +1 -0
  65. texsmith/adapters/latex/partials/smallcaps.tex +1 -0
  66. texsmith/adapters/latex/partials/strikethrough.tex +1 -0
  67. texsmith/adapters/latex/partials/strong.tex +1 -0
  68. texsmith/adapters/latex/partials/subscript.tex +1 -0
  69. texsmith/adapters/latex/partials/substitution.tex +1 -0
  70. texsmith/adapters/latex/partials/superscript.tex +1 -0
  71. texsmith/adapters/latex/partials/tabbed.tex +3 -0
  72. texsmith/adapters/latex/partials/table.tex +48 -0
  73. texsmith/adapters/latex/partials/underline.tex +1 -0
  74. texsmith/adapters/latex/partials/unordered_list.tex +5 -0
  75. texsmith/adapters/latex/partials/url.tex +1 -0
  76. texsmith/adapters/latex/pygments.py +98 -0
  77. texsmith/adapters/latex/pyxindy.py +64 -0
  78. texsmith/adapters/latex/renderer.py +220 -0
  79. texsmith/adapters/latex/tectonic.py +366 -0
  80. texsmith/adapters/latex/utils.py +86 -0
  81. texsmith/adapters/markdown/__init__.py +342 -0
  82. texsmith/adapters/plugins/__init__.py +8 -0
  83. texsmith/adapters/plugins/material.py +174 -0
  84. texsmith/adapters/plugins/snippet.py +1734 -0
  85. texsmith/adapters/transformers/__init__.py +122 -0
  86. texsmith/adapters/transformers/base.py +140 -0
  87. texsmith/adapters/transformers/strategies.py +1456 -0
  88. texsmith/adapters/transformers/utils.py +59 -0
  89. texsmith/api/__init__.py +80 -0
  90. texsmith/api/_utils.py +56 -0
  91. texsmith/api/document.py +645 -0
  92. texsmith/api/pipeline.py +229 -0
  93. texsmith/api/service.py +648 -0
  94. texsmith/api/templates.py +287 -0
  95. texsmith/core/__init__.py +6 -0
  96. texsmith/core/bibliography/__init__.py +57 -0
  97. texsmith/core/bibliography/collection.py +354 -0
  98. texsmith/core/bibliography/doi.py +194 -0
  99. texsmith/core/bibliography/issues.py +15 -0
  100. texsmith/core/bibliography/parsing.py +45 -0
  101. texsmith/core/callouts.py +99 -0
  102. texsmith/core/config.py +191 -0
  103. texsmith/core/context.py +242 -0
  104. texsmith/core/conversion/__init__.py +103 -0
  105. texsmith/core/conversion/core.py +780 -0
  106. texsmith/core/conversion/debug.py +87 -0
  107. texsmith/core/conversion/inputs.py +474 -0
  108. texsmith/core/conversion/renderer.py +578 -0
  109. texsmith/core/conversion/templates.py +646 -0
  110. texsmith/core/conversion_contexts.py +95 -0
  111. texsmith/core/diagnostics.py +118 -0
  112. texsmith/core/exceptions.py +41 -0
  113. texsmith/core/fonts/__init__.py +3 -0
  114. texsmith/core/fragments/__init__.py +716 -0
  115. texsmith/core/fragments/base.py +117 -0
  116. texsmith/core/metadata.py +280 -0
  117. texsmith/core/mustache.py +86 -0
  118. texsmith/core/partials.py +20 -0
  119. texsmith/core/rules.py +394 -0
  120. texsmith/core/templates/__init__.py +58 -0
  121. texsmith/core/templates/base.py +343 -0
  122. texsmith/core/templates/builtins.py +68 -0
  123. texsmith/core/templates/context_usage.py +137 -0
  124. texsmith/core/templates/loader.py +303 -0
  125. texsmith/core/templates/manifest.py +861 -0
  126. texsmith/core/templates/runtime.py +347 -0
  127. texsmith/core/templates/text.py +14 -0
  128. texsmith/core/templates/wrapper.py +340 -0
  129. texsmith/core/user_dir.py +179 -0
  130. texsmith/devtools.py +28 -0
  131. texsmith/extensions/__init__.py +162 -0
  132. texsmith/extensions/index/__init__.py +21 -0
  133. texsmith/extensions/index/markdown.py +94 -0
  134. texsmith/extensions/index/mkdocs_plugin.py +136 -0
  135. texsmith/extensions/index/registry.py +57 -0
  136. texsmith/extensions/index/renderer.py +183 -0
  137. texsmith/extensions/index/templates/index.tex +1 -0
  138. texsmith/extensions/latex_raw.py +115 -0
  139. texsmith/extensions/latex_text.py +117 -0
  140. texsmith/extensions/mermaid.py +267 -0
  141. texsmith/extensions/missing_footnotes.py +125 -0
  142. texsmith/extensions/multi_citations.py +54 -0
  143. texsmith/extensions/progressbar/__init__.py +9 -0
  144. texsmith/extensions/progressbar/markdown.py +212 -0
  145. texsmith/extensions/progressbar/renderer.py +117 -0
  146. texsmith/extensions/smallcaps.py +48 -0
  147. texsmith/extensions/texlogos/__init__.py +10 -0
  148. texsmith/extensions/texlogos/markdown.py +236 -0
  149. texsmith/extensions/texlogos/renderer.py +81 -0
  150. texsmith/extensions/texlogos/specs.py +66 -0
  151. texsmith/fonts/__init__.py +57 -0
  152. texsmith/fonts/cache.py +46 -0
  153. texsmith/fonts/constants.py +60 -0
  154. texsmith/fonts/coverage.py +275 -0
  155. texsmith/fonts/downloader.py +103 -0
  156. texsmith/fonts/fallback.py +438 -0
  157. texsmith/fonts/html_scripts.py +168 -0
  158. texsmith/fonts/logging.py +127 -0
  159. texsmith/fonts/pipeline.py +338 -0
  160. texsmith/fonts/scripts.py +493 -0
  161. texsmith/fonts/ucharclasses.py +164 -0
  162. texsmith/fragments/__init__.py +11 -0
  163. texsmith/fragments/bibliography/__init__.py +65 -0
  164. texsmith/fragments/bibliography/fragment.toml +3 -0
  165. texsmith/fragments/bibliography/ts-bibliography-backmatter.jinja.tex +35 -0
  166. texsmith/fragments/bibliography/ts-bibliography.jinja.tex +10 -0
  167. texsmith/fragments/callouts/__init__.py +88 -0
  168. texsmith/fragments/callouts/fragment.toml +3 -0
  169. texsmith/fragments/callouts/ts-callouts.jinja.sty +129 -0
  170. texsmith/fragments/code/__init__.py +82 -0
  171. texsmith/fragments/code/fragment.toml +3 -0
  172. texsmith/fragments/code/ts-code.jinja.sty +140 -0
  173. texsmith/fragments/extra/__init__.py +180 -0
  174. texsmith/fragments/extra/fragment.toml +3 -0
  175. texsmith/fragments/extra/ts-extra.jinja.tex +13 -0
  176. texsmith/fragments/fonts/__init__.py +880 -0
  177. texsmith/fragments/fonts/fragment.toml +3 -0
  178. texsmith/fragments/fonts/ts-fonts.jinja.sty +292 -0
  179. texsmith/fragments/frame/__init__.py +170 -0
  180. texsmith/fragments/frame/fragment.toml +3 -0
  181. texsmith/fragments/frame/ts-frame.tex.jinja +49 -0
  182. texsmith/fragments/geometry/__init__.py +169 -0
  183. texsmith/fragments/geometry/fragment.toml +3 -0
  184. texsmith/fragments/geometry/paper.py +526 -0
  185. texsmith/fragments/geometry/ts_geometry.tex.jinja +53 -0
  186. texsmith/fragments/glossary/__init__.py +67 -0
  187. texsmith/fragments/glossary/fragment.toml +3 -0
  188. texsmith/fragments/glossary/ts-glossary-backmatter.jinja.tex +5 -0
  189. texsmith/fragments/glossary/ts-glossary.jinja.sty +28 -0
  190. texsmith/fragments/index/__init__.py +66 -0
  191. texsmith/fragments/index/fragment.toml +3 -0
  192. texsmith/fragments/index/ts-index-backmatter.jinja.tex +3 -0
  193. texsmith/fragments/index/ts-index.jinja.sty +12 -0
  194. texsmith/fragments/keystrokes/__init__.py +69 -0
  195. texsmith/fragments/keystrokes/fragment.toml +3 -0
  196. texsmith/fragments/keystrokes/ts-keystrokes.jinja.sty +20 -0
  197. texsmith/fragments/todolist/__init__.py +71 -0
  198. texsmith/fragments/todolist/fragment.toml +3 -0
  199. texsmith/fragments/todolist/ts-todolist.jinja.sty +21 -0
  200. texsmith/fragments/typesetting/__init__.py +214 -0
  201. texsmith/fragments/typesetting/fragment.toml +3 -0
  202. texsmith/fragments/typesetting/ts-typesetting.tex.jinja +81 -0
  203. texsmith/index.py +26 -0
  204. texsmith/plugins/__init__.py +14 -0
  205. texsmith/progressbar.py +8 -0
  206. texsmith/quotes.py +40 -0
  207. texsmith/smart_dashes.py +66 -0
  208. texsmith/templates/__init__.py +3 -0
  209. texsmith/templates/article/README.md +33 -0
  210. texsmith/templates/article/__init__.py +281 -0
  211. texsmith/templates/article/template/manifest.toml +125 -0
  212. texsmith/templates/article/template/mermaid-config.json +18 -0
  213. texsmith/templates/article/template/template.tex +74 -0
  214. texsmith/templates/book/README.md +26 -0
  215. texsmith/templates/book/__init__.py +75 -0
  216. texsmith/templates/book/overrides/codeblock.tex +7 -0
  217. texsmith/templates/book/overrides/codeinline.tex +1 -0
  218. texsmith/templates/book/template/fixtoc.sty +81 -0
  219. texsmith/templates/book/template/manifest.toml +198 -0
  220. texsmith/templates/book/template/template.tex +314 -0
  221. texsmith/templates/common/__init__.py +1 -0
  222. texsmith/templates/common/latexmkrc +46 -0
  223. texsmith/templates/letter/README.md +59 -0
  224. texsmith/templates/letter/__init__.py +495 -0
  225. texsmith/templates/letter/demo.md +31 -0
  226. texsmith/templates/letter/fonts/modernline bold.otf +0 -0
  227. texsmith/templates/letter/fonts/modernline.otf +0 -0
  228. texsmith/templates/letter/manifest.toml +197 -0
  229. texsmith/templates/letter/template/callouts.jinja.sty +290 -0
  230. texsmith/templates/letter/template/template.tex +123 -0
  231. texsmith/templates/snippet/README.md +21 -0
  232. texsmith/templates/snippet/__init__.py +80 -0
  233. texsmith/templates/snippet/template/manifest.toml +66 -0
  234. texsmith/templates/snippet/template/template.tex +47 -0
  235. texsmith/texlogos.py +15 -0
  236. texsmith/ui/__init__.py +6 -0
  237. texsmith/ui/cli/__init__.py +22 -0
  238. texsmith/ui/cli/_options.py +338 -0
  239. texsmith/ui/cli/app.py +65 -0
  240. texsmith/ui/cli/bibliography.py +300 -0
  241. texsmith/ui/cli/commands/__init__.py +14 -0
  242. texsmith/ui/cli/commands/render.py +1128 -0
  243. texsmith/ui/cli/commands/templates.py +397 -0
  244. texsmith/ui/cli/diagnostics.py +36 -0
  245. texsmith/ui/cli/presenter.py +663 -0
  246. texsmith/ui/cli/state.py +263 -0
  247. texsmith/ui/cli/utils.py +235 -0
  248. texsmith-0.0.2.dev0.dist-info/METADATA +187 -0
  249. texsmith-0.0.2.dev0.dist-info/RECORD +252 -0
  250. texsmith-0.0.2.dev0.dist-info/WHEEL +4 -0
  251. texsmith-0.0.2.dev0.dist-info/entry_points.txt +22 -0
  252. 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})