bxc 1.1.0__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.
- bxc/__init__.py +76 -0
- bxc/author.py +62 -0
- bxc/cache.py +450 -0
- bxc/cli.py +191 -0
- bxc/exceptions.py +17 -0
- bxc/formatter.py +720 -0
- bxc/latex.py +205 -0
- bxc/parser.py +221 -0
- bxc/py.typed +0 -0
- bxc/registry.py +133 -0
- bxc/styles_bundle.tar.xz +0 -0
- bxc/styles_index.json +1 -0
- bxc-1.1.0.dist-info/METADATA +130 -0
- bxc-1.1.0.dist-info/RECORD +18 -0
- bxc-1.1.0.dist-info/WHEEL +4 -0
- bxc-1.1.0.dist-info/entry_points.txt +2 -0
- bxc-1.1.0.dist-info/licenses/LICENSE +176 -0
- bxc-1.1.0.dist-info/licenses/NOTICE +14 -0
bxc/__init__.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""
|
|
2
|
+
bxc - Bibliographic Reference Formatter
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from bxc.parser import parse_bibtex
|
|
7
|
+
from bxc.exceptions import BxcError, BibTeXParseError, StyleNotFoundError
|
|
8
|
+
from bxc.cache import (
|
|
9
|
+
get_cache_dir,
|
|
10
|
+
resolve_style,
|
|
11
|
+
update_cache,
|
|
12
|
+
clear_cache,
|
|
13
|
+
rebuild_cache,
|
|
14
|
+
get_cache_status,
|
|
15
|
+
)
|
|
16
|
+
from bxc.registry import search_styles
|
|
17
|
+
|
|
18
|
+
__version__ = "1.1.0"
|
|
19
|
+
|
|
20
|
+
def format_bibtex(
|
|
21
|
+
bibtex_source: str,
|
|
22
|
+
style: str,
|
|
23
|
+
output_format: str = "plain",
|
|
24
|
+
mode: str = "bibliography",
|
|
25
|
+
cite: str | None = None,
|
|
26
|
+
is_path: bool = False
|
|
27
|
+
) -> str:
|
|
28
|
+
"""
|
|
29
|
+
Formats BibTeX references using a CSL style.
|
|
30
|
+
"""
|
|
31
|
+
if bibtex_source is None or style is None:
|
|
32
|
+
raise TypeError("bibtex_source and style cannot be None")
|
|
33
|
+
if not isinstance(bibtex_source, str) or not isinstance(style, str):
|
|
34
|
+
raise TypeError("bibtex_source and style must be strings")
|
|
35
|
+
|
|
36
|
+
valid_formats = {"plain", "markdown", "html"}
|
|
37
|
+
if output_format not in valid_formats:
|
|
38
|
+
raise BxcError(f"Unsupported output format: '{output_format}'")
|
|
39
|
+
|
|
40
|
+
valid_modes = {"bibliography", "citation"}
|
|
41
|
+
if mode not in valid_modes:
|
|
42
|
+
raise BxcError(f"Unsupported selection mode: '{mode}'")
|
|
43
|
+
|
|
44
|
+
if bibtex_source == "-":
|
|
45
|
+
is_path = True
|
|
46
|
+
elif not is_path and bibtex_source and not bibtex_source.strip().startswith("@"):
|
|
47
|
+
if bibtex_source.endswith(".bib") or os.path.exists(bibtex_source):
|
|
48
|
+
is_path = True
|
|
49
|
+
|
|
50
|
+
entries = parse_bibtex(bibtex_source, is_path=is_path)
|
|
51
|
+
style_path = resolve_style(style)
|
|
52
|
+
|
|
53
|
+
from bxc.formatter import format_references
|
|
54
|
+
return format_references(
|
|
55
|
+
entries=entries,
|
|
56
|
+
style_path=style_path,
|
|
57
|
+
format=output_format,
|
|
58
|
+
mode=mode,
|
|
59
|
+
cite=cite
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
__all__ = [
|
|
64
|
+
"parse_bibtex",
|
|
65
|
+
"format_bibtex",
|
|
66
|
+
"BxcError",
|
|
67
|
+
"BibTeXParseError",
|
|
68
|
+
"StyleNotFoundError",
|
|
69
|
+
"get_cache_dir",
|
|
70
|
+
"resolve_style",
|
|
71
|
+
"update_cache",
|
|
72
|
+
"clear_cache",
|
|
73
|
+
"rebuild_cache",
|
|
74
|
+
"get_cache_status",
|
|
75
|
+
"search_styles",
|
|
76
|
+
]
|
bxc/author.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Author and editor parsing for BibTeX files.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AuthorParser:
|
|
9
|
+
"""
|
|
10
|
+
Parses BibTeX author and editor name strings into structured CSL formats.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def parse_authors(self, author_str: str) -> list[dict[str, str]]:
|
|
14
|
+
"""
|
|
15
|
+
Parses a BibTeX author/editor string (separated by 'and') into CSL-JSON family/given components.
|
|
16
|
+
"""
|
|
17
|
+
if not author_str:
|
|
18
|
+
return []
|
|
19
|
+
# Split by 'and' case-insensitively; a single \s (not \s+) avoids any
|
|
20
|
+
# quantifier ambiguity, since leftover whitespace is stripped below anyway.
|
|
21
|
+
parts = re.split(r'\sand\s', author_str, flags=re.IGNORECASE)
|
|
22
|
+
authors = []
|
|
23
|
+
for part in parts:
|
|
24
|
+
part = part.strip()
|
|
25
|
+
if part:
|
|
26
|
+
authors.append(self._parse_name(part))
|
|
27
|
+
return authors
|
|
28
|
+
|
|
29
|
+
@staticmethod
|
|
30
|
+
def _is_braced_literal(part: str) -> bool:
|
|
31
|
+
return part.startswith('{') and part.endswith('}') and '{' not in part[1:-1] and '}' not in part[1:-1]
|
|
32
|
+
|
|
33
|
+
@staticmethod
|
|
34
|
+
def _split_name(part: str) -> tuple[str, str, str]:
|
|
35
|
+
"""Returns (family, given, suffix) for a single BibTeX name."""
|
|
36
|
+
if ',' in part:
|
|
37
|
+
pieces = [x.strip() for x in part.split(',')]
|
|
38
|
+
if len(pieces) >= 3:
|
|
39
|
+
# "von Last, Jr, First"
|
|
40
|
+
return pieces[0], ", ".join(pieces[2:]), pieces[1]
|
|
41
|
+
return pieces[0], pieces[1], ""
|
|
42
|
+
words = part.rsplit(None, 1)
|
|
43
|
+
if len(words) == 2:
|
|
44
|
+
return words[1].strip(), words[0].strip(), ""
|
|
45
|
+
return part.strip(), "", ""
|
|
46
|
+
|
|
47
|
+
def _parse_name(self, part: str) -> dict[str, str]:
|
|
48
|
+
if part.lower() == "others":
|
|
49
|
+
return {"literal": "et al."}
|
|
50
|
+
if self._is_braced_literal(part):
|
|
51
|
+
# Fully braced name is an institution / single literal name
|
|
52
|
+
return {"literal": part[1:-1].strip()}
|
|
53
|
+
family, given, suffix = self._split_name(part)
|
|
54
|
+
# Clean curly braces that protect case
|
|
55
|
+
author = {
|
|
56
|
+
"family": family.replace('{', '').replace('}', ''),
|
|
57
|
+
"given": given.replace('{', '').replace('}', ''),
|
|
58
|
+
}
|
|
59
|
+
suffix = suffix.replace('{', '').replace('}', '')
|
|
60
|
+
if suffix:
|
|
61
|
+
author["suffix"] = suffix
|
|
62
|
+
return author
|
bxc/cache.py
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cache management module for bxc.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import platformdirs
|
|
8
|
+
import tarfile
|
|
9
|
+
import tempfile
|
|
10
|
+
import urllib.request
|
|
11
|
+
import urllib.error
|
|
12
|
+
import xml.etree.ElementTree as ET
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import shutil as _shutil
|
|
16
|
+
from typing import Any
|
|
17
|
+
from bxc.exceptions import StyleNotFoundError, BxcError
|
|
18
|
+
|
|
19
|
+
CSL_REMOTE_URL_PREFIX = "https://raw.githubusercontent.com/citation-style-language/styles/master/"
|
|
20
|
+
|
|
21
|
+
# Local fallback for when the CDN above is unreachable or rate-limited: a
|
|
22
|
+
# bundled snapshot of the full CSL styles repository (top-level + dependent/),
|
|
23
|
+
# compressed with LZMA2 (xz) at max settings so it stays readable via the
|
|
24
|
+
# standard library's tarfile/lzma modules without adding a runtime dependency.
|
|
25
|
+
BUNDLED_STYLES_ARCHIVE = os.path.join(os.path.dirname(__file__), "styles_bundle.tar.xz")
|
|
26
|
+
STYLES_INDEX_FILENAME = "styles_index.json"
|
|
27
|
+
BUNDLED_STYLES_INDEX = os.path.join(os.path.dirname(__file__), STYLES_INDEX_FILENAME)
|
|
28
|
+
|
|
29
|
+
# A CSL style id is a short lowercase slug; anything else (path separators, "..",
|
|
30
|
+
# absolute paths, drive letters) must never reach the filesystem or the remote URL.
|
|
31
|
+
STYLE_NAME_PATTERN = re.compile(r"[a-z0-9][a-z0-9._-]{0,127}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def validate_style_name(style_filename: str) -> str:
|
|
35
|
+
"""Returns the style file name if it is a safe slug ending in .csl, else raises StyleNotFoundError."""
|
|
36
|
+
stem = style_filename[:-4] if style_filename.endswith(".csl") else style_filename
|
|
37
|
+
if not STYLE_NAME_PATTERN.fullmatch(stem) or ".." in stem:
|
|
38
|
+
raise StyleNotFoundError(
|
|
39
|
+
f"Invalid CSL style name '{style_filename}': expected a lowercase name such as 'ieee' "
|
|
40
|
+
"or the path to an existing .csl file."
|
|
41
|
+
)
|
|
42
|
+
return style_filename
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _user_agent() -> str:
|
|
46
|
+
from bxc import __version__
|
|
47
|
+
|
|
48
|
+
return f"bxc-bibliographic-formatter/{__version__}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class CacheManager:
|
|
52
|
+
"""
|
|
53
|
+
Manages the local OS-native CSL cache directory and remote updates.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, remote_url: str | None = None) -> None:
|
|
57
|
+
self.remote_url_prefix = remote_url or CSL_REMOTE_URL_PREFIX
|
|
58
|
+
|
|
59
|
+
def get_cache_dir(self) -> str:
|
|
60
|
+
"""
|
|
61
|
+
Returns the absolute path to the local OS-native CSL cache directory.
|
|
62
|
+
Uses the BXC_CACHE_DIR environment variable if set to allow test isolation.
|
|
63
|
+
"""
|
|
64
|
+
base_dir = os.environ.get("BXC_CACHE_DIR")
|
|
65
|
+
if not base_dir:
|
|
66
|
+
base_dir = platformdirs.user_cache_dir(appname="bxc")
|
|
67
|
+
|
|
68
|
+
styles_dir = os.path.join(base_dir, "styles")
|
|
69
|
+
return styles_dir
|
|
70
|
+
|
|
71
|
+
def _fetch_url(self, url: str) -> bytes:
|
|
72
|
+
req = urllib.request.Request(
|
|
73
|
+
url,
|
|
74
|
+
headers={"User-Agent": _user_agent()}
|
|
75
|
+
)
|
|
76
|
+
with urllib.request.urlopen(req, timeout=10) as response:
|
|
77
|
+
return response.read()
|
|
78
|
+
|
|
79
|
+
def _write_cache_file_atomic(self, cache_dir: str, cache_path: str, content: bytes) -> None:
|
|
80
|
+
# Use delete=False for cross-platform (Windows) compatibility
|
|
81
|
+
tmp_file = tempfile.NamedTemporaryFile(dir=cache_dir, suffix=".tmp", delete=False)
|
|
82
|
+
temp_path = tmp_file.name
|
|
83
|
+
try:
|
|
84
|
+
tmp_file.write(content)
|
|
85
|
+
tmp_file.close() # Must close before os.replace
|
|
86
|
+
os.replace(temp_path, cache_path)
|
|
87
|
+
except Exception:
|
|
88
|
+
tmp_file.close()
|
|
89
|
+
if os.path.exists(temp_path):
|
|
90
|
+
try:
|
|
91
|
+
os.remove(temp_path)
|
|
92
|
+
except OSError:
|
|
93
|
+
pass
|
|
94
|
+
raise
|
|
95
|
+
|
|
96
|
+
def _extract_from_bundle(self, style_name: str) -> bytes | None:
|
|
97
|
+
"""
|
|
98
|
+
Looks up style_name in the bundled local CSL archive shipped with bxc.
|
|
99
|
+
Returns the raw CSL bytes if found, or None if the archive is missing
|
|
100
|
+
or doesn't contain this style.
|
|
101
|
+
"""
|
|
102
|
+
if not os.path.exists(BUNDLED_STYLES_ARCHIVE):
|
|
103
|
+
return None
|
|
104
|
+
try:
|
|
105
|
+
with tarfile.open(BUNDLED_STYLES_ARCHIVE, mode="r:xz") as archive:
|
|
106
|
+
for member_name in (f"./{style_name}", f"./dependent/{style_name}"):
|
|
107
|
+
try:
|
|
108
|
+
member = archive.getmember(member_name)
|
|
109
|
+
except KeyError:
|
|
110
|
+
continue
|
|
111
|
+
extracted = archive.extractfile(member)
|
|
112
|
+
if extracted is not None:
|
|
113
|
+
return extracted.read()
|
|
114
|
+
except (tarfile.TarError, OSError):
|
|
115
|
+
return None
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
def _download_from_cdn(self, style_name: str, cache_dir: str, cache_path: str) -> Exception | None:
|
|
119
|
+
"""
|
|
120
|
+
Tries every candidate CDN URL for style_name. On success, writes the
|
|
121
|
+
result to cache_path and returns None. On total failure, returns the
|
|
122
|
+
last error encountered instead of raising, so callers can decide what
|
|
123
|
+
to try next (e.g. the bundled archive) before giving up.
|
|
124
|
+
"""
|
|
125
|
+
remote_base = os.environ.get("BXC_REMOTE_URL", self.remote_url_prefix)
|
|
126
|
+
if not remote_base.endswith("/"):
|
|
127
|
+
remote_base += "/"
|
|
128
|
+
|
|
129
|
+
# Dependent styles (e.g. journal-specific variants) live under a
|
|
130
|
+
# dependent/ prefix on the real CSL repository, not at the top level.
|
|
131
|
+
candidate_urls = [f"{remote_base}{style_name}", f"{remote_base}dependent/{style_name}"]
|
|
132
|
+
|
|
133
|
+
last_error: Exception | None = None
|
|
134
|
+
for url in candidate_urls:
|
|
135
|
+
try:
|
|
136
|
+
content = self._fetch_url(url)
|
|
137
|
+
self._write_cache_file_atomic(cache_dir, cache_path, content)
|
|
138
|
+
return None
|
|
139
|
+
except urllib.error.HTTPError as e:
|
|
140
|
+
last_error = e
|
|
141
|
+
if e.code == 404:
|
|
142
|
+
continue # try the next candidate path
|
|
143
|
+
break # a non-404 HTTP error means the CDN itself is misbehaving
|
|
144
|
+
except Exception as e:
|
|
145
|
+
last_error = e
|
|
146
|
+
break # network-level failure (DNS/timeout/connection) - no point retrying paths
|
|
147
|
+
return last_error
|
|
148
|
+
|
|
149
|
+
def download_style(self, style_name: str, cache_path: str, prefer_bundle: bool = True) -> None:
|
|
150
|
+
"""
|
|
151
|
+
Resolves a CSL style and saves it to cache_path.
|
|
152
|
+
|
|
153
|
+
By default (prefer_bundle=True), checks the local bundled archive
|
|
154
|
+
shipped with bxc first - instant, offline, and avoids hammering the
|
|
155
|
+
CDN for the 10,000+ styles already snapshotted locally - and only
|
|
156
|
+
talks to the network for styles the bundle doesn't have. This is
|
|
157
|
+
what everyday style resolution (`bxc format`) uses.
|
|
158
|
+
|
|
159
|
+
Sync-oriented callers that specifically want fresh content from the
|
|
160
|
+
CDN (`bxc cache update` / `bxc cache rebuild`) pass prefer_bundle=False
|
|
161
|
+
to check the CDN first instead, still falling back to the bundle if
|
|
162
|
+
the CDN is unreachable. Ensures thread-safe and process-safe atomic
|
|
163
|
+
writes either way.
|
|
164
|
+
|
|
165
|
+
An explicit BXC_REMOTE_URL override always wins over prefer_bundle:
|
|
166
|
+
pointing bxc at a specific remote (a private mirror, a test mock
|
|
167
|
+
server) is a deliberate signal to talk to that remote, not to get
|
|
168
|
+
silently intercepted by the bundled snapshot.
|
|
169
|
+
"""
|
|
170
|
+
cache_dir = os.path.dirname(cache_path)
|
|
171
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
172
|
+
|
|
173
|
+
effective_prefer_bundle = prefer_bundle and "BXC_REMOTE_URL" not in os.environ
|
|
174
|
+
|
|
175
|
+
if effective_prefer_bundle:
|
|
176
|
+
bundled_content = self._extract_from_bundle(style_name)
|
|
177
|
+
if bundled_content is not None:
|
|
178
|
+
self._write_cache_file_atomic(cache_dir, cache_path, bundled_content)
|
|
179
|
+
return
|
|
180
|
+
last_error = self._download_from_cdn(style_name, cache_dir, cache_path)
|
|
181
|
+
if last_error is None:
|
|
182
|
+
return
|
|
183
|
+
else:
|
|
184
|
+
last_error = self._download_from_cdn(style_name, cache_dir, cache_path)
|
|
185
|
+
if last_error is None:
|
|
186
|
+
return
|
|
187
|
+
bundled_content = self._extract_from_bundle(style_name)
|
|
188
|
+
if bundled_content is not None:
|
|
189
|
+
self._write_cache_file_atomic(cache_dir, cache_path, bundled_content)
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
if isinstance(last_error, urllib.error.HTTPError):
|
|
193
|
+
if last_error.code == 404:
|
|
194
|
+
raise StyleNotFoundError(
|
|
195
|
+
f"Failed to download CSL style '{style_name}': not found on remote server (404)."
|
|
196
|
+
) from last_error
|
|
197
|
+
raise StyleNotFoundError(
|
|
198
|
+
f"Failed to download CSL style '{style_name}' (HTTP {last_error.code})."
|
|
199
|
+
) from last_error
|
|
200
|
+
raise StyleNotFoundError(
|
|
201
|
+
f"Failed to download CSL style '{style_name}': not found locally and "
|
|
202
|
+
f"could not be fetched from the remote repository: {last_error}"
|
|
203
|
+
) from last_error
|
|
204
|
+
|
|
205
|
+
def extract_independent_parent(self, csl_content: bytes) -> str | None:
|
|
206
|
+
"""
|
|
207
|
+
Parses CSL XML content and extracts the style name of the independent parent if present.
|
|
208
|
+
"""
|
|
209
|
+
if not csl_content:
|
|
210
|
+
raise BxcError("CSL style file is empty.")
|
|
211
|
+
try:
|
|
212
|
+
root = ET.fromstring(csl_content)
|
|
213
|
+
for elem in root.iter():
|
|
214
|
+
tag = elem.tag
|
|
215
|
+
if tag.endswith("link") or tag == "link":
|
|
216
|
+
rel = elem.attrib.get("rel")
|
|
217
|
+
href = elem.attrib.get("href")
|
|
218
|
+
if rel == "independent-parent" and href:
|
|
219
|
+
parent_name = href.rstrip("/").split("/")[-1]
|
|
220
|
+
return parent_name
|
|
221
|
+
except ET.ParseError as e:
|
|
222
|
+
raise BxcError(f"XML parse error: {e}") from e
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
def _resolve_parent_or_path(self, content: bytes, path: str, visited: set[str]) -> str:
|
|
226
|
+
parent_name = self.extract_independent_parent(content)
|
|
227
|
+
if parent_name:
|
|
228
|
+
return self.resolve_style(parent_name, visited)
|
|
229
|
+
return os.path.abspath(path)
|
|
230
|
+
|
|
231
|
+
def _read_and_resolve(self, path: str, visited: set[str], error_context: str) -> str:
|
|
232
|
+
try:
|
|
233
|
+
with open(path, "rb") as f:
|
|
234
|
+
content = f.read()
|
|
235
|
+
except Exception as e:
|
|
236
|
+
raise BxcError(f"{error_context}: {e}") from e
|
|
237
|
+
return self._resolve_parent_or_path(content, path, visited)
|
|
238
|
+
|
|
239
|
+
def _resolve_local_style(self, style_identifier: str, visited: set[str]) -> str:
|
|
240
|
+
if not os.path.isfile(style_identifier):
|
|
241
|
+
raise StyleNotFoundError(
|
|
242
|
+
f"Local CSL style file '{style_identifier}' could not be resolved. "
|
|
243
|
+
"The file does not exist or is not a valid file."
|
|
244
|
+
)
|
|
245
|
+
abs_path = os.path.abspath(style_identifier)
|
|
246
|
+
return self._read_and_resolve(
|
|
247
|
+
abs_path, visited, f"Failed to read local CSL style '{style_identifier}'"
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
def _resolve_cached_style(self, style_identifier: str, visited: set[str]) -> str:
|
|
251
|
+
style_name_lower = style_identifier.lower()
|
|
252
|
+
style_filename = style_name_lower if style_name_lower.endswith(".csl") else f"{style_name_lower}.csl"
|
|
253
|
+
validate_style_name(style_filename)
|
|
254
|
+
|
|
255
|
+
cache_dir = self.get_cache_dir()
|
|
256
|
+
cache_path = os.path.join(cache_dir, style_filename)
|
|
257
|
+
cache_exists = os.path.exists(cache_path) and os.path.isfile(cache_path)
|
|
258
|
+
|
|
259
|
+
if cache_exists and os.path.getsize(cache_path) == 0:
|
|
260
|
+
self.download_style(style_filename, cache_path)
|
|
261
|
+
return self._read_and_resolve(
|
|
262
|
+
cache_path, visited, f"Failed to read cached CSL style '{cache_path}' after download"
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
if cache_exists:
|
|
266
|
+
try:
|
|
267
|
+
return self._read_and_resolve(cache_path, visited, "")
|
|
268
|
+
except Exception:
|
|
269
|
+
pass # corrupted/unreadable cache entry - fall through and redownload
|
|
270
|
+
|
|
271
|
+
self.download_style(style_filename, cache_path)
|
|
272
|
+
return self._read_and_resolve(
|
|
273
|
+
cache_path, visited, f"Failed to read cached CSL style '{cache_path}'"
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
@staticmethod
|
|
277
|
+
def _normalize_style_identifier(style_identifier: str) -> tuple[str, bool]:
|
|
278
|
+
is_local_path = style_identifier.lower().endswith(".csl") or os.path.exists(style_identifier)
|
|
279
|
+
if is_local_path:
|
|
280
|
+
norm_id = os.path.abspath(style_identifier)
|
|
281
|
+
else:
|
|
282
|
+
norm_id = style_identifier.lower()
|
|
283
|
+
if not norm_id.endswith(".csl"):
|
|
284
|
+
norm_id = f"{norm_id}.csl"
|
|
285
|
+
return norm_id, is_local_path
|
|
286
|
+
|
|
287
|
+
def resolve_style(self, style_identifier: str, visited: set[str] | None = None) -> str:
|
|
288
|
+
"""
|
|
289
|
+
Resolves a style name or a local path to the absolute path of a CSL style file.
|
|
290
|
+
"""
|
|
291
|
+
if visited is None:
|
|
292
|
+
visited = set()
|
|
293
|
+
|
|
294
|
+
norm_id, is_local_path = self._normalize_style_identifier(style_identifier)
|
|
295
|
+
if norm_id in visited:
|
|
296
|
+
raise BxcError(f"Circular dependency detected in style resolution for '{style_identifier}'")
|
|
297
|
+
visited.add(norm_id)
|
|
298
|
+
|
|
299
|
+
if is_local_path:
|
|
300
|
+
return self._resolve_local_style(style_identifier, visited)
|
|
301
|
+
return self._resolve_cached_style(style_identifier, visited)
|
|
302
|
+
|
|
303
|
+
def update_cache(self) -> bool:
|
|
304
|
+
"""
|
|
305
|
+
Refreshes the style index. Returns True when a fresh index was downloaded from the
|
|
306
|
+
remote, False when the default remote has none (HTTP 404) and the bundled index
|
|
307
|
+
was installed instead.
|
|
308
|
+
"""
|
|
309
|
+
cache_dir = self.get_cache_dir()
|
|
310
|
+
base_dir = os.path.dirname(cache_dir)
|
|
311
|
+
os.makedirs(base_dir, exist_ok=True)
|
|
312
|
+
|
|
313
|
+
index_path = os.path.join(base_dir, STYLES_INDEX_FILENAME)
|
|
314
|
+
|
|
315
|
+
remote_base = os.environ.get("BXC_REMOTE_URL", self.remote_url_prefix)
|
|
316
|
+
if not remote_base.endswith("/"):
|
|
317
|
+
remote_base += "/"
|
|
318
|
+
url = f"{remote_base}{STYLES_INDEX_FILENAME}"
|
|
319
|
+
|
|
320
|
+
tmp_file = tempfile.NamedTemporaryFile(dir=base_dir, suffix=".tmp", delete=False)
|
|
321
|
+
temp_path = tmp_file.name
|
|
322
|
+
|
|
323
|
+
try:
|
|
324
|
+
req = urllib.request.Request(
|
|
325
|
+
url,
|
|
326
|
+
headers={"User-Agent": _user_agent()}
|
|
327
|
+
)
|
|
328
|
+
with urllib.request.urlopen(req, timeout=10) as response:
|
|
329
|
+
content = response.read()
|
|
330
|
+
|
|
331
|
+
json.loads(content.decode("utf-8"))
|
|
332
|
+
|
|
333
|
+
tmp_file.write(content)
|
|
334
|
+
tmp_file.close()
|
|
335
|
+
|
|
336
|
+
os.replace(temp_path, index_path)
|
|
337
|
+
return True
|
|
338
|
+
except Exception as e:
|
|
339
|
+
tmp_file.close()
|
|
340
|
+
if os.path.exists(temp_path):
|
|
341
|
+
try:
|
|
342
|
+
os.remove(temp_path)
|
|
343
|
+
except OSError:
|
|
344
|
+
pass
|
|
345
|
+
# The upstream CSL repository does not publish styles_index.json; when using the
|
|
346
|
+
# default remote (no explicit override), refresh from the bundled index instead.
|
|
347
|
+
using_default_remote = "BXC_REMOTE_URL" not in os.environ and self.remote_url_prefix == CSL_REMOTE_URL_PREFIX
|
|
348
|
+
if (
|
|
349
|
+
using_default_remote
|
|
350
|
+
and isinstance(e, urllib.error.HTTPError)
|
|
351
|
+
and e.code == 404
|
|
352
|
+
and os.path.isfile(BUNDLED_STYLES_INDEX)
|
|
353
|
+
):
|
|
354
|
+
_shutil.copyfile(BUNDLED_STYLES_INDEX, index_path)
|
|
355
|
+
return False
|
|
356
|
+
raise BxcError(f"Failed to update cache: {e}") from e
|
|
357
|
+
|
|
358
|
+
def clear_cache(self) -> None:
|
|
359
|
+
"""
|
|
360
|
+
Clears all cached CSL styles and deletes the index file.
|
|
361
|
+
"""
|
|
362
|
+
cache_dir = self.get_cache_dir()
|
|
363
|
+
if os.path.exists(cache_dir):
|
|
364
|
+
for filename in os.listdir(cache_dir):
|
|
365
|
+
file_path = os.path.join(cache_dir, filename)
|
|
366
|
+
try:
|
|
367
|
+
if os.path.isfile(file_path) or os.path.islink(file_path):
|
|
368
|
+
os.unlink(file_path)
|
|
369
|
+
elif os.path.isdir(file_path):
|
|
370
|
+
shutil.rmtree(file_path)
|
|
371
|
+
except Exception as e:
|
|
372
|
+
raise BxcError(f"Failed to delete cached style file '{file_path}': {e}")
|
|
373
|
+
|
|
374
|
+
base_dir = os.path.dirname(cache_dir)
|
|
375
|
+
index_path = os.path.join(base_dir, STYLES_INDEX_FILENAME)
|
|
376
|
+
if os.path.exists(index_path):
|
|
377
|
+
try:
|
|
378
|
+
os.remove(index_path)
|
|
379
|
+
except Exception as e:
|
|
380
|
+
raise BxcError(f"Failed to delete style index file '{index_path}': {e}")
|
|
381
|
+
|
|
382
|
+
def get_cache_status(self) -> dict[str, Any]:
|
|
383
|
+
"""
|
|
384
|
+
Returns status information about the cache.
|
|
385
|
+
"""
|
|
386
|
+
cache_dir = self.get_cache_dir()
|
|
387
|
+
base_dir = os.path.dirname(cache_dir)
|
|
388
|
+
index_path = os.path.join(base_dir, STYLES_INDEX_FILENAME)
|
|
389
|
+
|
|
390
|
+
styles_count = 0
|
|
391
|
+
corrupted_count = 0
|
|
392
|
+
if os.path.exists(cache_dir):
|
|
393
|
+
for filename in os.listdir(cache_dir):
|
|
394
|
+
file_path = os.path.join(cache_dir, filename)
|
|
395
|
+
if os.path.isfile(file_path) and filename.endswith(".csl"):
|
|
396
|
+
styles_count += 1
|
|
397
|
+
if os.path.getsize(file_path) == 0:
|
|
398
|
+
corrupted_count += 1
|
|
399
|
+
|
|
400
|
+
return {
|
|
401
|
+
"cache_dir": cache_dir,
|
|
402
|
+
"index_exists": os.path.exists(index_path),
|
|
403
|
+
"styles_count": styles_count,
|
|
404
|
+
"corrupted_count": corrupted_count
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
def rebuild_cache(self) -> None:
|
|
408
|
+
"""
|
|
409
|
+
Rebuilds the cache by updating the index and re-downloading all cached CSL styles.
|
|
410
|
+
This is a "sync with CDN" operation, so it checks the CDN first for
|
|
411
|
+
fresh content rather than the (static) bundled archive - it only
|
|
412
|
+
falls back to the bundle if the CDN itself is unreachable.
|
|
413
|
+
"""
|
|
414
|
+
self.update_cache()
|
|
415
|
+
|
|
416
|
+
cache_dir = self.get_cache_dir()
|
|
417
|
+
if os.path.exists(cache_dir):
|
|
418
|
+
for filename in os.listdir(cache_dir):
|
|
419
|
+
file_path = os.path.join(cache_dir, filename)
|
|
420
|
+
if os.path.isfile(file_path) and filename.endswith(".csl"):
|
|
421
|
+
self.download_style(filename, file_path, prefer_bundle=False)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
# --- Global Backward Compatible API Functions ---
|
|
425
|
+
|
|
426
|
+
_default_cache_manager = CacheManager()
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def get_cache_dir() -> str:
|
|
430
|
+
return _default_cache_manager.get_cache_dir()
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def resolve_style(style_identifier: str, visited: set[str] | None = None) -> str:
|
|
434
|
+
return _default_cache_manager.resolve_style(style_identifier, visited)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def update_cache() -> bool:
|
|
438
|
+
return _default_cache_manager.update_cache()
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def clear_cache() -> None:
|
|
442
|
+
return _default_cache_manager.clear_cache()
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def get_cache_status() -> dict[str, Any]:
|
|
446
|
+
return _default_cache_manager.get_cache_status()
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def rebuild_cache() -> None:
|
|
450
|
+
return _default_cache_manager.rebuild_cache()
|