Skip to content

Commit f14aaf7

Browse files
authored
Merge pull request #9 from CDCgov/ap66_adding_reporting_functionality
Ap66_adding_reporting_functionality
2 parents f25a10e + 3d0f6bc commit f14aaf7

21 files changed

Lines changed: 1604 additions & 74 deletions

.coveragerc

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
[run]
2+
omit =
3+
*/reports/*
4+
*/transform_templates/*

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,3 +380,5 @@ docs/site/
380380
*.lock
381381
.vscode
382382
tmp/
383+
*/logs/
384+
logs/

.pre-commit-config.yaml

Lines changed: 65 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,65 @@
1-
repos:
2-
#####
3-
# Basic file cleanliness
4-
- repo: https://github.com/pre-commit/pre-commit-hooks
5-
rev: v4.5.0
6-
hooks:
7-
- id: check-added-large-files
8-
- id: check-yaml
9-
- id: check-toml
10-
- id: end-of-file-fixer
11-
- id: mixed-line-ending
12-
- id: trailing-whitespace
13-
#####
14-
# Python
15-
- repo: https://github.com/astral-sh/ruff-pre-commit
16-
rev: v0.6.8
17-
hooks:
18-
# Sort imports
19-
- id: ruff
20-
args: ['check', '--select', 'I', '--fix']
21-
# Run the linter
22-
- id: ruff
23-
args: ['--line-length', '79']
24-
# Run the formatter
25-
- id: ruff-format
26-
args: ['--line-length', '79']
27-
#####
28-
# R
29-
# - repo: https://github.com/lorenzwalthert/precommit
30-
# rev: v0.4.2
31-
# hooks:
32-
# - id: style-files
33-
# - id: lintr
34-
#####
35-
# Java
36-
# - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks
37-
# rev: v2.11.0
38-
# hooks:
39-
# - id: pretty-format-java
40-
# args: [--aosp,--autofix]
41-
#####
42-
# Julia
43-
# Due to lack of first-class Julia support, this needs Julia local install
44-
# and JuliaFormatter.jl installed in the library
45-
# - repo: https://github.com/domluna/JuliaFormatter.jl
46-
# rev: v1.0.39
47-
# hooks:
48-
# - id: julia-formatter
49-
#####
50-
# Secrets
51-
- repo: https://github.com/Yelp/detect-secrets
52-
rev: v1.4.0
53-
hooks:
54-
- id: detect-secrets
55-
args: ['--baseline', '.secrets.baseline']
56-
exclude: package.lock.json
1+
repos:
2+
#####
3+
# Basic file cleanliness
4+
- repo: https://github.com/pre-commit/pre-commit-hooks
5+
rev: v4.5.0
6+
hooks:
7+
- id: check-added-large-files
8+
- id: check-yaml
9+
- id: check-toml
10+
- id: end-of-file-fixer
11+
- id: mixed-line-ending
12+
- id: trailing-whitespace
13+
#####
14+
# Python
15+
- repo: https://github.com/astral-sh/ruff-pre-commit
16+
rev: v0.6.8
17+
hooks:
18+
# Sort imports
19+
- id: ruff
20+
args: ['check', '--select', 'I', '--fix']
21+
# Run the linter
22+
- id: ruff
23+
args: ['--line-length', '79']
24+
# Run the formatter
25+
- id: ruff-format
26+
args: ['--line-length', '79']
27+
#####
28+
# R
29+
# - repo: https://github.com/lorenzwalthert/precommit
30+
# rev: v0.4.2
31+
# hooks:
32+
# - id: style-files
33+
# - id: lintr
34+
#####
35+
# Java
36+
# - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks
37+
# rev: v2.11.0
38+
# hooks:
39+
# - id: pretty-format-java
40+
# args: [--aosp,--autofix]
41+
#####
42+
# Julia
43+
# Due to lack of first-class Julia support, this needs Julia local install
44+
# and JuliaFormatter.jl installed in the library
45+
# - repo: https://github.com/domluna/JuliaFormatter.jl
46+
# rev: v1.0.39
47+
# hooks:
48+
# - id: julia-formatter
49+
#####
50+
# Secrets
51+
- repo: https://github.com/Yelp/detect-secrets
52+
rev: v1.4.0
53+
hooks:
54+
- id: detect-secrets
55+
args: ['--baseline', '.secrets.baseline']
56+
exclude: package.lock.json
57+
58+
- repo: local
59+
hooks:
60+
- id: jupyter-nb-clear-output
61+
name: Clear Jupyter Notebook outputs
62+
files: \.ipynb$
63+
stages: [pre-commit]
64+
language: system
65+
entry: jupyter nbconvert --ClearOutputPreprocessor.enabled=True --inplace

