Skip to content

Commit edf0925

Browse files
authored
Merge pull request #50 from DeepPSP/feat/gbmedium-and-set-config
feat: add gbmedium config, set CLI subcommand, and config warning
2 parents c79f342 + bc35391 commit edf0925

10 files changed

Lines changed: 440 additions & 37 deletions

File tree

CHANGELOG.rst

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,24 @@ Added
1616
- Added ``format_err`` (``"Format Error"``) and ``parse_err`` (``"Parse Error"``)
1717
as dedicated error types on ``BibLookup``, with corresponding ``@property``
1818
getters, to distinguish local processing failures from remote lookup failures.
19+
- Added ``set`` subcommand to the CLI (``bib-lookup set KEY VALUE``) for setting
20+
individual configuration values; ``VALUE`` of ``none``/``null`` resets the key
21+
to ``None`` (falling back to built-in logic).
22+
- Added ``gbmedium`` configuration option for the GB/T 7714-2015 style:
23+
``True`` forces ``[J/OL]`` (online), ``False`` forces ``[J]`` (print),
24+
``None`` (default) auto-detects based on the presence of ``doi``/``url`` fields.
25+
- Added ``STYLE_PARAMETERS`` registry in ``_const.py`` documenting per-style
26+
configuration parameters, used for validation in ``--config`` and ``set``.
1927

2028
Changed
2129
~~~~~~~
30+
- ``--config`` now warns (instead of silently discards) when unknown keys are
31+
encountered; the keys are still written to the config file.
32+
- ``str2bool`` now passes ``None`` through unchanged so that config values
33+
reset to ``None`` work correctly for boolean fields.
34+
- Style-specific settings are now collected in a ``_style_kwargs`` dictionary
35+
inside ``BibLookup`` (populated by ``_init_style_kwargs``) rather than as
36+
individual attributes, making it easy to add new per-style parameters.
2237

2338
Deprecated
2439
~~~~~~~~~~

