dp_wizard_templates 0.1.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.

Potentially problematic release.


This version of dp_wizard_templates might be problematic. Click here for more details.

@@ -0,0 +1 @@
1
+ 0.1.0
@@ -0,0 +1,6 @@
1
+ """Code templating tools"""
2
+
3
+ from pathlib import Path
4
+
5
+
6
+ __version__ = (Path(__file__).parent / "VERSION").read_text().strip()
@@ -0,0 +1,127 @@
1
+ import re
2
+
3
+
4
+ def _get_body(func):
5
+ import inspect
6
+ import re
7
+
8
+ source_lines = inspect.getsource(func).splitlines()
9
+ first_line = source_lines[0]
10
+ if not re.match(r"def \w+\((\w+(, \w+)*)?\):", first_line.strip()):
11
+ # Parsing to AST and unparsing is a more robust option,
12
+ # but more complicated.
13
+ raise Exception(f"def and parameters should fit on one line: {first_line}")
14
+
15
+ # The "def" should not be in the output,
16
+ # and cleandoc handles the first line differently.
17
+ source_lines[0] = ""
18
+ body = inspect.cleandoc("\n".join(source_lines))
19
+ body = re.sub(
20
+ r"\s*#\s+type:\s+ignore\s*",
21
+ "\n",
22
+ body,
23
+ )
24
+ body = re.sub(
25
+ r"\s*#\s+noqa:.+",
26
+ "",
27
+ body,
28
+ )
29
+ return body
30
+
31
+
32
+ class Template:
33
+ def __init__(self, template, root=None):
34
+ if root is not None:
35
+ template_name = f"_{template}.py"
36
+ template_path = root / template_name
37
+ self._source = f"'{template_name}'"
38
+ self._template = template_path.read_text()
39
+ else:
40
+ if callable(template):
41
+ self._source = "function template"
42
+ self._template = _get_body(template)
43
+ else:
44
+ self._source = "string template"
45
+ self._template = template
46
+ # We want a list of the initial slots, because substitutions
47
+ # can produce sequences of upper case letters that could be mistaken for slots.
48
+ self._initial_slots = self._find_slots()
49
+
50
+ def _find_slots(self):
51
+ # Slots:
52
+ # - are all caps or underscores
53
+ # - have word boundary on either side
54
+ # - are at least three characters
55
+ slot_re = r"\b[A-Z][A-Z_]{2,}\b"
56
+ return set(re.findall(slot_re, self._template))
57
+
58
+ def fill_expressions(self, **kwargs):
59
+ """
60
+ Fill in variable names, or dicts or lists represented as strings.
61
+ """
62
+ for k, v in kwargs.items():
63
+ k_re = re.escape(k)
64
+ self._template, count = re.subn(rf"\b{k_re}\b", str(v), self._template)
65
+ if count == 0:
66
+ raise Exception(
67
+ f"No '{k}' slot to fill with '{v}' in "
68
+ f"{self._source}:\n\n{self._template}"
69
+ )
70
+ return self
71
+
72
+ def fill_values(self, **kwargs):
73
+ """
74
+ Fill in string or numeric values. `repr` is called before filling.
75
+ """
76
+ for k, v in kwargs.items():
77
+ k_re = re.escape(k)
78
+ self._template, count = re.subn(rf"\b{k_re}\b", repr(v), self._template)
79
+ if count == 0:
80
+ raise Exception(
81
+ f"No '{k}' slot to fill with '{v}' in "
82
+ f"{self._source}:\n\n{self._template}"
83
+ )
84
+ return self
85
+
86
+ def fill_blocks(self, **kwargs):
87
+ """
88
+ Fill in code blocks. Slot must be alone on line.
89
+ """
90
+ for k, v in kwargs.items():
91
+ if not isinstance(v, str):
92
+ raise Exception(f"For {k} in {self._source}, expected string, not {v}")
93
+
94
+ def match_indent(match):
95
+ # This does what we want, but binding is confusing.
96
+ return "\n".join(
97
+ match.group(1) + line for line in v.split("\n") # noqa: B023
98
+ )
99
+
100
+ k_re = re.escape(k)
101
+ self._template, count = re.subn(
102
+ rf"^([ \t]*){k_re}$",
103
+ match_indent,
104
+ self._template,
105
+ flags=re.MULTILINE,
106
+ )
107
+ if count == 0:
108
+ base_message = (
109
+ f"No '{k}' slot to fill with '{v}' in "
110
+ f"{self._source}:\n\n{self._template}"
111
+ )
112
+ if k in self._template:
113
+ raise Exception(
114
+ f"Block slots must be alone on line; {base_message}"
115
+ )
116
+ raise Exception(base_message)
117
+ return self
118
+
119
+ def finish(self) -> str:
120
+ unfilled_slots = self._initial_slots & self._find_slots()
121
+ if unfilled_slots:
122
+ slots_str = ", ".join(sorted(f"'{slot}'" for slot in unfilled_slots))
123
+ raise Exception(
124
+ f"{slots_str} slot not filled "
125
+ f"in {self._source}:\n\n{self._template}"
126
+ )
127
+ return self._template
@@ -0,0 +1,119 @@
1
+ from pathlib import Path
2
+ from tempfile import TemporaryDirectory
3
+ from dataclasses import dataclass
4
+ from sys import executable
5
+ import subprocess
6
+ import json
7
+ import nbformat
8
+ import nbconvert
9
+ import jupytext
10
+
11
+
12
+ def _is_kernel_installed() -> bool:
13
+ try:
14
+ # This method isn't well documented, so it may be fragile.
15
+ jupytext.kernels.kernelspec_from_language("python") # type: ignore
16
+ return True
17
+ except ValueError: # pragma: no cover
18
+ return False
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class ConversionException(Exception):
23
+ command: str
24
+ stderr: str
25
+
26
+ def __str__(self):
27
+ return f"Script to notebook conversion failed: {self.command}\n{self.stderr})"
28
+
29
+
30
+ def convert_py_to_nb(python_str: str, title: str, execute: bool = False):
31
+ """
32
+ Given Python code as a string, returns a notebook as a string.
33
+ Calls jupytext as a subprocess:
34
+ Not ideal, but only the CLI is documented well.
35
+ """
36
+ with TemporaryDirectory() as temp_dir:
37
+ if not _is_kernel_installed():
38
+ subprocess.run( # pragma: no cover
39
+ [executable]
40
+ + "-m ipykernel install --name kernel_name --user".split(" "),
41
+ check=True,
42
+ )
43
+
44
+ temp_dir_path = Path(temp_dir)
45
+ py_path = temp_dir_path / "input.py"
46
+ py_path.write_text(python_str)
47
+
48
+ argv = [executable] + "-m jupytext --from .py --to .ipynb --output -".split(" ")
49
+ if execute:
50
+ argv.append("--execute")
51
+ argv.append(str(py_path.absolute())) # type: ignore
52
+ result = subprocess.run(argv, text=True, capture_output=True)
53
+ if result.returncode != 0:
54
+ # If there is an error, we want a copy of the file that will stay around,
55
+ # outside the "with TemporaryDirectory()" block.
56
+ # The command we show in the error message isn't exactly what was run,
57
+ # but it should reproduce the error.
58
+ debug_path = Path("/tmp/script.py")
59
+ debug_path.write_text(python_str)
60
+ argv.pop()
61
+ argv.append(str(debug_path)) # type: ignore
62
+ raise ConversionException(command=" ".join(argv), stderr=result.stderr)
63
+ nb_dict = json.loads(result.stdout.strip())
64
+ nb_dict["metadata"]["title"] = title
65
+ return _clean_nb(json.dumps(nb_dict))
66
+
67
+
68
+ def _stable_hash(lines: list[str]):
69
+ import hashlib
70
+
71
+ return hashlib.sha1("\n".join(lines).encode()).hexdigest()[:8]
72
+
73
+
74
+ def _clean_nb(nb_json: str):
75
+ """
76
+ Given a notebook as a string of JSON, remove the coda and pip output.
77
+ (The code may produce reports that we do need,
78
+ but the code isn't actually interesting to end users.)
79
+ """
80
+ nb = json.loads(nb_json)
81
+ new_cells = []
82
+ for cell in nb["cells"]:
83
+ if "pip install" in cell["source"][0]:
84
+ cell["outputs"] = []
85
+ if "# Coda\n" in cell["source"]:
86
+ break
87
+ # Make ID stable:
88
+ cell["id"] = _stable_hash(cell["source"])
89
+ # Delete execution metadata:
90
+ try:
91
+ del cell["metadata"]["execution"]
92
+ except KeyError:
93
+ pass
94
+ new_cells.append(cell)
95
+ nb["cells"] = new_cells
96
+ return json.dumps(nb, indent=1)
97
+
98
+
99
+ def convert_nb_to_html(python_nb: str):
100
+ return _convert_nb(python_nb, nbconvert.HTMLExporter)
101
+
102
+
103
+ def _convert_nb(python_nb: str, exporter_constructor):
104
+ notebook = nbformat.reads(python_nb, as_version=4)
105
+ exporter = exporter_constructor(
106
+ template_name="lab",
107
+ # The "classic" template's CSS forces large code cells on to
108
+ # the next page rather than breaking, so use "lab" instead.
109
+ #
110
+ # If you want to tweak the CSS, enable this block and make changes
111
+ # in nbconvert_templates/custom:
112
+ #
113
+ # template_name="custom",
114
+ # extra_template_basedirs=[
115
+ # str((Path(__file__).parent / "nbconvert_templates").absolute())
116
+ # ],
117
+ )
118
+ (body, _resources) = exporter.from_notebook_node(notebook)
119
+ return body
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.4
2
+ Name: dp_wizard_templates
3
+ Version: 0.1.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: Home, https://github.com/opendp/dp-wizard-templates
14
+
15
+ # DP Wizard Templates
16
+
17
+ [![pypi](https://img.shields.io/pypi/v/dp_wizard_templates)](https://pypi.org/project/dp_wizard_templates/)
18
+
19
+ DP Wizard Templates lets you use syntactically valid Python code as a template.
20
+ Templates can be filled and composed to generate entire notebooks.
21
+
22
+ [`README_test.py`](https://github.com/opendp/dp-wizard-templates/blob/main/README_test.py) provides an example of use,
23
+ and an example [output notebook](https://github.com/opendp/dp-wizard-templates/blob/main/README_examples/hello-world.ipynb)
24
+ is also available.
25
+
26
+ DP Wizard Templates was developed for [DP Wizard](https://github.com/opendp/dp-wizard),
27
+ and that codebase remains a good place to look for further examples.
28
+
29
+
30
+ ## Development
31
+
32
+ ### Getting Started
33
+
34
+ On MacOS:
35
+ ```shell
36
+ $ git clone https://github.com/opendp/dp-wizard-templates.git
37
+ $ cd dp-wizard-templates
38
+ $ brew install python@3.10
39
+ $ python3.10 -m venv .venv
40
+ $ source .venv/bin/activate
41
+ ```
42
+
43
+ You can now install dependencies:
44
+ ```shell
45
+ $ pip install -r requirements-dev.txt
46
+ $ pre-commit install
47
+ $ flit install
48
+ ```
49
+
50
+ Tests should pass, and code coverage should be complete (except blocks we explicitly ignore):
51
+ ```shell
52
+ $ scripts/ci.sh
53
+ ```
54
+
55
+ ### Release
56
+
57
+ - Make sure you're up to date, and have the git-ignored credentials file `.pypirc`.
58
+ - Make one last feature branch with the new version number in the name:
59
+ - Run `scripts/changelog.py` to update the `CHANGELOG.md`.
60
+ - Review the updates and pull a couple highlights to the top.
61
+ - Bump `dp_wizard/VERSION`, and add the new number at the top of the `CHANGELOG.md`.
62
+ - Commit your changes, make a PR, and merge this branch to main.
63
+ - Update `main` with the latest changes: `git checkout main; git pull`
64
+ - Publish: `flit publish --pypirc .pypirc`
65
+
@@ -0,0 +1,8 @@
1
+ dp_wizard_templates/VERSION,sha256=atlhOkVXmNbZLl9fOQq0uqcFlryGntaxf1zdKyhjXwY,5
2
+ dp_wizard_templates/__init__.py,sha256=E2xrnvZGY24pU3PCryH4TmvhNYsLmxXCtfvIcYNbTYw,126
3
+ dp_wizard_templates/code_template.py,sha256=GW4wcAguGRFEE_eKLPedjmsklZR9dtx4wn8CYq-avh8,4389
4
+ dp_wizard_templates/converters.py,sha256=l-MYAiTAH_isjggH03DKlgV-j-uxc0CyW1hiCLD39yQ,3957
5
+ dp_wizard_templates-0.1.0.dist-info/licenses/LICENSE,sha256=FDrIMeZPiT4g_4w0i1Ec4Bc8h9wfNytroheQN4508yU,1063
6
+ dp_wizard_templates-0.1.0.dist-info/WHEEL,sha256=Dyt6SBfaasWElUrURkknVFAZDHSTwxg3PaTza7RSbkY,100
7
+ dp_wizard_templates-0.1.0.dist-info/METADATA,sha256=ZMvavNpdRK7GvyB-j4ht2JWSzPjhIC52n7VHVCyX70c,2139
8
+ dp_wizard_templates-0.1.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.