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.
Files changed (57) hide show
  1. pygacity/__init__.py +0 -0
  2. pygacity/cli.py +132 -0
  3. pygacity/generate/__init__.py +0 -0
  4. pygacity/generate/answerset.py +194 -0
  5. pygacity/generate/block.py +166 -0
  6. pygacity/generate/build.py +188 -0
  7. pygacity/generate/config.py +91 -0
  8. pygacity/generate/document.py +50 -0
  9. pygacity/generate/pick.py +81 -0
  10. pygacity/resources/__init__.py +0 -0
  11. pygacity/resources/autoprob-package/tex/latex/autoprob.cls +208 -0
  12. pygacity/resources/corresponding-states-data/enthalpy-departures.csv +242 -0
  13. pygacity/resources/corresponding-states-data/enthalpy-departures.raw +246 -0
  14. pygacity/resources/corresponding-states-data/entropy-departures.csv +155 -0
  15. pygacity/resources/corresponding-states-data/entropy-departures.raw +158 -0
  16. pygacity/resources/pythontex/chemeq.pycode +6 -0
  17. pygacity/resources/pythontex/matplotlib.pycode +11 -0
  18. pygacity/resources/pythontex/sandlerprops.pycode +3 -0
  19. pygacity/resources/pythontex/sandlersteam.pycode +6 -0
  20. pygacity/resources/pythontex/setup.pycode +27 -0
  21. pygacity/resources/pythontex/showsteamtables.pycode +4 -0
  22. pygacity/resources/pythontex/teardown.pycode +14 -0
  23. pygacity/resources/templates/blank_template.tex +16 -0
  24. pygacity/resources/templates/footer.tex +1 -0
  25. pygacity/resources/templates/header.tex +13 -0
  26. pygacity/resources/templates/short.tex +99 -0
  27. pygacity/topics/__init__.py +0 -0
  28. pygacity/topics/chem/__init__.py +0 -0
  29. pygacity/topics/chem/chemeqsystem.py +201 -0
  30. pygacity/topics/chem/compound.py +251 -0
  31. pygacity/topics/chem/properties.py +43 -0
  32. pygacity/topics/chem/reaction.py +138 -0
  33. pygacity/topics/distillation/__init__.py +0 -0
  34. pygacity/topics/distillation/binaryduties.py +457 -0
  35. pygacity/topics/distillation/binaryflashdepriester.py +206 -0
  36. pygacity/topics/distillation/fug.py +71 -0
  37. pygacity/topics/distillation/mccabethiele.py +443 -0
  38. pygacity/topics/steam/__init__.py +0 -0
  39. pygacity/topics/steam/steamtank.py +67 -0
  40. pygacity/topics/thermo/__init__.py +0 -0
  41. pygacity/topics/thermo/corrsts.py +32 -0
  42. pygacity/topics/thermo/prcalcs.py +343 -0
  43. pygacity/topics/vle/__init__.py +0 -0
  44. pygacity/topics/vle/txyplot.py +98 -0
  45. pygacity/topics/vle/vle.py +357 -0
  46. pygacity/util/__init__.py +0 -0
  47. pygacity/util/collectors.py +248 -0
  48. pygacity/util/command.py +19 -0
  49. pygacity/util/corrsts_wpd2csv.py +37 -0
  50. pygacity/util/pdfutils.py +24 -0
  51. pygacity/util/stringthings.py +170 -0
  52. pygacity/util/texutils.py +211 -0
  53. pygacity-0.4.0.dist-info/METADATA +76 -0
  54. pygacity-0.4.0.dist-info/RECORD +57 -0
  55. pygacity-0.4.0.dist-info/WHEEL +4 -0
  56. pygacity-0.4.0.dist-info/entry_points.txt +2 -0
  57. 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)