marcgrep 0.2.0__tar.gz

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.
@@ -0,0 +1,107 @@
1
+ Metadata-Version: 2.1
2
+ Name: marcgrep
3
+ Version: 0.2.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
+ -v, --version Show marcgrep version
57
+ -c, --count Count matching records
58
+ -i, --include TEXT Include matching records (repeatable)
59
+ -e, --exclude TEXT Exclude matching records (repeatable)
60
+ ```
61
+
62
+ 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:
63
+
64
+ ```sh
65
+ # records with a 780 field
66
+ $ marcgrep -i 780 FILE.mrc
67
+ # records with Ulysses in the 245 field
68
+ $ marcgrep -i '245,Ulysses' FILE.mrc
69
+ # titles _without_ "Collected Poems" in the 245 $a subfield
70
+ $ marcgrep -e '245,a,Collected Poems' FILE.mrc
71
+ # titles with second indicator = 4 that do not start with "The "
72
+ $ marcgrep -i '245,,4,,^(?!The )' FILE.mrc
73
+ ```
74
+
75
+ The meaning of the pattern's components depends upon their number:
76
+
77
+ - 1: field, `910` -> 910 is in record
78
+ - 2: field and value (regular expression), `100,Lorde` -> 100 contains string "Lorde"
79
+ - 3: field, subfield, and value, `506,a,Open Access` -> 506$a contains string "Open Access"
80
+ - 4: field, subfield, first indicator, and value, `856,0,u,@lcsh\.gov` -> 856$u with 1st indicator 0 contains string "@lcsh.gov"
81
+ - 5: field, subfield, first & second indicators, and value, `245,0,4,a,The Communist Manifesto`
82
+
83
+ 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.
84
+
85
+ Multiple criteria are combined with logical AND. Multiple `--include` flags is narrower than one, as is an `--include` and an `--exclude`.
86
+
87
+ ## Development
88
+
89
+ - [x] -c count
90
+ - [x] -v version
91
+ - [ ] -l limit (number of records to process)
92
+ - [x] -i include criteria (multiple)
93
+ - [x] -e exclude criteria (multiple)
94
+ - [ ] -f fields to print
95
+ - [ ] work with MARC leader
96
+ - [ ] regex for all components? e.g. `24.,text in any 240-249 field`
97
+ - [ ] relatedly, specify _not_ to treat value as a regex?
98
+
99
+ ```sh
100
+ poetry install # install dependencies
101
+ poetry run pytest # run tests
102
+ ```
103
+
104
+ ## License
105
+
106
+ [MIT](https://opensource.org/license/mit) © Eric Phetteplace 2024.
107
+
@@ -0,0 +1,67 @@
1
+ from importlib import metadata
2
+ from typing import BinaryIO
3
+
4
+ import click
5
+ from pymarc import MARCReader
6
+
7
+ from .filter import Filter
8
+ from .utils import count_records
9
+
10
+
11
+ @click.command(help="Find MARC records matching patterns in a file.")
12
+ @click.help_option("-h", "--help")
13
+ # TODO explain pattern syntax in command line help
14
+ @click.argument("file", type=click.File("rb"), default="-")
15
+ @click.option("--version", "-v", help="Show marcgrep version", is_flag=True)
16
+ @click.option("--count", "-c", help="Count matching records", is_flag=True)
17
+ @click.option(
18
+ "--include", "-i", help="Include matching records (repeatable)", multiple=True
19
+ )
20
+ @click.option(
21
+ "--exclude", "-e", help="Exclude matching records (repeatable)", multiple=True
22
+ )
23
+ def main(
24
+ file: BinaryIO,
25
+ count: bool,
26
+ version: bool,
27
+ include: list[str],
28
+ exclude: list[str],
29
+ ):
30
+ if version:
31
+ print(metadata.version("marcgrep"))
32
+ return exit(0)
33
+
34
+ num_records = 0
35
+ reader = MARCReader(file)
36
+
37
+ # build a list of filters, start with exclusive because they rule out records quicker
38
+ filters = [Filter(pattern, inclusive=False) for pattern in exclude]
39
+ filters.extend(Filter(pattern) for pattern in include)
40
+
41
+ # if no filters, count or print all records
42
+ if not len(filters):
43
+ if count:
44
+ num_records = count_records(reader)
45
+ print(num_records)
46
+ # non-zero exit if no records found
47
+ return exit(0 if num_records else 1)
48
+ for record in reader:
49
+ print(record)
50
+ return exit(0)
51
+
52
+ for record in reader:
53
+ if record:
54
+ if all(f.match(record) for f in filters):
55
+ num_records += 1
56
+ if not count:
57
+ print(record)
58
+
59
+ if count:
60
+ print(num_records)
61
+
62
+ # non-zero exit if no records match
63
+ return exit(0 if num_records else 1)
64
+
65
+
66
+ if __name__ == "__main__":
67
+ main()
@@ -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,5 @@
1
+ from pymarc import Record
2
+
3
+
4
+ def count_records(reader):
5
+ return sum(1 for _ in reader if isinstance(_, Record))
@@ -0,0 +1,39 @@
1
+ [tool.poetry]
2
+ name = "marcgrep"
3
+ version = "0.2.0"
4
+ description = "search MARC files for regex matches"
5
+ authors = ["phette23 <phette23@gmail.com>"]
6
+ license = "MIT"
7
+ readme = "readme.md"
8
+ homepage = "https://github.com/phette23/marcgreppy"
9
+ repository = "https://github.com/phette23/marcgreppy"
10
+ keywords = ["marc", "grep", "regex", "libraries", "cli", "metadata", "bibliographic", "cataloging"]
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Environment :: Console",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Programming Language :: Python :: 3",
16
+ "Operating System :: OS Independent",
17
+ "Topic :: Utilities",
18
+ ]
19
+
20
+ [tool.poetry.urls]
21
+ "Issues" = "https://github.com/phette23/marcgreppy/issues"
22
+
23
+ [tool.poetry.dependencies]
24
+ python = ">3.8"
25
+ pymarc = "5.1.2"
26
+ click = "8.1.7"
27
+
28
+ [tool.poetry.group.dev.dependencies]
29
+ pytest = "8.1.1"
30
+
31
+ [tool.poetry.scripts]
32
+ marcgrep = "marcgrep.cli:main"
33
+
34
+ [tool.setuptools.packages.find]
35
+ where = ["marcgrep"]
36
+
37
+ [build-system]
38
+ requires = ["poetry-core"]
39
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,80 @@
1
+ # marcgrep
2
+
3
+ A CLI for searching MARC files like [MARCgrep.pl](https://pusc.it/bib/MARCgrep) but in Python and a bit different syntax.
4
+
5
+ [marcli](https://github.com/hectorcorrea/marcli) is also a similar project that's faster but a little less flexible.
6
+
7
+ ## Installation
8
+
9
+ Python 3.9 or later. [Poetry](https://python-poetry.org/) is used for development.
10
+
11
+ ```sh
12
+ pipx install marcgrep # install globally with pipx
13
+ pip install marcgrep # or use pip/pip3
14
+ ```
15
+
16
+ ## Usage
17
+
18
+ ```sh
19
+ # general command format
20
+ $ marcgrep OPTIONS FILE.mrc
21
+ $ cat FILE.mrc | marcgrep OPTIONS
22
+ # full usage information
23
+ $ marcgrep -h
24
+ Usage: marcgrep [OPTIONS] [FILE]
25
+
26
+ Find MARC records matching patterns in a file.
27
+
28
+ Options:
29
+ -h, --help Show this message and exit.
30
+ -v, --version Show marcgrep version
31
+ -c, --count Count matching records
32
+ -i, --include TEXT Include matching records (repeatable)
33
+ -e, --exclude TEXT Exclude matching records (repeatable)
34
+ ```
35
+
36
+ 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:
37
+
38
+ ```sh
39
+ # records with a 780 field
40
+ $ marcgrep -i 780 FILE.mrc
41
+ # records with Ulysses in the 245 field
42
+ $ marcgrep -i '245,Ulysses' FILE.mrc
43
+ # titles _without_ "Collected Poems" in the 245 $a subfield
44
+ $ marcgrep -e '245,a,Collected Poems' FILE.mrc
45
+ # titles with second indicator = 4 that do not start with "The "
46
+ $ marcgrep -i '245,,4,,^(?!The )' FILE.mrc
47
+ ```
48
+
49
+ The meaning of the pattern's components depends upon their number:
50
+
51
+ - 1: field, `910` -> 910 is in record
52
+ - 2: field and value (regular expression), `100,Lorde` -> 100 contains string "Lorde"
53
+ - 3: field, subfield, and value, `506,a,Open Access` -> 506$a contains string "Open Access"
54
+ - 4: field, subfield, first indicator, and value, `856,0,u,@lcsh\.gov` -> 856$u with 1st indicator 0 contains string "@lcsh.gov"
55
+ - 5: field, subfield, first & second indicators, and value, `245,0,4,a,The Communist Manifesto`
56
+
57
+ 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.
58
+
59
+ Multiple criteria are combined with logical AND. Multiple `--include` flags is narrower than one, as is an `--include` and an `--exclude`.
60
+
61
+ ## Development
62
+
63
+ - [x] -c count
64
+ - [x] -v version
65
+ - [ ] -l limit (number of records to process)
66
+ - [x] -i include criteria (multiple)
67
+ - [x] -e exclude criteria (multiple)
68
+ - [ ] -f fields to print
69
+ - [ ] work with MARC leader
70
+ - [ ] regex for all components? e.g. `24.,text in any 240-249 field`
71
+ - [ ] relatedly, specify _not_ to treat value as a regex?
72
+
73
+ ```sh
74
+ poetry install # install dependencies
75
+ poetry run pytest # run tests
76
+ ```
77
+
78
+ ## License
79
+
80
+ [MIT](https://opensource.org/license/mit) © Eric Phetteplace 2024.