smartscan 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.
smartscan/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("smartscan")
5
+ except PackageNotFoundError:
6
+ __version__ = "0.0.0"
smartscan/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from smartscan.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
smartscan/cli.py ADDED
@@ -0,0 +1,139 @@
1
+ # PYTHON_ARGCOMPLETE_OK
2
+
3
+ """CLI entry point with argparse subcommands for collect and query modes."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ import logging
9
+ import sys
10
+
11
+ import argcomplete
12
+
13
+ from .commands import do_collect, do_query
14
+ from .config import load_config
15
+ from .constants import DEFAULT_CONFIG_PATH, DEFAULT_DB_PATH, DEFAULT_LOG_FILE
16
+ from .exceptions import SmartScanError
17
+ from .logging import setup_logging
18
+ from .models import SmartScanConfig
19
+
20
+
21
+ def create_parser() -> argparse.ArgumentParser:
22
+ """Build the argument parser for argcomplete integration, without config defaults."""
23
+ return _build_parser()
24
+
25
+
26
+ def _build_parser(config: SmartScanConfig | None = None) -> argparse.ArgumentParser:
27
+ """Build the argument parser with config-driven defaults."""
28
+ cfg = config or SmartScanConfig()
29
+
30
+ parser = argparse.ArgumentParser(
31
+ description="Extract and display important SMART information from disk devices.",
32
+ )
33
+
34
+ parser.add_argument(
35
+ "--config",
36
+ default=DEFAULT_CONFIG_PATH,
37
+ help=f"TOML config file path (default: {DEFAULT_CONFIG_PATH})",
38
+ )
39
+ parser.add_argument(
40
+ "--db-path",
41
+ default=cfg.db_path,
42
+ help=f"Database file path (default: {DEFAULT_DB_PATH})",
43
+ )
44
+ parser.add_argument(
45
+ "--log-file",
46
+ default=cfg.log_file,
47
+ help=f"Log file path (default: {DEFAULT_LOG_FILE})",
48
+ )
49
+ parser.add_argument(
50
+ "--json",
51
+ action="store_true",
52
+ default=cfg.format == "json",
53
+ help="Output as JSON lines instead of table format",
54
+ )
55
+
56
+ sub = parser.add_subparsers(dest="command", required=True)
57
+
58
+ collect_parser = sub.add_parser(
59
+ "collect", help="Collect SMART data from disk devices"
60
+ )
61
+ collect_parser.add_argument(
62
+ "pattern",
63
+ nargs="?",
64
+ default=".*",
65
+ help="Regex pattern to filter disk devices",
66
+ )
67
+ collect_parser.add_argument(
68
+ "--no-save",
69
+ action="store_true",
70
+ default=cfg.no_save,
71
+ help="Skip saving SMART data to database",
72
+ )
73
+ collect_parser.add_argument(
74
+ "--force-llm",
75
+ action="store_true",
76
+ help="Force LLM analysis on all disks, bypassing both config and threshold checks",
77
+ )
78
+ collect_parser.add_argument(
79
+ "-v",
80
+ "--verbose",
81
+ action="store_true",
82
+ help="Show extended fields in terminal output",
83
+ )
84
+
85
+ query_parser = sub.add_parser(
86
+ "query", help="Query historical SMART data from database"
87
+ )
88
+ query_parser.add_argument(
89
+ "pattern",
90
+ nargs="?",
91
+ default=".*",
92
+ help="Regex pattern to filter disk_name",
93
+ )
94
+ query_parser.add_argument(
95
+ "--since",
96
+ metavar="DATE",
97
+ help="Start date (YYYY-MM-DD) for query",
98
+ )
99
+ query_parser.add_argument(
100
+ "--until",
101
+ metavar="DATE",
102
+ help="End date (YYYY-MM-DD) for query",
103
+ )
104
+ query_parser.add_argument(
105
+ "--verbose",
106
+ "-v",
107
+ action="store_true",
108
+ help="Show extended fields in terminal output",
109
+ )
110
+
111
+ argcomplete.autocomplete(parser)
112
+ return parser
113
+
114
+
115
+ def main() -> None:
116
+ """Entry point: load config, parse args, and dispatch to the appropriate subcommand."""
117
+ try:
118
+ pre_parser = argparse.ArgumentParser(add_help=False)
119
+ pre_parser.add_argument("--config", default=DEFAULT_CONFIG_PATH)
120
+ pre_args, remaining = pre_parser.parse_known_args()
121
+
122
+ config = load_config(pre_args.config)
123
+
124
+ parser = _build_parser(config)
125
+ args = parser.parse_args(remaining)
126
+
127
+ setup_logging(args.log_file)
128
+
129
+ args.thresholds_enabled = config.thresholds.enabled
130
+ args.threshold_rules = config.thresholds
131
+ args.llm_config = config.llm
132
+
133
+ if args.command == "query":
134
+ do_query(args)
135
+ else:
136
+ do_collect(args)
137
+ except SmartScanError as exc:
138
+ logging.error("%s", exc)
139
+ sys.exit(1)
smartscan/commands.py ADDED
@@ -0,0 +1,144 @@
1
+ """High-level command implementations for collect and query workflows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import sqlite3
8
+ import sys
9
+ import time
10
+ from argparse import Namespace
11
+
12
+ from .database import init_db, open_db, query_smart_info, save_to_db
13
+ from .exceptions import DiskNotFoundError
14
+ from .llm import call_llm
15
+ from .output import (
16
+ print_json_output,
17
+ print_llm_analysis,
18
+ print_query_table,
19
+ print_table,
20
+ row_to_fields,
21
+ )
22
+ from .smartctl import extract_fields, find_disks, run_smartctl
23
+ from .thresholds import check_thresholds
24
+
25
+
26
+ def do_collect(args: Namespace) -> None:
27
+ if os.geteuid() != 0:
28
+ logging.warning(
29
+ "This program typically requires root privileges to access SMART data."
30
+ )
31
+
32
+ conn = None
33
+ if not args.no_save:
34
+ try:
35
+ conn = init_db(args.db_path)
36
+ except OSError as exc:
37
+ logging.error("Failed to open database at %s: %s", args.db_path, exc)
38
+
39
+ try:
40
+ disks = find_disks(args.pattern)
41
+ except DiskNotFoundError as exc:
42
+ logging.error("%s", exc)
43
+ sys.exit(1)
44
+
45
+ thresholds_enabled = args.thresholds_enabled
46
+
47
+ exit_code = 0
48
+ for disk_idx, symlink in enumerate(disks):
49
+ disk_name = symlink.name
50
+ disk_path = os.readlink(symlink)
51
+
52
+ data, rc = run_smartctl(symlink)
53
+ if rc != 0:
54
+ exit_code = 1
55
+
56
+ if not data:
57
+ continue
58
+
59
+ fields = extract_fields(data)
60
+
61
+ alerts = []
62
+ if thresholds_enabled:
63
+ alerts = check_thresholds(fields, args.threshold_rules)
64
+
65
+ if args.json:
66
+ print_json_output(disk_name, disk_path, fields, data)
67
+ else:
68
+ print_table(disk_name, fields, alerts, verbose=args.verbose)
69
+
70
+ llm_analysis = None
71
+ if args.force_llm or (args.llm_config.enabled and alerts):
72
+ alerts_text = (
73
+ "\n".join(f" - {a.message}" for a in alerts) if alerts else " (none)"
74
+ )
75
+ llm_analysis = call_llm(fields, alerts_text, args.llm_config, raw_data=data)
76
+ if llm_analysis:
77
+ if not args.json:
78
+ print_llm_analysis(llm_analysis)
79
+ if args.llm_config.delay > 0 and disk_idx < len(disks) - 1:
80
+ time.sleep(args.llm_config.delay)
81
+
82
+ if conn:
83
+ try:
84
+ save_to_db(
85
+ conn, disk_name, disk_path, fields, data, llm_analysis=llm_analysis
86
+ )
87
+ except sqlite3.Error as exc:
88
+ logging.error("Failed to save SMART data for %s: %s", disk_name, exc)
89
+
90
+ if conn:
91
+ conn.close()
92
+
93
+ sys.exit(exit_code)
94
+
95
+
96
+ def do_query(args: Namespace) -> None:
97
+ from .database import parse_date
98
+
99
+ since = parse_date(args.since) if args.since else None
100
+ until = parse_date(args.until) if args.until else None
101
+
102
+ conn = open_db(args.db_path)
103
+ if conn is None:
104
+ sys.exit(0)
105
+
106
+ try:
107
+ rows = query_smart_info(conn, args.pattern, since, until)
108
+ except sqlite3.OperationalError as exc:
109
+ logging.error("Query failed: %s", exc)
110
+ conn.close()
111
+ sys.exit(1)
112
+
113
+ if not rows:
114
+ logging.info("No SMART records found matching the query.")
115
+ conn.close()
116
+ sys.exit(0)
117
+
118
+ for row in rows:
119
+ fields = row_to_fields(row)
120
+
121
+ alerts = []
122
+ if args.thresholds_enabled:
123
+ alerts = check_thresholds(fields, args.threshold_rules)
124
+
125
+ if args.json:
126
+ llm_col = row["llm_analysis"] if "llm_analysis" in row.keys() else None
127
+ print_json_output(
128
+ row["disk_name"],
129
+ row["disk_path"],
130
+ fields,
131
+ timestamp=row["timestamp"],
132
+ llm_analysis=llm_col,
133
+ )
134
+ else:
135
+ print_query_table(
136
+ row["disk_name"],
137
+ row["disk_path"],
138
+ row["timestamp"],
139
+ fields,
140
+ alerts=alerts,
141
+ verbose=args.verbose,
142
+ )
143
+
144
+ conn.close()
smartscan/config.py ADDED
@@ -0,0 +1,54 @@
1
+ """TOML configuration file loading with Pydantic validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import re
8
+ import tomllib
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from pydantic import ValidationError
13
+
14
+ from .models import SmartScanConfig
15
+
16
+ _ENV_VAR_RE = re.compile(r"\$\{(\w+)\}")
17
+
18
+
19
+ def _expand_env_vars(obj: Any) -> Any:
20
+ if isinstance(obj, dict):
21
+ return {k: _expand_env_vars(v) for k, v in obj.items()}
22
+ if isinstance(obj, list):
23
+ return [_expand_env_vars(v) for v in obj]
24
+ if isinstance(obj, str):
25
+ return _ENV_VAR_RE.sub(lambda m: os.environ.get(m.group(1), ""), obj)
26
+ return obj
27
+
28
+
29
+ def load_config(config_path: str | None = None) -> SmartScanConfig:
30
+ """Read a TOML configuration file and return a validated :class:`SmartScanConfig`."""
31
+ from .constants import DEFAULT_CONFIG_PATH
32
+
33
+ path = config_path or DEFAULT_CONFIG_PATH
34
+ expanded = Path(path).expanduser()
35
+ if not expanded.is_file():
36
+ return SmartScanConfig()
37
+
38
+ try:
39
+ raw = tomllib.loads(expanded.read_text(encoding="utf-8"))
40
+ except tomllib.TOMLDecodeError as exc:
41
+ logging.warning("Failed to parse config file %s: %s", expanded, exc)
42
+ return SmartScanConfig()
43
+
44
+ if not isinstance(raw, dict):
45
+ logging.warning("Invalid config file format; expected a TOML table.")
46
+ return SmartScanConfig()
47
+
48
+ raw = _expand_env_vars(raw)
49
+
50
+ try:
51
+ return SmartScanConfig.model_validate(raw)
52
+ except ValidationError as exc:
53
+ logging.warning("Invalid config values: %s", exc)
54
+ return SmartScanConfig()
smartscan/constants.py ADDED
@@ -0,0 +1,86 @@
1
+ """Default paths, database schema, and error message constants."""
2
+
3
+ DEFAULT_DB_PATH = "~/.local/share/smartscan/smartscan.db"
4
+ DEFAULT_LOG_FILE = "~/.local/state/smartscan/smartscan.log"
5
+ DEFAULT_CONFIG_PATH = "~/.config/smartscan/smartscan.toml"
6
+
7
+ SMARTCTL_ERROR_MSGS = [
8
+ "Bit 0: Command line did not parse.",
9
+ "Bit 1: Device open failed, device did not return an IDENTIFY DEVICE structure, or device is in a low-power mode.",
10
+ "Bit 2: Some SMART or other ATA command to the disk failed, or there was a checksum error in a SMART data structure.",
11
+ "Bit 3: SMART status check returned 'DISK FAILING'.",
12
+ "Bit 4: We found prefail Attributes <= threshold.",
13
+ "Bit 5: SMART status check returned 'DISK OK' but we found that some (usage or prefail) Attributes have been <= threshold at some time in the past.",
14
+ "Bit 6: The device error log contains records of errors.",
15
+ "Bit 7: The device self-test log contains records of errors. [ATA only] Failed self-tests outdated by a newer successful extended self-test are ignored.",
16
+ ]
17
+
18
+ DB_SCHEMA = """
19
+ CREATE TABLE IF NOT EXISTS smart_info (
20
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
21
+ timestamp TEXT NOT NULL,
22
+ disk_name TEXT NOT NULL,
23
+ disk_path TEXT NOT NULL,
24
+ model_family TEXT,
25
+ model_name TEXT,
26
+ serial_number TEXT,
27
+ firmware_version TEXT,
28
+ user_capacity_bytes INTEGER,
29
+ user_capacity_gib REAL,
30
+ rotation_rate TEXT,
31
+ interface_speed TEXT,
32
+ power_on_time_hours TEXT,
33
+ power_cycle_count TEXT,
34
+ smart_status TEXT,
35
+ temperature_celsius TEXT,
36
+ reallocated_sector_ct TEXT,
37
+ current_pending_sector TEXT,
38
+ offline_uncorrectable TEXT,
39
+ reallocated_event_count TEXT,
40
+ ata_smart_error_log_count TEXT,
41
+ self_test_status TEXT,
42
+ udma_crc_error_count TEXT,
43
+ raw_read_error_rate TEXT,
44
+ spin_retry_count TEXT,
45
+ power_off_retract_count TEXT,
46
+ load_cycle_count TEXT,
47
+ helium_level TEXT,
48
+ raw_json TEXT,
49
+ llm_analysis TEXT
50
+ );
51
+ CREATE INDEX IF NOT EXISTS idx_smart_info_disk_ts
52
+ ON smart_info(disk_path, timestamp);
53
+ """
54
+
55
+ DB_MIGRATIONS = [
56
+ "ALTER TABLE smart_info ADD COLUMN serial_number TEXT",
57
+ "ALTER TABLE smart_info ADD COLUMN firmware_version TEXT",
58
+ "ALTER TABLE smart_info ADD COLUMN smart_status TEXT",
59
+ "ALTER TABLE smart_info ADD COLUMN current_pending_sector TEXT",
60
+ "ALTER TABLE smart_info ADD COLUMN offline_uncorrectable TEXT",
61
+ "ALTER TABLE smart_info ADD COLUMN reallocated_event_count TEXT",
62
+ "ALTER TABLE smart_info ADD COLUMN udma_crc_error_count TEXT",
63
+ "ALTER TABLE smart_info ADD COLUMN raw_read_error_rate TEXT",
64
+ "ALTER TABLE smart_info ADD COLUMN spin_retry_count TEXT",
65
+ "ALTER TABLE smart_info ADD COLUMN power_off_retract_count TEXT",
66
+ "ALTER TABLE smart_info ADD COLUMN load_cycle_count TEXT",
67
+ "ALTER TABLE smart_info ADD COLUMN helium_level TEXT",
68
+ "ALTER TABLE smart_info ADD COLUMN llm_analysis TEXT",
69
+ ]
70
+
71
+ DEFAULT_SYSTEM_PROMPT = """\
72
+ You are a hard drive health diagnostic expert analyzing SMART data.
73
+ Examine the provided SMART attributes and give a concise assessment:
74
+
75
+ 1. Overall health status: HEALTHY / WARNING / CRITICAL
76
+ 2. Key concerns with specific metric values (if any)
77
+ 3. Recommended action (one sentence)
78
+
79
+ Be factual and conservative. Do not cause unnecessary alarm for borderline values.
80
+ If all metrics are within normal ranges, state the drive is healthy.
81
+ If you cannot make a definitive assessment, say so honestly.
82
+
83
+ Additionally, check the self-test log for the most recent long (extended)
84
+ self-test and compare its lifetime hours against the drive's total power-on
85
+ hours. If a significant portion of the drive's lifetime has passed since the
86
+ last long self-test, recommend running one."""
smartscan/database.py ADDED
@@ -0,0 +1,183 @@
1
+ """SQLite database operations: init, save, query for SMART history."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import re
8
+ import sqlite3
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from .constants import DB_MIGRATIONS, DB_SCHEMA
14
+ from .exceptions import InvalidDateError
15
+ from .models import SmartInfo
16
+
17
+
18
+ def _run_migrations(conn: sqlite3.Connection) -> None:
19
+ for sql in DB_MIGRATIONS:
20
+ try:
21
+ conn.execute(sql)
22
+ except sqlite3.OperationalError as exc:
23
+ if "duplicate column" not in str(exc):
24
+ logging.warning("Migration failed: %s", exc)
25
+
26
+
27
+ def init_db(db_path: str) -> sqlite3.Connection:
28
+ """Create or open the SQLite database, applying the schema and enabling WAL mode."""
29
+ expanded = Path(db_path).expanduser()
30
+ expanded.parent.mkdir(parents=True, exist_ok=True)
31
+ conn = sqlite3.connect(str(expanded))
32
+ conn.row_factory = sqlite3.Row
33
+ conn.execute("PRAGMA journal_mode=WAL")
34
+ conn.executescript(DB_SCHEMA)
35
+ _run_migrations(conn)
36
+ return conn
37
+
38
+
39
+ def open_db(db_path: str) -> sqlite3.Connection | None:
40
+ """Open the database in read-only mode for querying, returning ``None`` if it doesn't exist."""
41
+ expanded = Path(db_path).expanduser()
42
+ if not expanded.is_file():
43
+ logging.warning("Database not found: %s", expanded)
44
+ return None
45
+ conn = sqlite3.connect(f"file:{expanded}?mode=ro", uri=True)
46
+ conn.row_factory = sqlite3.Row
47
+ return conn
48
+
49
+
50
+ def register_regexp(conn: sqlite3.Connection) -> None:
51
+ def _regexp(pattern: str, value: str | None) -> bool:
52
+ if value is None:
53
+ return False
54
+ return bool(re.search(pattern, value))
55
+
56
+ conn.create_function("REGEXP", 2, _regexp)
57
+
58
+
59
+ def parse_date(date_str: str) -> datetime:
60
+ """Parse a ``YYYY-MM-DD`` date string into a UTC-aware datetime.
61
+
62
+ Raises:
63
+ InvalidDateError: If the string does not match the expected format.
64
+ """
65
+ try:
66
+ return datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc)
67
+ except ValueError as exc:
68
+ raise InvalidDateError(
69
+ f"Invalid date format: {date_str} (expected YYYY-MM-DD)"
70
+ ) from exc
71
+
72
+
73
+ def query_smart_info(
74
+ conn: sqlite3.Connection,
75
+ pattern: str | None,
76
+ since: datetime | None,
77
+ until: datetime | None,
78
+ ) -> list[sqlite3.Row]:
79
+ """Query historical SMART records with optional disk name regex and date range filters."""
80
+ register_regexp(conn)
81
+
82
+ conditions: list[str] = []
83
+ params: list[str] = []
84
+
85
+ if pattern:
86
+ conditions.append("disk_name REGEXP ?")
87
+ params.append(pattern)
88
+
89
+ if since:
90
+ conditions.append("timestamp >= ?")
91
+ params.append(since.strftime("%Y-%m-%dT00:00:00Z"))
92
+
93
+ if until:
94
+ conditions.append("timestamp <= ?")
95
+ params.append(until.strftime("%Y-%m-%dT23:59:59Z"))
96
+
97
+ where = " AND ".join(conditions) if conditions else "1=1"
98
+ sql = f"SELECT * FROM smart_info WHERE {where} ORDER BY disk_path, timestamp"
99
+
100
+ logging.debug("Query: %s params: %s", sql, params)
101
+ return conn.execute(sql, params).fetchall()
102
+
103
+
104
+ _COLUMNS_INSERT = (
105
+ "timestamp",
106
+ "disk_name",
107
+ "disk_path",
108
+ "model_family",
109
+ "model_name",
110
+ "serial_number",
111
+ "firmware_version",
112
+ "user_capacity_bytes",
113
+ "user_capacity_gib",
114
+ "rotation_rate",
115
+ "interface_speed",
116
+ "power_on_time_hours",
117
+ "power_cycle_count",
118
+ "smart_status",
119
+ "temperature_celsius",
120
+ "reallocated_sector_ct",
121
+ "current_pending_sector",
122
+ "offline_uncorrectable",
123
+ "reallocated_event_count",
124
+ "ata_smart_error_log_count",
125
+ "self_test_status",
126
+ "udma_crc_error_count",
127
+ "raw_read_error_rate",
128
+ "spin_retry_count",
129
+ "power_off_retract_count",
130
+ "load_cycle_count",
131
+ "helium_level",
132
+ "raw_json",
133
+ "llm_analysis",
134
+ )
135
+
136
+
137
+ def save_to_db(
138
+ conn: sqlite3.Connection,
139
+ disk_name: str,
140
+ disk_path: str,
141
+ fields: SmartInfo,
142
+ raw_data: dict[str, Any],
143
+ llm_analysis: str | None = None,
144
+ ) -> None:
145
+ """Persist a single SMART data record into the database."""
146
+ timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
147
+ placeholders = ", ".join("?" * len(_COLUMNS_INSERT))
148
+ columns = ", ".join(_COLUMNS_INSERT)
149
+ conn.execute(
150
+ f"INSERT INTO smart_info ({columns}) VALUES ({placeholders})",
151
+ (
152
+ timestamp,
153
+ disk_name,
154
+ disk_path,
155
+ fields["model_family"],
156
+ fields["model_name"],
157
+ fields["serial_number"],
158
+ fields["firmware_version"],
159
+ fields["user_capacity_bytes"],
160
+ fields["user_capacity_gib"],
161
+ fields["rotation_rate"],
162
+ fields["interface_speed"],
163
+ fields["power_on_time"],
164
+ fields["power_cycle_count"],
165
+ fields["smart_status"],
166
+ fields["temperature"],
167
+ fields["reallocated_sector_ct"],
168
+ fields["current_pending_sector"],
169
+ fields["offline_uncorrectable"],
170
+ fields["reallocated_event_count"],
171
+ fields["ata_smart_error_log"],
172
+ fields["self_test_status"],
173
+ fields["udma_crc_error_count"],
174
+ fields["raw_read_error_rate"],
175
+ fields["spin_retry_count"],
176
+ fields["power_off_retract_count"],
177
+ fields["load_cycle_count"],
178
+ fields["helium_level"],
179
+ json.dumps(raw_data, ensure_ascii=False),
180
+ llm_analysis,
181
+ ),
182
+ )
183
+ conn.commit()
@@ -0,0 +1,25 @@
1
+ """Custom exception hierarchy for smartscan error handling."""
2
+
3
+
4
+ class SmartScanError(Exception):
5
+ pass
6
+
7
+
8
+ class ConfigError(SmartScanError):
9
+ pass
10
+
11
+
12
+ class DiskNotFoundError(SmartScanError):
13
+ pass
14
+
15
+
16
+ class SmartctlError(SmartScanError):
17
+ pass
18
+
19
+
20
+ class DatabaseError(SmartScanError):
21
+ pass
22
+
23
+
24
+ class InvalidDateError(SmartScanError):
25
+ pass