Skip to content

Commit 87ac4b0

Browse files
authored
Add a CI pipeline and fix static code issues (#1)
This adds a CI pipeline to run the unittests and perform some static code analysis on the project. We also fix the issues the static code analysis detected.
1 parent 0b2fc68 commit 87ac4b0

8 files changed

Lines changed: 107 additions & 43 deletions

File tree

.github/workflows/ci.yaml

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
name: ci
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
pull_request:
8+
9+
env:
10+
X_PYTHON_MIN_VERSION: "3.11"
11+
12+
jobs:
13+
test:
14+
# This job runs the unittests on the python versions specified down at the matrix
15+
runs-on: ubuntu-latest
16+
steps:
17+
- uses: actions/checkout@v4
18+
- name: Set up Python ${{ env.X_PYTHON_MIN_VERSION }}
19+
uses: actions/setup-python@v5
20+
with:
21+
python-version: ${{env.X_PYTHON_MIN_VERSION }}
22+
- name: Install Python dependencies
23+
run: |
24+
python -m pip install --upgrade pip
25+
pip install .[dev]
26+
- name: Test with coverage + unittest
27+
run: |
28+
coverage run --source=bibtex_linter -m unittest
29+
- name: Report test coverage
30+
if: ${{ always() }}
31+
run: |
32+
coverage report -m
33+
34+
static-analysis:
35+
# This job runs static code analysis, namely pycodestyle and mypy
36+
runs-on: ubuntu-latest
37+
steps:
38+
- uses: actions/checkout@v4
39+
- name: Set up Python ${{ env.X_PYTHON_MIN_VERSION }}
40+
uses: actions/setup-python@v5
41+
with:
42+
python-version: ${{ env.X_PYTHON_MIN_VERSION }}
43+
- name: Install Python dependencies
44+
run: |
45+
python -m pip install --upgrade pip
46+
pip install .[dev]
47+
- name: Check typing with MyPy
48+
run: |
49+
mypy --strict bibtex_linter test
50+
- name: Check code style with PyCodestyle
51+
run: |
52+
pycodestyle --count --max-line-length 120 bibtex_linter test

bibtex_linter/default_rules.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ def check_online(entry: BibTeXEntry) -> List[str]:
8787
"address",
8888
"url",
8989
}
90-
if not "note" in entry.fields.keys():
90+
if "note" not in entry.fields.keys():
9191
invariant_violations.append(f"Entry '{entry.name}' is of type 'online' and needs a field 'note' with the URL.")
9292
# Todo: In the future, we could actually check that it contains an URL
9393
invariant_violations.extend(check_required_fields(entry, required_fields))
@@ -145,7 +145,7 @@ def check_in_book(entry: BibTeXEntry) -> List[str]:
145145
"number",
146146
"url",
147147
}
148-
if not "chapter" in entry.fields.keys() or not "pages" in entry.fields.keys():
148+
if "chapter" not in entry.fields.keys() or "pages" not in entry.fields.keys():
149149
invariant_violations.append(f"Entry {entry.name} needs to contain one of the "
150150
f"following fields: [chapter, pages].")
151151
invariant_violations.extend(check_required_fields(entry, required_fields))

bibtex_linter/main.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from bibtex_linter.parser import BibTeXEntry, parse_bibtex_file
88

99

10-
def import_from_path(file_path: str):
10+
def import_from_path(file_path: str) -> None:
1111
"""
1212
Import a given module using its path.
1313
"""
@@ -17,13 +17,14 @@ def import_from_path(file_path: str):
1717
# It seems a bit cursed, but I guess as long as it works and really only used on known and safe `rules.py`...
1818
module_name: str = "ruleset"
1919
spec = importlib.util.spec_from_file_location(module_name, file_path)
20+
if not spec or not spec.loader:
21+
raise ImportError(f"Could not import ruleset from '{file_path}'.")
2022
module = importlib.util.module_from_spec(spec)
2123
sys.modules[module_name] = module
2224
spec.loader.exec_module(module)
23-
return module
2425

2526

