marcgrep 0.3.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.
- marcgrep/cli.py +55 -0
- marcgrep/filter.py +94 -0
- marcgrep-0.3.0.dist-info/METADATA +108 -0
- marcgrep-0.3.0.dist-info/RECORD +6 -0
- marcgrep-0.3.0.dist-info/WHEEL +4 -0
- marcgrep-0.3.0.dist-info/entry_points.txt +3 -0
marcgrep/cli.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import math
|
|
2
|
+
from typing import BinaryIO, Literal
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
from pymarc import MARCReader
|
|
6
|
+
|
|
7
|
+
from .filter import Filter
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.command(help="Find MARC records matching patterns in a file.")
|
|
11
|
+
@click.help_option("-h", "--help")
|
|
12
|
+
@click.argument("file", type=click.File("rb"), default="-")
|
|
13
|
+
@click.option("--count", "-c", help="Count matching records", is_flag=True)
|
|
14
|
+
@click.option(
|
|
15
|
+
"--include", "-i", help="Include matching records (repeatable)", multiple=True
|
|
16
|
+
)
|
|
17
|
+
@click.option(
|
|
18
|
+
"--exclude", "-e", help="Exclude matching records (repeatable)", multiple=True
|
|
19
|
+
)
|
|
20
|
+
@click.option("--limit", "-l", help="Limit number of records to process", type=int)
|
|
21
|
+
@click.version_option(package_name="marcgrep", message="%(prog)s %(version)s")
|
|
22
|
+
def main(
|
|
23
|
+
file: BinaryIO,
|
|
24
|
+
count: bool,
|
|
25
|
+
include: list[str],
|
|
26
|
+
exclude: list[str],
|
|
27
|
+
limit: int,
|
|
28
|
+
):
|
|
29
|
+
counter = 0
|
|
30
|
+
matched_records = 0
|
|
31
|
+
reader = MARCReader(file)
|
|
32
|
+
|
|
33
|
+
# build a list of filters, start with exclusive because they rule out records quicker
|
|
34
|
+
filters = [Filter(pattern, inclusive=False) for pattern in exclude]
|
|
35
|
+
filters.extend(Filter(pattern) for pattern in include)
|
|
36
|
+
|
|
37
|
+
for record in reader:
|
|
38
|
+
if record:
|
|
39
|
+
counter += 1
|
|
40
|
+
if all(f.match(record) for f in filters):
|
|
41
|
+
matched_records += 1
|
|
42
|
+
if not count:
|
|
43
|
+
print(record)
|
|
44
|
+
if limit and counter >= limit:
|
|
45
|
+
break
|
|
46
|
+
|
|
47
|
+
if count:
|
|
48
|
+
print(matched_records)
|
|
49
|
+
|
|
50
|
+
# non-zero exit if no records match
|
|
51
|
+
return exit(0 if matched_records else 1)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
if __name__ == "__main__":
|
|
55
|
+
main()
|
marcgrep/filter.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
from pymarc import Record
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def parse_pattern(pattern):
|
|
7
|
+
parts = pattern.split(",")
|
|
8
|
+
# Convert empty strings to None to support "123,,,,value" use case
|
|
9
|
+
parts = [part if part else None for part in parts]
|
|
10
|
+
# 3.10 Match-Case would be better here
|
|
11
|
+
# We prioritize the field, then the value, then the subfield, and indicators come last
|
|
12
|
+
if len(parts) == 1:
|
|
13
|
+
return parts[0], None, None, None, None
|
|
14
|
+
if len(parts) == 2:
|
|
15
|
+
return parts[0], None, None, None, parts[1]
|
|
16
|
+
if len(parts) == 3:
|
|
17
|
+
return parts[0], None, None, parts[1], parts[2]
|
|
18
|
+
if len(parts) == 4:
|
|
19
|
+
return parts[0], parts[1], None, parts[2], parts[3]
|
|
20
|
+
# combine the rest into the value, convert Nones past the 4th index back to empty string
|
|
21
|
+
value = ",".join([p if p else "" for p in parts[4:]])
|
|
22
|
+
return parts[0], parts[1], parts[2], parts[3], value
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class FilterFormatError(Exception):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Filter:
|
|
30
|
+
def __init__(self, pattern, inclusive=True) -> None:
|
|
31
|
+
"""Pettern is a comma-separated string like
|
|
32
|
+
FIELD,INDICATOR1,INDICATOR2,SUBFIELD,VALUE
|
|
33
|
+
where only FIELD is required and VALUE can itself contain commas
|
|
34
|
+
e.g. 245,cat in the hat -> field:245, value:cat in the hat
|
|
35
|
+
245,4,0,a,cat in the hat -> field:245, ind1:4, ind2:0, subfield:a, value:cat in the hat
|
|
36
|
+
"""
|
|
37
|
+
self.inclusive = inclusive
|
|
38
|
+
self.pattern = pattern
|
|
39
|
+
parts = parse_pattern(pattern)
|
|
40
|
+
self.field: str | None = parts[0]
|
|
41
|
+
self.ind1: str | None = parts[1]
|
|
42
|
+
self.ind2: str | None = parts[2]
|
|
43
|
+
self.subfield: str | None = parts[3]
|
|
44
|
+
self.value: str | None = parts[4]
|
|
45
|
+
self.validate()
|
|
46
|
+
|
|
47
|
+
def __repr__(self) -> str:
|
|
48
|
+
return f"{'Inclusive' if self.inclusive else 'Exclusive'} Filter: field={self.field}, ind1={self.ind1}, ind2={self.ind2}, subfield={self.subfield}, value={self.value}"
|
|
49
|
+
|
|
50
|
+
def validate(self) -> None:
|
|
51
|
+
if not self.field:
|
|
52
|
+
raise FilterFormatError(
|
|
53
|
+
"Field is required; filter pattern cannot be empty."
|
|
54
|
+
)
|
|
55
|
+
if len(self.field) != 3 or not self.field.isdigit():
|
|
56
|
+
raise FilterFormatError(
|
|
57
|
+
f"Field {self.field} is invalid, must be three digits 000-999."
|
|
58
|
+
)
|
|
59
|
+
if self.ind1 is not None and (len(self.ind1) != 1 or not self.ind1.isdigit()):
|
|
60
|
+
raise FilterFormatError(
|
|
61
|
+
f"Indicator 1 {self.ind1} is invalid, must one one digit 0-9."
|
|
62
|
+
)
|
|
63
|
+
if self.ind2 is not None and (len(self.ind2) != 1 or not self.ind2.isdigit()):
|
|
64
|
+
raise FilterFormatError(
|
|
65
|
+
f"Indicator 2 {self.ind2} is invalid, must one one digit 0-9."
|
|
66
|
+
)
|
|
67
|
+
if self.subfield is not None and len(self.subfield) != 1:
|
|
68
|
+
raise FilterFormatError(
|
|
69
|
+
f"Subfield {self.subfield} is invalid, must be one character."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def match(self, record: Record) -> bool:
|
|
73
|
+
result = self.inclusive
|
|
74
|
+
for field in record.get_fields(self.field):
|
|
75
|
+
# print(re.match(self.value, field.format_field()))
|
|
76
|
+
if self.ind1 and field.indicator1 != self.ind1:
|
|
77
|
+
continue
|
|
78
|
+
if self.ind2 and field.indicator2 != self.ind2:
|
|
79
|
+
continue
|
|
80
|
+
if self.subfield:
|
|
81
|
+
subfields = field.get_subfields(self.subfield)
|
|
82
|
+
if (
|
|
83
|
+
self.value
|
|
84
|
+
and not any(
|
|
85
|
+
re.search(self.value, subfield) for subfield in subfields
|
|
86
|
+
)
|
|
87
|
+
or not len(subfields)
|
|
88
|
+
):
|
|
89
|
+
continue
|
|
90
|
+
if self.value and not re.search(self.value, field.format_field()):
|
|
91
|
+
continue
|
|
92
|
+
# if we get here, the filter matched
|
|
93
|
+
return result
|
|
94
|
+
return not result
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: marcgrep
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: search MARC files for regex matches
|
|
5
|
+
Home-page: https://github.com/phette23/marcgreppy
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: marc,grep,regex,libraries,cli,metadata,bibliographic,cataloging
|
|
8
|
+
Author: phette23
|
|
9
|
+
Author-email: phette23@gmail.com
|
|
10
|
+
Requires-Python: >3.8
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Utilities
|
|
21
|
+
Requires-Dist: click (==8.1.7)
|
|
22
|
+
Requires-Dist: pymarc (==5.1.2)
|
|
23
|
+
Project-URL: Issues, https://github.com/phette23/marcgreppy/issues
|
|
24
|
+
Project-URL: Repository, https://github.com/phette23/marcgreppy
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# marcgrep
|
|
28
|
+
|
|
29
|
+
A CLI for searching MARC files like [MARCgrep.pl](https://pusc.it/bib/MARCgrep) but in Python and a bit different syntax.
|
|
30
|
+
|
|
31
|
+
[marcli](https://github.com/hectorcorrea/marcli) is also a similar project that's faster but a little less flexible.
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
Python 3.9 or later. [Poetry](https://python-poetry.org/) is used for development.
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
pipx install marcgrep # install globally with pipx
|
|
39
|
+
pip install marcgrep # or use pip/pip3
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
# general command format
|
|
46
|
+
$ marcgrep OPTIONS FILE.mrc
|
|
47
|
+
$ cat FILE.mrc | marcgrep OPTIONS
|
|
48
|
+
# full usage information
|
|
49
|
+
$ marcgrep -h
|
|
50
|
+
Usage: marcgrep [OPTIONS] [FILE]
|
|
51
|
+
|
|
52
|
+
Find MARC records matching patterns in a file.
|
|
53
|
+
|
|
54
|
+
Options:
|
|
55
|
+
-h, --help Show this message and exit.
|
|
56
|
+
-c, --count Count matching records
|
|
57
|
+
-i, --include TEXT Include matching records (repeatable)
|
|
58
|
+
-e, --exclude TEXT Exclude matching records (repeatable)
|
|
59
|
+
-l, --limit INTEGER Limit number of records to process
|
|
60
|
+
--version Show the version and exit.
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The `--include` and `--exclude` flags can be used multiple times to specify multiple criteria. They accept a pattern which is a sort of comma-separated filter expression for matching MARC fields. Examples:
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
# records with a 780 field
|
|
67
|
+
$ marcgrep -i 780 FILE.mrc
|
|
68
|
+
# records with Ulysses in the 245 field
|
|
69
|
+
$ marcgrep -i '245,Ulysses' FILE.mrc
|
|
70
|
+
# titles _without_ "Collected Poems" in the 245 $a subfield
|
|
71
|
+
$ marcgrep -e '245,a,Collected Poems' FILE.mrc
|
|
72
|
+
# titles with second indicator = 4 that do not start with "The "
|
|
73
|
+
$ marcgrep -i '245,,4,,^(?!The )' FILE.mrc
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The meaning of the pattern's components depends upon their number:
|
|
77
|
+
|
|
78
|
+
- 1: field, `910` -> 910 is in record
|
|
79
|
+
- 2: field and value (regular expression), `100,Lorde` -> 100 contains string "Lorde"
|
|
80
|
+
- 3: field, subfield, and value, `506,a,Open Access` -> 506$a contains string "Open Access"
|
|
81
|
+
- 4: field, subfield, first indicator, and value, `856,0,u,@lcsh\.gov` -> 856$u with 1st indicator 0 contains string "@lcsh.gov"
|
|
82
|
+
- 5: field, subfield, first & second indicators, and value, `245,0,4,a,The Communist Manifesto`
|
|
83
|
+
|
|
84
|
+
The intention of this syntax is to facilitate searching subfields and field values more easily than MARCgrep.pl since we care about them more often than indicators. To ignore a component but use one of lesser priority, leave the component empty. For instance, `856,s,` refers to records with an `856` field with a `$s` subfield but the trailing comma means we don't care about the subfield's value. The pattern `245,,4,,` refers to records with a `245` field with a second indicator of `4` regardless its subfields or value.
|
|
85
|
+
|
|
86
|
+
Multiple criteria are combined with logical AND. Multiple `--include` flags is narrower than one, as is an `--include` and an `--exclude`.
|
|
87
|
+
|
|
88
|
+
## Development
|
|
89
|
+
|
|
90
|
+
- [x] -c count
|
|
91
|
+
- [x] -v version
|
|
92
|
+
- [x] -l limit (number of records to process)
|
|
93
|
+
- [x] -i include criteria (multiple)
|
|
94
|
+
- [x] -e exclude criteria (multiple)
|
|
95
|
+
- [ ] -f fields to print
|
|
96
|
+
- [ ] work with MARC leader
|
|
97
|
+
- [ ] regex for all components? e.g. `24.,text in any 240-249 field`
|
|
98
|
+
- [ ] relatedly, specify _not_ to treat value as a regex?
|
|
99
|
+
|
|
100
|
+
```sh
|
|
101
|
+
poetry install # install dependencies
|
|
102
|
+
poetry run pytest # run tests
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
[MIT](https://opensource.org/license/mit) © Eric Phetteplace 2024.
|
|
108
|
+
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
marcgrep/cli.py,sha256=Hh0OUY3NriRH7NpwLIMAEcuGzCjPO46c2Qn3ESFo5Gs,1596
|
|
2
|
+
marcgrep/filter.py,sha256=mxZu1D7wwRgUmZNZqYwRD7LmGrL7aw7fS59JK5S65xE,3840
|
|
3
|
+
marcgrep-0.3.0.dist-info/METADATA,sha256=jSmVGPZYjLgRwpJ-CtyfHf-PAFU6XBU-9hXMrNvHSLE,4270
|
|
4
|
+
marcgrep-0.3.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
|
|
5
|
+
marcgrep-0.3.0.dist-info/entry_points.txt,sha256=bSFMiZ0ZiNlC2jZyRSnm7YePHoRKad1QpsnPRpDBDkA,46
|
|
6
|
+
marcgrep-0.3.0.dist-info/RECORD,,
|