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.
- dp_wizard_templates/VERSION +1 -0
- dp_wizard_templates/__init__.py +6 -0
- dp_wizard_templates/code_template.py +127 -0
- dp_wizard_templates/converters.py +119 -0
- dp_wizard_templates-0.1.0.dist-info/METADATA +65 -0
- dp_wizard_templates-0.1.0.dist-info/RECORD +8 -0
- dp_wizard_templates-0.1.0.dist-info/WHEEL +5 -0
- dp_wizard_templates-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.1.0
|
|
@@ -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
|
+
[](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,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.
|