MANIFEST.in

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
recursive-include cfa/dataops/reporting/reports
2+
recursive-include cfa/dataops/etl/transform_templates

cfa/dataops/catalog.py

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,24 +19,29 @@
1919

2020
_here_dir = os.path.split(os.path.abspath(__file__))[0]
2121
_dataset_config_paths = glob.glob(
22-
os.path.join(_here_dir, "datasets", "**", "*.toml")
22+
os.path.join(_here_dir, "**", "*.toml"), recursive=True
2323
)
2424

2525
name_paths = []
2626
dataset_configs = {}
2727
for cp_i in _dataset_config_paths:
28-
ns_pre = cp_i.split(f"datasets{os.sep}")[-1].split(os.sep)[:-1]
29-
with open(cp_i, "rb") as f:
30-
config = tomli.load(f)
31-
config["_metadata"] = dict(filename=os.path.split(cp_i)[1])
32-
validate_dataset_config(config)
33-
current = dataset_configs
34-
for part in ns_pre:
35-
if part not in current:
36-
current[part] = {}
37-
current = current[part]
38-
current[config["properties"]["name"]] = config
39-
name_paths.append(".".join(ns_pre + [config["properties"]["name"]]))
28+
if cp_i.startswith(os.path.join(_here_dir, "datasets")):
29+
ns_pre = cp_i.split(f"datasets{os.sep}")[-1].split(os.sep)[:-1]
30+
with open(cp_i, "rb") as f:
31+
config = tomli.load(f)
32+
config["_metadata"] = dict(
33+
filename=os.path.split(cp_i)[1], config_path=cp_i
34+
)
35+
validate_dataset_config(config)
36+
current = dataset_configs
37+
for part in ns_pre:
38+
if part not in current:
39+
current[part] = {}
40+
current = current[part]
41+
current[config["properties"]["name"]] = config
42+
name_paths.append(
43+
".".join(ns_pre + [config["properties"]["name"]])
44+
)
4045

4146
verify_no_repeats(name_paths)
4247

