pygacity 0.4.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.
- pygacity/__init__.py +0 -0
- pygacity/cli.py +132 -0
- pygacity/generate/__init__.py +0 -0
- pygacity/generate/answerset.py +194 -0
- pygacity/generate/block.py +166 -0
- pygacity/generate/build.py +188 -0
- pygacity/generate/config.py +91 -0
- pygacity/generate/document.py +50 -0
- pygacity/generate/pick.py +81 -0
- pygacity/resources/__init__.py +0 -0
- pygacity/resources/autoprob-package/tex/latex/autoprob.cls +208 -0
- pygacity/resources/corresponding-states-data/enthalpy-departures.csv +242 -0
- pygacity/resources/corresponding-states-data/enthalpy-departures.raw +246 -0
- pygacity/resources/corresponding-states-data/entropy-departures.csv +155 -0
- pygacity/resources/corresponding-states-data/entropy-departures.raw +158 -0
- pygacity/resources/pythontex/chemeq.pycode +6 -0
- pygacity/resources/pythontex/matplotlib.pycode +11 -0
- pygacity/resources/pythontex/sandlerprops.pycode +3 -0
- pygacity/resources/pythontex/sandlersteam.pycode +6 -0
- pygacity/resources/pythontex/setup.pycode +27 -0
- pygacity/resources/pythontex/showsteamtables.pycode +4 -0
- pygacity/resources/pythontex/teardown.pycode +14 -0
- pygacity/resources/templates/blank_template.tex +16 -0
- pygacity/resources/templates/footer.tex +1 -0
- pygacity/resources/templates/header.tex +13 -0
- pygacity/resources/templates/short.tex +99 -0
- pygacity/topics/__init__.py +0 -0
- pygacity/topics/chem/__init__.py +0 -0
- pygacity/topics/chem/chemeqsystem.py +201 -0
- pygacity/topics/chem/compound.py +251 -0
- pygacity/topics/chem/properties.py +43 -0
- pygacity/topics/chem/reaction.py +138 -0
- pygacity/topics/distillation/__init__.py +0 -0
- pygacity/topics/distillation/binaryduties.py +457 -0
- pygacity/topics/distillation/binaryflashdepriester.py +206 -0
- pygacity/topics/distillation/fug.py +71 -0
- pygacity/topics/distillation/mccabethiele.py +443 -0
- pygacity/topics/steam/__init__.py +0 -0
- pygacity/topics/steam/steamtank.py +67 -0
- pygacity/topics/thermo/__init__.py +0 -0
- pygacity/topics/thermo/corrsts.py +32 -0
- pygacity/topics/thermo/prcalcs.py +343 -0
- pygacity/topics/vle/__init__.py +0 -0
- pygacity/topics/vle/txyplot.py +98 -0
- pygacity/topics/vle/vle.py +357 -0
- pygacity/util/__init__.py +0 -0
- pygacity/util/collectors.py +248 -0
- pygacity/util/command.py +19 -0
- pygacity/util/corrsts_wpd2csv.py +37 -0
- pygacity/util/pdfutils.py +24 -0
- pygacity/util/stringthings.py +170 -0
- pygacity/util/texutils.py +211 -0
- pygacity-0.4.0.dist-info/METADATA +76 -0
- pygacity-0.4.0.dist-info/RECORD +57 -0
- pygacity-0.4.0.dist-info/WHEEL +4 -0
- pygacity-0.4.0.dist-info/entry_points.txt +2 -0
- pygacity-0.4.0.dist-info/licenses/LICENSE +21 -0
pygacity/__init__.py
ADDED
|
File without changes
|
pygacity/cli.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# Author: Cameron F. Abrams, <cfa22@drexel.edu>
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
|
|
8
|
+
import argparse as ap
|
|
9
|
+
|
|
10
|
+
from .generate.build import build, answerset_subcommand
|
|
11
|
+
from .util.pdfutils import combine_pdfs
|
|
12
|
+
from .util.stringthings import oxford, banner
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
def setup_logging(args):
|
|
17
|
+
loglevel_numeric = getattr(logging, args.logging_level.upper())
|
|
18
|
+
if args.log:
|
|
19
|
+
if os.path.exists(args.log):
|
|
20
|
+
shutil.copyfile(args.log, args.log+'.bak')
|
|
21
|
+
logging.basicConfig(filename=args.log,
|
|
22
|
+
filemode='w',
|
|
23
|
+
format='%(asctime)s %(name)s %(message)s',
|
|
24
|
+
level=loglevel_numeric
|
|
25
|
+
)
|
|
26
|
+
console = logging.StreamHandler()
|
|
27
|
+
console.setLevel(logging.INFO)
|
|
28
|
+
formatter = logging.Formatter('%(levelname)s> %(message)s')
|
|
29
|
+
console.setFormatter(formatter)
|
|
30
|
+
logging.getLogger('').addHandler(console)
|
|
31
|
+
|
|
32
|
+
def cli():
|
|
33
|
+
subcommands = {
|
|
34
|
+
'build': dict(
|
|
35
|
+
func = build,
|
|
36
|
+
help = 'build document',
|
|
37
|
+
),
|
|
38
|
+
'answerset' : dict(
|
|
39
|
+
func = answerset_subcommand,
|
|
40
|
+
help = 'remake answer set document from a previous build',
|
|
41
|
+
),
|
|
42
|
+
'combine': dict(
|
|
43
|
+
func = combine_pdfs,
|
|
44
|
+
help = 'combine PDFs',
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
parser = ap.ArgumentParser(
|
|
48
|
+
prog='pygacity',
|
|
49
|
+
)
|
|
50
|
+
parser.add_argument(
|
|
51
|
+
'-b',
|
|
52
|
+
'--banner',
|
|
53
|
+
default=True,
|
|
54
|
+
action=ap.BooleanOptionalAction,
|
|
55
|
+
help='toggle banner message'
|
|
56
|
+
)
|
|
57
|
+
parser.add_argument(
|
|
58
|
+
'--logging-level',
|
|
59
|
+
type=str,
|
|
60
|
+
default='debug',
|
|
61
|
+
choices=[None, 'info', 'debug', 'warning'],
|
|
62
|
+
help='Logging level for messages written to diagnostic log'
|
|
63
|
+
)
|
|
64
|
+
parser.add_argument(
|
|
65
|
+
'-l',
|
|
66
|
+
'--log',
|
|
67
|
+
type=str,
|
|
68
|
+
default='pygacity-diagnostics.log',
|
|
69
|
+
help='File to which diagnostic log messages are written'
|
|
70
|
+
)
|
|
71
|
+
subparsers = parser.add_subparsers(
|
|
72
|
+
title="subcommands",
|
|
73
|
+
dest="command",
|
|
74
|
+
metavar="<command>",
|
|
75
|
+
required=True,
|
|
76
|
+
)
|
|
77
|
+
command_parsers={}
|
|
78
|
+
for k, specs in subcommands.items():
|
|
79
|
+
command_parsers[k] = subparsers.add_parser(
|
|
80
|
+
k,
|
|
81
|
+
help=specs['help'],
|
|
82
|
+
formatter_class=ap.RawDescriptionHelpFormatter
|
|
83
|
+
)
|
|
84
|
+
command_parsers[k].set_defaults(func=specs['func'])
|
|
85
|
+
|
|
86
|
+
command_parsers['build'].add_argument(
|
|
87
|
+
'-o',
|
|
88
|
+
'--overwrite',
|
|
89
|
+
type=bool,
|
|
90
|
+
default=False,
|
|
91
|
+
action=ap.BooleanOptionalAction,
|
|
92
|
+
help='completely remove old save dir and build new exams')
|
|
93
|
+
command_parsers['build'].add_argument(
|
|
94
|
+
'-s',
|
|
95
|
+
'--solutions',
|
|
96
|
+
type=bool,
|
|
97
|
+
default=True,
|
|
98
|
+
action=ap.BooleanOptionalAction,
|
|
99
|
+
help='build solutions document(s)')
|
|
100
|
+
command_parsers['build'].add_argument(
|
|
101
|
+
'f',
|
|
102
|
+
help='mandatory YAML input file')
|
|
103
|
+
command_parsers['answerset'].add_argument(
|
|
104
|
+
'f',
|
|
105
|
+
help='mandatory YAML input file used in a previous build to generate the answer set')
|
|
106
|
+
command_parsers['combine'].add_argument(
|
|
107
|
+
'-i',
|
|
108
|
+
'--input-pdfs',
|
|
109
|
+
type=str,
|
|
110
|
+
default=[],
|
|
111
|
+
nargs='+',
|
|
112
|
+
help='space-separated list of PDF file names to combine'
|
|
113
|
+
)
|
|
114
|
+
command_parsers['combine'].add_argument(
|
|
115
|
+
'-o',
|
|
116
|
+
'--pdf-out',
|
|
117
|
+
type=str,
|
|
118
|
+
default='out.pdf',
|
|
119
|
+
help='name of new output PDF to be created')
|
|
120
|
+
|
|
121
|
+
args = parser.parse_args()
|
|
122
|
+
|
|
123
|
+
setup_logging(args)
|
|
124
|
+
|
|
125
|
+
if args.banner:
|
|
126
|
+
banner(print)
|
|
127
|
+
if hasattr(args, 'func'):
|
|
128
|
+
args.func(args)
|
|
129
|
+
else:
|
|
130
|
+
my_list = oxford(list(subcommands.keys()))
|
|
131
|
+
print(f'No subcommand found. Expected one of {my_list}')
|
|
132
|
+
logger.info('Thanks for using pygacity!')
|
|
File without changes
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# Author: Cameron F. Abrams, <cfa22@drexel.edu>
|
|
2
|
+
import yaml
|
|
3
|
+
import os
|
|
4
|
+
from collections import UserList
|
|
5
|
+
import pandas as pd
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
class AnswerSet:
|
|
11
|
+
_keys = ['label', 'value', 'units', 'formatter']
|
|
12
|
+
def __init__(self, serial: int = 0):
|
|
13
|
+
self.serial = serial
|
|
14
|
+
self.dumpname = f'answers-{serial:08d}.yaml'
|
|
15
|
+
self.D = {}
|
|
16
|
+
self.first_index = None
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def from_yaml(cls, filename, delete=False):
|
|
20
|
+
"""
|
|
21
|
+
Create an AnswerSet instance by loading from a YAML file of the same format as that
|
|
22
|
+
generated by the to_yaml() method.
|
|
23
|
+
"""
|
|
24
|
+
root, ext = os.path.splitext(filename)
|
|
25
|
+
assert ext in ['.yaml', '.yml'], f'{filename} does not end in .yaml or .yml'
|
|
26
|
+
tokens = root.split('-')
|
|
27
|
+
assert len(tokens) == 2, f'{filename} should be of the format "answers-<serial#>.yaml"'
|
|
28
|
+
serial = int(tokens[1])
|
|
29
|
+
R = cls(serial)
|
|
30
|
+
with open(filename, 'r') as f:
|
|
31
|
+
R.D = yaml.safe_load(f)
|
|
32
|
+
if delete:
|
|
33
|
+
os.remove(filename)
|
|
34
|
+
return R
|
|
35
|
+
|
|
36
|
+
def register(self, index, label=None, value=None, units=None, formatter=None, group=None):
|
|
37
|
+
"""
|
|
38
|
+
Register an answer entry for a particular question index.
|
|
39
|
+
"""
|
|
40
|
+
if not self.first_index:
|
|
41
|
+
self.first_index = index
|
|
42
|
+
if not index in self.D:
|
|
43
|
+
self.D[index] = []
|
|
44
|
+
# if value is a numpy data type, convert to native python type
|
|
45
|
+
if hasattr(value, 'item'):
|
|
46
|
+
value = value.item()
|
|
47
|
+
self.D[index].append(dict( label=label,
|
|
48
|
+
value=value,
|
|
49
|
+
units=units,
|
|
50
|
+
formatter=formatter,
|
|
51
|
+
group=group))
|
|
52
|
+
logger.debug(f'AnswerSet.register index={index} label={label} value={value} units={units} formatter={formatter} group={group}')
|
|
53
|
+
|
|
54
|
+
def display(self, index, element=0):
|
|
55
|
+
D = None
|
|
56
|
+
if element < len(self.D[index]):
|
|
57
|
+
D = self.D[index][element]
|
|
58
|
+
if D:
|
|
59
|
+
fmt = D.get('formatter',None)
|
|
60
|
+
val = D.get('value',None)
|
|
61
|
+
label = D.get('label',None)
|
|
62
|
+
units = D.get('units',None)
|
|
63
|
+
vstr = ''
|
|
64
|
+
if val:
|
|
65
|
+
if fmt:
|
|
66
|
+
vstr = fmt.format(val)
|
|
67
|
+
else:
|
|
68
|
+
vstr = str(val)
|
|
69
|
+
if units:
|
|
70
|
+
vstr += f' {units}'
|
|
71
|
+
if label:
|
|
72
|
+
if vstr:
|
|
73
|
+
return f'{label} = {vstr}'
|
|
74
|
+
else:
|
|
75
|
+
return label
|
|
76
|
+
return ''
|
|
77
|
+
|
|
78
|
+
# def to_yaml(self):
|
|
79
|
+
# # check all indices for common bytes at the start, and remove them
|
|
80
|
+
# raw_indices = list(self.D.keys())
|
|
81
|
+
# common_prefix = os.path.commonprefix([str(x) for x in raw_indices])
|
|
82
|
+
# logger.debug(f'AnswerSet.to_yaml common prefix: "{common_prefix}"')
|
|
83
|
+
# if common_prefix:
|
|
84
|
+
# new_D = {}
|
|
85
|
+
# for index, AL in self.D.items():
|
|
86
|
+
# new_index = str(index)[len(common_prefix):]
|
|
87
|
+
# new_D[new_index] = AL
|
|
88
|
+
# self.D = new_D
|
|
89
|
+
# with open(self.dumpname, 'w') as f:
|
|
90
|
+
# yaml.safe_dump(self.D, f)
|
|
91
|
+
|
|
92
|
+
class AnswerSuperSet(UserList):
|
|
93
|
+
|
|
94
|
+
def __init__(self, initial: list[AnswerSet] = None):
|
|
95
|
+
self.data: list[AnswerSet] = initial if initial is not None else []
|
|
96
|
+
super().__init__(self.data)
|
|
97
|
+
if not self._check_congruency():
|
|
98
|
+
print(f'Error: There is a lack of congruency among answer sets')
|
|
99
|
+
self._make_dfs()
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
def from_dumpfiles(cls, files=[], delete=False):
|
|
103
|
+
data=[]
|
|
104
|
+
for f in files:
|
|
105
|
+
data.append(AnswerSet.from_yaml(f, delete=delete))
|
|
106
|
+
return cls(initial=data)
|
|
107
|
+
|
|
108
|
+
def to_latex(self):
|
|
109
|
+
result = ''
|
|
110
|
+
for group_name, group_data in self.groups.items():
|
|
111
|
+
df = group_data['df']
|
|
112
|
+
formatters = group_data.get('formatters', None)
|
|
113
|
+
logger.debug(f'AnswerSuperSet.to_latex group "{group_name}" with formatters: {formatters}')
|
|
114
|
+
result += df.to_latex(formatters=formatters, index=False, longtable=True)#,header=self.headings)
|
|
115
|
+
return result
|
|
116
|
+
|
|
117
|
+
def _check_congruency(self):
|
|
118
|
+
if len(self)>0:
|
|
119
|
+
indices=list(self.data[0].D.keys())
|
|
120
|
+
for l in self.data[1:]:
|
|
121
|
+
test_indices=list(l.D.keys())
|
|
122
|
+
check=all([x==y for x,y in zip(indices,test_indices)])
|
|
123
|
+
if not check:
|
|
124
|
+
return False
|
|
125
|
+
for i in indices:
|
|
126
|
+
ilen=len(self.data[0].D[i])
|
|
127
|
+
for l in self.data[1:]:
|
|
128
|
+
test_ilen=len(l.D[i])
|
|
129
|
+
check=ilen==test_ilen
|
|
130
|
+
if not check:
|
|
131
|
+
return False
|
|
132
|
+
return True
|
|
133
|
+
|
|
134
|
+
def _make_dfs(self):
|
|
135
|
+
# to do: groupify
|
|
136
|
+
serials = [x.serial for x in self.data]
|
|
137
|
+
# logger.debug(serials)
|
|
138
|
+
# self.headings = ['serials']
|
|
139
|
+
# keys = ['serials']
|
|
140
|
+
values = {'serials': serials}
|
|
141
|
+
pattern = self.data[0] # keys in first AnswerSet form the pattern all sets follow
|
|
142
|
+
self.formatters = {}
|
|
143
|
+
self.groups = {}
|
|
144
|
+
# keys in D may be prepended with a common prefix; remove it
|
|
145
|
+
common_prefix = os.path.commonprefix([str(x) for x in pattern.D.keys()])
|
|
146
|
+
logger.debug(f'Overall common prefix: "{common_prefix}"')
|
|
147
|
+
for dataset in self.data:
|
|
148
|
+
new_dataset_D = {}
|
|
149
|
+
for index in dataset.D.keys():
|
|
150
|
+
new_index = str(index)[len(common_prefix):]
|
|
151
|
+
new_dataset_D[new_index] = dataset.D[index]
|
|
152
|
+
dataset.D = new_dataset_D
|
|
153
|
+
for index, AL in pattern.D.items():
|
|
154
|
+
for a in AL:
|
|
155
|
+
key = f'{index}-{a["label"]}'
|
|
156
|
+
if 'units' in a and a['units']:
|
|
157
|
+
key += f' ({a["units"]})'
|
|
158
|
+
group = a.get('group', None)
|
|
159
|
+
if group:
|
|
160
|
+
if group not in self.groups:
|
|
161
|
+
self.groups[group] = dict(formatters={}, df=None, values={'serials': serials})
|
|
162
|
+
self.groups[group]['values'][key] = []
|
|
163
|
+
if 'formatter' in a:
|
|
164
|
+
self.groups[group]['formatters'][key] = a['formatter']
|
|
165
|
+
else:
|
|
166
|
+
values[key] = []
|
|
167
|
+
if 'formatter' in a:
|
|
168
|
+
self.formatters[key] = a['formatter']
|
|
169
|
+
# logger.debug(values)
|
|
170
|
+
for inst in self.data:
|
|
171
|
+
for index, AL in inst.D.items():
|
|
172
|
+
for a in AL:
|
|
173
|
+
key = f'{index}-{a["label"]}'
|
|
174
|
+
if 'units' in a and a['units']:
|
|
175
|
+
key += f' ({a["units"]})'
|
|
176
|
+
group = a.get('group', None)
|
|
177
|
+
if group:
|
|
178
|
+
self.groups[group]['values'][key].append(a['value'])
|
|
179
|
+
else:
|
|
180
|
+
values[key].append(a['value'])
|
|
181
|
+
# logger.debug(values)
|
|
182
|
+
if not self.groups:
|
|
183
|
+
DF = pd.DataFrame(values)
|
|
184
|
+
DF.sort_values(by='serials', inplace=True)
|
|
185
|
+
self.groups['base'] = dict(formatters=self.formatters, df=DF)
|
|
186
|
+
else:
|
|
187
|
+
for gname, gdata in self.groups.items():
|
|
188
|
+
logger.debug(f'Building DataFrame for group {gname} with values: {gdata["values"]}')
|
|
189
|
+
DF = pd.DataFrame(gdata['values'])
|
|
190
|
+
DF.sort_values(by='serials', inplace=True)
|
|
191
|
+
self.groups[gname]['df'] = DF
|
|
192
|
+
# print(self.DF)
|
|
193
|
+
|
|
194
|
+
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# Author: Cameron F. Abrams, <cfa22@drexel.edu>
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from copy import deepcopy
|
|
8
|
+
from importlib.resources import files
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from shutil import copy2
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
def path_resolver(filename: str, search_paths: list[Path] = [], ext: str ='') -> Path:
|
|
15
|
+
local_filename = filename if filename.endswith(ext) else filename + ext
|
|
16
|
+
# check local directory first
|
|
17
|
+
local_path = Path(local_filename)
|
|
18
|
+
if local_path.exists():
|
|
19
|
+
return local_path
|
|
20
|
+
# check search path next
|
|
21
|
+
else:
|
|
22
|
+
for search_path in search_paths:
|
|
23
|
+
template_path = search_path / local_filename
|
|
24
|
+
if template_path.exists():
|
|
25
|
+
return template_path
|
|
26
|
+
spm = ':'.join([str(sp) for sp in search_paths])
|
|
27
|
+
raise FileNotFoundError((f'Could not locate source file {local_filename} in cwd ({os.getcwd()}) '
|
|
28
|
+
f'or search path {spm}.'))
|
|
29
|
+
|
|
30
|
+
class LatexCompoundBlock:
|
|
31
|
+
resources_root: Path = files('pygacity') / 'resources'
|
|
32
|
+
templates_dir: Path = resources_root / 'templates'
|
|
33
|
+
pythontex_dir = resources_root / 'pythontex'
|
|
34
|
+
substitution_delimiters: tuple = (r'<<<', r'>>>')
|
|
35
|
+
|
|
36
|
+
def __init__(self, block_specs: dict, parent_idx: str = '', idx: int = 0):
|
|
37
|
+
self.block_specs = block_specs
|
|
38
|
+
self.idx = parent_idx + (f'.{idx}' if parent_idx != '' else f'{idx}')
|
|
39
|
+
self.textcontents: str = block_specs.get('text', '')
|
|
40
|
+
|
|
41
|
+
self.sourcename: str = block_specs.get('source', None)
|
|
42
|
+
self.substitution_map: dict = block_specs.get('substitutions', {})
|
|
43
|
+
|
|
44
|
+
self.points: int = block_specs.get('points', 0)
|
|
45
|
+
self.config_filename: str = block_specs.get('config', None)
|
|
46
|
+
self.group: int = block_specs.get('group', 0)
|
|
47
|
+
|
|
48
|
+
self.pythontex: list[str] = block_specs.get('pythontex', [])
|
|
49
|
+
|
|
50
|
+
self.enumerate = [LatexCompoundBlock(block_specs=child, parent_idx=self.idx, idx=i+1) for i, child in enumerate(block_specs.get('enumerate', []))]
|
|
51
|
+
self.itemize = [LatexCompoundBlock(block_specs=child, parent_idx=self.idx, idx=i+1) for i, child in enumerate(block_specs.get('itemize', []))]
|
|
52
|
+
self.children = self.enumerate + self.itemize
|
|
53
|
+
|
|
54
|
+
self.sourcepath = None
|
|
55
|
+
self.config_path = Path(self.config_filename) if self.config_filename else None
|
|
56
|
+
self.processedcontents: str = ''
|
|
57
|
+
self.has_pycode: bool = False
|
|
58
|
+
self.embedded_graphics: list[str | Path] = []
|
|
59
|
+
|
|
60
|
+
self._check_schema()
|
|
61
|
+
|
|
62
|
+
logger.debug(f'LatexCompoundBlock.__init__ substitution_map: {self.substitution_map}')
|
|
63
|
+
|
|
64
|
+
def _check_schema(self):
|
|
65
|
+
# cannot specify both text content and a source file
|
|
66
|
+
if self.textcontents != '' and self.sourcename is not None:
|
|
67
|
+
raise ValueError('Block cannot specify both "text" content and a "source" file.')
|
|
68
|
+
# cannot specify both enumerate and itemize
|
|
69
|
+
if len(self.enumerate) > 0 and len(self.itemize) > 0:
|
|
70
|
+
raise ValueError('Block cannot specify both "enumerate" and "itemize" children.')
|
|
71
|
+
# if list of pythontext files is non-empty, cannot specify text or source
|
|
72
|
+
if len(self.pythontex) > 0:
|
|
73
|
+
if self.textcontents != '' or self.sourcename is not None:
|
|
74
|
+
raise ValueError('Block cannot specify "pythontex" files along with "text" content or a "source" file.')
|
|
75
|
+
|
|
76
|
+
def load(self) -> LatexCompoundBlock:
|
|
77
|
+
if self.sourcename:
|
|
78
|
+
self.sourcepath = path_resolver(self.sourcename, search_paths=[self.templates_dir])
|
|
79
|
+
with open(self.sourcepath, 'r') as f:
|
|
80
|
+
self.textcontents = f.read()
|
|
81
|
+
elif len(self.pythontex) > 0:
|
|
82
|
+
self.textcontents = r'\begin{pycode}' + '\n'
|
|
83
|
+
for ptfile in self.pythontex:
|
|
84
|
+
ptpath = path_resolver(ptfile, search_paths=[LatexCompoundBlock.pythontex_dir], ext='.pycode')
|
|
85
|
+
with open(ptpath, 'r') as f:
|
|
86
|
+
self.textcontents += f.read() + '\n\n'
|
|
87
|
+
self.textcontents += r'\end{pycode}' + '\n'
|
|
88
|
+
self.has_pycode = r'\begin{pycode}' in self.textcontents or self.has_pycode
|
|
89
|
+
# check contents for substitution keys and embedded graphics files
|
|
90
|
+
KEY_RE = re.compile(rf'{self.substitution_delimiters[0]}\s*([A-Za-z0-9_-]+)\s*{self.substitution_delimiters[1]}')
|
|
91
|
+
for line in self.textcontents.split('\n'):
|
|
92
|
+
keys = set(KEY_RE.findall(line))
|
|
93
|
+
for key in keys:
|
|
94
|
+
if not key in self.substitution_map:
|
|
95
|
+
self.substitution_map[key] = None
|
|
96
|
+
# check for embedded graphics
|
|
97
|
+
GRAPHICS_RE = re.compile(r'\\includegraphics(?:\[[^\]]*\])?\{([^\}]+)\}')
|
|
98
|
+
graphics_files = GRAPHICS_RE.findall(line)
|
|
99
|
+
for gf in graphics_files:
|
|
100
|
+
if gf not in self.embedded_graphics:
|
|
101
|
+
self.embedded_graphics.append(gf)
|
|
102
|
+
self.processedcontents = self.textcontents[:]
|
|
103
|
+
if self.config_path:
|
|
104
|
+
if not self.config_path.exists():
|
|
105
|
+
raise FileNotFoundError(f'Configuration file {self.config_path.as_posix()} does not exist.')
|
|
106
|
+
self.substitution_map['config'] = self.config_path.as_posix()
|
|
107
|
+
if self.points:
|
|
108
|
+
self.substitution_map['points'] = self.points
|
|
109
|
+
if self.group:
|
|
110
|
+
self.substitution_map['group'] = self.group
|
|
111
|
+
self.substitution_map['idx'] = self.idx
|
|
112
|
+
|
|
113
|
+
for child in self.children:
|
|
114
|
+
child.load()
|
|
115
|
+
self.has_pycode = child.has_pycode or self.has_pycode
|
|
116
|
+
self.embedded_graphics.extend(child.embedded_graphics)
|
|
117
|
+
|
|
118
|
+
logger.debug(f'LatexCompoundBlock.load completed for idx={self.idx} with substitutions: {self.substitution_map}')
|
|
119
|
+
return self
|
|
120
|
+
|
|
121
|
+
def substitute(self, super_substitutions: dict = {}, match_all: bool = True):
|
|
122
|
+
self.processedcontents = self.textcontents[:]
|
|
123
|
+
substitutions = deepcopy(super_substitutions)
|
|
124
|
+
logger.debug(f'block at idx {self.idx} incoming substitutions: {substitutions}')
|
|
125
|
+
logger.debug(f'block at idx {self.idx} own substitution_map: {self.substitution_map}')
|
|
126
|
+
substitutions.update({k: v for k, v in self.substitution_map.items() if v is not None})
|
|
127
|
+
logger.debug(f'block at idx {self.idx} substitutions: {substitutions}')
|
|
128
|
+
# apply substitutions to the contents
|
|
129
|
+
for key, value in substitutions.items():
|
|
130
|
+
if value is not None:
|
|
131
|
+
self.processedcontents = self.processedcontents.replace(f'{self.substitution_delimiters[0]}{key}{self.substitution_delimiters[1]}', str(value))
|
|
132
|
+
elif match_all:
|
|
133
|
+
raise KeyError(f'Substitution key {key} has no associated value for text {self.textcontents[:30]}...')
|
|
134
|
+
# apply substitutions to children
|
|
135
|
+
for child in self.children:
|
|
136
|
+
child.substitute(super_substitutions=substitutions, match_all=match_all)
|
|
137
|
+
|
|
138
|
+
def copy_referenced_configs(self, output_dir: str):
|
|
139
|
+
config_paths = []
|
|
140
|
+
if self.config_path and self.config_path.exists():
|
|
141
|
+
dest_path = Path(output_dir) / self.config_path.name
|
|
142
|
+
if not dest_path.exists():
|
|
143
|
+
copy2(self.config_path, dest_path)
|
|
144
|
+
logger.debug(f'Copied config file {self.config_path} to {dest_path}')
|
|
145
|
+
config_paths.append(str(dest_path))
|
|
146
|
+
for child in self.children:
|
|
147
|
+
child_paths = child.copy_referenced_configs(output_dir)
|
|
148
|
+
if child_paths:
|
|
149
|
+
config_paths.extend(child_paths)
|
|
150
|
+
return config_paths
|
|
151
|
+
|
|
152
|
+
def __str__(self):
|
|
153
|
+
contents = self.processedcontents
|
|
154
|
+
if self.enumerate:
|
|
155
|
+
enum_str = r'\begin{enumerate}' + '\n'
|
|
156
|
+
for item in self.enumerate:
|
|
157
|
+
enum_str += r'\item ' + str(item) + '\n'
|
|
158
|
+
enum_str += r'\end{enumerate}' + '\n'
|
|
159
|
+
contents += '\n' + enum_str
|
|
160
|
+
if self.itemize:
|
|
161
|
+
item_str = r'\begin{itemize}' + '\n'
|
|
162
|
+
for item in self.itemize:
|
|
163
|
+
item_str += r'\item ' + str(item) + '\n'
|
|
164
|
+
item_str += r'\end{itemize}' + '\n'
|
|
165
|
+
contents += '\n' + item_str
|
|
166
|
+
return contents
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# Author: Cameron F. Abrams, <cfa22@drexel.edu>
|
|
2
|
+
from copy import deepcopy
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import pickle
|
|
6
|
+
import stat
|
|
7
|
+
import random
|
|
8
|
+
from shutil import rmtree
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from .answerset import AnswerSet, AnswerSuperSet
|
|
11
|
+
from .config import Config
|
|
12
|
+
from .document import Document
|
|
13
|
+
from ..util.stringthings import chmod_recursive
|
|
14
|
+
from ..util.collectors import FileCollector
|
|
15
|
+
from ..util.texutils import LatexBuilder
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
logging.getLogger("ycleptic").setLevel(logging.WARNING)
|
|
21
|
+
logging.getLogger("matplotlib").setLevel(logging.WARNING)
|
|
22
|
+
|
|
23
|
+
def build(args):
|
|
24
|
+
logger.info(f'Building document(s) as specified in {args.f}...')
|
|
25
|
+
FC = FileCollector()
|
|
26
|
+
config = Config(args.f)
|
|
27
|
+
seed = config.build_specs.get('seed', None)
|
|
28
|
+
if seed is not None:
|
|
29
|
+
random.seed(seed)
|
|
30
|
+
logger.info(f'Setting random seed to {seed}.')
|
|
31
|
+
|
|
32
|
+
build_path: Path = Path(config.build_specs['paths']['build-dir'])
|
|
33
|
+
pickle_cache = build_path / '.cache'
|
|
34
|
+
if not build_path.exists():
|
|
35
|
+
build_path.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
else:
|
|
37
|
+
if args.overwrite:
|
|
38
|
+
permissions = stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR
|
|
39
|
+
chmod_recursive(build_path, permissions)
|
|
40
|
+
rmtree(build_path)
|
|
41
|
+
build_path.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
else:
|
|
43
|
+
raise Exception(f'Build directory "{build_path.as_posix()}" already exists and "--overwrite" was not specified.')
|
|
44
|
+
|
|
45
|
+
if not pickle_cache.exists():
|
|
46
|
+
pickle_cache.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
|
|
48
|
+
base_builder = LatexBuilder(config.build_specs,
|
|
49
|
+
searchdirs = [config.autoprob_package_dir])
|
|
50
|
+
base_doc = Document(config.document_specs)
|
|
51
|
+
logger.debug(f'base_doc has {len(base_doc.blocks)} blocks')
|
|
52
|
+
if args.solutions:
|
|
53
|
+
solution_build_specs = deepcopy(config.build_specs)
|
|
54
|
+
# solution_build_specs['output-name'] = config.build_specs.get('output-name', 'document') + '_soln'
|
|
55
|
+
solution_build_specs['job-name'] = config.build_specs.get('job-name', 'document') + '_soln'
|
|
56
|
+
soln_builder = LatexBuilder(solution_build_specs,
|
|
57
|
+
searchdirs = [config.autoprob_package_dir])
|
|
58
|
+
|
|
59
|
+
solution_document_specs = deepcopy(config.document_specs)
|
|
60
|
+
solution_document_specs['class']['options'].append('solutions')
|
|
61
|
+
solution_doc = Document(solution_document_specs)
|
|
62
|
+
logger.debug(f'solution_doc has {len(solution_doc.blocks)} blocks')
|
|
63
|
+
|
|
64
|
+
if build_path != Path.cwd():
|
|
65
|
+
# find any configs referenced in document blocks and copy them to output_dir
|
|
66
|
+
for block in base_doc.blocks:
|
|
67
|
+
file_or_files_or_none = block.copy_referenced_configs(build_path)
|
|
68
|
+
if file_or_files_or_none:
|
|
69
|
+
if isinstance(file_or_files_or_none, list):
|
|
70
|
+
for f in file_or_files_or_none:
|
|
71
|
+
FC.append(f)
|
|
72
|
+
else:
|
|
73
|
+
FC.append(file_or_files_or_none)
|
|
74
|
+
|
|
75
|
+
if config.build_specs.get('copies', 1) > 1:
|
|
76
|
+
if config.build_specs.get('serials', None):
|
|
77
|
+
# check for explict serials
|
|
78
|
+
serials = [int(x) for x in config.build_specs['serials']]
|
|
79
|
+
elif config.build_specs.get('serial-range', None):
|
|
80
|
+
# check for a serial range
|
|
81
|
+
serials = list(range(config.build_specs['serial-range'][0],
|
|
82
|
+
config.build_specs['serial-range'][1] + 1))
|
|
83
|
+
elif config.build_specs.get('serial-file', None):
|
|
84
|
+
# check for a file containing serials, one integer per line
|
|
85
|
+
with open(config.build_specs['serial-file'], 'r') as f:
|
|
86
|
+
serials = [int(line.strip()) for line in f if line.strip()]
|
|
87
|
+
else:
|
|
88
|
+
serial_digits = config.build_specs.get('serial-digits', len(str(config.build_specs['copies'])))
|
|
89
|
+
# generate 'copies' random serial numbers
|
|
90
|
+
serials = set()
|
|
91
|
+
while len(serials) < config.build_specs['copies']:
|
|
92
|
+
serial = random.randint(10**(serial_digits-1), 10**serial_digits - 1)
|
|
93
|
+
serials.add(serial)
|
|
94
|
+
serials = list(serials)
|
|
95
|
+
serials.sort()
|
|
96
|
+
else:
|
|
97
|
+
if config.build_specs.get('serials', None):
|
|
98
|
+
# check for explict serials
|
|
99
|
+
serials = [int(x) for x in config.build_specs['serials']]
|
|
100
|
+
else:
|
|
101
|
+
serials = [0]
|
|
102
|
+
|
|
103
|
+
for i, serial in enumerate(serials):
|
|
104
|
+
outer_substitutions = dict(serial=serial)
|
|
105
|
+
base_doc.make_substitutions(outer_substitutions)
|
|
106
|
+
base_builder.build_document(base_doc)
|
|
107
|
+
FC.append(f'{base_builder.working_job_name}.tex')
|
|
108
|
+
logger.info(f'serial # {serial} ({i+1}/{len(serials)}) => {build_path.absolute().relative_to(Path.cwd()).as_posix()}/{base_builder.working_job_name}.pdf')
|
|
109
|
+
if args.solutions:
|
|
110
|
+
solution_doc.make_substitutions(outer_substitutions)
|
|
111
|
+
soln_builder.build_document(solution_doc)
|
|
112
|
+
FC.append(f'{soln_builder.working_job_name}.tex')
|
|
113
|
+
logger.info(f'serial # {serial} ({i+1}/{len(serials)}) => {build_path.absolute().relative_to(Path.cwd()).as_posix()}/{soln_builder.working_job_name}.pdf')
|
|
114
|
+
|
|
115
|
+
AnswerSets = []
|
|
116
|
+
if pickle_cache.exists():
|
|
117
|
+
# there may be a pickle file for each serial that holds a FileCollector instance
|
|
118
|
+
commonFC = FileCollector()
|
|
119
|
+
for pfile in pickle_cache.glob('*.pkl'):
|
|
120
|
+
with pfile.open('rb') as f:
|
|
121
|
+
obj = pickle.load(f)
|
|
122
|
+
if isinstance(obj, FileCollector):
|
|
123
|
+
for item in obj.data:
|
|
124
|
+
commonFC.append(build_path / item)
|
|
125
|
+
elif isinstance(obj, AnswerSet):
|
|
126
|
+
# serial is second token in filename split by '-'
|
|
127
|
+
tokens = pfile.stem.split('-')
|
|
128
|
+
serial = int(tokens[1])
|
|
129
|
+
AnswerSets.append(obj)
|
|
130
|
+
else:
|
|
131
|
+
logger.debug(f'Unrecognized object type {type(obj)} in pickle file {pfile.as_posix()}')
|
|
132
|
+
logger.debug(f'Removing pickle cache file {pfile.as_posix()}')
|
|
133
|
+
pfile.unlink()
|
|
134
|
+
|
|
135
|
+
archive_path = build_path / 'common_files_from_pickle_cache'
|
|
136
|
+
common_archive = commonFC.archive(archive_path, delete=True)
|
|
137
|
+
logger.info(f'Archived common files from pickle cache to {common_archive.absolute().relative_to(Path.cwd()).as_posix()}')
|
|
138
|
+
rmtree(pickle_cache)
|
|
139
|
+
logger.debug(f'Removed pickle cache at {pickle_cache.as_posix()}')
|
|
140
|
+
if len(AnswerSets) > 0:
|
|
141
|
+
logger.info(f'Collected {len(AnswerSets)} answer sets from pickle cache.')
|
|
142
|
+
FC.append(answerset(config, AnswerSets=AnswerSets))
|
|
143
|
+
for f in FC.data:
|
|
144
|
+
logger.debug(f'Generated file: {f.absolute().relative_to(Path.cwd()).as_posix()}')
|
|
145
|
+
tex_archive = FC.archive(build_path / 'tex_artifacts', delete=True)
|
|
146
|
+
logger.info(f'Archived TeX artifacts to {tex_archive.absolute().relative_to(Path.cwd()).as_posix()}')
|
|
147
|
+
buildfiles_archive = base_builder.FC.archive(build_path / 'buildfiles', delete=True)
|
|
148
|
+
solnbuildfiles_archive = soln_builder.FC.archive(build_path / 'solnbuildfiles', delete=True)
|
|
149
|
+
logger.info(f'Archived build files to {buildfiles_archive.absolute().relative_to(Path.cwd()).as_posix()}')
|
|
150
|
+
logger.info(f'Archived solution build files to {solnbuildfiles_archive.absolute().relative_to(Path.cwd()).as_posix()}')
|
|
151
|
+
|
|
152
|
+
def answerset(config: Config = None, AnswerSets: dict[str | int, AnswerSet] = None) -> str:
|
|
153
|
+
build_path: Path = Path(config.build_specs['paths']['build-dir'])
|
|
154
|
+
if len(AnswerSets) == 0:
|
|
155
|
+
apparent_answer_files = list(build_path.glob('answers-*.yaml'))
|
|
156
|
+
if not apparent_answer_files:
|
|
157
|
+
raise FileNotFoundError(f'No answer files found in {build_path} matching pattern "answers-*.yaml"')
|
|
158
|
+
filenames = [str(x) for x in apparent_answer_files]
|
|
159
|
+
filenames.sort()
|
|
160
|
+
logger.debug(f'Found answer set files: {filenames}')
|
|
161
|
+
AS = AnswerSuperSet.from_dumpfiles(filenames, delete=True)
|
|
162
|
+
else:
|
|
163
|
+
AS = AnswerSuperSet(initial=AnswerSets)
|
|
164
|
+
|
|
165
|
+
answer_buildspecs = {'job-name': config.build_specs.get('answer-name', 'answerset'),
|
|
166
|
+
'paths': config.build_specs['paths']}
|
|
167
|
+
AnswerSetBuilder = LatexBuilder(answer_buildspecs,
|
|
168
|
+
searchdirs = [config.autoprob_package_dir])
|
|
169
|
+
|
|
170
|
+
answer_docspecs = deepcopy(config.document_specs)
|
|
171
|
+
answer_docspecs['structure'] = []
|
|
172
|
+
answer_docspecs['structure'].append(deepcopy(config.document_specs['structure'][0]))
|
|
173
|
+
answer_docspecs['structure'].append({'text': AS.to_latex()})
|
|
174
|
+
answer_docspecs['structure'].append(deepcopy(config.document_specs['structure'][-1]))
|
|
175
|
+
AnswerSetDoc = Document(answer_docspecs)
|
|
176
|
+
AnswerSetDoc.make_substitutions(dict(serial='Answer Set'))
|
|
177
|
+
AnswerSetBuilder.build_document(AnswerSetDoc)
|
|
178
|
+
logger.info(f'Combined answer set => {build_path.absolute().relative_to(Path.cwd()).as_posix()}/{AnswerSetBuilder.working_job_name}.pdf')
|
|
179
|
+
answerset_archive = AnswerSetBuilder.FC.archive(build_path / 'answerset_buildfiles', delete=True)
|
|
180
|
+
logger.info(f'Archived answer set build files to {answerset_archive.absolute().relative_to(Path.cwd()).as_posix()}')
|
|
181
|
+
return Path.cwd() / f'{AnswerSetBuilder.working_job_name}.tex'
|
|
182
|
+
|
|
183
|
+
def answerset_subcommand(args):
|
|
184
|
+
logger.info(f'Generating answer set document from previous build specified in {args.f}...')
|
|
185
|
+
config = Config(args.f)
|
|
186
|
+
tex_file = answerset(config)
|
|
187
|
+
# remove the tex source
|
|
188
|
+
os.remove(tex_file)
|