26-
def main():
27+
def main() -> None:
2728
parser = argparse.ArgumentParser(description="Verify a .bib file using a set of defined rules.")
2829
parser.add_argument("filepath", type=str, help="Path to the .bib file to verify")
2930
parser.add_argument("ruleset",
@@ -65,5 +66,6 @@ def main():
6566

6667
sys.exit(1) # Exit as failure
6768

69+
6870
if __name__ == "__main__":
6971
main()

bibtex_linter/parser.py

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,6 @@ def _split_fields(entry_string: str) -> List[str]:
129129

130130
return raw_fields
131131

132-
133132
@staticmethod
134133
def _parse_field_value(raw_value: str) -> str:
135134
"""
@@ -199,9 +198,3 @@ def parse_bibtex_file(filename: str) -> List[BibTeXEntry]:
199198
for raw_entry_string in raw_entries:
200199
entries.append(BibTeXEntry.from_string(raw_entry_string))
201200
return entries
202-
203-
204-
if __name__ == '__main__':
205-
e = parse_bibtex_file("../test/test_refs.bib")
206-
for i in e:
207-
print(i)

bibtex_linter/py.typed

Whitespace-only changes.

bibtex_linter/verification.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,26 @@
44
When using the decorators, they automatically load the method below them into the `_rules` list at time
55
of import.
66
"""
7-
from typing import Callable, Tuple, List, Optional, Set
7+
from typing import Callable, TypeVar, List, Optional, Set
88

99
from bibtex_linter.parser import BibTeXEntry, EntryType
1010

1111
# The dynamic list of known rules.
1212
# This list gets updated when a method with the `@linter_rule` decorator gets imported.
13-
_rules: List[Callable] = []
13+
_rules: List[Callable[[BibTeXEntry], List[str]]] = []
1414

15-
def linter_rule(entry_type: Optional[EntryType] = None) -> Callable:
15+
# For the type annotations, we define a `LINTER_RULE_TYPE` variable, which describes the type of the methods that
16+
# define the linter rules.
17+
LINTER_RULE_TYPE = TypeVar("LINTER_RULE_TYPE", bound=Callable[[BibTeXEntry], List[str]])
18+
19+
20+
def linter_rule(entry_type: Optional[EntryType] = None) -> Callable[[LINTER_RULE_TYPE], LINTER_RULE_TYPE]:
1621
"""
1722
Decorator to mark a method defines rules to be checked by the linter for a specific entry type.
1823
1924
If `entry_type` is `None`, we assume it is valid for all types.
2025
"""
21-
def wrapper(func: Callable[[BibTeXEntry], List[Tuple[bool, str]]]) -> Callable:
26+
def wrapper(func: LINTER_RULE_TYPE) -> LINTER_RULE_TYPE:
2227
setattr(func, "_is_invariant", True)
2328
setattr(func, "_entry_type", entry_type)
2429
_rules.append(func)

pyproject.toml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,10 @@ readme = "README.md"
3030
requires-python = ">=3.11"
3131

3232
[project.optional-dependencies]
33-
testing = [
34-
"pytest",
35-
"pytest-cov"
33+
dev = [
34+
"mypy",
35+
"pycodestyle",
36+
"coverage",
3637
]
3738

3839
[tool.pytest.ini_options]

test/test_parser.py

Lines changed: 35 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import unittest
2+
import os
3+
from typing import Dict, List
4+
25
from bibtex_linter.parser import EntryType, BibTeXEntry, split_entries, parse_bibtex_file
36

7+
48
class TestBibTeXEntry(unittest.TestCase):
5-
def test_parse_field_value(self):
9+
def test_parse_field_value(self) -> None:
610
test_cases = [
711
("{John Doe}", "John Doe"),
812
("{{John Doe}}", "John Doe"),
@@ -31,7 +35,7 @@ def test_parse_field_value(self):
3135
result = BibTeXEntry._parse_field_value(raw_value)
3236
self.assertEqual(expected, result)
3337

34-
def test_split_fields_basic(self):
38+
def test_split_fields_basic(self) -> None:
3539
entry = """@article{doe2020,
3640
author = {John Doe},
3741
title = {A Study},
@@ -45,7 +49,7 @@ def test_split_fields_basic(self):
4549
result = BibTeXEntry._split_fields(entry)
4650
self.assertEqual(expected, result)
4751

48-
def test_split_fields_with_trailing_comma_and_newline(self):
52+
def test_split_fields_with_trailing_comma_and_newline(self) -> None:
4953
entry = """@book{smith2021,
5054
author = {Jane Smith},
5155
title = {The Book of Testing},
@@ -59,7 +63,7 @@ def test_split_fields_with_trailing_comma_and_newline(self):
5963
result = BibTeXEntry._split_fields(entry)
6064
self.assertEqual(expected, result)
6165

62-
def test_split_fields_multiline_values(self):
66+
def test_split_fields_multiline_values(self) -> None:
6367
entry = """@misc{nested2022,
6468
author = {{Industrial Digital Twin Association e. V.}},
6569
url = {https://example.com},
@@ -74,12 +78,11 @@ def test_split_fields_multiline_values(self):
7478
result = BibTeXEntry._split_fields(entry)
7579
self.assertEqual(expected, result)
7680

77-
def test_split_fields_with_extra_whitespace(self):
78-
entry = """@misc{id123,
79-
author = {Someone} ,
80-
title= { Extra Spaces } ,
81-
year= {2023}
82-
}"""
81+
def test_split_fields_with_extra_whitespace(self) -> None:
82+
entry = ("@misc{id123, \n "
83+
"author = {Someone} , \n "
84+
"title= { Extra Spaces } , \n "
85+
"year= {2023} }")
8386
expected = [
8487
" author = {Someone} ",
8588
" title= { Extra Spaces } ",
@@ -88,7 +91,7 @@ def test_split_fields_with_extra_whitespace(self):
8891
result = BibTeXEntry._split_fields(entry)
8992
self.assertEqual(expected, result)
9093

91-
def test_split_fields_with_linebreak_after_entry_type(self):
94+
def test_split_fields_with_linebreak_after_entry_type(self) -> None:
9295
entry = """@misc
9396
{
9497
id456,
@@ -102,14 +105,14 @@ def test_split_fields_with_linebreak_after_entry_type(self):
102105
result = BibTeXEntry._split_fields(entry)
103106
self.assertEqual(expected, result)
104107

105-
def test_split_fields_missing_open_brace(self):
108+
def test_split_fields_missing_open_brace(self) -> None:
106109
entry = "article, author = {John Doe}, title = {Oops}"
107110
with self.assertRaises(KeyError):
108111
BibTeXEntry._split_fields(entry)
109112

110113

111114
class TestSplitEntries(unittest.TestCase):
112-
def test_single_entry(self):
115+
def test_single_entry(self) -> None:
113116
raw = """@article{key1,
114117
author = {John Doe},
115118
title = {Example},
@@ -119,7 +122,7 @@ def test_single_entry(self):
119122
self.assertEqual(1, len(entries))
120123
self.assertIn("key1", entries[0])
121124

122-
def test_multiple_entries(self):
125+
def test_multiple_entries(self) -> None:
123126
raw = """@article{key1,
124127
author = {John Doe},
125128
title = {Example 1},
@@ -136,15 +139,15 @@ def test_multiple_entries(self):
136139
self.assertIn("key1", entries[0])
137140
self.assertIn("key2", entries[1])
138141

139-
def test_entry_with_nested_braces(self):
142+
def test_entry_with_nested_braces(self) -> None:
140143
raw = """@misc{key3,
141144
note = {Something with {nested} braces}
142145
}"""
143146
entries = split_entries(raw)
144147
self.assertEqual(1, len(entries))
145148
self.assertIn("nested", entries[0])
146149

147-
def test_entry_with_line_breaks(self):
150+
def test_entry_with_line_breaks(self) -> None:
148151
raw = """@online{key4,
149152
author = {Someone},
150153
title = {Line
@@ -155,7 +158,7 @@ def test_entry_with_line_breaks(self):
155158
self.assertEqual(1, len(entries))
156159
self.assertIn("Line\nBreak", entries[0])
157160

158-
def test_incomplete_entry(self):
161+
def test_incomplete_entry(self) -> None:
159162
raw = """@article{key5,
160163
title = {Missing closing brace}
161164
"""
@@ -164,8 +167,9 @@ def test_incomplete_entry(self):
164167

165168

166169
class TestParseBibtexFile(unittest.TestCase):
167-
def test_parse_all_entries(self):
168-
entries = parse_bibtex_file("./test_refs.bib")
170+
def test_parse_all_entries(self) -> None:
171+
bib_path = os.path.join(os.path.dirname(__file__), "test_refs.bib")
172+
entries = parse_bibtex_file(bib_path)
169173
self.assertEqual(17, len(entries))
170174

171175
expected_types = {
@@ -185,8 +189,8 @@ def test_parse_all_entries(self):
185189
actual_count = sum(1 for e in entries if e.entry_type == entry_type)
186190
self.assertEqual(expected_count, actual_count)
187191

188-
def test_entry_fields_and_values(self):
189-
expected_entries = [
192+
def test_entry_fields_and_values(self) -> None:
193+
expected_entries: List[Dict[str, EntryType | Dict[str, str]]] = [
190194
{
191195
"type": EntryType.ARTICLE,
192196
"fields": {
@@ -309,12 +313,16 @@ def test_entry_fields_and_values(self):
309313
},
310314
]
311315

312-
parsed_entries = parse_bibtex_file("./test_refs.bib")
316+
bib_path = os.path.join(os.path.dirname(__file__), "test_refs.bib")
317+
parsed_entries = parse_bibtex_file(bib_path)
313318

314319
for expected in expected_entries:
315320
with self.subTest(expected=expected["fields"]):
321+
# (2025-04-24, s-heppner)
322+
# We can safely ignore the mypy warning here, since we wrote the `expected_entries` this way just above.
323+
expected_fields: Dict[str, str] = expected["fields"] # type: ignore
316324
match = next(
317-
(e for e in parsed_entries if all(e.fields.get(k) == v for k, v in expected["fields"].items())),
325+
(e for e in parsed_entries if all(e.fields.get(k) == v for k, v in expected_fields.items())),
318326
None
319327
)
320328
all_field_sets = [e.fields for e in parsed_entries]
@@ -323,7 +331,10 @@ def test_entry_fields_and_values(self):
323331
f"Missing or incorrect entry for:\nExpected Fields: {expected['fields']}\n"
324332
f"Parsed Entries:\n{all_field_sets}"
325333
)
326-
self.assertEqual(expected["type"], match.entry_type)
334+
# (2025-04-24, s-heppner)
335+
# We can safely ignore the mypy warning here, since we already asserted that `match` is not `None` in
336+
# the line above.
337+
self.assertEqual(expected["type"], match.entry_type) # type: ignore
327338

328339

329340
if __name__ == "__main__":

0 commit comments

Comments
 (0)