cfa/dataops/reporting/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""Shortcut imports for reporting."""
2+
3+
from .catalog import get_report_catalog
4+
5+
reportcat = get_report_catalog()
6+
7+
__all__ = ["reportcat"]

cfa/dataops/reporting/catalog.py

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import glob
2+
import os
3+
from pprint import pprint
4+
from tempfile import TemporaryDirectory
5+
from types import SimpleNamespace
6+
from typing import Any
7+
8+
import papermill as pm
9+
from cfa_azure.blob_helpers import write_blob_stream
10+
from nbconvert import HTMLExporter
11+
from traitlets.config import Config
12+
13+
_here_dir = os.path.split(os.path.abspath(__file__))[0]
14+
_report_paths = glob.glob(
15+
os.path.join(_here_dir, "**", "**.ipynb"), recursive=True
16+
)
17+
18+
19+
def remove_ws_and_nonalpha(s: str) -> str:
20+
"""Remove whitespace and non-alphanumeric characters from a string.
21+
22+
Args:
23+
s (str): The input string.
24+
25+
Returns:
26+
str: The cleaned string with whitespace and non-alphanumeric characters removed.
27+
28+
Example:
29+
>>> remove_ws_and_nonalpha("Hello World! 123.ipynb")
30+
'hello_world_123_ipynb'
31+
"""
32+
s = s.replace(" ", "_").replace(".", "_").lower()
33+
return "".join(c for c in s if c.isalnum() or c == "_")
34+
35+
36+
# get the namespace mapping for the available reports
37+
report_ns_map = {}
38+
for rp_i in _report_paths:
39+
if rp_i.startswith(os.path.join(_here_dir, "reports")):
40+
ns_list = [
41+
remove_ws_and_nonalpha(i)
42+
for i in rp_i.split(f"reports{os.sep}")[-1].split(os.sep)
43+
]
44+
current = report_ns_map
45+
for part in ns_list[:-1]:
46+
if part not in current:
47+
current[part] = {}
48+
current = current[part]
49+
current[ns_list[-1]] = rp_i
50+
51+
52+
def nb_to_html(nb_path: str) -> str:
53+
"""Convert the executed notebook to HTML format.
54+
55+
Returns:
56+
str: The HTML content of the notebook.
57+
"""
58+
c = Config()
59+
c.TagRemovePreprocessor.remove_cell_tags = ("remove_cell",)
60+
c.TagRemovePreprocessor.remove_input_tags = ("remove_input",)
61+
c.TagRemovePreprocessor.remove_all_outputs_tags = ("remove_outputs",)
62+
c.TagRemovePreprocessor.enabled = True
63+
c.HTMLExporter.preprocessors = [
64+
"nbconvert.preprocessors.TagRemovePreprocessor"
65+
]
66+
c.TemplateExporter.extra_template_basedirs = os.path.join(
67+
_here_dir, "jupyter_templates"
68+
)
69+
body, _ = HTMLExporter(template_name="lab", config=c).from_filename(
70+
nb_path
71+
)
72+
return body
73+
74+
75+
class NotebookEndpoint:
76+
"""Class to handle the execution and conversion of Jupyter notebooks to HTML."""
77+
78+
def __init__(self, notebook_path: str):
79+
self.notebook_path = notebook_path
80+
81+
def get_params(self) -> dict:
82+
"""Get the parameters for the notebook execution."""
83+
return pm.inspect_notebook(self.notebook_path)
84+
85+
def print_params(self):
86+
"""Pretty print the parameters of the notebook."""
87+
pprint(self.get_params())
88+
89+
def nb_to_html_str(self, **kwargs) -> str:
90+
"""Convert the notebook to HTML format and optionally save it to a file.
91+
92+
Args:
93+
html_out_path (str): Path to save the HTML output. If None, does not save.
94+
**kwargs: Parameters to replace defaults in notebook.
95+
96+
Returns:
97+
str: The HTML content of the notebook.
98+
"""
99+
with TemporaryDirectory() as temp_dir:
100+
out_file_path = os.path.join(temp_dir, "output.ipynb")
101+
pm.execute_notebook(
102+
input_path=self.notebook_path,
103+
output_path=out_file_path,
104+
parameters=kwargs,
105+
progress_bar=True,
106+
)
107+
html_content = nb_to_html(out_file_path)
108+
109+
return html_content
110+
111+
def nb_to_html_file(self, html_out_path: str, **kwargs) -> None:
112+
"""Convert the notebook to HTML format.
113+
114+
Args:
115+
**kwargs: Parameters to replace defaults in notebook.
116+
117+
Returns:
118+
None, saves the html content to a file.
119+
"""
120+
assert html_out_path.endswith(
121+
".html"
122+
), "Output path must end with .html"
123+
html_content = self.nb_to_html_str(**kwargs)
124+
os.makedirs(os.path.dirname(html_out_path), exist_ok=True)
125+
with open(html_out_path, "w") as f:
126+
f.write(html_content)
127+
print(f"HTML report saved to {os.path.abspath(html_out_path)}")
128+
129+
def nb_to_html_blob(
130+
self, blob_account: str, blob_container: str, blob_path: str, **kwargs
131+
) -> None:
132+
"""Convert the notebook to HTML format save to blob storage.
133+
134+
Args:
135+
**kwargs: Parameters to replace defaults in notebook.
136+
137+
Returns:
138+
None, saves the HTML content to a blob storage.
139+
"""
140+
assert blob_path.endswith(".html"), "Output path must end with .html"
141+
html_content = self.nb_to_html_str(**kwargs)
142+
write_blob_stream(
143+
account_name=blob_account,
144+
container_name=blob_container,
145+
blob_url=blob_path,
146+
data=html_content.encode("utf-8"),
147+
)
148+
149+
150+
def report_dict_to_sn(d: Any) -> SimpleNamespace:
151+
"""Simple recursive namespace construction
152+
153+
Args:
154+
d (Any): a dict, list or other
155+
156+
Returns:
157+
SimpleNamespace: namespace representation
158+
"""
159+
x = SimpleNamespace()
160+
_ = [
161+
setattr(
162+
x,
163+
k,
164+
NotebookEndpoint(notebook_path=v)
165+
if k.endswith("ipynb")
166+
else report_dict_to_sn(v)
167+
if isinstance(v, dict)
168+
else [report_dict_to_sn(e) for e in v]
169+
if isinstance(v, list)
170+
else v,
171+
)
172+
for k, v in d.items()
173+
]
174+
return x
175+
176+
177+
def get_report_catalog() -> SimpleNamespace:
178+
"""Get the report catalog as a SimpleNamespace object.
179+
180+
Returns:
181+
SimpleNamespace: The report catalog.
182+
"""
183+
return report_dict_to_sn(report_ns_map)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{%- macro cell_id_anchor(cell) -%}
2+
{% if cell.id | length > 0 -%}
3+
id="{{ ('cell-id=' ~ cell.id) | escape_html -}}"
4+
{%- endif %}
5+
{%- endmacro %}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{%- macro celltags(cell) -%}
2+
{% if cell.metadata.tags | length > 0 -%}
3+
{% for tag in (cell.metadata.tags) -%}
4+
{{ (' celltag_' ~ tag) | escape_html -}}
5+
{%- endfor -%}
6+
{%- endif %}
7+
{%- endmacro %}

0 commit comments

Comments
 (0)