From d1a12e62962f8161d6d1bb69dc5c44f3d664c45b Mon Sep 17 00:00:00 2001 From: Andy Shapiro Date: Thu, 22 May 2025 11:21:58 -0400 Subject: [PATCH 01/17] update gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 23447a1..da42bd7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ *.mo *.nja *.py[co] +.benchmarks .cache .coverage .idea @@ -19,7 +20,7 @@ /.project /.pydevproject build -coverage_html +htmlcov dist venv From a10d2c79e09c96d04b4ecb6c8cb86e46b84cdc6f Mon Sep 17 00:00:00 2001 From: Andy Shapiro Date: Thu, 22 May 2025 11:24:58 -0400 Subject: [PATCH 02/17] switch to poethepoet; attempt to update github actions --- .github/workflows/main.yml | 38 +++++++++++++++--------- Makefile | 59 -------------------------------------- README.md | 15 +++++----- pyproject.toml | 34 ++++++++++++++++++++++ 4 files changed, 66 insertions(+), 80 deletions(-) delete mode 100644 Makefile diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index fed0896..ee45a9a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -6,7 +6,7 @@ jobs: backend: name: lint + test - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 strategy: max-parallel: 4 @@ -14,18 +14,30 @@ jobs: python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - name: install - run: | - python -m pip install -U pip - pip install -e ".[dev]" - - name: lint - run: | - make lint - - name: test + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: "**/pyproject.toml" + - name: Install dependencies + run: uv sync --all-extras + - name: Check linting + run: uv run poe lint + - name: Test with pytest run: | - make coverage - coverage report + uv run coverage run -m pytest --benchmark-skip + echo "# Python coverage report" >> $GITHUB_STEP_SUMMARY + uv run coverage report --format=markdown >> $GITHUB_STEP_SUMMARY || true + - name: Build a wheel + run: uv run poe build + - name: Upload wheel + if: matrix.python-version == '3.12' + uses: actions/upload-artifact@v4 + with: + name: wheel + path: | + dist/*.whl diff --git a/Makefile b/Makefile deleted file mode 100644 index ca201f8..0000000 --- a/Makefile +++ /dev/null @@ -1,59 +0,0 @@ -.PHONY: clean lint format test coverage build publish - -.DEFAULT_GOAL := help - -define PRINT_HELP_PYSCRIPT -import re, sys - -for line in sys.stdin: - match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line) - if match: - target, help = match.groups() - print("%-20s %s" % (target, help)) -endef -export PRINT_HELP_PYSCRIPT - -VERSION := $(shell python -c "import rispy; print(rispy.__version__)") - -help: - @python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST) - -clean: ## Remove all build, test and Python artifacts - # build artifacts - rm -fr build/ - rm -fr dist/ - rm -fr .eggs/ - find . -name '*.egg-info' -exec rm -fr {} + - find . -name '*.egg' -exec rm -f {} + - # test artifacts - rm -fr htmlcov/ - # python artifacts - find . -name '*.pyc' -exec rm -f {} + - find . -name '*.pyo' -exec rm -f {} + - find . -name '*~' -exec rm -f {} + - find . -name '__pycache__' -exec rm -fr {} + - -lint: ## Check python formatting issues - @ruff format . --check && ruff . - -format: ## Fix python formatting issues where possible - @ruff format . && ruff . --fix --show-fixes - -test: ## Run unit test suite - @py.test --benchmark-skip - -bench: ## Run benchmark test suite - @py.test --benchmark-only - -coverage: ## Run coverage and create html report - coverage run -m pytest --benchmark-skip - coverage html -d coverage_html - -build: clean ## builds source and wheel package - flit build - ls -l dist - -publish: ## package and upload a release - flit publish - git tag $(VERSION) - git push --tags diff --git a/README.md b/README.md index 6de11ed..5339ca6 100644 --- a/README.md +++ b/README.md @@ -301,23 +301,22 @@ support. Software specializing on these formats include: ## Developer instructions -Common developer commands are in the provided `Makefile`; if you don't have `make` installed, you can view the make commands and run the commands from the command-line manually: +Install [uv](https://docs.astral.sh/uv/) and make it available and on your path. Then: ```bash # setup environment -python -m venv venv -source venv/bin/activate -python -m pip install -U pip -python -m pip install -e ".[dev]" +uv venv --python=3.12 +source .venv/bin/activate # On Windows: .venv\Scripts\activate +uv pip install -e ".[dev]" # check if code format changes are required -make lint +poe lint # reformat code -make format +poe format # run tests -make test +poe test ``` Github Actions are currently enabled to run `lint` and `test` when submitting a pull-request. diff --git a/pyproject.toml b/pyproject.toml index b28d4fe..3cc301b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ requires-python = ">=3.8" [project.optional-dependencies] dev = [ + "poethepoet ~= 0.34.0", "pytest ~=7.4.4", "pytest-benchmark", "flit ~= 3.9.0", @@ -59,3 +60,36 @@ select = ["F", "E", "W", "I", "UP", "S", "B", "T20", "RUF"] [tool.pytest.ini_options] addopts = "--doctest-glob='*.md'" + +[tool.poe.tasks.lint] +help = "Check for formatting issues" +sequence = [ + {cmd = "ruff format . --check"}, + {cmd = "ruff check ."}, +] + +[tool.poe.tasks.format] +help = "Fix formatting issues (where possible)" +sequence = [ + {cmd = "ruff format ."}, + {cmd = "ruff check . --fix --show-fixes"}, +] + +[tool.poe.tasks.test] +help = "Run tests" +cmd = "pytest --benchmark-skip" + +[tool.poe.tasks.bench] +help = "Run benchmark tests" +cmd = "pytest --benchmark-only" + +[tool.poe.tasks.coverage] +help = "Generate test coverage report" +sequence = [ + {cmd = "coverage run -m pytest --benchmark-skip"}, + {cmd = "coverage html"}, +] + +[tool.poe.tasks.build] +help = "Build wheel package" +cmd = "uv build" From 6718903534aa32b268bb2e99896135eeab3fe03a Mon Sep 17 00:00:00 2001 From: Andy Shapiro Date: Thu, 22 May 2025 11:25:08 -0400 Subject: [PATCH 03/17] set a minimum coverage version --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 3cc301b..3e6fb4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,10 @@ omit = [ "tests/*", ] +[tool.coverage.report] +fail_under=94 +precision=1 + [tool.flit.sdist] exclude = [".github", "Makefile", "tests"] From 3defe7f3b49aaa47672d58ec64e01ac2d6b1a33d Mon Sep 17 00:00:00 2001 From: Andy Shapiro Date: Thu, 22 May 2025 11:40:06 -0400 Subject: [PATCH 04/17] fix indentation error --- .github/workflows/main.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 23136e0..8412156 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -35,9 +35,9 @@ jobs: - name: Build a wheel run: uv run poe build - name: Upload wheel - if: matrix.python-version == '3.12' - uses: actions/upload-artifact@v4 - with: - name: wheel - path: | - dist/*.whl + if: matrix.python-version == '3.13' + uses: actions/upload-artifact@v4 + with: + name: wheel + path: | + dist/*.whl From 1c11e523760f40ecf4ef1d08fe7923bc4fcb083f Mon Sep 17 00:00:00 2001 From: Andy Shapiro Date: Thu, 22 May 2025 11:42:36 -0400 Subject: [PATCH 05/17] only show coverage report for latest python version --- .github/workflows/main.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 8412156..7970257 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -30,6 +30,9 @@ jobs: - name: Test with pytest run: | uv run coverage run -m pytest --benchmark-skip + - name: Generate coverage report + if: matrix.python-version == '3.13' + run: | echo "# Python coverage report" >> $GITHUB_STEP_SUMMARY uv run coverage report --format=markdown >> $GITHUB_STEP_SUMMARY || true - name: Build a wheel From 8d54455fbef4638ef9f13b54c9de96e989626b05 Mon Sep 17 00:00:00 2001 From: Andy Shapiro Date: Thu, 22 May 2025 11:43:44 -0400 Subject: [PATCH 06/17] test (intentional) CI failure --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 50bb57c..c0403d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ omit = [ ] [tool.coverage.report] -fail_under=94 +fail_under=99 precision=1 [tool.flit.sdist] From fd9b876a091a64b0432e38b1312b837ed313bed0 Mon Sep 17 00:00:00 2001 From: Andy Shapiro Date: Thu, 22 May 2025 11:50:49 -0400 Subject: [PATCH 07/17] allow failure --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 7970257..a17db11 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -34,7 +34,7 @@ jobs: if: matrix.python-version == '3.13' run: | echo "# Python coverage report" >> $GITHUB_STEP_SUMMARY - uv run coverage report --format=markdown >> $GITHUB_STEP_SUMMARY || true + uv run coverage report --format=markdown >> $GITHUB_STEP_SUMMARY - name: Build a wheel run: uv run poe build - name: Upload wheel From cc87dccd6940a1ceef69c92c546e15a7c4c22cca Mon Sep 17 00:00:00 2001 From: Andy Shapiro Date: Thu, 22 May 2025 11:59:25 -0400 Subject: [PATCH 08/17] always write coverage to stdout; add an additional report that may cause a failure --- .github/workflows/main.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a17db11..9a14139 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -34,7 +34,8 @@ jobs: if: matrix.python-version == '3.13' run: | echo "# Python coverage report" >> $GITHUB_STEP_SUMMARY - uv run coverage report --format=markdown >> $GITHUB_STEP_SUMMARY + uv run coverage report --format=markdown >> $GITHUB_STEP_SUMMARY || true + uv run coverage json -q - name: Build a wheel run: uv run poe build - name: Upload wheel From ca284e80482f41e642ae0ad73497724f003b1e49 Mon Sep 17 00:00:00 2001 From: Andy Shapiro Date: Thu, 22 May 2025 12:01:13 -0400 Subject: [PATCH 09/17] revert intentional coverage failure --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c0403d9..50bb57c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ omit = [ ] [tool.coverage.report] -fail_under=99 +fail_under=94 precision=1 [tool.flit.sdist] From ae9174abbbcd82be01aae101ecb23408f8d361d7 Mon Sep 17 00:00:00 2001 From: Andy Shapiro Date: Thu, 22 May 2025 12:07:01 -0400 Subject: [PATCH 10/17] add comments --- .github/workflows/main.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 9a14139..48f4223 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -31,7 +31,7 @@ jobs: run: | uv run coverage run -m pytest --benchmark-skip - name: Generate coverage report - if: matrix.python-version == '3.13' + if: matrix.python-version == '3.13' # only show a single copy of the report run: | echo "# Python coverage report" >> $GITHUB_STEP_SUMMARY uv run coverage report --format=markdown >> $GITHUB_STEP_SUMMARY || true @@ -39,7 +39,7 @@ jobs: - name: Build a wheel run: uv run poe build - name: Upload wheel - if: matrix.python-version == '3.13' + if: matrix.python-version == '3.13' # only upload a single wheel; not Python-version specific uses: actions/upload-artifact@v4 with: name: wheel From 7f5667112b317a17f4a1b48ade6c8be3598590c4 Mon Sep 17 00:00:00 2001 From: Andy Shapiro Date: Thu, 22 May 2025 12:17:29 -0400 Subject: [PATCH 11/17] minor edits --- .github/workflows/main.yml | 2 +- README.md | 2 +- pyproject.toml | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 48f4223..dfee3ee 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -35,7 +35,7 @@ jobs: run: | echo "# Python coverage report" >> $GITHUB_STEP_SUMMARY uv run coverage report --format=markdown >> $GITHUB_STEP_SUMMARY || true - uv run coverage json -q + uv run coverage json -q # will cause pipeline failure if coverage < minimum - name: Build a wheel run: uv run poe build - name: Upload wheel diff --git a/README.md b/README.md index 7c4447f..9537729 100644 --- a/README.md +++ b/README.md @@ -318,7 +318,7 @@ Install [uv](https://docs.astral.sh/uv/) and make it available and on your path. ```bash # setup environment -uv venv --python=3.12 +uv venv --python=3.13 source .venv/bin/activate # On Windows: .venv\Scripts\activate uv pip install -e ".[dev]" diff --git a/pyproject.toml b/pyproject.toml index 50bb57c..9f988e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ requires-python = ">=3.9" dev = [ "poethepoet ~= 0.34.0", "pytest ~=7.4.4", - "pytest-benchmark", + "pytest-benchmark ~= 5.1.0", "flit ~= 3.9.0", "ruff ~= 0.11.6", "coverage ~= 7.4.0", @@ -52,7 +52,7 @@ fail_under=94 precision=1 [tool.flit.sdist] -exclude = [".github", "Makefile", "tests"] +exclude = [".github", "tests"] [tool.ruff] line-length = 100 From 04e09bb034050508a5c04cbebcfcbf3e02623e8d Mon Sep 17 00:00:00 2001 From: Andy Shapiro Date: Thu, 22 May 2025 12:18:57 -0400 Subject: [PATCH 12/17] revert pinning --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9f988e9..b61a95e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ requires-python = ">=3.9" dev = [ "poethepoet ~= 0.34.0", "pytest ~=7.4.4", - "pytest-benchmark ~= 5.1.0", + "pytest-benchmark", "flit ~= 3.9.0", "ruff ~= 0.11.6", "coverage ~= 7.4.0", From 9afe7b50efdd4efede8ff29aeadaaf593da97dcd Mon Sep 17 00:00:00 2001 From: Andy Shapiro Date: Thu, 22 May 2025 13:34:56 -0400 Subject: [PATCH 13/17] update dev dependences --- pyproject.toml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b61a95e..424efab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,11 +31,11 @@ requires-python = ">=3.9" [project.optional-dependencies] dev = [ "poethepoet ~= 0.34.0", - "pytest ~=7.4.4", - "pytest-benchmark", - "flit ~= 3.9.0", - "ruff ~= 0.11.6", - "coverage ~= 7.4.0", + "pytest ~=8.3.5", + "pytest-benchmark ~= 5.1.0", + "flit ~= 3.12.0", + "ruff ~= 0.11.10", + "coverage ~= 7.8.0", ] [build-system] From 34143507e47d0714803c7f756e1cebab63fa813d Mon Sep 17 00:00:00 2001 From: Andy Shapiro Date: Thu, 22 May 2025 13:35:11 -0400 Subject: [PATCH 14/17] add tests for utils --- tests/test_utils.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 tests/test_utils.py diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..ae5347f --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,14 @@ +import pytest + +from rispy.utils import invert_dictionary + + +def test_invert_dictionary(): + d = {"a": "b"} + assert invert_dictionary(d) == {"b": "a"} + + +def test_invert_dictionary_failure(): + d = {"a": "b", "c": "b"} + with pytest.raises(ValueError, match="Dictionary cannot be inverted"): + invert_dictionary(d) From 5470adc1d01365ed178f3f75d92d4b2a0befc5b1 Mon Sep 17 00:00:00 2001 From: Andy Shapiro Date: Thu, 22 May 2025 13:35:22 -0400 Subject: [PATCH 15/17] add tests for writer --- pyproject.toml | 3 ++ rispy/writer.py | 51 ++++++------------ tests/test_writer.py | 121 +++++++++++++++++-------------------------- 3 files changed, 66 insertions(+), 109 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 424efab..a490f8f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,9 @@ omit = [ [tool.coverage.report] fail_under=94 precision=1 +exclude_also = [ + "@abstractmethod", +] [tool.flit.sdist] exclude = [".github", "tests"] diff --git a/rispy/writer.py b/rispy/writer.py index dd04069..785b85d 100644 --- a/rispy/writer.py +++ b/rispy/writer.py @@ -1,7 +1,7 @@ """RIS Writer.""" import warnings -from abc import ABC +from abc import ABC, abstractmethod from pathlib import Path from typing import ClassVar, Optional, TextIO, Union @@ -90,21 +90,15 @@ def __init__( def _get_reference_type(self, ref): if self.REFERENCE_TYPE_KEY in ref: - # TODO add check return ref[self.REFERENCE_TYPE_KEY] - - if self.DEFAULT_REFERENCE_TYPE is not None: - return self.DEFAULT_REFERENCE_TYPE - else: - raise ValueError("Unknown type of reference") + return self.DEFAULT_REFERENCE_TYPE def _format_line(self, tag, value=""): """Format a RIS line.""" return self.PATTERN.format(tag=tag, value=value) def _format_reference(self, ref, count, n): - header = self.set_header(count) - if header is not None: + if header := self.set_header(count): yield header yield self._format_line(self.START_TAG, self._get_reference_type(ref)) @@ -166,9 +160,10 @@ def formats(self, references: list[dict]) -> str: lines = self._yield_lines(references, extra_line=True) return self.NEWLINE.join(lines) - def set_header(self, count: int) -> Optional[str]: - """Create the header for each reference.""" - return None + @abstractmethod + def set_header(self, count: int) -> str: + """Create the header for each reference; if empty string, unused.""" + ... class RisWriter(BaseWriter): @@ -189,7 +184,7 @@ def dump( file: Union[TextIO, Path], *, encoding: Optional[str] = None, - implementation: Optional[BaseWriter] = None, + implementation: type[BaseWriter] = RisWriter, **kw, ): """Write an RIS file to file or file-like object. @@ -204,26 +199,18 @@ def dump( references (list[dict]): List of references. file (TextIO): File handle to store ris formatted data. encoding (str, optional): Encoding to use when opening file. - implementation (RisImplementation): RIS implementation; base by - default. + implementation (BaseWriter): RIS implementation; base by default. """ - if implementation is None: - writer = RisWriter - else: - writer = implementation - - if hasattr(file, "write"): - writer(**kw).format_lines(file, references) - elif hasattr(file, "open"): + if isinstance(file, Path): with file.open(mode="w", encoding=encoding) as f: - writer(**kw).format_lines(f, references) + implementation(**kw).format_lines(f, references) + elif hasattr(file, "write"): + implementation(**kw).format_lines(file, references) else: raise ValueError("File must be a file-like object or a Path object") -def dumps( - references: list[dict], *, implementation: Optional[type[BaseWriter]] = None, **kw -) -> str: +def dumps(references: list[dict], *, implementation: type[BaseWriter] = RisWriter, **kw) -> str: """Return an RIS formatted string. Entries are codified as dictionaries whose keys are the @@ -234,12 +221,6 @@ def dumps( Args: references (list[dict]): List of references. - implementation (RisImplementation): RIS implementation; base by - default. + implementation (BaseWriter): RIS implementation; RisWriter by default. """ - if implementation is None: - writer = RisWriter - else: - writer = implementation - - return writer(**kw).formats(references) + return implementation(**kw).formats(references) diff --git a/tests/test_writer.py b/tests/test_writer.py index 8bb363b..809a965 100644 --- a/tests/test_writer.py +++ b/tests/test_writer.py @@ -9,6 +9,20 @@ DATA_DIR = Path(__file__).parent.resolve() / "data" +@pytest.fixture +def ris_data(): + return [ + { + "type_of_reference": "JOUR", + "authors": ["Shannon, Claude E.", "Doe, John"], + "year": "1948/07//", + "title": "A Mathematical Theory of Communication", + "start_page": "379", + "urls": ["https://example.com", "https://example2.com"], + } + ] + + def test_dump_and_load(): # check that we can write the same file we read source_fp = DATA_DIR / "example_full.ris" @@ -131,81 +145,39 @@ class CustomWriter(rispy.RisWriter): assert reload == entries -def test_write_single_unknown_tag(): - entries = [ - { - "type_of_reference": "JOUR", - "authors": ["Shannon, Claude E."], - "year": "1948/07//", - "title": "A Mathematical Theory of Communication", - "start_page": "379", - "unknown_tag": {"JP": ["CRISPR"]}, - } - ] - - text_output = rispy.dumps(entries) - +def test_write_single_unknown_tag(ris_data): + ris_data[0]["unknown_tag"] = {"JP": ["CRISPR"]} + text_output = rispy.dumps(ris_data) # check output is as expected lines = text_output.splitlines() - assert lines[6] == "JP - CRISPR" - assert len(lines) == 8 + assert lines[9] == "JP - CRISPR" + assert len(lines) == 11 -def test_write_multiple_unknown_tag_same_type(): - entries = [ - { - "type_of_reference": "JOUR", - "authors": ["Shannon, Claude E."], - "year": "1948/07//", - "title": "A Mathematical Theory of Communication", - "start_page": "379", - "unknown_tag": {"JP": ["CRISPR", "PEOPLE"]}, - } - ] - - text_output = rispy.dumps(entries) +def test_write_multiple_unknown_tag_same_type(ris_data): + ris_data[0]["unknown_tag"] = {"JP": ["CRISPR", "PEOPLE"]} + text_output = rispy.dumps(ris_data) # check output is as expected lines = text_output.splitlines() - assert lines[6] == "JP - CRISPR" - assert lines[7] == "JP - PEOPLE" - assert len(lines) == 9 + assert lines[9] == "JP - CRISPR" + assert lines[10] == "JP - PEOPLE" + assert len(lines) == 12 -def test_write_multiple_unknown_tag_diff_type(): - entries = [ - { - "type_of_reference": "JOUR", - "authors": ["Shannon, Claude E."], - "year": "1948/07//", - "title": "A Mathematical Theory of Communication", - "start_page": "379", - "unknown_tag": {"JP": ["CRISPR"], "ED": ["Swinburne, Ricardo"]}, - } - ] - - text_output = rispy.dumps(entries) +def test_write_multiple_unknown_tag_diff_type(ris_data): + ris_data[0]["unknown_tag"] = {"JP": ["CRISPR"], "ED": ["Swinburne, Ricardo"]} + text_output = rispy.dumps(ris_data) # check output is as expected lines = text_output.splitlines() - assert lines[6] == "JP - CRISPR" - assert lines[7] == "ED - Swinburne, Ricardo" - assert len(lines) == 9 + assert lines[9] == "JP - CRISPR" + assert lines[10] == "ED - Swinburne, Ricardo" + assert len(lines) == 12 -def test_default_dump(): - entries = [ - { - "type_of_reference": "JOUR", - "authors": ["Shannon, Claude E.", "Doe, John"], - "year": "1948/07//", - "title": "A Mathematical Theory of Communication", - "start_page": "379", - "urls": ["https://example.com", "https://example2.com"], - } - ] - - text_output = rispy.dumps(entries) +def test_default_dump(ris_data): + text_output = rispy.dumps(ris_data) lines = text_output.splitlines() assert lines[2] == "AU - Shannon, Claude E." assert lines[3] == "AU - Doe, John" @@ -214,20 +186,9 @@ def test_default_dump(): assert len(lines) == 10 -def test_delimited_dump(): - entries = [ - { - "type_of_reference": "JOUR", - "authors": ["Shannon, Claude E.", "Doe, John"], - "year": "1948/07//", - "title": "A Mathematical Theory of Communication", - "start_page": "379", - "urls": ["https://example.com", "https://example2.com"], - } - ] - +def test_delimited_dump(ris_data): # remove URLs from list_tags and give it a custom delimiter - text_output = rispy.dumps(entries, list_tags=["AU"], delimiter_tags_mapping={"UR": ","}) + text_output = rispy.dumps(ris_data, list_tags=["AU"], delimiter_tags_mapping={"UR": ","}) # check output is as expected lines = text_output.splitlines() @@ -235,3 +196,15 @@ def test_delimited_dump(): assert lines[3] == "AU - Doe, John" assert lines[7] == "UR - https://example.com,https://example2.com" assert len(lines) == 9 + + +def test_dump_path(tmp_path, ris_data): + # check that dump works with a Path object + path = tmp_path / "file.ris" + rispy.dump(ris_data, path) + assert len(path.read_text()) > 0 + + +def test_bad_dump(ris_data): + with pytest.raises(ValueError, match="File must be a file-like object or a Path object"): + rispy.dump(ris_data, 123) # type: ignore From 01a7801c7ab339d45f787cf03c44a12a5b4a1f76 Mon Sep 17 00:00:00 2001 From: Andy Shapiro Date: Thu, 22 May 2025 14:32:25 -0400 Subject: [PATCH 16/17] add tests for parser --- rispy/parser.py | 39 +++++++---------------- tests/test_parser.py | 75 +++++++++++++++++++++++++++----------------- 2 files changed, 59 insertions(+), 55 deletions(-) diff --git a/rispy/parser.py b/rispy/parser.py index 3c159e2..8417de2 100644 --- a/rispy/parser.py +++ b/rispy/parser.py @@ -199,8 +199,6 @@ def _add_list_value(self, record: dict, name: str, value: Union[str, list[str]]) except KeyError: record[name] = value_list except AttributeError: - if not isinstance(record[name], str): - raise must_exist = record[name] record[name] = [must_exist, *value_list] @@ -267,7 +265,7 @@ def load( *, encoding: Optional[str] = None, newline: Optional[str] = None, - implementation: Optional[RisParser] = None, + implementation: type[RisParser] = RisParser, **kw, ) -> list[dict]: """Load a RIS file and return a list of entries. @@ -279,35 +277,28 @@ def load( of strings. Args: - file (Union[TextIO, Path]): File handle to read ris formatted data. + file (Union[TextIO, Path]): File handle of RIS data. encoding(str, optional): File encoding, only used when a Path is supplied. Consistent with the python standard library, if `None` is supplied, the default system encoding is used. newline(str, optional): File line separator. - implementation (RisImplementation): RIS implementation; base by - default. + implementation (RisParser): RIS implementation; RisParser by default. Returns: list: Returns list of RIS entries. """ - if implementation is None: - parser = RisParser - else: - parser = implementation - - if hasattr(file, "readline"): - return parser(newline=newline, **kw).parse_lines(file) - elif hasattr(file, "open"): + if isinstance(file, Path): with file.open(mode="r", newline=newline, encoding=encoding) as f: - return parser(**kw).parse_lines(f) + return implementation(**kw).parse_lines(f) + if hasattr(file, "readline"): + return implementation(newline=newline, **kw).parse_lines(file) elif hasattr(file, "read"): return loads(file.read(), implementation=implementation, newline=newline, **kw) - else: - raise ValueError("File must be a file-like object or a Path object") + raise ValueError("File must be a file-like object or a Path object") -def loads(text: str, *, implementation: Optional[type[RisParser]] = None, **kw) -> list[dict]: +def loads(text: str, *, implementation: type[RisParser] = RisParser, **kw) -> list[dict]: """Load a RIS file and return a list of entries. Entries are codified as dictionaries whose keys are the @@ -317,16 +308,10 @@ def loads(text: str, *, implementation: Optional[type[RisParser]] = None, **kw) of strings. Args: - text (str): A string version of an RIS file. - implementation (RisImplementation): RIS implementation; base by - default. + text (str): A string version of RIS data + implementation (RisParser): RIS implementation; RisParser by default. Returns: list: Returns list of RIS entries. """ - if implementation is None: - parser = RisParser - else: - parser = implementation - - return parser(**kw).parse(text) + return implementation(**kw).parse(text) diff --git a/tests/test_parser.py b/tests/test_parser.py index 78a2a36..0ad0e60 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -1,3 +1,4 @@ +from io import StringIO from pathlib import Path import pytest @@ -7,44 +8,62 @@ DATA_DIR = Path(__file__).parent.resolve() / "data" -def test_load_example_basic_ris(): - filepath = DATA_DIR / "example_basic.ris" - expected = { - "type_of_reference": "JOUR", - "authors": ["Shannon,Claude E."], - "year": "1948/07//", - "title": "A Mathematical Theory of Communication", - "alternate_title3": "Bell System Technical Journal", - "start_page": "379", - "end_page": "423", - "volume": "27", - } +@pytest.fixture +def example_basic(): + # expected output from `example_basic.ris` + return [ + { + "type_of_reference": "JOUR", + "authors": ["Shannon,Claude E."], + "year": "1948/07//", + "title": "A Mathematical Theory of Communication", + "alternate_title3": "Bell System Technical Journal", + "start_page": "379", + "end_page": "423", + "volume": "27", + } + ] + +def test_load_file(example_basic): # test with file object + filepath = DATA_DIR / "example_basic.ris" with open(filepath) as f: entries = rispy.load(f) - assert expected == entries[0] + assert example_basic == entries + + +def test_load_file_noreadline(example_basic): + # test with file object that has no readline - # test with pathlib object + class NoReadline(StringIO): + @property + def readline(self): # type: ignore + raise AttributeError("Not found") + + filepath = DATA_DIR / "example_basic.ris" + f = NoReadline(filepath.read_text()) + assert not hasattr(f, "readline") + entries = rispy.load(f) + assert example_basic == entries + + +def test_load_path(example_basic): + # test with Path object + filepath = DATA_DIR / "example_basic.ris" p = Path(filepath) entries = rispy.load(p) - assert expected == entries[0] + assert example_basic == entries -def test_loads(): - ristext = (DATA_DIR / "example_basic.ris").read_text() - expected = { - "type_of_reference": "JOUR", - "authors": ["Shannon,Claude E."], - "year": "1948/07//", - "title": "A Mathematical Theory of Communication", - "alternate_title3": "Bell System Technical Journal", - "start_page": "379", - "end_page": "423", - "volume": "27", - } +def test_load_bad_file(): + with pytest.raises(ValueError, match="File must be a file-like object or a Path object"): + rispy.load("test") # type: ignore - assert expected == rispy.loads(ristext)[0] + +def test_loads(example_basic): + ristext = (DATA_DIR / "example_basic.ris").read_text() + assert example_basic == rispy.loads(ristext) def test_load_multiline_ris(): From e011107595ea73da240fe1cf5f56e97577250aac Mon Sep 17 00:00:00 2001 From: Andy Shapiro Date: Thu, 22 May 2025 14:34:21 -0400 Subject: [PATCH 17/17] increase minimum coverage --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a490f8f..1ab4871 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ omit = [ ] [tool.coverage.report] -fail_under=94 +fail_under=99.5 precision=1 exclude_also = [ "@abstractmethod",