solrub 0.1.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.
solrub-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 higger
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
solrub-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.4
2
+ Name: solrub
3
+ Version: 0.1.0
4
+ Summary: Build the student, solution and rubric PDFs of a LaTeX assignment.
5
+ Author-email: higger <matt.higger@gmail.com>
6
+ License-Expression: MIT
7
+ Keywords: latex,pdflatex,teaching,exam,rubric,grading
8
+ Classifier: Environment :: Console
9
+ Classifier: Intended Audience :: Education
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: Education
12
+ Classifier: Topic :: Text Processing :: Markup :: LaTeX
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Provides-Extra: pts
17
+ Requires-Dist: sum-pts>=0.0.4; extra == "pts"
18
+ Dynamic: license-file
19
+
20
+ # solrub
21
+
22
+ Build the student, solution and rubric PDFs of a LaTeX assignment.
23
+
24
+ One `.tex` source carries all three copies. `\sol` marks what only the
25
+ answer key shows and `\rub` what only the grading rubric shows; each copy is
26
+ a `pdflatex` run defining a different subset of them.
27
+
28
+ ```bash
29
+ pip install solrub
30
+ ```
31
+
32
+ ## Use
33
+
34
+ ```bash
35
+ solrub quiz1a # quiz1a.pdf, quiz1a_sol.pdf, quiz1a_rub.pdf
36
+ solrub quiz1* # every version at once
37
+ solrub quiz1/ # every document in a folder
38
+ solrub --no-sol --no-rub blank
39
+ ```
40
+
41
+ All three copies are the default; `--no-sol` and `--no-rub` opt out. Add
42
+ `-p` to sum the points (needs `pip install "solrub[pts]"`).
43
+
44
+ Arguments are files, globs or directories, with the `.tex` suffix optional.
45
+ Globs are expanded by the tool rather than left to the shell, so `quiz1*`
46
+ works the same on Windows. Two filters keep a wide pattern honest: sibling
47
+ builds (`quiz1a.pdf`, `quiz1a_sol.pdf`) collapse onto the `.tex` they came
48
+ from, and a fragment with no `\documentclass` is skipped. A failed document
49
+ does not stop the rest of the batch; the failures are listed at the end.
50
+
51
+ ## In the document
52
+
53
+ Three macros divide the source. `\answer` and `\rubric` are hidden by
54
+ default and revealed by their build; `\exam` is the reverse, student-only
55
+ content such as the blank space left for the work, dropped from both keys.
56
+
57
+ ```latex
58
+ \newcommand{\answer}[1]{}
59
+ \newcommand{\rubric}[1]{}
60
+ \newcommand{\exam}[1]{#1}
61
+
62
+ \ifdefined\sol
63
+ \renewcommand{\answer}[1]{#1}
64
+ \renewcommand{\exam}[1]{}
65
+ \fi
66
+
67
+ \ifdefined\rub
68
+ \renewcommand{\rubric}[1]{#1}
69
+ \renewcommand{\exam}[1]{}
70
+ \fi
71
+ ```
72
+
73
+ ## Requires
74
+
75
+ `pdflatex` on the PATH, and Python 3.9 or newer.
solrub-0.1.0/README.md ADDED
@@ -0,0 +1,56 @@
1
+ # solrub
2
+
3
+ Build the student, solution and rubric PDFs of a LaTeX assignment.
4
+
5
+ One `.tex` source carries all three copies. `\sol` marks what only the
6
+ answer key shows and `\rub` what only the grading rubric shows; each copy is
7
+ a `pdflatex` run defining a different subset of them.
8
+
9
+ ```bash
10
+ pip install solrub
11
+ ```
12
+
13
+ ## Use
14
+
15
+ ```bash
16
+ solrub quiz1a # quiz1a.pdf, quiz1a_sol.pdf, quiz1a_rub.pdf
17
+ solrub quiz1* # every version at once
18
+ solrub quiz1/ # every document in a folder
19
+ solrub --no-sol --no-rub blank
20
+ ```
21
+
22
+ All three copies are the default; `--no-sol` and `--no-rub` opt out. Add
23
+ `-p` to sum the points (needs `pip install "solrub[pts]"`).
24
+
25
+ Arguments are files, globs or directories, with the `.tex` suffix optional.
26
+ Globs are expanded by the tool rather than left to the shell, so `quiz1*`
27
+ works the same on Windows. Two filters keep a wide pattern honest: sibling
28
+ builds (`quiz1a.pdf`, `quiz1a_sol.pdf`) collapse onto the `.tex` they came
29
+ from, and a fragment with no `\documentclass` is skipped. A failed document
30
+ does not stop the rest of the batch; the failures are listed at the end.
31
+
32
+ ## In the document
33
+
34
+ Three macros divide the source. `\answer` and `\rubric` are hidden by
35
+ default and revealed by their build; `\exam` is the reverse, student-only
36
+ content such as the blank space left for the work, dropped from both keys.
37
+
38
+ ```latex
39
+ \newcommand{\answer}[1]{}
40
+ \newcommand{\rubric}[1]{}
41
+ \newcommand{\exam}[1]{#1}
42
+
43
+ \ifdefined\sol
44
+ \renewcommand{\answer}[1]{#1}
45
+ \renewcommand{\exam}[1]{}
46
+ \fi
47
+
48
+ \ifdefined\rub
49
+ \renewcommand{\rubric}[1]{#1}
50
+ \renewcommand{\exam}[1]{}
51
+ \fi
52
+ ```
53
+
54
+ ## Requires
55
+
56
+ `pdflatex` on the PATH, and Python 3.9 or newer.
@@ -0,0 +1,30 @@
1
+ [project]
2
+ name = "solrub"
3
+ version = "0.1.0"
4
+ description = "Build the student, solution and rubric PDFs of a LaTeX assignment."
5
+ readme = "README.md"
6
+ requires-python = ">=3.9"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ authors = [{ name = "higger", email = "matt.higger@gmail.com" }]
10
+ keywords = ["latex", "pdflatex", "teaching", "exam", "rubric", "grading"]
11
+ classifiers = [
12
+ "Environment :: Console",
13
+ "Intended Audience :: Education",
14
+ "Programming Language :: Python :: 3",
15
+ "Topic :: Education",
16
+ "Topic :: Text Processing :: Markup :: LaTeX",
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ pts = ["sum-pts>=0.0.4"]
21
+
22
+ [project.scripts]
23
+ solrub = "solrub:main"
24
+
25
+ [build-system]
26
+ requires = ["setuptools>=77"]
27
+ build-backend = "setuptools.build_meta"
28
+
29
+ [tool.setuptools]
30
+ packages = ["solrub"]
solrub-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,193 @@
1
+ #!/usr/bin/env python3
2
+ """Build the student, solution and rubric PDFs of a LaTeX assignment.
3
+
4
+ One .tex source carries all three copies: the \\sol and \\rub macros mark
5
+ the parts only the answer key and the grading rubric should show, and each
6
+ copy is a pdflatex run that defines a different subset of them. All three
7
+ are built unless --no-sol or --no-rub says otherwise.
8
+
9
+ Arguments are files, globs or directories, resolved here rather than left
10
+ to the shell, so quiz1* reaches quiz1a and quiz1b on every platform. Two
11
+ filters keep a wide pattern honest: sibling builds (quiz1a.pdf,
12
+ quiz1a_sol.pdf) collapse onto the .tex they came from, and a fragment with
13
+ no \\documentclass, e.g. reminder.tex, is skipped.
14
+ """
15
+
16
+ import argparse
17
+ import glob
18
+ import importlib.util
19
+ import pathlib
20
+ import re
21
+ import subprocess
22
+ import sys
23
+ from collections.abc import Iterator
24
+
25
+ __version__ = '0.1.0'
26
+
27
+ DOCUMENTCLASS = re.compile(r'^\s*\\documentclass', re.MULTILINE)
28
+
29
+
30
+ def build_pdf(path: pathlib.Path, jobname: str = None, sol: bool = False,
31
+ rub: bool = False, clean: bool = True) -> None:
32
+ """Run pdflatex on one document.
33
+
34
+ Args:
35
+ path: the .tex file, suffix optional
36
+ jobname: output basename, defaults to the input's
37
+ sol: define \\sol, revealing the answers
38
+ rub: define \\rub, revealing the rubric (needs sol too)
39
+ clean: delete the aux, out and log files afterwards. A failed
40
+ run keeps them either way, since the log holds the error.
41
+
42
+ Raises:
43
+ subprocess.CalledProcessError: pdflatex rejected the document
44
+ """
45
+ path = pathlib.Path(path).with_suffix('')
46
+
47
+ # nonstopmode so a broken document reports and exits rather than
48
+ # stopping for input, which would strand a whole batch of builds.
49
+ command = ['pdflatex', '-interaction=nonstopmode']
50
+ if jobname:
51
+ command += ['--jobname', jobname]
52
+ if sol and rub:
53
+ command += ['\\def\\sol{1} \\def\\rub{1} \\input{' + path.name + '}']
54
+ elif sol:
55
+ command += ['\\def\\sol{1} \\input{' + path.name + '}']
56
+ else:
57
+ command += [path.name]
58
+
59
+ subprocess.run(command, check=True, cwd=path.parent)
60
+
61
+ if clean:
62
+ clean_up(path)
63
+
64
+
65
+ def sum_points(path: pathlib.Path) -> None:
66
+ """Print the point total of a document.
67
+
68
+ Raises:
69
+ SystemExit: the optional sum-pts dependency is not installed
70
+ """
71
+ if importlib.util.find_spec('sum_pts') is None:
72
+ sys.exit('summing points needs sum-pts: pip install "solrub[pts]"')
73
+
74
+ command = [
75
+ sys.executable, '-m', 'sum_pts', f"{path}",
76
+ '--left', '\\[', '--right', '\\]',
77
+ '--points', 'pts?', '--prefix', ' *\\\\prob',
78
+ '-r', '\\(\\d+.?\\d* each\\)',
79
+ '-r', '\\((\\d+.?\\d*,? ?)+\\)',
80
+ '-r', '\\{', '-r', '\\}', '-r', ':'
81
+ ]
82
+ subprocess.run(command, check=True)
83
+
84
+
85
+ def clean_up(path: pathlib.Path) -> None:
86
+ """Delete the aux, out and log files of a document's three builds."""
87
+ for stem in [path.stem, f'{path.stem}_sol', f'{path.stem}_rub']:
88
+ for ext in ['aux', 'out', 'log']:
89
+ (path.parent / f'{stem}.{ext}').unlink(missing_ok=True)
90
+
91
+
92
+ def is_document(path: pathlib.Path) -> bool:
93
+ """Report whether a .tex file is a whole document, not a fragment."""
94
+ return bool(DOCUMENTCLASS.search(path.read_text(errors='ignore')))
95
+
96
+
97
+ def expand(pattern: str) -> Iterator[pathlib.Path]:
98
+ """Yield the candidate files one command-line argument names.
99
+
100
+ A pattern the shell already expanded arrives as a plain path and passes
101
+ through; one it left alone is globbed here. A directory stands for the
102
+ .tex files directly inside it.
103
+ """
104
+ for match in sorted(glob.glob(pattern)) or [pattern]:
105
+ path = pathlib.Path(match)
106
+ if path.is_dir():
107
+ yield from sorted(path.glob('*.tex'))
108
+ else:
109
+ yield path
110
+
111
+
112
+ def resolve(patterns: list[str]) -> list[pathlib.Path]:
113
+ """Map command-line arguments onto the .tex files to build.
114
+
115
+ Args:
116
+ patterns: paths, globs or directories, .tex suffix optional
117
+
118
+ Returns:
119
+ paths: the .tex files, deduplicated, in the order named. A
120
+ pattern matching several files keeps only the whole documents
121
+ among them; a file named on its own is built whatever it holds.
122
+
123
+ Raises:
124
+ FileNotFoundError: a pattern matches nothing on disk
125
+ """
126
+ paths = []
127
+ for pattern in patterns:
128
+ candidates = list(expand(pattern))
129
+ found = []
130
+ for candidate in candidates:
131
+ tex = candidate.with_suffix('.tex')
132
+ if tex.is_file() and tex not in found:
133
+ found.append(tex)
134
+ if len(found) > 1:
135
+ found = [tex for tex in found if is_document(tex)]
136
+ if not found:
137
+ # An existing file with no source of its own is a build the
138
+ # shell swept in beside it (quiz1a_sol.pdf), so drop it
139
+ # quietly; a name matching nothing at all is a typo.
140
+ if not any(candidate.exists() for candidate in candidates):
141
+ raise FileNotFoundError(
142
+ f'no document to build matches: {pattern}')
143
+ continue
144
+ paths += [tex for tex in found if tex not in paths]
145
+ return paths
146
+
147
+
148
+ def main() -> None:
149
+ """Run the command line interface."""
150
+ parser = argparse.ArgumentParser(
151
+ prog='solrub',
152
+ description='Build the student, solution and rubric PDFs of a '
153
+ 'LaTeX assignment.')
154
+ parser.add_argument('-s', '--sol', action=argparse.BooleanOptionalAction,
155
+ default=True, help='build solution copy')
156
+ parser.add_argument('-r', '--rub', action=argparse.BooleanOptionalAction,
157
+ default=True, help='build rubric copy')
158
+ parser.add_argument('-p', '--pts', action='store_true', help='sum points')
159
+ parser.add_argument('-V', '--version', action='version',
160
+ version=f'%(prog)s {__version__}')
161
+ parser.add_argument('path', nargs='+',
162
+ help='.tex files, globs or directories to build')
163
+
164
+ args = parser.parse_args()
165
+
166
+ failed = []
167
+ for path in resolve(args.path):
168
+ print(f'==> {path}', flush=True)
169
+
170
+ # One unbuildable document must not strand the rest of the batch,
171
+ # so collect the failures and report them together at the end.
172
+ try:
173
+ build_pdf(path)
174
+
175
+ if args.sol:
176
+ build_pdf(path, jobname=f'{path.stem}_sol', sol=True)
177
+
178
+ if args.rub:
179
+ build_pdf(path, jobname=f'{path.stem}_rub', sol=True,
180
+ rub=True)
181
+
182
+ if args.pts:
183
+ sum_points(path)
184
+ except subprocess.CalledProcessError:
185
+ failed.append(path)
186
+
187
+ if failed:
188
+ names = ', '.join(str(path) for path in failed)
189
+ sys.exit(f'failed: {names}\nthe .log beside each holds the error')
190
+
191
+
192
+ if __name__ == "__main__":
193
+ main()
@@ -0,0 +1,5 @@
1
+ """Entry point for python -m solrub."""
2
+
3
+ from solrub import main
4
+
5
+ main()
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.4
2
+ Name: solrub
3
+ Version: 0.1.0
4
+ Summary: Build the student, solution and rubric PDFs of a LaTeX assignment.
5
+ Author-email: higger <matt.higger@gmail.com>
6
+ License-Expression: MIT
7
+ Keywords: latex,pdflatex,teaching,exam,rubric,grading
8
+ Classifier: Environment :: Console
9
+ Classifier: Intended Audience :: Education
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: Education
12
+ Classifier: Topic :: Text Processing :: Markup :: LaTeX
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Provides-Extra: pts
17
+ Requires-Dist: sum-pts>=0.0.4; extra == "pts"
18
+ Dynamic: license-file
19
+
20
+ # solrub
21
+
22
+ Build the student, solution and rubric PDFs of a LaTeX assignment.
23
+
24
+ One `.tex` source carries all three copies. `\sol` marks what only the
25
+ answer key shows and `\rub` what only the grading rubric shows; each copy is
26
+ a `pdflatex` run defining a different subset of them.
27
+
28
+ ```bash
29
+ pip install solrub
30
+ ```
31
+
32
+ ## Use
33
+
34
+ ```bash
35
+ solrub quiz1a # quiz1a.pdf, quiz1a_sol.pdf, quiz1a_rub.pdf
36
+ solrub quiz1* # every version at once
37
+ solrub quiz1/ # every document in a folder
38
+ solrub --no-sol --no-rub blank
39
+ ```
40
+
41
+ All three copies are the default; `--no-sol` and `--no-rub` opt out. Add
42
+ `-p` to sum the points (needs `pip install "solrub[pts]"`).
43
+
44
+ Arguments are files, globs or directories, with the `.tex` suffix optional.
45
+ Globs are expanded by the tool rather than left to the shell, so `quiz1*`
46
+ works the same on Windows. Two filters keep a wide pattern honest: sibling
47
+ builds (`quiz1a.pdf`, `quiz1a_sol.pdf`) collapse onto the `.tex` they came
48
+ from, and a fragment with no `\documentclass` is skipped. A failed document
49
+ does not stop the rest of the batch; the failures are listed at the end.
50
+
51
+ ## In the document
52
+
53
+ Three macros divide the source. `\answer` and `\rubric` are hidden by
54
+ default and revealed by their build; `\exam` is the reverse, student-only
55
+ content such as the blank space left for the work, dropped from both keys.
56
+
57
+ ```latex
58
+ \newcommand{\answer}[1]{}
59
+ \newcommand{\rubric}[1]{}
60
+ \newcommand{\exam}[1]{#1}
61
+
62
+ \ifdefined\sol
63
+ \renewcommand{\answer}[1]{#1}
64
+ \renewcommand{\exam}[1]{}
65
+ \fi
66
+
67
+ \ifdefined\rub
68
+ \renewcommand{\rubric}[1]{#1}
69
+ \renewcommand{\exam}[1]{}
70
+ \fi
71
+ ```
72
+
73
+ ## Requires
74
+
75
+ `pdflatex` on the PATH, and Python 3.9 or newer.
@@ -0,0 +1,11 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ solrub/__init__.py
5
+ solrub/__main__.py
6
+ solrub.egg-info/PKG-INFO
7
+ solrub.egg-info/SOURCES.txt
8
+ solrub.egg-info/dependency_links.txt
9
+ solrub.egg-info/entry_points.txt
10
+ solrub.egg-info/requires.txt
11
+ solrub.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ solrub = solrub:main
@@ -0,0 +1,3 @@
1
+
2
+ [pts]
3
+ sum-pts>=0.0.4
@@ -0,0 +1 @@
1
+ solrub