README.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,16 @@ bib-lookup --config /path/to/config.json
154154
bib-lookup --config /path/to/config.yaml
155155
```
156156

157-
Note that unrecognized fields will be ignored and warning messages will be printed. The following table lists all the available configuration options:
157+
Set a single configuration value (new shortcut):
158+
159+
```bash
160+
bib-lookup set timeout 2.0
161+
bib-lookup set style gbt
162+
bib-lookup set gbmedium true # force [J/OL] for all articles
163+
bib-lookup set gbmedium none # reset to built-in auto-detection
164+
```
165+
166+
Note that unrecognized fields will trigger a warning but are still written to the configuration file. The following table lists all the available configuration options:
158167

159168
| Option | Type | Default | Description |
160169
|-----------------|---------|-----------------------------------------------|-----------------------------------------------------|
@@ -169,6 +178,7 @@ Note that unrecognized fields will be ignored and warning messages will be print
169178
| `verbose` | `int` | `0` | Verbosity level. |
170179
| `print_result` | `bool` | `False` | Whether to print the result. |
171180
| `ordering` | `list` | `['title', 'author', 'journal', 'booktitle']` | Ordering of the fields. |
181+
| `gbmedium` | `bool` | `None` | (**GB/T 7714 only**) Medium tag for articles: `True` forces `[J/OL]`, `False` forces `[J]`, `None` auto-detects from DOI/URL fields. |
172182

173183
:point_right: [Back to TOC](#bib_lookup)
174184

bib_lookup/_const.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,27 @@
2424
cache_limit=1e6,
2525
capitalize_title=False,
2626
max_names=3,
27+
gbmedium=None,
2728
)
29+
30+
# Style-specific parameters documentation.
31+
# These parameters are passed to the style class constructor
32+
# when the corresponding style is active, and are recognised
33+
# by the `set` and `--config` commands in addition to the keys
34+
# in DEFAULT_CONFIG above.
35+
STYLE_PARAMETERS = {
36+
"gbt7714": {
37+
"gbmedium": {
38+
"type": "bool_or_none",
39+
"default": None,
40+
"description": (
41+
"Medium type tag for articles in GB/T 7714 style. "
42+
"True -> [J/OL] (online), False -> [J] (print), "
43+
"None -> auto-detect based on presence of DOI/URL fields."
44+
),
45+
},
46+
},
47+
"ieee": {},
48+
"apa": {},
49+
"chicago": {},
50+
}

bib_lookup/bib_lookup.py

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,12 @@ def __init__(
308308
if self.max_names < 1:
309309
raise ValueError(f"`max_names` must be an integer >= 1, but got `{max_names_raw}`")
310310

311+
# Style-specific settings collected into a dict mapping style name → kwargs.
312+
# This keeps BibLookup free of per-setting attributes and makes it easy
313+
# to add new style-specific parameters in the future.
314+
self._style_kwargs: Dict[str, dict] = {}
315+
self._init_style_kwargs(bl_config)
316+
311317
self.__field_pattern = f""",\\s*({"|".join(list(BIB_FIELDS))})\\s*=\\s*"""
312318

313319
self.__info_color = "blue"
@@ -395,6 +401,22 @@ def _get_supported_styles():
395401

396402
return supported_styles
397403

404+
def _init_style_kwargs(self, bl_config: dict) -> None:
405+
"""Populate ``self._style_kwargs`` from the merged config.
406+
407+
This dict maps normalised style names to the extra keyword arguments
408+
that should be passed to the corresponding style class constructor.
409+
It is the single place to add new style-specific settings. Each
410+
block updates ``self._style_kwargs`` in place so that adding new
411+
style-specific settings does not clobber existing ones.
412+
"""
413+
# gbmedium for GB/T 7714
414+
gbmedium_raw = bl_config.get("gbmedium", None)
415+
if gbmedium_raw is not None and not (isinstance(gbmedium_raw, str) and gbmedium_raw.lower() in ("none", "null")):
416+
_gbmedium_val = str2bool(gbmedium_raw)
417+
for _name in ("gbt7714", "gbt", "gbt-7714"):
418+
self._style_kwargs.setdefault(_name, {})["gbmedium"] = _gbmedium_val
419+
398420
def __call__(
399421
self,
400422
identifier: Union[Path, str, Sequence[str]],
@@ -580,7 +602,8 @@ def __call__(
580602
if len(self.__cached_lookup_results) > self.__cache_limit:
581603
self.__cached_lookup_results.popitem(last=False)
582604
except Exception as e: # pragma: no cover
583-
res = f"{self.parse_err}: {e}"
605+
_truncated = res if len(res) <= 200 else res[:200] + "..."
606+
res = f"{self.parse_err} ({idtf}): {e}\n Input: {_truncated}"
584607
elif format == "text":
585608
if style and style.lower() in self.supported_styles:
586609
# --- parse phase ---
@@ -602,7 +625,8 @@ def __call__(
602625
except Exception as e: # pragma: no cover
603626
if self.verbose > 0:
604627
print(f"Error parsing BibTeX: {e}")
605-
res = f"{self.parse_err}: {e}"
628+
_truncated = res if len(res) <= 200 else res[:200] + "..."
629+
res = f"{self.parse_err} ({idtf}): {e}\n Input: {_truncated}"
606630
else:
607631
# --- format phase ---
608632
try:
@@ -614,13 +638,14 @@ def __call__(
614638
del entry.fields[field]
615639

616640
style_class = self.supported_styles[style.lower()]
617-
style_defaults = {"gbt7714": 3, "gbt": 3, "gbt-7714": 3, "ieee": 6, "apa": 20, "chicago": 10}
618-
style_default_max = style_defaults.get(style.lower(), 3)
619641

642+
# Build kwargs for the style constructor:
643+
# merge max_names logic with any style-specific settings
644+
_style_kwargs = dict(self._style_kwargs.get(style.lower(), {}))
620645
if self.max_names != 3 or style.lower() in ["gbt7714", "gbt", "gbt-7714"]:
621-
custom_style = style_class(max_names=self.max_names)
622-
else:
623-
custom_style = style_class()
646+
_style_kwargs.setdefault("max_names", self.max_names)
647+
648+
custom_style = style_class(**_style_kwargs)
624649
formatted_entries = custom_style.format_entries(bib_data.entries.values())
625650

626651
backend = TextBackend()
@@ -865,8 +890,13 @@ def _handle_doi(self, feed_content: dict) -> str:
865890
if res in self.lookup_errors:
866891
return res
867892

868-
# If we successfully got content (even if it doesn't look like BibTeX but wasn't an error), return it.
869-
# This handles the case where we asked for BibTeX, got something else (200 OK), fallback skipped (no doi.org), and we just return what we got.
893+
# If we asked for BibTeX but the response looks like HTML,
894+
# the server ignored our Accept header (returned a landing page,
895+
# redirect page, error page, etc.) — treat this as a network error
896+
# rather than returning raw HTML to the BibTeX parser.
897+
if is_requesting_bibtex and re.match(r"\s*<(!DOCTYPE|html)", res, re.IGNORECASE):
898+
return self.network_err
899+
870900
if res:
871901
return res
872902

bib_lookup/cli.py

Lines changed: 89 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import sys
99
import warnings
1010
from pathlib import Path
11-
from typing import Any, Dict
11+
from typing import Any, Dict, Sequence
1212

1313
try:
1414
import yaml
@@ -17,11 +17,54 @@
1717

1818
from bib_lookup._const import CONFIG_FILE as _CONFIG_FILE
1919
from bib_lookup._const import DEFAULT_CONFIG as _DEFAULT_CONFIG
20+
from bib_lookup._const import STYLE_PARAMETERS as _STYLE_PARAMETERS
2021
from bib_lookup.bib_lookup import BibLookup
2122
from bib_lookup.utils import str2bool
2223
from bib_lookup.version import __version__ as bl_version
2324

2425

26+
def _valid_config_keys() -> set:
27+
"""Return the set of all recognised configuration keys."""
28+
keys = set(_DEFAULT_CONFIG.keys())
29+
for style_params in _STYLE_PARAMETERS.values():
30+
keys.update(style_params.keys())
31+
return keys
32+
33+
34+
def _parse_config_value(value: str) -> Any:
35+
"""Parse a string config value into the appropriate Python type.
36+
37+
- ``none``/``null`` → ``None``
38+
- booleans (``true``/``false``/``yes``/``no``/…) → ``bool``
39+
- ``[a, b, c]`` → ``list`` (quotes stripped, empty list ``[]`` handled)
40+
- otherwise → ``str``
41+
"""
42+
if value.lower() in ("none", "null"):
43+
return None
44+
try:
45+
return str2bool(value)
46+
except ValueError:
47+
pass
48+
if value.startswith("[") and value.endswith("]"):
49+
inner = value.strip("[]").strip()
50+
if not inner:
51+
return []
52+
return [v.strip().strip("'\"") for v in inner.split(",")]
53+
return value
54+
55+
56+
def _read_user_config() -> dict:
57+
"""Read the user config file, returning an empty dict if it does not exist."""
58+
if _CONFIG_FILE.is_file():
59+
return json.loads(_CONFIG_FILE.read_text())
60+
return {}
61+
62+
63+
def _write_user_config(config: dict) -> None:
64+
"""Write the config dict to the user config file."""
65+
_CONFIG_FILE.write_text(json.dumps(config, indent=4))
66+
67+
2568
def _handle_config(config_arg: str) -> None:
2669
"""Handle configuration commands."""
2770
if config_arg == "show":
@@ -50,10 +93,11 @@ def _handle_config(config_arg: str) -> None:
5093
return
5194
else:
5295
if "=" in config_arg:
53-
config = dict([kv.strip().split("=") for kv in config_arg.split(";")])
96+
config = {k.strip(): _parse_config_value(v.strip()) for k, v in (kv.split("=", 1) for kv in config_arg.split(";"))}
5497
else:
5598
config_path = Path(config_arg)
56-
assert config_path.is_file(), f"Configuration file ``{config_arg}`` does not exist. Please check and try again."
99+
if not config_path.is_file():
100+
raise ValueError(f"Configuration file ``{config_arg}`` does not exist. Please check and try again.")
57101

58102
if config_path.suffix == ".json":
59103
config = json.loads(config_path.read_text())
@@ -68,24 +112,50 @@ def _handle_config(config_arg: str) -> None:
68112
f"Unknown configuration file type ``{config_path.suffix}``. " "Only json and yaml files are supported."
69113
)
70114

71-
# discard unknown keys
72-
unknown_keys = set(config.keys()) - set(_DEFAULT_CONFIG.keys())
73-
config = {k: v for k, v in config.items() if k in _DEFAULT_CONFIG}
74-
# parse lists in the config
75-
for k, v in config.items():
76-
if isinstance(v, str) and v.startswith("[") and v.endswith("]"):
77-
config[k] = [vv.strip() for vv in v.strip("[]").split(",")]
115+
# Warn about unknown keys (but keep them — they may be style-specific)
116+
unknown_keys = set(config.keys()) - _valid_config_keys()
78117
if len(unknown_keys) > 0:
79118
verb = "are" if len(unknown_keys) > 1 else "is"
80119
warnings.warn(
81-
f"Unknown configuration keys ``{unknown_keys}`` {verb} discarded.",
120+
f"Unknown configuration keys ``{unknown_keys}`` {verb} kept but might have no effect.",
82121
RuntimeWarning,
83122
)
123+
124+
# Merge with existing user config and write back
125+
existing = _read_user_config()
126+
existing.update(config)
127+
_write_user_config(existing)
84128
print(f"Setting configurations:\n{json.dumps(config, indent=4)}")
85-
_CONFIG_FILE.write_text(json.dumps(config, indent=4))
86129
return
87130

88131

132+
def _handle_set(args_set: Sequence[str]) -> None:
133+
"""Handle the ``set`` subcommand.
134+
135+
Usage: ``bib-lookup set KEY VALUE``
136+
``VALUE`` of ``none``/``null`` resets the key to ``None`` (built-in logic).
137+
"""
138+
if len(args_set) != 2:
139+
print("Error: ``set`` accepts exactly 2 arguments: KEY VALUE.")
140+
sys.exit(1)
141+
142+
key, value = args_set
143+
144+
if key not in _valid_config_keys():
145+
verb = "is"
146+
warnings.warn(
147+
f"Unknown configuration key ``{key}`` {verb} set anyway.",
148+
RuntimeWarning,
149+
)
150+
151+
parsed_value = _parse_config_value(value)
152+
153+
existing = _read_user_config()
154+
existing[key] = parsed_value
155+
_write_user_config(existing)
156+
print(f"Set ``{key}`` to ``{parsed_value}``")
157+
158+
89159
def _handle_gather(args: Dict[str, Any]) -> None:
90160
"""Handle the gather command."""
91161
from bib_lookup.utils import gather_tex_source_files_in_one
@@ -151,6 +221,13 @@ def _handle_simplify_bib(args: Dict[str, Any]) -> None:
151221

152222
def main():
153223
"""Command-line interface for the bib_lookup package."""
224+
# Intercept the ``set`` subcommand before argparse runs,
225+
# since the main parser uses a variable-length positional arg
226+
# that would otherwise swallow ``set`` as an identifier.
227+
if len(sys.argv) > 1 and sys.argv[1] == "set":
228+
_handle_set(sys.argv[2:])
229+
return
230+
154231
parser = argparse.ArgumentParser(description="Look up a BibTeX entry from a DOI identifier, PMID (URL) or arXiv ID (URL).")
155232
parser.add_argument(
156233
"identifiers",

bib_lookup/styles/gbt7714.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,11 +99,13 @@ def __init__(
9999
name_style: Optional[Any] = None,
100100
sorting_style: Optional[Any] = None,
101101
max_names: int = 3,
102+
gbmedium: Optional[bool] = None,
102103
**kwargs: Any,
103104
):
104105
sorting_style = "none"
105106
super().__init__(label_style, name_style, sorting_style, **kwargs)
106107
self.max_names = max_names
108+
self.gbmedium = gbmedium
107109

108110
def format_names(self, role: str, as_sentence: bool = True) -> Union[Node, sentence]:
109111
formatted_names = GBTNames(role, self._format_person, limit=self.max_names)
@@ -133,8 +135,16 @@ def _format_person(self, person: Person) -> str:
133135
return f"{surname} {initials}".strip()
134136

135137
def get_article_template(self, e: Entry) -> Node:
136-
# Use [J/OL] for online articles (with DOI or URL), [J] for print
137-
medium_tag = "[J/OL]" if ("doi" in e.fields or "url" in e.fields) else "[J]"
138+
# Determine medium tag per GB/T 7714:
139+
# - gbmedium is True -> force [J/OL] (online)
140+
# - gbmedium is False -> force [J] (print)
141+
# - gbmedium is None -> auto-detect from DOI/URL fields
142+
if self.gbmedium is True:
143+
medium_tag = "[J/OL]"
144+
elif self.gbmedium is False:
145+
medium_tag = "[J]"
146+
else:
147+
medium_tag = "[J/OL]" if ("doi" in e.fields or "url" in e.fields) else "[J]"
138148
template = join(sep=". ")[
139149
self.format_names("author", as_sentence=False),
140150
join[field("title"), medium_tag],

bib_lookup/utils.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -269,26 +269,30 @@ def printmd(md_str: str) -> None:
269269
print(md_str) # pragma: no cover
270270

271271

272-
def str2bool(v: Union[str, bool, int, float]) -> bool:
272+
def str2bool(v: Union[str, bool, int, float, None]) -> Union[bool, None]:
273273
"""Converts a "boolean" value possibly in the format of str to bool.
274274
275275
Implementation from StackOverflow [#sa]_.
276276
277277
Parameters
278278
----------
279-
v : str or bool or int or float
280-
The "boolean" value.
279+
v : str or bool or int or float or None
280+
The "boolean" value. ``None`` is passed through unchanged so that
281+
config keys reset to ``None`` (via ``set key none``) keep working
282+
for fields that default to ``None`` (e.g. ``gbmedium``).
281283
282284
Returns
283285
-------
284-
bool
285-
`v` in the format of bool.
286+
bool or None
287+
`v` in the format of bool, or ``None`` if `v` is ``None``.
286288
287289
References
288290
----------
289291
.. [#sa] https://stackoverflow.com/questions/15008758/parsing-boolean-values-with-argparse
290292
291293
"""
294+
if v is None:
295+
return None
292296
if isinstance(v, bool):
293297
b = v
294298
elif isinstance(v, (int, float)):

0 commit comments

Comments
 (0)