diff --git a/r2t2/core.py b/r2t2/core.py index 5390086..0219306 100755 --- a/r2t2/core.py +++ b/r2t2/core.py @@ -1,6 +1,6 @@ import inspect import wrapt -from typing import NamedTuple, List +from typing import NamedTuple, List, Optional, Callable from functools import reduce @@ -22,7 +22,7 @@ def add_record(out, record): out += f"\nSource file: {record.source}" out += f"\nLine: {record.line}\n" for short, ref in zip(record.short_purpose, record.references): - out += f"\t[{index}] {short} - {ref}\n" + out += f"\t[{index}] {short} - {ref.split(']', 1)[-1]}\n" index += 1 out += "\n" return out @@ -46,18 +46,34 @@ def tracking(self, enabled=True): BIBLIOGRAPHY: Biblio = Biblio() -def add_reference(*, short_purpose: str, reference: str): +def add_reference( + *, short_purpose: str, reference: Optional[str] = None, doi: Optional[str] = None +) -> Callable: """Decorator to link a reference to a function or method. Acts as a marker in code where particular alogrithms/data/... originates. General execution of code silently passes these markers, but remembers how and where they were called. Which markers were passed in a particular program run - can be recalled with print_references(). + can be recalled with `print(BIBLIOGRAPHY)`. - Arguments: - short_purpose: Identify the thing being referenced (string) - reference: The reference itself, in any sensible format. + One and only one method for providing the reference is allowed. + + Args: + short_purpose (str): Identify the thing being referenced. + reference (Optional, str): The reference itself, as a plain text string. + doi (Optional, str): DOI of the reference. + + Returns: + The decorated function. """ + if reference and doi: + raise ValueError("Only one method for providing the reference is allowed.") + elif reference: + ref = f"[plain]{reference}" + elif doi: + ref = f"[doi]{doi}" if "doi.org" in doi else f"[doi]https://doi.org/{doi}" + else: + raise ValueError("No reference information provided!") @wrapt.decorator(enabled=lambda: BIBLIOGRAPHY.track_references) def wrapper(wrapped, instance, args, kwargs): @@ -65,17 +81,14 @@ def wrapper(wrapped, instance, args, kwargs): line = inspect.getsourcelines(wrapped)[1] identifier = f"{source}:{line}" - if ( - identifier in BIBLIOGRAPHY - and reference in BIBLIOGRAPHY[identifier].references - ): + if identifier in BIBLIOGRAPHY and ref in BIBLIOGRAPHY[identifier].references: return wrapped(*args, **kwargs) if identifier not in BIBLIOGRAPHY: BIBLIOGRAPHY[identifier] = FunctionReference(wrapped.__name__, line, source) BIBLIOGRAPHY[identifier].short_purpose.append(short_purpose) - BIBLIOGRAPHY[identifier].references.append(reference) + BIBLIOGRAPHY[identifier].references.append(ref) return wrapped(*args, **kwargs) diff --git a/r2t2/reference_processors.py b/r2t2/reference_processors.py new file mode 100644 index 0000000..3841fdb --- /dev/null +++ b/r2t2/reference_processors.py @@ -0,0 +1,66 @@ +from typing import Callable, Dict +from functools import partial, lru_cache +from abc import ABC, abstractmethod +import re + + +PROCESSORS: Dict[str, Callable] = {} +"""Dictionary of available processors.""" + + +def register_processor(f: Callable = None, name: str = None) -> Callable: + """Register a reference processor. + + Args: + f (callable): Function processing the reference string. + name (str): Name of the processor. + + Returns: + The input processor. + """ + if f is None: + return partial(register_processor, name=name) + + name = name if name else f.__name__ + PROCESSORS[name] = f + + return f + + +@lru_cache +def process_reference(ref: str, output_format: str = "plain"): + """Process the reference string in the chosen format. + + This function is cached, so multiple calls with identical arguments are processed + faster, specially useful when information needs to be read from a file or retrieved + from the internet. + + Args: + ref (str): The reference string. Should start with [XXX], where XXX is one of + the known processors. + output_format (str): The format the reference should be provided + ("plain" or "bibtex"). + + Returns: + The reference in the requested format. + """ + match = re.findall(r'([\w.-/\\:\s]+)', ref) + if len(match) != 2: + raise ValueError(f"Could not process reference {ref}") + + return getattr(PROCESSORS[match[0]](match[1]), output_format) + + +class ProcessorBase(ABC): + """Base class for all reference processors.""" + + def __init__(self, ref): + self._ref = ref + + @abstractmethod + def plain(self) -> str: + """Return the reference as plain text in a sensible format.""" + + @abstractmethod + def bibtex(self) -> str: + """Return the reference as a bibtex entry.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..1b0a0f9 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,25 @@ +from pytest import fixture + + +@fixture +def decorated_function(): + from r2t2 import add_reference + + @add_reference( + short_purpose="Roasted chicken recipe", reference="Great British Roasts, 2019" + ) + def roasted_chicken(ingredients=None): + pass + + return roasted_chicken + + +@fixture +def decorated_with_doi(): + from r2t2 import add_reference + + @add_reference(short_purpose="DOI reference", doi="10.5281/zenodo.1185316") + def a_great_function(): + pass + + return a_great_function diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..54a5eb5 --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,39 @@ +def test_add_reference(decorated_function): + from r2t2 import BIBLIOGRAPHY + + assert "[plain]Great British Roasts, 2019" not in BIBLIOGRAPHY.references + decorated_function() + assert "[plain]Great British Roasts, 2019" not in BIBLIOGRAPHY.references + + BIBLIOGRAPHY.tracking() + decorated_function("Chicken") + assert "[plain]Great British Roasts, 2019" in BIBLIOGRAPHY.references + + BIBLIOGRAPHY.clear() + BIBLIOGRAPHY.tracking(False) + + +def test_print_references(capsys, decorated_function): + from r2t2 import BIBLIOGRAPHY + + BIBLIOGRAPHY.tracking() + decorated_function("Chicken") + print(BIBLIOGRAPHY) + captured = capsys.readouterr() + + assert "Great British Roasts, 2019" in captured.out + + BIBLIOGRAPHY.clear() + BIBLIOGRAPHY.tracking(False) + + +def test_add_reference_from_doi(decorated_with_doi): + from r2t2 import BIBLIOGRAPHY + + BIBLIOGRAPHY.tracking() + + decorated_with_doi() + assert "[doi]https://doi.org/" in BIBLIOGRAPHY.references[-1] + + BIBLIOGRAPHY.clear() + BIBLIOGRAPHY.tracking(False) diff --git a/tests/test_r2t2.py b/tests/test_r2t2.py deleted file mode 100644 index da14103..0000000 --- a/tests/test_r2t2.py +++ /dev/null @@ -1,41 +0,0 @@ -def decorated_function(): - from r2t2 import add_reference - - @add_reference( - short_purpose="Roasted chicken recipe", reference="Great British Roasts, 2019" - ) - def roasted_chicken(ingredients=None): - pass - - return roasted_chicken - - -def test_add_reference(): - from r2t2 import BIBLIOGRAPHY - - chicken = decorated_function() - assert "Great British Roasts, 2019" not in BIBLIOGRAPHY.references - chicken() - assert "Great British Roasts, 2019" not in BIBLIOGRAPHY.references - - BIBLIOGRAPHY.tracking() - chicken("Chicken") - assert "Great British Roasts, 2019" in BIBLIOGRAPHY.references - - BIBLIOGRAPHY.clear() - BIBLIOGRAPHY.tracking(False) - - -def test_print_references(capsys): - from r2t2 import BIBLIOGRAPHY - - BIBLIOGRAPHY.tracking() - chicken = decorated_function() - chicken("Chicken") - print(BIBLIOGRAPHY) - captured = capsys.readouterr() - - assert "Great British Roasts, 2019" in captured.out - - BIBLIOGRAPHY.clear() - BIBLIOGRAPHY.tracking(False)