dp_wizard_templates 0.7.0__py2.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.
@@ -0,0 +1 @@
1
+ 0.7.0
@@ -0,0 +1,5 @@
1
+ """Code templating tools"""
2
+
3
+ from pathlib import Path
4
+
5
+ __version__ = (Path(__file__).parent / "VERSION").read_text().strip()
@@ -0,0 +1,305 @@
1
+ import inspect
2
+ import json
3
+ import re
4
+ from pathlib import Path
5
+ from typing import Callable, Iterable, Optional
6
+
7
+ import black
8
+
9
+
10
+ class TemplateException(Exception):
11
+ pass
12
+
13
+
14
+ def _get_body(func):
15
+
16
+ source_lines = inspect.getsource(func).splitlines()
17
+ first_line = source_lines[0]
18
+ if not re.match(r"def \w+\((\w+(, \w+)*)?\):", first_line.strip()):
19
+ # Parsing to AST and unparsing is a more robust option,
20
+ # but more complicated.
21
+ raise TemplateException(
22
+ f"def and parameters should fit on one line: {first_line}"
23
+ )
24
+
25
+ # The "def" should not be in the output,
26
+ # and cleandoc handles the first line differently.
27
+ source_lines[0] = ""
28
+ body = inspect.cleandoc("\n".join(source_lines))
29
+ comments_to_strip = [
30
+ r"\s+#\s+type:\s+ignore\s*",
31
+ r"\s+#\s+noqa:.+\s*",
32
+ r"\s+#\s+pragma:\s+no cover\s*",
33
+ ]
34
+ for comment_re in comments_to_strip:
35
+ body = re.sub(
36
+ comment_re,
37
+ "\n",
38
+ body,
39
+ )
40
+
41
+ return body
42
+
43
+
44
+ def _check_repr(value):
45
+ """
46
+ Confirms that the string returned by repr()
47
+ can be evaluated to recreate the original value.
48
+ Takes a conservative approach by checking
49
+ if the value can be serialized to JSON.
50
+ """
51
+ try:
52
+ json.dumps(value)
53
+ except TypeError as e:
54
+ raise TemplateException(e)
55
+ return repr(value)
56
+
57
+
58
+ _slot_re = r"\b[A-Z][A-Z_]{2,}\b"
59
+
60
+
61
+ def _check_kwargs(func):
62
+ def wrapper(*args, **kwargs):
63
+ WHEN = "when"
64
+ errors = []
65
+ for k in kwargs.keys():
66
+ if k in args[0]._ignore:
67
+ errors.append(f'kwarg "{k}" is an ignored slot name')
68
+ if not (re.fullmatch(_slot_re, k) or k == WHEN):
69
+ errors.append(f'kwarg "{k}" is not a valid slot name')
70
+ if errors:
71
+ raise TemplateException(
72
+ "; ".join(errors)
73
+ + f'. Slots should match "{_slot_re}". '
74
+ + "Some slots are ignored, and should not be filled: "
75
+ + ",".join(f'"{v}"' for v in args[0]._ignore)
76
+ )
77
+ if not kwargs.get(WHEN, True):
78
+ # return self:
79
+ return args[0]
80
+ kwargs.pop(WHEN, None)
81
+ return func(*args, **kwargs)
82
+
83
+ return wrapper
84
+
85
+
86
+ class Template:
87
+
88
+ def __init__(
89
+ self,
90
+ template: str | Callable,
91
+ root: Optional[Path] = None,
92
+ ignore: Iterable[str] = ("TODO",),
93
+ ):
94
+ if root is None:
95
+ if callable(template):
96
+ self._source = "function template"
97
+ self._template = _get_body(template)
98
+ else:
99
+ self._source = "string template"
100
+ self._template = template
101
+ else:
102
+ if callable(template):
103
+ raise TemplateException(
104
+ "If template is function, root kwarg not allowed"
105
+ )
106
+ else:
107
+ template_name = f"_{template}.py"
108
+ template_path = root / template_name
109
+ self._source = f"'{template_name}'"
110
+ self._template = template_path.read_text()
111
+ # We want a list of the initial slots, because substitutions
112
+ # can produce sequences of upper case letters that could be mistaken for slots.
113
+ self._initial_slots = self._find_slots()
114
+ self._ignore = ignore
115
+
116
+ def _find_slots(self) -> set[str]:
117
+ # Slots:
118
+ # - are all caps or underscores
119
+ # - have word boundary on either side
120
+ # - are at least three characters
121
+ return set(re.findall(_slot_re, self._template))
122
+
123
+ def _make_message(self, errors: list[str]) -> str:
124
+ return (
125
+ f"In {self._source}, " + ", ".join(sorted(errors)) + f":\n{self._template}"
126
+ )
127
+
128
+ def _loop_kwargs(
129
+ self,
130
+ function: Callable[[str, str, list[str]], None],
131
+ **kwargs,
132
+ ) -> None:
133
+ errors = []
134
+ for k, v in kwargs.items():
135
+ function(k, v, errors)
136
+ if errors:
137
+ raise TemplateException(self._make_message(errors))
138
+
139
+ def _fill_inline_slots(
140
+ self,
141
+ stringifier: Callable[[str], str],
142
+ **kwargs,
143
+ ) -> None:
144
+ def function(k, v, errors):
145
+ k_re = re.escape(k)
146
+ self._template, count = re.subn(
147
+ rf"\b{k_re}\b", stringifier(v), self._template
148
+ )
149
+ if count == 0:
150
+ errors.append(f"no '{k}' slot to fill with '{v}'")
151
+
152
+ self._loop_kwargs(function, **kwargs)
153
+
154
+ def _fill_attribute_slots(self, **kwargs) -> None:
155
+ def function(k, v, errors):
156
+ k_re = re.escape(k)
157
+ attr_re = rf"\.\b{k_re}\b"
158
+ self._template, count = re.subn(
159
+ attr_re, f".{v}" if v else "", self._template
160
+ )
161
+ if count == 0:
162
+ errors.append(
163
+ f"no '.{k}' slot to fill with '{v}'"
164
+ if v
165
+ else f"no '.{k}' slot to delete (because replacement is false-y)"
166
+ )
167
+
168
+ self._loop_kwargs(function, **kwargs)
169
+
170
+ def _fill_argument_slots(
171
+ self,
172
+ stringifier: Callable[[str], str],
173
+ **kwargs,
174
+ ) -> None:
175
+ def function(k, v, errors):
176
+ k_re = re.escape(k)
177
+ arg_re = rf"\s*\b{k_re}\b,\s*"
178
+ self._template, count = re.subn(
179
+ arg_re, f"{stringifier(v)}," if v else "", self._template
180
+ )
181
+ if count == 0:
182
+ errors.append(
183
+ f"no '{k},' slot to fill with '{v}'"
184
+ if v
185
+ else f"no '{k},' slot to delete (because replacement is false-y)"
186
+ )
187
+
188
+ self._loop_kwargs(function, **kwargs)
189
+
190
+ def _fill_block_slots(
191
+ self,
192
+ prefix_re: str,
193
+ splitter: Callable[[str], list[str]],
194
+ **kwargs,
195
+ ) -> None:
196
+ def function(k, v, errors):
197
+ if not isinstance(v, str):
198
+ errors.append(f"for '{k}' slot, expected string, not '{v}'")
199
+ return
200
+
201
+ def match_indent(match):
202
+ # This does what we want, but binding is confusing.
203
+ return "\n".join(
204
+ match.group(1) + line for line in splitter(v) # noqa: B023
205
+ )
206
+
207
+ k_re = re.escape(k)
208
+ self._template, count = re.subn(
209
+ rf"^([ \t]*{prefix_re}){k_re}$",
210
+ match_indent,
211
+ self._template,
212
+ flags=re.MULTILINE,
213
+ )
214
+ if count == 0:
215
+ base_message = f"no '{k}' slot to fill with '{v}'"
216
+ if k in self._template:
217
+ note = (
218
+ "comment slots must be prefixed with '#'"
219
+ if prefix_re
220
+ else "block slots must be alone on line"
221
+ )
222
+ errors.append(f"{base_message} ({note})")
223
+ else:
224
+ errors.append(base_message)
225
+
226
+ self._loop_kwargs(function, **kwargs)
227
+
228
+ @_check_kwargs
229
+ def fill_expressions(self, **kwargs) -> "Template":
230
+ """
231
+ Fill in variable names, or dicts or lists represented as strings.
232
+ """
233
+ self._fill_inline_slots(stringifier=str, **kwargs)
234
+ return self
235
+
236
+ @_check_kwargs
237
+ def fill_attributes(self, **kwargs) -> "Template":
238
+ """
239
+ Fill in attributes with expressions, or remove leading "." if false-y.
240
+ """
241
+ self._fill_attribute_slots(**kwargs)
242
+ return self
243
+
244
+ @_check_kwargs
245
+ def fill_argument_expressions(self, **kwargs) -> "Template":
246
+ """
247
+ Fill in argument expressions, or removing trailing "," if false-y.
248
+ """
249
+ self._fill_argument_slots(stringifier=str, **kwargs)
250
+ return self
251
+
252
+ @_check_kwargs
253
+ def fill_argument_values(self, **kwargs) -> "Template":
254
+ """
255
+ Fill in argument values, or removing trailing "," if false-y.
256
+ """
257
+ self._fill_argument_slots(stringifier=_check_repr, **kwargs)
258
+ return self
259
+
260
+ @_check_kwargs
261
+ def fill_values(self, **kwargs) -> "Template":
262
+ """
263
+ Fill in string or numeric values. `repr` is called before filling.
264
+ """
265
+ self._fill_inline_slots(stringifier=_check_repr, **kwargs)
266
+ return self
267
+
268
+ @_check_kwargs
269
+ def fill_code_blocks(self, **kwargs) -> "Template":
270
+ """
271
+ Fill in code blocks. Slot must be alone on line.
272
+ """
273
+
274
+ def splitter(s):
275
+ return s.split("\n")
276
+
277
+ self._fill_block_slots(prefix_re=r"", splitter=splitter, **kwargs)
278
+ return self
279
+
280
+ @_check_kwargs
281
+ def fill_comment_blocks(self, **kwargs) -> "Template":
282
+ """
283
+ Fill in comment blocks. Slot must be commented.
284
+ """
285
+
286
+ def splitter(s):
287
+ stripped = [line.strip() for line in s.split("\n")]
288
+ return [line for line in stripped if line]
289
+
290
+ self._fill_block_slots(prefix_re=r"#\s+", splitter=splitter, **kwargs)
291
+ return self
292
+
293
+ def finish(self, reformat: bool = False) -> str:
294
+ # The reformat default is False here,
295
+ # because it is true downstream for notebook generation,
296
+ # and we don't need to be redundant.
297
+ unfilled_slots = (self._initial_slots & self._find_slots()) - set(self._ignore)
298
+ if unfilled_slots:
299
+ errors = [f"'{slot}' slot not filled" for slot in unfilled_slots]
300
+ raise TemplateException(self._make_message(errors))
301
+
302
+ if reformat:
303
+ self._template = black.format_str(self._template, mode=black.Mode())
304
+
305
+ return self._template
@@ -0,0 +1,140 @@
1
+ import json
2
+ import subprocess
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from sys import executable
6
+ from tempfile import TemporaryDirectory
7
+
8
+ import black
9
+ import jupytext
10
+ import nbconvert
11
+
12
+
13
+ def _is_kernel_installed() -> bool:
14
+ try:
15
+ # This method isn't well documented, so it may be fragile.
16
+ jupytext.kernels.kernelspec_from_language("python") # type: ignore
17
+ return True
18
+ except ValueError: # pragma: no cover
19
+ return False
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class ConversionException(Exception):
24
+ command: str
25
+ stderr: str
26
+
27
+ def __str__(self):
28
+ return f"Script to notebook conversion failed: {self.command}\n{self.stderr})"
29
+
30
+
31
+ def convert_py_to_nb(
32
+ python_str: str, title: str, execute: bool = False, reformat: bool = True
33
+ ) -> str:
34
+ """
35
+ Given Python code as a string, returns a notebook as a string.
36
+ Calls jupytext as a subprocess:
37
+ Not ideal, but only the CLI is documented well.
38
+ """
39
+ with TemporaryDirectory() as temp_dir:
40
+ if not _is_kernel_installed():
41
+ subprocess.run( # pragma: no cover
42
+ [executable]
43
+ + "-m ipykernel install --name kernel_name --user".split(" "),
44
+ check=True,
45
+ )
46
+
47
+ temp_dir_path = Path(temp_dir)
48
+ py_path = temp_dir_path / "input.py"
49
+ if reformat:
50
+ # Line length determined by PDF rendering.
51
+ python_str = black.format_str(python_str, mode=black.Mode(line_length=74))
52
+ py_path.write_text(python_str)
53
+
54
+ argv = [executable] + "-m jupytext --from .py --to .ipynb --output -".split(" ")
55
+ if execute:
56
+ argv.append("--execute")
57
+ argv.append(str(py_path.absolute())) # type: ignore
58
+ result = subprocess.run(argv, text=True, capture_output=True)
59
+ if result.returncode != 0:
60
+ # If there is an error, we want a copy of the file that will stay around,
61
+ # outside the "with TemporaryDirectory()" block.
62
+ # The command we show in the error message isn't exactly what was run,
63
+ # but it should reproduce the error.
64
+ debug_path = Path("/tmp/script.py")
65
+ debug_path.write_text(python_str)
66
+ argv.pop()
67
+ argv.append(str(debug_path)) # type: ignore
68
+ raise ConversionException(command=" ".join(argv), stderr=result.stderr)
69
+ nb_dict = json.loads(result.stdout.strip())
70
+ nb_dict["metadata"]["title"] = title
71
+ return _clean_nb(json.dumps(nb_dict))
72
+
73
+
74
+ def _stable_hash(lines: list[str]) -> str:
75
+ import hashlib
76
+
77
+ return hashlib.sha1("\n".join(lines).encode()).hexdigest()[:8]
78
+
79
+
80
+ def _clean_nb(nb_json: str) -> str:
81
+ """
82
+ Given a notebook as a string of JSON, remove the coda and pip output.
83
+ (The code may produce reports that we do need,
84
+ but the code isn't actually interesting to end users.)
85
+ """
86
+ nb = json.loads(nb_json)
87
+ new_cells = []
88
+ for cell in nb["cells"]:
89
+ if "pip install" in cell["source"][0]:
90
+ cell["outputs"] = []
91
+ # "Coda" may, or may not be followed by "\n".
92
+ # Be flexible!
93
+ if any(line.startswith("# Coda") for line in cell["source"]):
94
+ break
95
+ # Make ID stable:
96
+ cell["id"] = _stable_hash(cell["source"])
97
+ # Delete execution metadata:
98
+ try:
99
+ del cell["metadata"]["execution"]
100
+ except KeyError:
101
+ pass
102
+ new_cells.append(cell)
103
+ nb["cells"] = new_cells
104
+ return json.dumps(nb, indent=1)
105
+
106
+
107
+ def convert_nb_to_html(python_nb: str, numbered=True) -> str:
108
+ import warnings
109
+
110
+ import nbformat.warnings
111
+
112
+ with warnings.catch_warnings():
113
+ warnings.simplefilter(
114
+ action="ignore", category=nbformat.warnings.DuplicateCellId
115
+ )
116
+ notebook = nbformat.reads(python_nb, as_version=4)
117
+ exporter = nbconvert.HTMLExporter(
118
+ template_name="lab",
119
+ # The "classic" template's CSS forces large code cells on to
120
+ # the next page rather than breaking, so use "lab" instead.
121
+ #
122
+ # If you want to tweak the CSS, enable this block and make changes
123
+ # in nbconvert_templates/custom:
124
+ #
125
+ # template_name="custom",
126
+ # extra_template_basedirs=[
127
+ # str((Path(__file__).parent / "nbconvert_templates").absolute())
128
+ # ],
129
+ )
130
+ (body, _resources) = exporter.from_notebook_node(notebook)
131
+ if not numbered:
132
+ body = body.replace(
133
+ "</head>",
134
+ """
135
+ <style>
136
+ .jp-InputPrompt {display: none;}
137
+ </style>
138
+ </head>""",
139
+ )
140
+ return body
File without changes
@@ -0,0 +1,61 @@
1
+ Metadata-Version: 2.4
2
+ Name: dp_wizard_templates
3
+ Version: 0.7.0
4
+ Summary: Code templating tools
5
+ Author-email: The OpenDP Project <info@opendp.org>
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENSE
8
+ Requires-Dist: black
9
+ Requires-Dist: ipykernel
10
+ Requires-Dist: jupyter-client
11
+ Requires-Dist: jupytext
12
+ Requires-Dist: nbconvert
13
+ Project-URL: GitHub, https://github.com/opendp/dp-wizard-templates
14
+ Project-URL: Homepage, https://opendp.github.io/dp-wizard-templates
15
+
16
+ # DP Wizard Templates
17
+
18
+ [![pypi](https://img.shields.io/pypi/v/dp_wizard_templates)](https://pypi.org/project/dp_wizard_templates/)
19
+
20
+ DP Wizard Templates lets you use syntactically valid Python code as a template.
21
+ Templates can be filled and composed to generate entire notebooks.
22
+
23
+ See the [documentation](https://opendp.github.io/dp-wizard-templates) for more information.
24
+
25
+
26
+ ## Development
27
+
28
+ ### Getting Started
29
+
30
+ On MacOS:
31
+ ```shell
32
+ $ git clone https://github.com/opendp/dp-wizard-templates.git
33
+ $ cd dp-wizard-templates
34
+ $ brew install python@3.10
35
+ $ python3.10 -m venv .venv
36
+ $ source .venv/bin/activate
37
+ ```
38
+
39
+ You can now install dependencies:
40
+ ```shell
41
+ $ pip install -r requirements-dev.txt
42
+ $ pre-commit install
43
+ $ flit install
44
+ ```
45
+
46
+ Tests should pass, and code coverage should be complete (except blocks we explicitly ignore):
47
+ ```shell
48
+ $ scripts/ci.sh
49
+ ```
50
+
51
+ ### Release
52
+
53
+ - Make sure you're up to date, and have the git-ignored credentials file `.pypirc`.
54
+ - Make one last feature branch with the new version number in the name:
55
+ - Run `scripts/changelog.py` to update the `CHANGELOG.md`.
56
+ - Review the updates and pull a couple highlights to the top.
57
+ - Bump `dp_wizard/VERSION`, and add the new number at the top of the `CHANGELOG.md`.
58
+ - Commit your changes, make a PR, and merge this branch to main.
59
+ - Update `main` with the latest changes: `git checkout main; git pull`
60
+ - Publish: `flit publish --pypirc .pypirc`
61
+
@@ -0,0 +1,9 @@
1
+ dp_wizard_templates/VERSION,sha256=sMsnbN5Rd3nKQ6X4_pStzWsco7Tn2l6X8szZw7Q4Hfg,5
2
+ dp_wizard_templates/__init__.py,sha256=DF2xPO1uVitkB9NRBpqd_HayLHk4hnckLo8jaccoEbI,125
3
+ dp_wizard_templates/code_template.py,sha256=GmKOSfU7eOX9QN9PJ0zq1nXUHSTePyn2wM5Wmhjda-g,9687
4
+ dp_wizard_templates/converters.py,sha256=Xzb0suMDCMpqLytgFUtlW80GKlVWRbzfTtfsD6cL8Uo,4548
5
+ dp_wizard_templates/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ dp_wizard_templates-0.7.0.dist-info/licenses/LICENSE,sha256=FDrIMeZPiT4g_4w0i1Ec4Bc8h9wfNytroheQN4508yU,1063
7
+ dp_wizard_templates-0.7.0.dist-info/WHEEL,sha256=Dyt6SBfaasWElUrURkknVFAZDHSTwxg3PaTza7RSbkY,100
8
+ dp_wizard_templates-0.7.0.dist-info/METADATA,sha256=y0A2fFxjkUuzYX-CQDyUWnipDjRTMA6k-wry0PJyS5w,1881
9
+ dp_wizard_templates-0.7.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: flit 3.12.0
3
+ Root-Is-Purelib: true
4
+ Tag: py2-none-any
5
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 OpenDP
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.