Skip to content

Commit d6ea00a

Browse files
JWock82JWock82
authored andcommitted
Support for html reports, help setting up pdfkit and wkhtmltopdf, and error logging
1 parent 27dce91 commit d6ea00a

File tree

1 file changed

+207
-48
lines changed

1 file changed

+207
-48
lines changed

Pynite/Reporting.py

Lines changed: 207 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,74 +1,233 @@
1-
# Import libraries necessary for report printing
2-
from __future__ import annotations # Allows more recent type hints features
1+
"""
2+
reporting.py
3+
------------
4+
Generates HTML or PDF reports for Pynite FE models.
5+
6+
Key points:
7+
- Uses Jinja2 to render an HTML template.
8+
- Optionally converts that HTML into a PDF using pdfkit + wkhtmltopdf.
9+
- Provides a single entry point: `create_report()`.
10+
"""
11+
12+
# --- Standard library imports ---
13+
import os
14+
import platform
15+
import shutil
16+
import logging
17+
from pathlib import Path
318
from typing import TYPE_CHECKING
419

20+
# --- Third-party imports ---
521
from jinja2 import Environment, PackageLoader
6-
import pdfkit
722

823
if TYPE_CHECKING:
924
from Pynite import FEModel3D
1025

11-
# Determine the filepath to the local Pynite installation
12-
from pathlib import Path
26+
27+
# =============================================================================
28+
# Logging Setup
29+
# =============================================================================
30+
31+
# Create a module-level logger
32+
logger = logging.getLogger(__name__)
33+
34+
# Only configure logging if this file is run directly.
35+
# When imported as a library, the caller controls logging configuration.
36+
if __name__ == "__main__" and not logger.handlers:
37+
logging.basicConfig(
38+
level=logging.INFO,
39+
format="%(levelname)s [%(name)s]: %(message)s"
40+
)
41+
42+
43+
# =============================================================================
44+
# Jinja2 Template Setup
45+
# =============================================================================
46+
47+
# Determine the directory where this file lives
1348
path = Path(__file__).parent
1449

15-
# Set up the jinja2 template environment
50+
# Set up Jinja2 environment
51+
# - PackageLoader tells Jinja2 where to find the HTML templates
1652
env = Environment(
1753
loader=PackageLoader('Pynite', '.'),
1854
)
1955

20-
# Get the report template
56+
# Load the main HTML report template
2157
template = env.get_template('Report_Template.html')
2258

