diagnostics-framework 0.1.0__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,8 @@
1
+ __version__ = "0.1.0"
2
+
3
+ from diagnostics_framework.registry import register_system, register_test, register_plot, register_report, registry
4
+ from diagnostics_framework.models import DiagnosticResult, DiagnosticStatus, DiagnosticSummary
5
+ from diagnostics_framework.runner import run_diagnostics, generate_plot, generate_report
6
+
7
+ # Auto-discover and register all system plugins
8
+ import diagnostics_framework.systems # noqa: F401
@@ -0,0 +1,185 @@
1
+ import io
2
+ import json
3
+
4
+ import pandas as pd
5
+ import streamlit as st
6
+
7
+ # Import systems to trigger decorator registration
8
+ import diagnostics_framework.systems # noqa: F401
9
+
10
+ from diagnostics_framework.models import DiagnosticStatus
11
+ from diagnostics_framework.registry import registry
12
+ from diagnostics_framework.runner import run_diagnostics, generate_plot, generate_report
13
+
14
+
15
+ STATUS_COLORS = {
16
+ DiagnosticStatus.PASS: "#28a745",
17
+ DiagnosticStatus.FAIL: "#dc3545",
18
+ DiagnosticStatus.WARNING: "#ffc107",
19
+ DiagnosticStatus.ERROR: "#6c757d",
20
+ }
21
+
22
+ STATUS_ICONS = {
23
+ DiagnosticStatus.PASS: "PASS",
24
+ DiagnosticStatus.FAIL: "FAIL",
25
+ DiagnosticStatus.WARNING: "WARN",
26
+ DiagnosticStatus.ERROR: "ERR",
27
+ }
28
+
29
+
30
+ def load_data(uploaded_file) -> pd.DataFrame | dict | None:
31
+ """Attempt to load an uploaded file as a DataFrame or dict."""
32
+ if uploaded_file is None:
33
+ return None
34
+
35
+ name = uploaded_file.name.lower()
36
+ try:
37
+ if name.endswith(".csv"):
38
+ return pd.read_csv(uploaded_file)
39
+ elif name.endswith(".json"):
40
+ content = json.load(uploaded_file)
41
+ if isinstance(content, list) and all(isinstance(r, dict) for r in content):
42
+ return pd.DataFrame(content)
43
+ return content
44
+ elif name.endswith((".xls", ".xlsx")):
45
+ return pd.read_excel(uploaded_file)
46
+ elif name.endswith(".parquet"):
47
+ return pd.read_parquet(io.BytesIO(uploaded_file.read()))
48
+ else:
49
+ return uploaded_file.read().decode("utf-8", errors="replace")
50
+ except Exception as e:
51
+ st.error(f"Failed to load file: {e}")
52
+ return None
53
+
54
+
55
+ def render_results(summary):
56
+ """Render diagnostic results as a styled table."""
57
+ st.subheader("Diagnostic Results")
58
+
59
+ cols = st.columns(4)
60
+ cols[0].metric("Total", len(summary.results))
61
+ cols[1].metric("Pass", summary.pass_count)
62
+ cols[2].metric("Fail", summary.fail_count)
63
+ cols[3].metric("Warn / Error", summary.warning_count + summary.error_count)
64
+
65
+ for result in summary.results:
66
+ color = STATUS_COLORS[result.status]
67
+ icon = STATUS_ICONS[result.status]
68
+ with st.container():
69
+ st.markdown(
70
+ f"<div style='border-left: 4px solid {color}; padding: 8px 12px; margin: 4px 0;'>"
71
+ f"<strong>[{icon}]</strong> <strong>{result.test_name}</strong><br/>"
72
+ f"{result.message}"
73
+ f"</div>",
74
+ unsafe_allow_html=True,
75
+ )
76
+ if result.details:
77
+ with st.expander("Details"):
78
+ st.json(result.details)
79
+
80
+
81
+ def render_plots(system_name, data):
82
+ """Render plot buttons and display generated plots."""
83
+ st.subheader("Plots")
84
+ plots = registry.get_plots(system_name)
85
+ if not plots:
86
+ st.info("No plots registered for this system.")
87
+ return
88
+
89
+ for plot_info in plots:
90
+ with st.expander(f"{plot_info.name} — {plot_info.description}"):
91
+ try:
92
+ fig = generate_plot(system_name, plot_info.name, data)
93
+ st.pyplot(fig)
94
+ except Exception as e:
95
+ st.error(f"Error generating plot: {e}")
96
+
97
+
98
+ def render_reports(system_name, data):
99
+ """Render report buttons and display generated reports."""
100
+ st.subheader("Reports")
101
+ reports = registry.get_reports(system_name)
102
+ if not reports:
103
+ st.info("No reports registered for this system.")
104
+ return
105
+
106
+ for report_info in reports:
107
+ with st.expander(f"{report_info.name} — {report_info.description}"):
108
+ try:
109
+ report_text = generate_report(system_name, report_info.name, data)
110
+ st.markdown(report_text)
111
+ except Exception as e:
112
+ st.error(f"Error generating report: {e}")
113
+
114
+
115
+ def main():
116
+ st.set_page_config(page_title="Diagnostics Dashboard", layout="wide")
117
+ st.title("Diagnostics Dashboard")
118
+
119
+ # --- Sidebar ---
120
+ with st.sidebar:
121
+ st.header("Configuration")
122
+
123
+ systems = registry.get_systems()
124
+ if not systems:
125
+ st.warning("No systems registered. Add a system module to diagnostics_framework/systems/.")
126
+ return
127
+
128
+ system_names = list(systems.keys())
129
+ selected = st.selectbox(
130
+ "Select System",
131
+ system_names,
132
+ format_func=lambda s: f"{s} — {systems[s].description}" if systems[s].description else s,
133
+ )
134
+
135
+ st.markdown("---")
136
+ st.subheader("Upload Data")
137
+ uploaded_file = st.file_uploader(
138
+ "Choose a file",
139
+ type=["csv", "json", "xlsx", "xls", "parquet", "txt"],
140
+ )
141
+
142
+ run_button = st.button("Run Diagnostics", type="primary", use_container_width=True)
143
+
144
+ # --- Main area ---
145
+ if uploaded_file is not None:
146
+ data = load_data(uploaded_file)
147
+ if data is None:
148
+ return
149
+
150
+ if isinstance(data, pd.DataFrame):
151
+ with st.expander("Preview uploaded data"):
152
+ st.dataframe(data.head(50))
153
+
154
+ if run_button:
155
+ with st.spinner("Running diagnostics..."):
156
+ summary = run_diagnostics(selected, data)
157
+ st.session_state["last_summary"] = summary
158
+ st.session_state["last_data"] = data
159
+ st.session_state["last_system"] = selected
160
+
161
+ if "last_summary" in st.session_state and st.session_state.get("last_system") == selected:
162
+ tab_results, tab_plots, tab_reports = st.tabs(["Results", "Plots", "Reports"])
163
+ with tab_results:
164
+ render_results(st.session_state["last_summary"])
165
+ with tab_plots:
166
+ render_plots(selected, st.session_state.get("last_data", data))
167
+ with tab_reports:
168
+ render_reports(selected, st.session_state.get("last_data", data))
169
+ else:
170
+ st.info("Upload a data file in the sidebar and click **Run Diagnostics** to begin.")
171
+
172
+ # Show registered info for the selected system
173
+ if "selected" not in dir():
174
+ return
175
+ st.markdown(f"### System: {selected}")
176
+ tests = registry.get_tests(selected)
177
+ plots = registry.get_plots(selected)
178
+ reports = registry.get_reports(selected)
179
+ st.markdown(f"- **{len(tests)}** diagnostic test(s) registered")
180
+ st.markdown(f"- **{len(plots)}** plot(s) registered")
181
+ st.markdown(f"- **{len(reports)}** report(s) registered")
182
+
183
+
184
+ if __name__ == "__main__":
185
+ main()
@@ -0,0 +1,71 @@
1
+ from dataclasses import dataclass, field
2
+ from datetime import datetime
3
+ from enum import Enum
4
+ from typing import Any, Callable
5
+
6
+
7
+ class DiagnosticStatus(Enum):
8
+ PASS = "pass"
9
+ FAIL = "fail"
10
+ WARNING = "warning"
11
+ ERROR = "error"
12
+
13
+
14
+ @dataclass
15
+ class DiagnosticResult:
16
+ test_name: str
17
+ status: DiagnosticStatus
18
+ message: str
19
+ details: dict[str, Any] = field(default_factory=dict)
20
+ timestamp: datetime = field(default_factory=datetime.now)
21
+
22
+
23
+ @dataclass
24
+ class SystemInfo:
25
+ name: str
26
+ description: str = ""
27
+ version: str = "0.1.0"
28
+
29
+
30
+ @dataclass
31
+ class TestInfo:
32
+ name: str
33
+ description: str
34
+ fn: Callable
35
+
36
+
37
+ @dataclass
38
+ class PlotInfo:
39
+ name: str
40
+ description: str
41
+ fn: Callable
42
+
43
+
44
+ @dataclass
45
+ class ReportInfo:
46
+ name: str
47
+ description: str
48
+ fn: Callable
49
+
50
+
51
+ @dataclass
52
+ class DiagnosticSummary:
53
+ system_name: str
54
+ results: list[DiagnosticResult]
55
+ timestamp: datetime = field(default_factory=datetime.now)
56
+
57
+ @property
58
+ def pass_count(self) -> int:
59
+ return sum(1 for r in self.results if r.status == DiagnosticStatus.PASS)
60
+
61
+ @property
62
+ def fail_count(self) -> int:
63
+ return sum(1 for r in self.results if r.status == DiagnosticStatus.FAIL)
64
+
65
+ @property
66
+ def warning_count(self) -> int:
67
+ return sum(1 for r in self.results if r.status == DiagnosticStatus.WARNING)
68
+
69
+ @property
70
+ def error_count(self) -> int:
71
+ return sum(1 for r in self.results if r.status == DiagnosticStatus.ERROR)
@@ -0,0 +1,79 @@
1
+ from diagnostics_framework.models import SystemInfo, TestInfo, PlotInfo, ReportInfo
2
+
3
+
4
+ class DiagnosticsRegistry:
5
+ """Singleton registry for systems, tests, plots, and reports."""
6
+
7
+ _instance = None
8
+
9
+ def __new__(cls):
10
+ if cls._instance is None:
11
+ cls._instance = super().__new__(cls)
12
+ cls._instance._systems = {}
13
+ cls._instance._tests = {}
14
+ cls._instance._plots = {}
15
+ cls._instance._reports = {}
16
+ return cls._instance
17
+
18
+ def add_system(self, name: str, description: str = "", version: str = "0.1.0"):
19
+ self._systems[name] = SystemInfo(name=name, description=description, version=version)
20
+ self._tests.setdefault(name, [])
21
+ self._plots.setdefault(name, [])
22
+ self._reports.setdefault(name, [])
23
+
24
+ def add_test(self, system: str, test_info: TestInfo):
25
+ self._tests.setdefault(system, []).append(test_info)
26
+
27
+ def add_plot(self, system: str, plot_info: PlotInfo):
28
+ self._plots.setdefault(system, []).append(plot_info)
29
+
30
+ def add_report(self, system: str, report_info: ReportInfo):
31
+ self._reports.setdefault(system, []).append(report_info)
32
+
33
+ def get_systems(self) -> dict[str, SystemInfo]:
34
+ return dict(self._systems)
35
+
36
+ def get_tests(self, system: str) -> list[TestInfo]:
37
+ return list(self._tests.get(system, []))
38
+
39
+ def get_plots(self, system: str) -> list[PlotInfo]:
40
+ return list(self._plots.get(system, []))
41
+
42
+ def get_reports(self, system: str) -> list[ReportInfo]:
43
+ return list(self._reports.get(system, []))
44
+
45
+
46
+ # Module-level singleton
47
+ registry = DiagnosticsRegistry()
48
+
49
+
50
+ def register_system(name: str, description: str = "", version: str = "0.1.0"):
51
+ """Decorator that registers a system. Apply to any function or class (it's returned unchanged)."""
52
+ def decorator(obj):
53
+ registry.add_system(name, description, version)
54
+ return obj
55
+ return decorator
56
+
57
+
58
+ def register_test(system: str, name: str, description: str = ""):
59
+ """Decorator that registers a diagnostic test function for a system."""
60
+ def decorator(fn):
61
+ registry.add_test(system, TestInfo(name=name, description=description, fn=fn))
62
+ return fn
63
+ return decorator
64
+
65
+
66
+ def register_plot(system: str, name: str, description: str = ""):
67
+ """Decorator that registers a plot function for a system."""
68
+ def decorator(fn):
69
+ registry.add_plot(system, PlotInfo(name=name, description=description, fn=fn))
70
+ return fn
71
+ return decorator
72
+
73
+
74
+ def register_report(system: str, name: str, description: str = ""):
75
+ """Decorator that registers a report function for a system."""
76
+ def decorator(fn):
77
+ registry.add_report(system, ReportInfo(name=name, description=description, fn=fn))
78
+ return fn
79
+ return decorator
@@ -0,0 +1,57 @@
1
+ import traceback
2
+ from datetime import datetime
3
+ from typing import Any
4
+
5
+ import matplotlib.figure
6
+
7
+ from diagnostics_framework.models import DiagnosticResult, DiagnosticStatus, DiagnosticSummary
8
+ from diagnostics_framework.registry import registry
9
+
10
+
11
+ def run_diagnostics(system_name: str, data: Any) -> DiagnosticSummary:
12
+ """Run all registered diagnostic tests for a system against the provided data."""
13
+ tests = registry.get_tests(system_name)
14
+ results = []
15
+
16
+ for test_info in tests:
17
+ try:
18
+ result = test_info.fn(data)
19
+ if isinstance(result, DiagnosticResult):
20
+ results.append(result)
21
+ else:
22
+ results.append(DiagnosticResult(
23
+ test_name=test_info.name,
24
+ status=DiagnosticStatus.ERROR,
25
+ message=f"Test '{test_info.name}' did not return a DiagnosticResult.",
26
+ ))
27
+ except Exception as e:
28
+ results.append(DiagnosticResult(
29
+ test_name=test_info.name,
30
+ status=DiagnosticStatus.ERROR,
31
+ message=f"Test raised an exception: {e}",
32
+ details={"traceback": traceback.format_exc()},
33
+ ))
34
+
35
+ return DiagnosticSummary(
36
+ system_name=system_name,
37
+ results=results,
38
+ timestamp=datetime.now(),
39
+ )
40
+
41
+
42
+ def generate_plot(system_name: str, plot_name: str, data: Any) -> matplotlib.figure.Figure:
43
+ """Generate a plot by name for a system. Returns a matplotlib Figure."""
44
+ plots = registry.get_plots(system_name)
45
+ for plot_info in plots:
46
+ if plot_info.name == plot_name:
47
+ return plot_info.fn(data)
48
+ raise ValueError(f"Plot '{plot_name}' not found for system '{system_name}'.")
49
+
50
+
51
+ def generate_report(system_name: str, report_name: str, data: Any) -> str:
52
+ """Generate a report by name for a system. Returns a string (plain text or markdown)."""
53
+ reports = registry.get_reports(system_name)
54
+ for report_info in reports:
55
+ if report_info.name == report_name:
56
+ return report_info.fn(data)
57
+ raise ValueError(f"Report '{report_name}' not found for system '{system_name}'.")
@@ -0,0 +1,9 @@
1
+ """Auto-discover and import all system modules so their decorators register on import."""
2
+ import importlib
3
+ import pkgutil
4
+ from pathlib import Path
5
+
6
+ _package_dir = Path(__file__).parent
7
+
8
+ for _finder, _name, _ispkg in pkgutil.iter_modules([str(_package_dir)]):
9
+ importlib.import_module(f"{__name__}.{_name}")
@@ -0,0 +1,201 @@
1
+ """
2
+ Generic Example System
3
+ ======================
4
+ A template system demonstrating the diagnostics framework plugin pattern.
5
+ Copy this file and modify it to create diagnostics for your own system.
6
+ """
7
+ import matplotlib.pyplot as plt
8
+ import numpy as np
9
+ import pandas as pd
10
+ import seaborn as sns
11
+
12
+ from diagnostics_framework.models import DiagnosticResult, DiagnosticStatus
13
+ from diagnostics_framework.registry import register_system, register_test, register_plot, register_report
14
+
15
+ SYSTEM_NAME = "generic_example"
16
+
17
+
18
+ @register_system(SYSTEM_NAME, description="Generic example system for tabular data", version="0.1.0")
19
+ class GenericExampleSystem:
20
+ pass
21
+
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Diagnostic Tests
25
+ # ---------------------------------------------------------------------------
26
+
27
+ @register_test(SYSTEM_NAME, name="check_not_empty", description="Verify the data is not empty")
28
+ def check_not_empty(data) -> DiagnosticResult:
29
+ if isinstance(data, pd.DataFrame):
30
+ is_empty = data.empty
31
+ size = len(data)
32
+ elif isinstance(data, (list, dict)):
33
+ is_empty = len(data) == 0
34
+ size = len(data)
35
+ else:
36
+ is_empty = data is None
37
+ size = 0 if data is None else 1
38
+
39
+ if is_empty:
40
+ return DiagnosticResult(
41
+ test_name="check_not_empty",
42
+ status=DiagnosticStatus.FAIL,
43
+ message="Data is empty.",
44
+ )
45
+ return DiagnosticResult(
46
+ test_name="check_not_empty",
47
+ status=DiagnosticStatus.PASS,
48
+ message=f"Data has {size} records.",
49
+ details={"record_count": size},
50
+ )
51
+
52
+
53
+ @register_test(SYSTEM_NAME, name="check_no_nulls", description="Check for null or missing values")
54
+ def check_no_nulls(data) -> DiagnosticResult:
55
+ if not isinstance(data, pd.DataFrame):
56
+ return DiagnosticResult(
57
+ test_name="check_no_nulls",
58
+ status=DiagnosticStatus.WARNING,
59
+ message="Null check only supported for DataFrame input. Skipped.",
60
+ )
61
+
62
+ null_counts = data.isnull().sum()
63
+ total_nulls = int(null_counts.sum())
64
+ cols_with_nulls = {col: int(count) for col, count in null_counts.items() if count > 0}
65
+
66
+ if total_nulls == 0:
67
+ return DiagnosticResult(
68
+ test_name="check_no_nulls",
69
+ status=DiagnosticStatus.PASS,
70
+ message="No null values found.",
71
+ )
72
+
73
+ return DiagnosticResult(
74
+ test_name="check_no_nulls",
75
+ status=DiagnosticStatus.WARNING if total_nulls < len(data) else DiagnosticStatus.FAIL,
76
+ message=f"Found {total_nulls} null value(s) across {len(cols_with_nulls)} column(s).",
77
+ details={"columns_with_nulls": cols_with_nulls},
78
+ )
79
+
80
+
81
+ @register_test(SYSTEM_NAME, name="check_numeric_ranges", description="Validate numeric columns have finite values")
82
+ def check_numeric_ranges(data) -> DiagnosticResult:
83
+ if not isinstance(data, pd.DataFrame):
84
+ return DiagnosticResult(
85
+ test_name="check_numeric_ranges",
86
+ status=DiagnosticStatus.WARNING,
87
+ message="Range check only supported for DataFrame input. Skipped.",
88
+ )
89
+
90
+ numeric_cols = data.select_dtypes(include="number").columns.tolist()
91
+ if not numeric_cols:
92
+ return DiagnosticResult(
93
+ test_name="check_numeric_ranges",
94
+ status=DiagnosticStatus.WARNING,
95
+ message="No numeric columns found to check.",
96
+ )
97
+
98
+ issues = {}
99
+ for col in numeric_cols:
100
+ inf_count = int(np.isinf(data[col].dropna()).sum())
101
+ if inf_count > 0:
102
+ issues[col] = {"infinite_values": inf_count}
103
+
104
+ if issues:
105
+ return DiagnosticResult(
106
+ test_name="check_numeric_ranges",
107
+ status=DiagnosticStatus.FAIL,
108
+ message=f"Found infinite values in {len(issues)} column(s).",
109
+ details={"columns_with_issues": issues},
110
+ )
111
+
112
+ return DiagnosticResult(
113
+ test_name="check_numeric_ranges",
114
+ status=DiagnosticStatus.PASS,
115
+ message=f"All {len(numeric_cols)} numeric column(s) have finite values.",
116
+ details={"numeric_columns": numeric_cols},
117
+ )
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # Plots
122
+ # ---------------------------------------------------------------------------
123
+
124
+ @register_plot(SYSTEM_NAME, name="data_overview", description="Overview histogram of numeric columns")
125
+ def data_overview_plot(data) -> plt.Figure:
126
+ if not isinstance(data, pd.DataFrame):
127
+ fig, ax = plt.subplots()
128
+ ax.text(0.5, 0.5, "Plot requires DataFrame input", ha="center", va="center")
129
+ return fig
130
+
131
+ numeric_cols = data.select_dtypes(include="number").columns.tolist()
132
+ if not numeric_cols:
133
+ fig, ax = plt.subplots()
134
+ ax.text(0.5, 0.5, "No numeric columns to plot", ha="center", va="center")
135
+ return fig
136
+
137
+ n_cols = len(numeric_cols)
138
+ fig, axes = plt.subplots(1, n_cols, figsize=(4 * n_cols, 4), squeeze=False)
139
+ for i, col in enumerate(numeric_cols):
140
+ axes[0][i].hist(data[col].dropna(), bins=20, edgecolor="black", alpha=0.7)
141
+ axes[0][i].set_title(col)
142
+ axes[0][i].set_xlabel("Value")
143
+ axes[0][i].set_ylabel("Count")
144
+ fig.suptitle("Numeric Column Distributions", fontsize=14)
145
+ fig.tight_layout()
146
+ return fig
147
+
148
+
149
+ @register_plot(SYSTEM_NAME, name="null_heatmap", description="Heatmap showing location of null values")
150
+ def null_heatmap(data) -> plt.Figure:
151
+ if not isinstance(data, pd.DataFrame):
152
+ fig, ax = plt.subplots()
153
+ ax.text(0.5, 0.5, "Plot requires DataFrame input", ha="center", va="center")
154
+ return fig
155
+
156
+ fig, ax = plt.subplots(figsize=(max(6, len(data.columns)), max(4, len(data) * 0.05)))
157
+ sns.heatmap(data.isnull().astype(int), cbar=False, cmap="YlOrRd", ax=ax, yticklabels=False)
158
+ ax.set_title("Null Values (yellow = present, red = null)")
159
+ fig.tight_layout()
160
+ return fig
161
+
162
+
163
+ # ---------------------------------------------------------------------------
164
+ # Reports
165
+ # ---------------------------------------------------------------------------
166
+
167
+ @register_report(SYSTEM_NAME, name="summary_report", description="Text summary of the dataset")
168
+ def summary_report(data) -> str:
169
+ if not isinstance(data, pd.DataFrame):
170
+ return f"Data type: {type(data).__name__}\nCannot generate detailed summary for non-DataFrame data."
171
+
172
+ lines = [
173
+ "# Data Summary Report",
174
+ "",
175
+ f"**Rows:** {len(data)}",
176
+ f"**Columns:** {len(data.columns)}",
177
+ "",
178
+ "## Column Types",
179
+ ]
180
+ for col in data.columns:
181
+ lines.append(f"- **{col}**: {data[col].dtype}")
182
+
183
+ lines.append("")
184
+ lines.append("## Null Counts")
185
+ null_counts = data.isnull().sum()
186
+ for col, count in null_counts.items():
187
+ if count > 0:
188
+ lines.append(f"- **{col}**: {count} nulls ({count / len(data) * 100:.1f}%)")
189
+ if null_counts.sum() == 0:
190
+ lines.append("- No null values found.")
191
+
192
+ numeric_cols = data.select_dtypes(include="number")
193
+ if not numeric_cols.empty:
194
+ lines.append("")
195
+ lines.append("## Numeric Summary")
196
+ for col in numeric_cols.columns:
197
+ series = numeric_cols[col].dropna()
198
+ lines.append(f"- **{col}**: min={series.min():.4g}, max={series.max():.4g}, "
199
+ f"mean={series.mean():.4g}, std={series.std():.4g}")
200
+
201
+ return "\n".join(lines)
@@ -0,0 +1,321 @@
1
+ """
2
+ Sensor Monitoring System
3
+ ========================
4
+ Diagnostics for IoT/environmental sensor data with time series,
5
+ battery health, and anomaly detection checks.
6
+ """
7
+ import matplotlib.pyplot as plt
8
+ import numpy as np
9
+ import pandas as pd
10
+ import seaborn as sns
11
+
12
+ from diagnostics_framework.models import DiagnosticResult, DiagnosticStatus
13
+ from diagnostics_framework.registry import register_system, register_test, register_plot, register_report
14
+
15
+ SYSTEM_NAME = "sensor_monitoring"
16
+
17
+
18
+ @register_system(SYSTEM_NAME, description="IoT sensor monitoring diagnostics", version="0.1.0")
19
+ class SensorMonitoringSystem:
20
+ pass
21
+
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Diagnostic Tests
25
+ # ---------------------------------------------------------------------------
26
+
27
+ @register_test(SYSTEM_NAME, name="check_not_empty", description="Verify sensor data is not empty")
28
+ def check_not_empty(data) -> DiagnosticResult:
29
+ if not isinstance(data, pd.DataFrame) or data.empty:
30
+ return DiagnosticResult(
31
+ test_name="check_not_empty",
32
+ status=DiagnosticStatus.FAIL,
33
+ message="No data found.",
34
+ )
35
+ return DiagnosticResult(
36
+ test_name="check_not_empty",
37
+ status=DiagnosticStatus.PASS,
38
+ message=f"Dataset has {len(data)} rows and {len(data.columns)} columns.",
39
+ details={"rows": len(data), "columns": list(data.columns)},
40
+ )
41
+
42
+
43
+ @register_test(SYSTEM_NAME, name="check_missing_readings", description="Check for missing sensor readings")
44
+ def check_missing_readings(data) -> DiagnosticResult:
45
+ if not isinstance(data, pd.DataFrame):
46
+ return DiagnosticResult(test_name="check_missing_readings", status=DiagnosticStatus.WARNING, message="Skipped: not a DataFrame.")
47
+
48
+ null_counts = data.isnull().sum()
49
+ total_nulls = int(null_counts.sum())
50
+ total_cells = int(data.size)
51
+ pct = total_nulls / total_cells * 100 if total_cells > 0 else 0
52
+ cols_with_nulls = {col: int(c) for col, c in null_counts.items() if c > 0}
53
+
54
+ if total_nulls == 0:
55
+ return DiagnosticResult(test_name="check_missing_readings", status=DiagnosticStatus.PASS, message="No missing readings.")
56
+
57
+ status = DiagnosticStatus.WARNING if pct < 5 else DiagnosticStatus.FAIL
58
+ return DiagnosticResult(
59
+ test_name="check_missing_readings",
60
+ status=status,
61
+ message=f"{total_nulls} missing values ({pct:.1f}% of all cells) across {len(cols_with_nulls)} column(s).",
62
+ details={"missing_by_column": cols_with_nulls, "total_missing": total_nulls, "percent_missing": round(pct, 2)},
63
+ )
64
+
65
+
66
+ @register_test(SYSTEM_NAME, name="check_battery_health", description="Flag sensors with low battery levels")
67
+ def check_battery_health(data) -> DiagnosticResult:
68
+ if not isinstance(data, pd.DataFrame) or "battery_level" not in data.columns:
69
+ return DiagnosticResult(test_name="check_battery_health", status=DiagnosticStatus.WARNING, message="No battery_level column found.")
70
+
71
+ low_threshold = 20.0
72
+ critical_threshold = 10.0
73
+ battery = data[["sensor_id", "battery_level"]].dropna() if "sensor_id" in data.columns else data[["battery_level"]].dropna()
74
+
75
+ if "sensor_id" in data.columns:
76
+ latest = battery.groupby("sensor_id")["battery_level"].last()
77
+ critical = latest[latest < critical_threshold].to_dict()
78
+ low = latest[(latest >= critical_threshold) & (latest < low_threshold)].to_dict()
79
+ else:
80
+ last_val = float(battery["battery_level"].iloc[-1])
81
+ critical = {"unknown": last_val} if last_val < critical_threshold else {}
82
+ low = {"unknown": last_val} if critical_threshold <= last_val < low_threshold else {}
83
+
84
+ if critical:
85
+ return DiagnosticResult(
86
+ test_name="check_battery_health",
87
+ status=DiagnosticStatus.FAIL,
88
+ message=f"{len(critical)} sensor(s) at CRITICAL battery level (<{critical_threshold}%).",
89
+ details={"critical_sensors": critical, "low_sensors": low},
90
+ )
91
+ if low:
92
+ return DiagnosticResult(
93
+ test_name="check_battery_health",
94
+ status=DiagnosticStatus.WARNING,
95
+ message=f"{len(low)} sensor(s) with low battery (<{low_threshold}%).",
96
+ details={"low_sensors": low},
97
+ )
98
+ return DiagnosticResult(
99
+ test_name="check_battery_health",
100
+ status=DiagnosticStatus.PASS,
101
+ message="All sensors have healthy battery levels.",
102
+ )
103
+
104
+
105
+ @register_test(SYSTEM_NAME, name="check_temperature_range", description="Validate temperature readings are within expected range")
106
+ def check_temperature_range(data) -> DiagnosticResult:
107
+ if not isinstance(data, pd.DataFrame) or "temperature" not in data.columns:
108
+ return DiagnosticResult(test_name="check_temperature_range", status=DiagnosticStatus.WARNING, message="No temperature column found.")
109
+
110
+ temp = data["temperature"].dropna()
111
+ min_expected, max_expected = -10.0, 50.0
112
+ out_of_range = temp[(temp < min_expected) | (temp > max_expected)]
113
+
114
+ if len(out_of_range) > 0:
115
+ return DiagnosticResult(
116
+ test_name="check_temperature_range",
117
+ status=DiagnosticStatus.FAIL,
118
+ message=f"{len(out_of_range)} readings outside expected range [{min_expected}, {max_expected}].",
119
+ details={"out_of_range_count": len(out_of_range), "min_observed": float(temp.min()), "max_observed": float(temp.max())},
120
+ )
121
+ return DiagnosticResult(
122
+ test_name="check_temperature_range",
123
+ status=DiagnosticStatus.PASS,
124
+ message=f"All {len(temp)} temperature readings within [{min_expected}, {max_expected}]. Range: {temp.min():.1f} to {temp.max():.1f}.",
125
+ details={"min": float(temp.min()), "max": float(temp.max()), "mean": float(temp.mean())},
126
+ )
127
+
128
+
129
+ @register_test(SYSTEM_NAME, name="check_sensor_status", description="Check for sensors in warning or critical status")
130
+ def check_sensor_status(data) -> DiagnosticResult:
131
+ if not isinstance(data, pd.DataFrame) or "status" not in data.columns:
132
+ return DiagnosticResult(test_name="check_sensor_status", status=DiagnosticStatus.WARNING, message="No status column found.")
133
+
134
+ status_counts = data["status"].value_counts().to_dict()
135
+ critical_count = status_counts.get("critical", 0)
136
+ warning_count = status_counts.get("warning", 0)
137
+
138
+ if critical_count > 0:
139
+ return DiagnosticResult(
140
+ test_name="check_sensor_status",
141
+ status=DiagnosticStatus.FAIL,
142
+ message=f"{critical_count} readings in 'critical' status, {warning_count} in 'warning'.",
143
+ details={"status_breakdown": status_counts},
144
+ )
145
+ if warning_count > 0:
146
+ return DiagnosticResult(
147
+ test_name="check_sensor_status",
148
+ status=DiagnosticStatus.WARNING,
149
+ message=f"{warning_count} readings in 'warning' status.",
150
+ details={"status_breakdown": status_counts},
151
+ )
152
+ return DiagnosticResult(
153
+ test_name="check_sensor_status",
154
+ status=DiagnosticStatus.PASS,
155
+ message="All sensors reporting normal status.",
156
+ details={"status_breakdown": status_counts},
157
+ )
158
+
159
+
160
+ # ---------------------------------------------------------------------------
161
+ # Plots
162
+ # ---------------------------------------------------------------------------
163
+
164
+ @register_plot(SYSTEM_NAME, name="temperature_timeseries", description="Temperature over time per sensor")
165
+ def temperature_timeseries(data) -> plt.Figure:
166
+ if not isinstance(data, pd.DataFrame) or "temperature" not in data.columns:
167
+ fig, ax = plt.subplots()
168
+ ax.text(0.5, 0.5, "Requires 'temperature' column", ha="center", va="center")
169
+ return fig
170
+
171
+ fig, ax = plt.subplots(figsize=(12, 5))
172
+ if "sensor_id" in data.columns and "timestamp" in data.columns:
173
+ for sensor_id, group in data.groupby("sensor_id"):
174
+ ts = pd.to_datetime(group["timestamp"], errors="coerce")
175
+ ax.plot(ts, group["temperature"], marker="o", markersize=3, label=sensor_id)
176
+ ax.legend(title="Sensor")
177
+ ax.set_xlabel("Time")
178
+ else:
179
+ ax.plot(data.index, data["temperature"], marker="o", markersize=3)
180
+ ax.set_xlabel("Index")
181
+ ax.set_ylabel("Temperature")
182
+ ax.set_title("Temperature Over Time")
183
+ ax.grid(True, alpha=0.3)
184
+ fig.autofmt_xdate()
185
+ fig.tight_layout()
186
+ return fig
187
+
188
+
189
+ @register_plot(SYSTEM_NAME, name="battery_levels", description="Battery level per sensor over time")
190
+ def battery_levels(data) -> plt.Figure:
191
+ if not isinstance(data, pd.DataFrame) or "battery_level" not in data.columns:
192
+ fig, ax = plt.subplots()
193
+ ax.text(0.5, 0.5, "Requires 'battery_level' column", ha="center", va="center")
194
+ return fig
195
+
196
+ fig, ax = plt.subplots(figsize=(12, 5))
197
+ if "sensor_id" in data.columns and "timestamp" in data.columns:
198
+ for sensor_id, group in data.groupby("sensor_id"):
199
+ ts = pd.to_datetime(group["timestamp"], errors="coerce")
200
+ ax.plot(ts, group["battery_level"], marker="o", markersize=3, label=sensor_id)
201
+ ax.legend(title="Sensor")
202
+ ax.set_xlabel("Time")
203
+ else:
204
+ ax.plot(data.index, data["battery_level"], marker="o", markersize=3)
205
+ ax.set_xlabel("Index")
206
+
207
+ ax.axhline(y=20, color="orange", linestyle="--", alpha=0.7, label="Low threshold (20%)")
208
+ ax.axhline(y=10, color="red", linestyle="--", alpha=0.7, label="Critical threshold (10%)")
209
+ ax.set_ylabel("Battery Level (%)")
210
+ ax.set_title("Battery Level Over Time")
211
+ ax.legend(title="Sensor")
212
+ ax.grid(True, alpha=0.3)
213
+ fig.autofmt_xdate()
214
+ fig.tight_layout()
215
+ return fig
216
+
217
+
218
+ @register_plot(SYSTEM_NAME, name="correlation_heatmap", description="Correlation matrix of numeric columns")
219
+ def correlation_heatmap(data) -> plt.Figure:
220
+ if not isinstance(data, pd.DataFrame):
221
+ fig, ax = plt.subplots()
222
+ ax.text(0.5, 0.5, "Requires DataFrame input", ha="center", va="center")
223
+ return fig
224
+
225
+ numeric = data.select_dtypes(include="number")
226
+ if numeric.empty:
227
+ fig, ax = plt.subplots()
228
+ ax.text(0.5, 0.5, "No numeric columns", ha="center", va="center")
229
+ return fig
230
+
231
+ fig, ax = plt.subplots(figsize=(8, 6))
232
+ corr = numeric.corr()
233
+ sns.heatmap(corr, annot=True, fmt=".2f", cmap="RdBu_r", center=0, ax=ax, square=True)
234
+ ax.set_title("Correlation Matrix")
235
+ fig.tight_layout()
236
+ return fig
237
+
238
+
239
+ @register_plot(SYSTEM_NAME, name="sensor_status_breakdown", description="Pie chart of sensor status counts")
240
+ def sensor_status_breakdown(data) -> plt.Figure:
241
+ if not isinstance(data, pd.DataFrame) or "status" not in data.columns:
242
+ fig, ax = plt.subplots()
243
+ ax.text(0.5, 0.5, "Requires 'status' column", ha="center", va="center")
244
+ return fig
245
+
246
+ status_counts = data["status"].value_counts()
247
+ colors = {"active": "#28a745", "warning": "#ffc107", "critical": "#dc3545", "inactive": "#6c757d"}
248
+ pie_colors = [colors.get(s, "#999999") for s in status_counts.index]
249
+
250
+ fig, ax = plt.subplots(figsize=(6, 6))
251
+ ax.pie(status_counts.values, labels=status_counts.index, autopct="%1.0f%%", colors=pie_colors, startangle=90)
252
+ ax.set_title("Sensor Status Breakdown")
253
+ fig.tight_layout()
254
+ return fig
255
+
256
+
257
+ # ---------------------------------------------------------------------------
258
+ # Reports
259
+ # ---------------------------------------------------------------------------
260
+
261
+ @register_report(SYSTEM_NAME, name="sensor_health_report", description="Full health report across all sensors")
262
+ def sensor_health_report(data) -> str:
263
+ if not isinstance(data, pd.DataFrame):
264
+ return "Report requires DataFrame input."
265
+
266
+ lines = ["# Sensor Health Report", ""]
267
+
268
+ lines.append(f"**Total readings:** {len(data)}")
269
+ if "sensor_id" in data.columns:
270
+ sensors = data["sensor_id"].nunique()
271
+ lines.append(f"**Unique sensors:** {sensors}")
272
+ lines.append(f"**Sensors:** {', '.join(sorted(data['sensor_id'].unique()))}")
273
+ lines.append("")
274
+
275
+ # Missing data
276
+ null_counts = data.isnull().sum()
277
+ total_nulls = int(null_counts.sum())
278
+ lines.append("## Data Completeness")
279
+ if total_nulls == 0:
280
+ lines.append("All readings complete — no missing values.")
281
+ else:
282
+ lines.append(f"**{total_nulls} missing values** detected:")
283
+ for col, count in null_counts.items():
284
+ if count > 0:
285
+ lines.append(f"- {col}: {count} missing ({count / len(data) * 100:.1f}%)")
286
+ lines.append("")
287
+
288
+ # Battery
289
+ if "battery_level" in data.columns and "sensor_id" in data.columns:
290
+ lines.append("## Battery Status")
291
+ latest_battery = data.groupby("sensor_id")["battery_level"].last()
292
+ for sensor_id, level in latest_battery.items():
293
+ if pd.isna(level):
294
+ emoji = "unknown"
295
+ elif level < 10:
296
+ emoji = "CRITICAL"
297
+ elif level < 20:
298
+ emoji = "LOW"
299
+ else:
300
+ emoji = "OK"
301
+ display_level = f"{level:.1f}%" if not pd.isna(level) else "N/A"
302
+ lines.append(f"- **{sensor_id}**: {display_level} [{emoji}]")
303
+ lines.append("")
304
+
305
+ # Temperature
306
+ if "temperature" in data.columns:
307
+ temp = data["temperature"].dropna()
308
+ lines.append("## Temperature Summary")
309
+ lines.append(f"- Min: {temp.min():.1f}")
310
+ lines.append(f"- Max: {temp.max():.1f}")
311
+ lines.append(f"- Mean: {temp.mean():.1f}")
312
+ lines.append(f"- Std Dev: {temp.std():.1f}")
313
+ lines.append("")
314
+
315
+ # Status
316
+ if "status" in data.columns:
317
+ lines.append("## Status Summary")
318
+ for status, count in data["status"].value_counts().items():
319
+ lines.append(f"- **{status}**: {count} readings ({count / len(data) * 100:.0f}%)")
320
+
321
+ return "\n".join(lines)
@@ -0,0 +1,162 @@
1
+ Metadata-Version: 2.4
2
+ Name: diagnostics-framework
3
+ Version: 0.1.0
4
+ Summary: A pluggable diagnostics framework for running system-specific tests, plots, and reports.
5
+ Author: Pam Painter
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/pampainter/diagnostics-framework
8
+ Project-URL: Repository, https://github.com/pampainter/diagnostics-framework
9
+ Project-URL: Issues, https://github.com/pampainter/diagnostics-framework/issues
10
+ Keywords: diagnostics,testing,dashboard,streamlit,data-quality,monitoring
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Scientific/Engineering
20
+ Classifier: Topic :: Software Development :: Quality Assurance
21
+ Classifier: Topic :: Software Development :: Testing
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: streamlit>=1.30
26
+ Requires-Dist: pandas>=2.0
27
+ Requires-Dist: matplotlib>=3.7
28
+ Requires-Dist: seaborn>=0.13
29
+ Dynamic: license-file
30
+
31
+ # Diagnostics Framework
32
+
33
+ A pluggable Python framework for running diagnostic tests, generating plots, and viewing reports for any system — all from a Streamlit dashboard.
34
+
35
+ ## Overview
36
+
37
+ The framework uses a **plugin architecture**: each "system" is a self-contained module that registers its own diagnostic tests, plots, and reports using simple decorators. Adding a new system is as easy as dropping a new Python file into the `systems/` folder.
38
+
39
+ **Built-in example systems:**
40
+
41
+ | System | Description |
42
+ |--------|-------------|
43
+ | `generic_example` | Basic tabular data checks (nulls, ranges, emptiness) |
44
+ | `sensor_monitoring` | IoT sensor diagnostics with battery health, temperature validation, and status tracking |
45
+
46
+ ## Quick Start
47
+
48
+ ### Install
49
+
50
+ ```bash
51
+ # Create a virtual environment (Python 3.10+ required)
52
+ python -m venv .venv
53
+ source .venv/bin/activate
54
+
55
+ # Install in editable mode
56
+ pip install -e .
57
+ ```
58
+
59
+ ### Run the Dashboard
60
+
61
+ ```bash
62
+ streamlit run diagnostics_framework/app.py
63
+ ```
64
+
65
+ Open http://localhost:8501, select a system, upload a data file, and click **Run Diagnostics**.
66
+
67
+ A sample dataset is included at `sample_data.csv` for testing the `sensor_monitoring` system.
68
+
69
+ ## Project Structure
70
+
71
+ ```
72
+ diagnostics_framework/
73
+ ├── models.py # Dataclasses: DiagnosticResult, DiagnosticStatus, etc.
74
+ ├── registry.py # Singleton registry + decorator API
75
+ ├── runner.py # Test execution engine with error isolation
76
+ ├── app.py # Streamlit dashboard
77
+ └── systems/
78
+ ├── __init__.py # Auto-discovers all system modules
79
+ ├── generic_example.py # Example: generic tabular data checks
80
+ └── sensor_monitoring.py # Example: IoT sensor diagnostics
81
+ ```
82
+
83
+ ## Adding a New System
84
+
85
+ Create a new file in `diagnostics_framework/systems/` — it will be auto-discovered on import.
86
+
87
+ ```python
88
+ # diagnostics_framework/systems/my_system.py
89
+ from diagnostics_framework import (
90
+ register_system, register_test, register_plot, register_report,
91
+ DiagnosticResult, DiagnosticStatus,
92
+ )
93
+
94
+ SYSTEM_NAME = "my_system"
95
+
96
+ @register_system(SYSTEM_NAME, description="My custom system")
97
+ class MySystem:
98
+ pass
99
+
100
+ @register_test(SYSTEM_NAME, name="my_check", description="Validates something important")
101
+ def my_check(data):
102
+ # Your test logic here
103
+ return DiagnosticResult(
104
+ test_name="my_check",
105
+ status=DiagnosticStatus.PASS,
106
+ message="Everything looks good.",
107
+ )
108
+
109
+ @register_plot(SYSTEM_NAME, name="my_plot", description="Visualizes the data")
110
+ def my_plot(data):
111
+ import matplotlib.pyplot as plt
112
+ fig, ax = plt.subplots()
113
+ # Your plotting logic here
114
+ return fig
115
+
116
+ @register_report(SYSTEM_NAME, name="my_report", description="Summary of findings")
117
+ def my_report(data):
118
+ return "# My Report\n\nEverything is fine."
119
+ ```
120
+
121
+ That's it — the new system will appear in the dashboard dropdown automatically.
122
+
123
+ ## Decorator API
124
+
125
+ | Decorator | Purpose |
126
+ |-----------|---------|
127
+ | `@register_system(name, description, version)` | Register a new system |
128
+ | `@register_test(system, name, description)` | Add a diagnostic test |
129
+ | `@register_plot(system, name, description)` | Add a plot generator |
130
+ | `@register_report(system, name, description)` | Add a report generator |
131
+
132
+ ## Diagnostic Result Statuses
133
+
134
+ | Status | Meaning |
135
+ |--------|---------|
136
+ | `PASS` | Test passed successfully |
137
+ | `FAIL` | Test found a problem |
138
+ | `WARNING` | Test found something worth noting |
139
+ | `ERROR` | Test itself crashed (caught automatically by the runner) |
140
+
141
+ ## Programmatic Usage
142
+
143
+ You can also use the framework without the dashboard:
144
+
145
+ ```python
146
+ import pandas as pd
147
+ from diagnostics_framework import run_diagnostics
148
+
149
+ data = pd.read_csv("sample_data.csv")
150
+ summary = run_diagnostics("sensor_monitoring", data)
151
+
152
+ for result in summary.results:
153
+ print(f"[{result.status.value}] {result.test_name}: {result.message}")
154
+ ```
155
+
156
+ ## Dependencies
157
+
158
+ - Python >= 3.10
159
+ - streamlit
160
+ - pandas
161
+ - matplotlib
162
+ - seaborn
@@ -0,0 +1,14 @@
1
+ diagnostics_framework/__init__.py,sha256=un66MjUaL-8GUU9ZaXAykQpiRhdH16Bn3dqyBEy4qMc,423
2
+ diagnostics_framework/app.py,sha256=L6rF7plmMKBruA1lcCZAFI-pQZG-WVPtVz_gEyYk3hk,6486
3
+ diagnostics_framework/models.py,sha256=2Q1Fwc-UXloiCp2MR3Nyhfx66N-yWMB7DzDFk50KCRc,1484
4
+ diagnostics_framework/registry.py,sha256=Mefbvr6IeTzkiLl8oC22a5T7TrGQdpgeNqrDhcyPNAQ,2810
5
+ diagnostics_framework/runner.py,sha256=4D1l50Q2MY3BN9HkuUc2Ux6JprcOXpgqutru3sisUqY,2165
6
+ diagnostics_framework/systems/__init__.py,sha256=zly8KsotoHLrSMC-FOiX8sUtETP2JhxXvp-PS08MoUY,310
7
+ diagnostics_framework/systems/generic_example.py,sha256=QQGdJ-UvsAm4S1Ru0ip6aJWQQuMRGjmyhVOQ1eCXolg,7415
8
+ diagnostics_framework/systems/sensor_monitoring.py,sha256=7pXFnnpWoEMjuev-jICxUMMHVOLlq45CQpii38OV-_c,13819
9
+ diagnostics_framework-0.1.0.dist-info/licenses/LICENSE,sha256=L2eX4LNu6Jo0kOKdN-x_a5IZ_AXtAgrKE-jmVXvAj3A,1068
10
+ diagnostics_framework-0.1.0.dist-info/METADATA,sha256=f9Z7AXAqg_g-9Y9NWlqtH5aRw2lTdkBGLfnh4xgrx9Q,5286
11
+ diagnostics_framework-0.1.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
12
+ diagnostics_framework-0.1.0.dist-info/entry_points.txt,sha256=OeeBCFxEsxcuQCPWlAKehfyBEXWwtwIpgxBH6ofyk_A,63
13
+ diagnostics_framework-0.1.0.dist-info/top_level.txt,sha256=kymk6vfk_5DwR815dhrFqL6V3PjBvcHQJEbsCxh9Vfg,22
14
+ diagnostics_framework-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ diagnostics = diagnostics_framework.app:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pam Painter
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.
@@ -0,0 +1 @@
1
+ diagnostics_framework