23-
def create_report(model: FEModel3D, output_filepath:Path=path/'./Pynite Report.pdf', **kwargs):
24-
"""Creates a pdf report for a given finite element model.
59+
60+
# =============================================================================
61+
# Helper: Find wkhtmltopdf
62+
# =============================================================================
63+
64+
def get_wkhtmltopdf_path() -> str | None:
65+
"""
66+
Try to locate wkhtmltopdf across platforms.
67+
Returns the full path if found, else None.
68+
"""
69+
70+
# 1. First check PATH (most common case)
71+
path = shutil.which("wkhtmltopdf")
72+
if path:
73+
return path
74+
75+
# 2. Check platform-specific common install locations
76+
system = platform.system()
77+
78+
if system == "Windows":
79+
candidates = [
80+
r"C:\Program Files\wkhtmltopdf\bin\wkhtmltopdf.exe",
81+
r"C:\Program Files (x86)\wkhtmltopdf\bin\wkhtmltopdf.exe",
82+
]
83+
elif system == "Darwin": # macOS
84+
candidates = [
85+
"/usr/local/bin/wkhtmltopdf",
86+
"/opt/homebrew/bin/wkhtmltopdf",
87+
]
88+
elif system == "Linux":
89+
candidates = [
90+
"/usr/bin/wkhtmltopdf",
91+
"/usr/local/bin/wkhtmltopdf",
92+
]
93+
else:
94+
candidates = []
95+
96+
for candidate in candidates:
97+
if os.path.isfile(candidate):
98+
return candidate
99+
100+
# None found
101+
return None
102+
103+
104+
# =============================================================================
105+
# PDFKit Setup
106+
# =============================================================================
107+
108+
try:
109+
import pdfkit
110+
111+
wkhtmltopdf_path = get_wkhtmltopdf_path()
112+
if wkhtmltopdf_path:
113+
config = pdfkit.configuration(executable=wkhtmltopdf_path)
114+
logger.info(f"wkhtmltopdf found at: {wkhtmltopdf_path}")
115+
else:
116+
raise FileNotFoundError("wkhtmltopdf not found on system. Install wkhtmltopdf.")
117+
except ImportError:
118+
logger.error("pdfkit not installed. Run: pip install pdfkit")
119+
pdfkit = None
120+
config = None
121+
except Exception as e:
122+
logger.error(f"PDFKit setup failed: {e}")
123+
pdfkit = None
124+
config = None
125+
126+
127+
# =============================================================================
128+
# Main Report Function
129+
# =============================================================================
130+
131+
def create_report(model: FEModel3D,
132+
output_filepath: Path | str = path / 'Pynite Report.pdf',
133+
format: str = 'pdf',
134+
**kwargs) -> None:
135+
"""
136+
Creates a report for a given finite element model.
25137
26138
:param model: The model to generate the report for.
27-
:type model: ``FEModel3D``
28-
:param output_filepath: The filepath to send the report to. Defaults to 'Pynite Report.pdf' in your ``PYTHONPATH``
29-
:type output_filepath: ``str``, optional
30-
:param \\**kwargs: See below for a list of valid arguments.
31-
:Keyword Arguments:
32-
* *node_table* (``bool``) -- Set to ``True`` if you want node data included in the report. Defaults to ``True``.
33-
* *member_table* (``bool``) -- Set to ``True`` if you want member data included in the report. Defaults to ``True``.
34-
* *member_releases* (``bool``) -- Set to ``True`` if you want member end release data included in the report. Defaults to ``True``.
35-
* *plate_table* (``bool``) -- Set to ``True if you want plate/quad data included in the report. Defaults to ``True``.
36-
* *node_reactions* (``bool``) -- Set to ``True`` if you want node reactions included in the report. Defaults to ``True``
37-
* *node_displacements* (``bool``) -- Set to ``True`` if you want node displacement results included in the report. Defaults to ``True``.
38-
* *member_end_forces* (``bool``) -- Set to ``True`` if you want member end force results included in the report. Defaults to ``True``.
39-
* *member_internal_forces* (``bool``) -- Set to ``True`` if you want member internal force results included in the report. Defaults to ``True``
40-
* *plate_corner_forces* (``bool``) -- Set to ``True`` if you want plate/quad corner force results (out-of-plane/bending) included in the report. Defaults to ``True``.
41-
* *plate_center_forces* (``bool``) -- Set to ``True`` if you want plate/quad center force results (out-of-plane/bending) included in the report. Defaults to ``True``.
42-
* *plate_corner_membrane* (``bool``) -- Set to ``True`` if you want plate/quad corner membrane (in-plane) force results included in the report. Defaults to ``True``.
43-
* *plate_center_membrane* (``bool``) -- Set to ``True`` if you want plate/quad center membrane (in-plane) force results included in the report. Defaults to ``True``.
139+
:param output_filepath: Filepath for the output file.
140+
:param format: Output format: 'pdf' or 'html'.
141+
:param kwargs: Report options (see below).
142+
143+
Keyword Arguments:
144+
* node_table (bool): Include node data (default: True).
145+
* member_table (bool): Include member data (default: True).
146+
* member_releases (bool): Include member end releases (default: True).
147+
* plate_table (bool): Include plate/quad data (default: True).
148+
* node_reactions (bool): Include node reactions (default: True).
149+
* node_displacements (bool): Include node displacements (default: True).
150+
* member_end_forces (bool): Include member end forces (default: True).
151+
* member_internal_forces (bool): Include member internal forces (default: True).
152+
* plate_corner_forces (bool): Include plate corner out-of-plane forces (default: True).
153+
* plate_center_forces (bool): Include plate center out-of-plane forces (default: True).
154+
* plate_corner_membrane (bool): Include plate corner in-plane forces (default: True).
155+
* plate_center_membrane (bool): Include plate center in-plane forces (default: True).
44156
"""
45157

46-
# Create default report settings
47-
if 'node_table' not in kwargs: kwargs['node_table'] = True
48-
if 'member_table' not in kwargs: kwargs['member_table'] = True
49-
if 'member_releases' not in kwargs: kwargs['member_releases'] = True
50-
if 'plate_table' not in kwargs: kwargs['plate_table'] = True
51-
if 'node_reactions' not in kwargs: kwargs['node_reactions'] = True
52-
if 'node_displacements' not in kwargs: kwargs['node_displacements'] = True
53-
if 'member_end_forces' not in kwargs: kwargs['member_end_forces'] = True
54-
if 'member_internal_forces' not in kwargs: kwargs['member_internal_forces'] = True
55-
if 'plate_corner_forces' not in kwargs: kwargs['plate_corner_forces'] = True
56-
if 'plate_center_forces' not in kwargs: kwargs['plate_center_forces'] = True
57-
if 'plate_corner_membrane' not in kwargs: kwargs['plate_corner_membrane'] = True
58-
if 'plate_center_membrane' not in kwargs: kwargs['plate_center_membrane'] = True
59-
60-
# Pass the dictionaries to the report template
158+
# -------------------------------------------------------------------------
159+
# Default Settings
160+
# -------------------------------------------------------------------------
161+
defaults = {
162+
'node_table': True,
163+
'member_table': True,
164+
'member_releases': True,
165+
'plate_table': True,
166+
'node_reactions': True,
167+
'node_displacements': True,
168+
'member_end_forces': True,
169+
'member_internal_forces': True,
170+
'plate_corner_forces': True,
171+
'plate_center_forces': True,
172+
'plate_corner_membrane': True,
173+
'plate_center_membrane': True,
174+
}
175+
176+
# Fill missing kwargs with defaults
177+
for key, val in defaults.items():
178+
kwargs.setdefault(key, val)
179+
180+
# -------------------------------------------------------------------------
181+
# Add Model Data to Context
182+
# -------------------------------------------------------------------------
61183
kwargs['nodes'] = model.nodes.values()
62184
kwargs['members'] = model.members.values()
63185
kwargs['plates'] = model.plates.values()
64186
kwargs['quads'] = model.quads.values()
65187
kwargs['load_combos'] = model.load_combos.values()
66188

67-
# Create the report HTML using jinja2
68-
HTML = template.render(**kwargs)
189+
# -------------------------------------------------------------------------
190+
# Render HTML from Template
191+
# -------------------------------------------------------------------------
192+
try:
193+
HTML = template.render(**kwargs)
194+
except Exception as e:
195+
logger.error(f"Template rendering failed: {e}")
196+
raise RuntimeError("Failed to render report template.") from e
197+
198+
# -------------------------------------------------------------------------
199+
# Export Report
200+
# -------------------------------------------------------------------------
201+
try:
202+
if format == 'pdf':
203+
if pdfkit is None or config is None:
204+
raise RuntimeError("pdfkit/wkhtmltopdf not available. Cannot create PDF report.")
205+
206+
pdfkit.from_string(
207+
HTML,
208+
str(output_filepath),
209+
css=str(path / 'MainStyleSheet.css'),
210+
configuration=config
211+
)
212+
logger.info(f"PDF report generated at: {output_filepath}")
213+
214+
elif format == 'html':
215+
with open(output_filepath, "w", encoding="utf-8") as file:
216+
file.write(HTML)
217+
logger.info(f"HTML report generated at: {output_filepath}")
218+
219+
else:
220+
raise ValueError("Invalid format. Use 'pdf' or 'html'.")
221+
222+
except Exception as e:
223+
logger.error(f"Report creation failed: {e}")
224+
raise
225+
226+
227+
# =============================================================================
228+
# Script Entry Point
229+
# =============================================================================
69230

70-
# Convert the HTML to pdf format using PDFKit
71-
# Note that wkhtmltopdf must be installed on the system, and included on the system's PATH environment variable for PDFKit to work
72-
pdfkit.from_string(HTML, output_filepath, css=path / './MainStyleSheet.css')
73-
74-
return
231+
if __name__ == "__main__":
232+
# Example: Run a demo if this file is executed directly
233+
logger.info("This module is intended to be imported, not run directly.")

0 commit comments

Comments
 (0)