ipmg 2.1.1__tar.gz → 2.2.0__tar.gz

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.
Files changed (72) hide show
  1. {ipmg-2.1.1/src/ipmg.egg-info → ipmg-2.2.0}/PKG-INFO +17 -1
  2. {ipmg-2.1.1 → ipmg-2.2.0}/README.md +16 -0
  3. {ipmg-2.1.1 → ipmg-2.2.0}/pyproject.toml +1 -1
  4. {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/__init__.py +1 -1
  5. {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/cli/parser.py +12 -0
  6. {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/infrastructure/incremental.py +213 -5
  7. {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/reporting/ui.py +2 -1
  8. {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/services/scan_service.py +61 -9
  9. {ipmg-2.1.1 → ipmg-2.2.0/src/ipmg.egg-info}/PKG-INFO +17 -1
  10. {ipmg-2.1.1 → ipmg-2.2.0}/tests/test_incremental.py +163 -0
  11. {ipmg-2.1.1 → ipmg-2.2.0}/tests/test_parser.py +10 -0
  12. {ipmg-2.1.1 → ipmg-2.2.0}/LICENSE +0 -0
  13. {ipmg-2.1.1 → ipmg-2.2.0}/setup.cfg +0 -0
  14. {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/__main__.py +0 -0
  15. {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/cli/__init__.py +0 -0
  16. {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/cli/commands.py +0 -0
  17. {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/core/__init__.py +0 -0
  18. {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/core/diff.py +0 -0
  19. {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/core/discovery.py +0 -0
  20. {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/core/engine.py +0 -0
  21. {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/core/ping.py +0 -0
  22. {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/core/portscan.py +0 -0
  23. {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/core/security.py +0 -0
  24. {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/exceptions.py +0 -0
  25. {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/infrastructure/__init__.py +0 -0
  26. {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/infrastructure/database.py +0 -0
  27. {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/infrastructure/file_io.py +0 -0
  28. {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/reporting/__init__.py +0 -0
  29. {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/reporting/diff_report.py +0 -0
  30. {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/reporting/frames.py +0 -0
  31. {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/reporting/live.py +0 -0
  32. {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/reporting/summary.py +0 -0
  33. {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/services/__init__.py +0 -0
  34. {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/services/history_service.py +0 -0
  35. {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/utils/__init__.py +0 -0
  36. {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/utils/helpers.py +0 -0
  37. {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/web/__init__.py +0 -0
  38. {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/web/app.py +0 -0
  39. {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/web/db.py +0 -0
  40. {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/web/manager.py +0 -0
  41. {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/web/server.py +0 -0
  42. {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/web/static/css/app.css +0 -0
  43. {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/web/static/index.html +0 -0
  44. {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/web/static/js/api.js +0 -0
  45. {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/web/static/js/app.js +0 -0
  46. {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/web/static/js/charts.js +0 -0
  47. {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/web/static/js/demo.js +0 -0
  48. {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/web/static/js/views.js +0 -0
  49. {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg.egg-info/SOURCES.txt +0 -0
  50. {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg.egg-info/dependency_links.txt +0 -0
  51. {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg.egg-info/entry_points.txt +0 -0
  52. {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg.egg-info/requires.txt +0 -0
  53. {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg.egg-info/top_level.txt +0 -0
  54. {ipmg-2.1.1 → ipmg-2.2.0}/tests/test_commands.py +0 -0
  55. {ipmg-2.1.1 → ipmg-2.2.0}/tests/test_database_history.py +0 -0
  56. {ipmg-2.1.1 → ipmg-2.2.0}/tests/test_diff.py +0 -0
  57. {ipmg-2.1.1 → ipmg-2.2.0}/tests/test_diff_report.py +0 -0
  58. {ipmg-2.1.1 → ipmg-2.2.0}/tests/test_discover.py +0 -0
  59. {ipmg-2.1.1 → ipmg-2.2.0}/tests/test_engine.py +0 -0
  60. {ipmg-2.1.1 → ipmg-2.2.0}/tests/test_file_io.py +0 -0
  61. {ipmg-2.1.1 → ipmg-2.2.0}/tests/test_history_service.py +0 -0
  62. {ipmg-2.1.1 → ipmg-2.2.0}/tests/test_live.py +0 -0
  63. {ipmg-2.1.1 → ipmg-2.2.0}/tests/test_ping.py +0 -0
  64. {ipmg-2.1.1 → ipmg-2.2.0}/tests/test_ping_command.py +0 -0
  65. {ipmg-2.1.1 → ipmg-2.2.0}/tests/test_portscan.py +0 -0
  66. {ipmg-2.1.1 → ipmg-2.2.0}/tests/test_scan_service.py +0 -0
  67. {ipmg-2.1.1 → ipmg-2.2.0}/tests/test_ui.py +0 -0
  68. {ipmg-2.1.1 → ipmg-2.2.0}/tests/test_utils.py +0 -0
  69. {ipmg-2.1.1 → ipmg-2.2.0}/tests/test_web_api.py +0 -0
  70. {ipmg-2.1.1 → ipmg-2.2.0}/tests/test_web_db.py +0 -0
  71. {ipmg-2.1.1 → ipmg-2.2.0}/tests/test_web_manager.py +0 -0
  72. {ipmg-2.1.1 → ipmg-2.2.0}/tests/test_web_server.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ipmg
3
- Version: 2.1.1
3
+ Version: 2.2.0
4
4
  Summary: IP Management & Ping Monitoring CLI Tool
5
5
  Author: Sameer Alam
6
6
  Maintainer-email: Sameer Alam <sameeralam3127@gmail.com>
@@ -542,6 +542,21 @@ default). A finished scan overwrites those files with the complete report, so
542
542
  the file names and contents are the same as they always were. Use
543
543
  `--no-incremental` to go back to writing only at the end.
544
544
 
545
+ **Pick up where an interrupted scan stopped.** `--resume` reads the hosts the
546
+ partial report already holds, scans only the rest, and finishes that same
547
+ report — same file names, same batch timestamp:
548
+
549
+ ```bash
550
+ ipmg --input 10.0.0.0/16 --formats jsonl xlsx # Ctrl+C partway through
551
+ ipmg --input 10.0.0.0/16 --formats jsonl xlsx --resume
552
+ ipmg --input 10.0.0.0/16 --resume results_20260917_120000.csv
553
+ ```
554
+
555
+ Without a path, `--resume` takes the newest report named after `--output`,
556
+ preferring `jsonl` or `csv` (current to the last host) over `json` or `xlsx`
557
+ (current to the last autosave). Hosts dropped from the target list since are
558
+ left out of the finished report.
559
+
545
560
  **Open ports.** `Open Ports` is only populated when `--scan-ports` is set: for
546
561
  each host that answers, IPMG probes a list of common TCP ports (SSH, HTTP,
547
562
  HTTPS, RDP, SMB, FTP, SMTP, DNS, MSSQL, MySQL, PostgreSQL by default)
@@ -572,6 +587,7 @@ them — so piping IPMG into a file or a log gives you clean text.
572
587
  | `--formats` | `xlsx` | One or more of `xlsx`, `csv`, `json`, `jsonl`, `md` |
573
588
  | `--no-incremental` | off | Only write the report once the scan has finished |
574
589
  | `--autosave` | `30` | How often a running scan re-saves `xlsx`, `json`, and `md` |
590
+ | `--resume` | off | Finish an interrupted scan from its partial report (newest one, or the path given) |
575
591
 
576
592
  **Speed and accuracy**
577
593
 
@@ -495,6 +495,21 @@ default). A finished scan overwrites those files with the complete report, so
495
495
  the file names and contents are the same as they always were. Use
496
496
  `--no-incremental` to go back to writing only at the end.
497
497
 
498
+ **Pick up where an interrupted scan stopped.** `--resume` reads the hosts the
499
+ partial report already holds, scans only the rest, and finishes that same
500
+ report — same file names, same batch timestamp:
501
+
502
+ ```bash
503
+ ipmg --input 10.0.0.0/16 --formats jsonl xlsx # Ctrl+C partway through
504
+ ipmg --input 10.0.0.0/16 --formats jsonl xlsx --resume
505
+ ipmg --input 10.0.0.0/16 --resume results_20260917_120000.csv
506
+ ```
507
+
508
+ Without a path, `--resume` takes the newest report named after `--output`,
509
+ preferring `jsonl` or `csv` (current to the last host) over `json` or `xlsx`
510
+ (current to the last autosave). Hosts dropped from the target list since are
511
+ left out of the finished report.
512
+
498
513
  **Open ports.** `Open Ports` is only populated when `--scan-ports` is set: for
499
514
  each host that answers, IPMG probes a list of common TCP ports (SSH, HTTP,
500
515
  HTTPS, RDP, SMB, FTP, SMTP, DNS, MSSQL, MySQL, PostgreSQL by default)
@@ -525,6 +540,7 @@ them — so piping IPMG into a file or a log gives you clean text.
525
540
  | `--formats` | `xlsx` | One or more of `xlsx`, `csv`, `json`, `jsonl`, `md` |
526
541
  | `--no-incremental` | off | Only write the report once the scan has finished |
527
542
  | `--autosave` | `30` | How often a running scan re-saves `xlsx`, `json`, and `md` |
543
+ | `--resume` | off | Finish an interrupted scan from its partial report (newest one, or the path given) |
528
544
 
529
545
  **Speed and accuracy**
530
546
 
@@ -13,7 +13,7 @@ build-backend = "setuptools.build_meta"
13
13
 
14
14
  [project]
15
15
  name = "ipmg"
16
- version = "2.1.1" # Managed automatically by semantic-release
16
+ version = "2.2.0" # Managed automatically by semantic-release
17
17
  description = "IP Management & Ping Monitoring CLI Tool"
18
18
  readme = "README.md"
19
19
  requires-python = ">=3.9"
@@ -2,4 +2,4 @@
2
2
  ipmg - IP Management & Ping Monitoring Tool
3
3
  """
4
4
 
5
- __version__ = "2.1.1"
5
+ __version__ = "2.2.0"
@@ -219,6 +219,18 @@ def build_parser() -> argparse.ArgumentParser:
219
219
  "csv and jsonl are written per host regardless."
220
220
  ),
221
221
  )
222
+ reports.add_argument(
223
+ "--resume",
224
+ nargs="?",
225
+ const="",
226
+ default=None,
227
+ metavar="REPORT",
228
+ help=(
229
+ "Finish an interrupted scan: skip the hosts its report already holds and "
230
+ "complete that report. REPORT is its jsonl, csv, json, or xlsx file "
231
+ "(default: the newest report named after --output)."
232
+ ),
233
+ )
222
234
 
223
235
  live = parser.add_argument_group("live output")
224
236
  live.add_argument(
@@ -17,23 +17,32 @@ writes at the end of the pass, which is why the scan shares its timestamp with
17
17
  the writer. A completed scan overwrites every snapshot with the canonical
18
18
  report, so incremental writing changes what survives an interruption without
19
19
  changing what a finished scan produces.
20
+
21
+ That partial report is also where an interrupted scan picks up again:
22
+ :func:`load_partial_report` reads the hosts it already holds, and ``--resume``
23
+ scans only the rest, writing to the same files under the same timestamp.
20
24
  """
21
25
 
22
26
  from __future__ import annotations
23
27
 
28
+ import io
24
29
  import json
25
30
  import logging
31
+ import math
26
32
  import os
33
+ import re
27
34
  import time
28
35
  from dataclasses import dataclass
36
+ from datetime import datetime
29
37
  from pathlib import Path
30
- from typing import IO, Dict, List, Optional, Sequence
38
+ from typing import IO, Dict, List, Optional, Sequence, Set, Tuple
31
39
 
32
40
  import pandas as pd
33
41
 
34
42
  from ipmg.core.engine import HostResult
43
+ from ipmg.exceptions import FileIOError
35
44
  from ipmg.reporting.frames import RESULT_COLUMNS, format_open_ports
36
- from ipmg.utils.helpers import spreadsheet_escape
45
+ from ipmg.utils.helpers import FORMULA_PREFIXES, spreadsheet_escape
37
46
 
38
47
  log = logging.getLogger(__name__)
39
48
 
@@ -48,6 +57,12 @@ REPORT_FORMATS = ("xlsx", "csv", "json", "jsonl", "md")
48
57
  STREAMING_FORMATS = ("csv", "jsonl")
49
58
  #: Formats that only exist as a whole file, so they are re-snapshotted.
50
59
  SNAPSHOT_FORMATS = ("xlsx", "json", "md")
60
+ #: Formats a scan can be resumed from, most faithful first. ``md`` only
61
+ #: previews the first 25 hosts, so it cannot stand in for the whole report.
62
+ RESUMABLE_FORMATS = ("jsonl", "csv", "json", "xlsx")
63
+
64
+ #: ``<base>_<YYYYMMDD_HHMMSS>.<format>``, the name every report is saved under.
65
+ _REPORT_NAME = re.compile(r"^(?P<prefix>.+)_(?P<timestamp>\d{8}_\d{6})\.(?P<fmt>[a-z]+)$")
51
66
 
52
67
 
53
68
  @dataclass(frozen=True)
@@ -120,6 +135,7 @@ class IncrementalReport:
120
135
  batch_timestamp: object,
121
136
  options: IncrementalOptions = IncrementalOptions(),
122
137
  previous: Optional[Sequence[HostResult]] = None,
138
+ previous_elapsed_s: float = 0.0,
123
139
  ) -> None:
124
140
  self.options = options.clamped()
125
141
  self._base = base
@@ -127,13 +143,17 @@ class IncrementalReport:
127
143
  self._batch_timestamp = batch_timestamp
128
144
  self._formats = [fmt for fmt in dict.fromkeys(formats)]
129
145
  self._results: List[HostResult] = list(previous or ())
146
+ # Resumed rows keep the time the earlier run had reached, and new rows
147
+ # count on from it, so a report interrupted twice still knows how long
148
+ # the whole scan has taken.
130
149
  self._rows: List[Dict[str, object]] = [
131
- result_row(result, batch_timestamp, None) for result in self._results
150
+ result_row(result, batch_timestamp, previous_elapsed_s if previous else None)
151
+ for result in self._results
132
152
  ]
133
153
  self._handles: Dict[str, IO[str]] = {}
134
154
  self._written: Dict[str, None] = {}
135
- self._started_at = time.monotonic()
136
- self._last_snapshot = self._started_at
155
+ self._started_at = time.monotonic() - previous_elapsed_s
156
+ self._last_snapshot = time.monotonic()
137
157
  self._closed = False
138
158
 
139
159
  # -- lifecycle ---------------------------------------------------------
@@ -259,6 +279,194 @@ class IncrementalReport:
259
279
  self._handles.clear()
260
280
 
261
281
 
282
+ @dataclass(frozen=True)
283
+ class PartialReport:
284
+ """The hosts an interrupted scan left in its report, ready to resume."""
285
+
286
+ path: str
287
+ #: The ``--output`` prefix and timestamp the report was saved under, so a
288
+ #: resumed scan writes back to the same files.
289
+ base: str
290
+ timestamp: str
291
+ results: Tuple[HostResult, ...]
292
+ batch_timestamp: Optional[datetime]
293
+ #: How long the earlier run had been scanning when it stopped.
294
+ elapsed_s: float
295
+
296
+ @property
297
+ def scanned(self) -> Set[str]:
298
+ return {result.ip for result in self.results}
299
+
300
+
301
+ def find_partial_report(base: str) -> Optional[str]:
302
+ """The newest report saved under ``base`` that a scan can resume from.
303
+
304
+ When one scan left several formats behind, the most faithful one wins:
305
+ ``jsonl`` and ``csv`` hold every host up to the moment of interruption,
306
+ while ``json`` and ``xlsx`` are only as recent as their last autosave.
307
+ """
308
+ directory = Path(base).parent
309
+ prefix = Path(base).name
310
+ candidates = []
311
+ try:
312
+ entries = list(directory.iterdir())
313
+ except OSError:
314
+ return None
315
+ for entry in entries:
316
+ match = _REPORT_NAME.match(entry.name)
317
+ if not match or match["prefix"] != prefix or match["fmt"] not in RESUMABLE_FORMATS:
318
+ continue
319
+ rank = RESUMABLE_FORMATS.index(match["fmt"])
320
+ candidates.append((match["timestamp"], -rank, str(entry)))
321
+ return max(candidates)[2] if candidates else None
322
+
323
+
324
+ def load_partial_report(path: str) -> PartialReport:
325
+ """Read the hosts a report already holds, including one cut off mid-write."""
326
+ name = _REPORT_NAME.match(Path(path).name)
327
+ if name is None:
328
+ raise FileIOError(
329
+ f"Cannot resume from {path}: expected a report named like "
330
+ "results_20260917_120000.jsonl, as IPMG saves them."
331
+ )
332
+ fmt = name["fmt"]
333
+ if fmt not in RESUMABLE_FORMATS:
334
+ raise FileIOError(
335
+ f"Cannot resume from a .{fmt} report; use the "
336
+ f"{', '.join(RESUMABLE_FORMATS)} report from the same scan instead."
337
+ )
338
+
339
+ try:
340
+ frame = _read_report(path, fmt)
341
+ except FileNotFoundError:
342
+ raise FileIOError(f"Report to resume not found: {path}") from None
343
+ except (OSError, ValueError) as exc:
344
+ raise FileIOError(f"Cannot read report {path}: {exc}") from exc
345
+
346
+ missing = [column for column in ("IP Address", "Status") if column not in frame.columns]
347
+ if missing:
348
+ raise FileIOError(f"{path} is not an IPMG report: missing {', '.join(missing)}.")
349
+
350
+ # A spreadsheet format stores cells escaped against formula injection;
351
+ # the scan itself needs the text the host actually reported.
352
+ unescape = fmt in ("csv", "xlsx")
353
+ by_ip: Dict[str, HostResult] = {}
354
+ for row in frame.to_dict(orient="records"):
355
+ result = _row_result(row, unescape)
356
+ if result is not None:
357
+ by_ip[result.ip] = result
358
+
359
+ base = str(Path(path).parent / name["prefix"])
360
+ return PartialReport(
361
+ path=path,
362
+ base=base,
363
+ timestamp=name["timestamp"],
364
+ results=tuple(by_ip.values()),
365
+ batch_timestamp=_batch_timestamp(frame),
366
+ elapsed_s=_elapsed(frame),
367
+ )
368
+
369
+
370
+ def _read_report(path: str, fmt: str) -> pd.DataFrame:
371
+ if fmt == "xlsx":
372
+ return pd.read_excel(path, dtype=object)
373
+ if fmt == "json":
374
+ with open(path, encoding="utf-8") as handle:
375
+ return pd.DataFrame(json.load(handle))
376
+
377
+ with open(path, encoding="utf-8", newline="") as handle:
378
+ text = handle.read()
379
+ # Rows are flushed whole, so a last line without its newline is one the
380
+ # previous run was killed while writing. It is dropped, and that host is
381
+ # simply scanned again.
382
+ if text and not text.endswith("\n"):
383
+ text = text[: text.rfind("\n") + 1]
384
+
385
+ if fmt == "csv":
386
+ if not text.strip():
387
+ return pd.DataFrame(columns=RESULT_COLUMNS)
388
+ return pd.read_csv(io.StringIO(text), dtype=str, keep_default_na=False)
389
+
390
+ records = []
391
+ for number, line in enumerate(text.splitlines(), start=1):
392
+ if not line.strip():
393
+ continue
394
+ try:
395
+ records.append(json.loads(line))
396
+ except json.JSONDecodeError as exc:
397
+ raise ValueError(f"line {number} is not valid JSON ({exc.msg})") from None
398
+ return pd.DataFrame(records)
399
+
400
+
401
+ def _is_blank(value: object) -> bool:
402
+ return value is None or (isinstance(value, float) and math.isnan(value)) or value == ""
403
+
404
+
405
+ def _text(value: object, unescape: bool = False) -> str:
406
+ if _is_blank(value):
407
+ return ""
408
+ text = str(value).strip()
409
+ if unescape and text.startswith("'") and text[1:].startswith(FORMULA_PREFIXES):
410
+ return text[1:]
411
+ return text
412
+
413
+
414
+ def _latency(value: object) -> Optional[float]:
415
+ if _is_blank(value):
416
+ return None
417
+ try:
418
+ return float(value) # type: ignore[arg-type]
419
+ except (TypeError, ValueError):
420
+ return None
421
+
422
+
423
+ def _open_ports(value: object) -> Tuple[int, ...]:
424
+ ports = []
425
+ for piece in _text(value).split(","):
426
+ try:
427
+ ports.append(int(float(piece)))
428
+ except ValueError:
429
+ continue
430
+ return tuple(ports)
431
+
432
+
433
+ def _row_result(row: Dict[str, object], unescape: bool) -> Optional[HostResult]:
434
+ ip = _text(row.get("IP Address"))
435
+ status = _text(row.get("Status"))
436
+ if not ip or not status:
437
+ return None
438
+ return HostResult(
439
+ ip=ip,
440
+ status=status,
441
+ latency=_latency(row.get("Latency")),
442
+ hostname=_text(row.get("Hostname"), unescape),
443
+ open_ports=_open_ports(row.get("Open Ports")),
444
+ )
445
+
446
+
447
+ def _batch_timestamp(frame: pd.DataFrame) -> Optional[datetime]:
448
+ """The resumed scan's start time, so both runs report as one batch."""
449
+ if "Batch Timestamp" not in frame.columns:
450
+ return None
451
+ for value in frame["Batch Timestamp"]:
452
+ if _is_blank(value):
453
+ continue
454
+ try:
455
+ # A finished json report stores it as epoch milliseconds.
456
+ unit = "ms" if isinstance(value, (int, float)) else None
457
+ return pd.to_datetime(value, unit=unit).to_pydatetime()
458
+ except (TypeError, ValueError, OverflowError):
459
+ return None
460
+ return None
461
+
462
+
463
+ def _elapsed(frame: pd.DataFrame) -> float:
464
+ if "Scan Duration (s)" not in frame.columns:
465
+ return 0.0
466
+ durations = pd.to_numeric(frame["Scan Duration (s)"], errors="coerce").dropna()
467
+ return float(durations.max()) if not durations.empty else 0.0
468
+
469
+
262
470
  def atomic_write_bytes(path: str, data: bytes) -> None:
263
471
  """Replace ``path`` with ``data`` in one step, or leave it untouched.
264
472
 
@@ -111,7 +111,8 @@ def heading(title: str) -> None:
111
111
  def field(label: str, value: object, value_style: str = "ipmg.value") -> None:
112
112
  """One aligned ``label value`` line."""
113
113
  text = Text(INDENT)
114
- text.append(f"{label:<{LABEL_WIDTH}}", style="ipmg.label")
114
+ # A label as long as the column still gets one space before its value.
115
+ text.append(f"{label:<{LABEL_WIDTH - 1}} ", style="ipmg.label")
115
116
  text.append(str(value), style=value_style)
116
117
  console.print(text)
117
118
 
@@ -15,7 +15,7 @@ from ipmg.core.diff import DiffOptions
15
15
  from ipmg.core.discovery import discover_local_subnet
16
16
  from ipmg.core.engine import HostResult, ScanConfig, execute_scan
17
17
  from ipmg.core.portscan import DEFAULT_PORTS
18
- from ipmg.exceptions import HistoryError
18
+ from ipmg.exceptions import FileIOError, HistoryError
19
19
  from ipmg.infrastructure.file_io import (
20
20
  DEFAULT_INPUT_FILE,
21
21
  create_sample_file,
@@ -26,6 +26,9 @@ from ipmg.infrastructure.incremental import (
26
26
  DEFAULT_AUTOSAVE_S,
27
27
  IncrementalOptions,
28
28
  IncrementalReport,
29
+ PartialReport,
30
+ find_partial_report,
31
+ load_partial_report,
29
32
  )
30
33
  from ipmg.reporting import ui
31
34
  from ipmg.reporting.diff_report import export_diff, print_diff
@@ -49,6 +52,8 @@ class ScanOutcome:
49
52
  source: str
50
53
  #: File-name stamp shared by the incremental writes and the final report.
51
54
  timestamp: str
55
+ #: Report file name prefix; a resumed scan keeps the one it started with.
56
+ output: str
52
57
 
53
58
 
54
59
  @dataclass(frozen=True)
@@ -88,6 +93,20 @@ def _incremental_options(args) -> IncrementalOptions:
88
93
  ).clamped()
89
94
 
90
95
 
96
+ def _load_resume(args) -> Optional[PartialReport]:
97
+ """The partial report ``--resume`` points at, or the newest one for ``--output``."""
98
+ target = getattr(args, "resume", None)
99
+ if target is None:
100
+ return None
101
+ path = target or find_partial_report(args.output)
102
+ if path is None:
103
+ raise FileIOError(
104
+ f"No report to resume: nothing named {args.output}_<timestamp>.<format> "
105
+ "was found. Pass the report's path, as in --resume results_20260917_120000.jsonl."
106
+ )
107
+ return load_partial_report(path)
108
+
109
+
91
110
  def _stream_options(args) -> StreamOptions:
92
111
  """Read live-output settings off the parsed arguments."""
93
112
  all_hosts = bool(getattr(args, "stream_all", False))
@@ -175,18 +194,32 @@ def _scan_with_progress(
175
194
  def _open_report(
176
195
  args,
177
196
  incremental: IncrementalOptions,
197
+ output: str,
178
198
  timestamp: str,
179
199
  batch_timestamp: datetime,
200
+ previous: List[HostResult],
201
+ previous_elapsed_s: float,
180
202
  ) -> Optional[IncrementalReport]:
181
203
  """Start writing this pass's report, unless incremental writing is off."""
182
204
  if not incremental.enabled or not args.formats:
183
205
  return None
184
206
  return IncrementalReport(
185
- base=args.output,
207
+ base=output,
186
208
  formats=args.formats,
187
209
  timestamp=timestamp,
188
210
  batch_timestamp=batch_timestamp,
189
211
  options=incremental,
212
+ previous=previous,
213
+ previous_elapsed_s=previous_elapsed_s,
214
+ )
215
+
216
+
217
+ def _announce_resume(resume: PartialReport, done: int, total: int) -> None:
218
+ ui.fields(
219
+ [
220
+ ("Resuming", resume.path),
221
+ ("Left", f"{ui.plural(total - done, 'host')} of {total} still to scan"),
222
+ ]
190
223
  )
191
224
 
192
225
 
@@ -224,18 +257,32 @@ def _run_single_pass(
224
257
  config: ScanConfig,
225
258
  stream: StreamOptions,
226
259
  incremental: IncrementalOptions,
260
+ resume: Optional[PartialReport] = None,
227
261
  ) -> ScanOutcome:
228
- batch_timestamp = current_timestamp()
229
- timestamp = timestamp_str()
262
+ batch_timestamp = (resume and resume.batch_timestamp) or current_timestamp()
263
+ timestamp = resume.timestamp if resume else timestamp_str()
264
+ output = resume.base if resume else args.output
230
265
  started_at = time.perf_counter()
231
266
 
232
267
  ip_list = discover_local_subnet() if args.discover else load_targets(args.input)
233
268
  source = "auto-discovery" if args.discover else args.input
234
269
 
270
+ # Hosts the earlier run finished are kept only if they are still targets,
271
+ # so resuming against an edited list never reports hosts it no longer has.
272
+ targets = set(ip_list)
273
+ previous = [result for result in resume.results if result.ip in targets] if resume else []
274
+ previous_elapsed_s = resume.elapsed_s if resume else 0.0
275
+ scanned = {result.ip for result in previous}
276
+ remaining = [ip for ip in ip_list if ip not in scanned]
277
+
235
278
  _print_configuration(source, len(ip_list), config)
236
- report = _open_report(args, incremental, timestamp, batch_timestamp)
237
- results = _run_pass(ip_list, config, stream, report)
238
- duration = time.perf_counter() - started_at
279
+ if resume:
280
+ _announce_resume(resume, len(previous), len(ip_list))
281
+ report = _open_report(
282
+ args, incremental, output, timestamp, batch_timestamp, previous, previous_elapsed_s
283
+ )
284
+ results = previous + _run_pass(remaining, config, stream, report)
285
+ duration = previous_elapsed_s + time.perf_counter() - started_at
239
286
 
240
287
  return ScanOutcome(
241
288
  results=results,
@@ -244,6 +291,7 @@ def _run_single_pass(
244
291
  duration_s=duration,
245
292
  source=source,
246
293
  timestamp=timestamp,
294
+ output=output,
247
295
  )
248
296
 
249
297
 
@@ -306,14 +354,18 @@ def run_scan(args) -> None:
306
354
  stream = _stream_options(args)
307
355
  incremental = _incremental_options(args)
308
356
  _ensure_input_file(args)
357
+ resume = _load_resume(args)
309
358
 
310
359
  while True:
311
- outcome = _run_single_pass(args, config, stream, incremental)
360
+ outcome = _run_single_pass(args, config, stream, incremental, resume)
361
+ # Only the first pass picks up where an earlier run stopped; every
362
+ # --interval pass after it is a fresh scan.
363
+ resume = None
312
364
 
313
365
  print_summary(outcome.frame, outcome.batch_timestamp, outcome.duration_s)
314
366
  # Overwrites whatever the incremental writer left on those same paths,
315
367
  # so a finished scan produces exactly the report it always did.
316
- save_results(outcome.frame, args.output, args.formats, timestamp=outcome.timestamp)
368
+ save_results(outcome.frame, outcome.output, args.formats, timestamp=outcome.timestamp)
317
369
  _store_and_compare(history_options, config, outcome)
318
370
 
319
371
  if not args.interval:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ipmg
3
- Version: 2.1.1
3
+ Version: 2.2.0
4
4
  Summary: IP Management & Ping Monitoring CLI Tool
5
5
  Author: Sameer Alam
6
6
  Maintainer-email: Sameer Alam <sameeralam3127@gmail.com>
@@ -542,6 +542,21 @@ default). A finished scan overwrites those files with the complete report, so
542
542
  the file names and contents are the same as they always were. Use
543
543
  `--no-incremental` to go back to writing only at the end.
544
544
 
545
+ **Pick up where an interrupted scan stopped.** `--resume` reads the hosts the
546
+ partial report already holds, scans only the rest, and finishes that same
547
+ report — same file names, same batch timestamp:
548
+
549
+ ```bash
550
+ ipmg --input 10.0.0.0/16 --formats jsonl xlsx # Ctrl+C partway through
551
+ ipmg --input 10.0.0.0/16 --formats jsonl xlsx --resume
552
+ ipmg --input 10.0.0.0/16 --resume results_20260917_120000.csv
553
+ ```
554
+
555
+ Without a path, `--resume` takes the newest report named after `--output`,
556
+ preferring `jsonl` or `csv` (current to the last host) over `json` or `xlsx`
557
+ (current to the last autosave). Hosts dropped from the target list since are
558
+ left out of the finished report.
559
+
545
560
  **Open ports.** `Open Ports` is only populated when `--scan-ports` is set: for
546
561
  each host that answers, IPMG probes a list of common TCP ports (SSH, HTTP,
547
562
  HTTPS, RDP, SMB, FTP, SMTP, DNS, MSSQL, MySQL, PostgreSQL by default)
@@ -572,6 +587,7 @@ them — so piping IPMG into a file or a log gives you clean text.
572
587
  | `--formats` | `xlsx` | One or more of `xlsx`, `csv`, `json`, `jsonl`, `md` |
573
588
  | `--no-incremental` | off | Only write the report once the scan has finished |
574
589
  | `--autosave` | `30` | How often a running scan re-saves `xlsx`, `json`, and `md` |
590
+ | `--resume` | off | Finish an interrupted scan from its partial report (newest one, or the path given) |
575
591
 
576
592
  **Speed and accuracy**
577
593
 
@@ -7,11 +7,14 @@ import pandas as pd
7
7
  import pytest
8
8
 
9
9
  from ipmg.core.engine import HostResult
10
+ from ipmg.exceptions import FileIOError
10
11
  from ipmg.infrastructure.file_io import save_results, write_report
11
12
  from ipmg.infrastructure.incremental import (
12
13
  IncrementalOptions,
13
14
  IncrementalReport,
14
15
  atomic_write_bytes,
16
+ find_partial_report,
17
+ load_partial_report,
15
18
  )
16
19
  from ipmg.services.scan_service import run_scan
17
20
  from ipmg.utils.helpers import console
@@ -225,3 +228,163 @@ def test_finished_scan_writes_one_set_of_reports(tmp_path, monkeypatch):
225
228
  assert sorted(row["IP Address"] for row in rows) == ["1.1.1.1", "8.8.8.8"]
226
229
  # Every row carries the duration of the whole pass, as it did before.
227
230
  assert len({row["Scan Duration (s)"] for row in rows}) == 1
231
+
232
+
233
+ # -- resuming an interrupted scan ---------------------------------------------
234
+
235
+
236
+ def _pinged(calls):
237
+ def ping_ip(ip, _timeout, _count):
238
+ calls.append(ip)
239
+ return "Active", 2.0
240
+
241
+ return ping_ip
242
+
243
+
244
+ def _interrupt_first_run(tmp_path, monkeypatch, targets, stop_at, **overrides):
245
+ monkeypatch.setattr("ipmg.services.scan_service.load_targets", lambda _source: targets)
246
+ monkeypatch.setattr("ipmg.core.engine.ping_ip", _interrupted_ping(stop_at))
247
+ with pytest.raises(KeyboardInterrupt):
248
+ run_scan(_scan_args(tmp_path, **overrides))
249
+
250
+
251
+ def test_resume_scans_only_the_hosts_left(tmp_path, monkeypatch):
252
+ targets = ["8.8.8.8", "1.1.1.1", "9.9.9.9"]
253
+ _interrupt_first_run(tmp_path, monkeypatch, targets, "1.1.1.1")
254
+ (partial,) = tmp_path.glob("scan_*.csv")
255
+
256
+ calls = []
257
+ monkeypatch.setattr("ipmg.core.engine.ping_ip", _pinged(calls))
258
+ run_scan(_scan_args(tmp_path, resume=str(partial)))
259
+
260
+ assert sorted(calls) == ["1.1.1.1", "9.9.9.9"]
261
+ # The finished report lands on the file the interrupted run started.
262
+ assert list(tmp_path.glob("scan_*.csv")) == [partial]
263
+ rows = list(csv.DictReader(open(partial, encoding="utf-8")))
264
+ assert sorted(row["IP Address"] for row in rows) == sorted(targets)
265
+ assert len({row["Batch Timestamp"] for row in rows}) == 1
266
+ assert len({row["Scan Duration (s)"] for row in rows}) == 1
267
+
268
+
269
+ def test_resume_without_a_path_picks_the_newest_report(tmp_path, monkeypatch):
270
+ (tmp_path / "scan_20200101_000000.jsonl").write_text(
271
+ json.dumps({"IP Address": "1.1.1.1", "Status": "Active"}) + "\n", encoding="utf-8"
272
+ )
273
+ _interrupt_first_run(
274
+ tmp_path, monkeypatch, ["8.8.8.8", "1.1.1.1"], "1.1.1.1", formats=["jsonl", "xlsx"]
275
+ )
276
+
277
+ calls = []
278
+ monkeypatch.setattr("ipmg.core.engine.ping_ip", _pinged(calls))
279
+ run_scan(_scan_args(tmp_path, formats=["jsonl", "xlsx"], resume=""))
280
+
281
+ # The older report lists 1.1.1.1 as done; the newer one, which wins, does not.
282
+ assert calls == ["1.1.1.1"]
283
+
284
+
285
+ def test_resume_with_no_report_to_find_is_an_error(tmp_path):
286
+ with pytest.raises(FileIOError, match="No report to resume"):
287
+ run_scan(_scan_args(tmp_path, resume=""))
288
+
289
+
290
+ def test_interrupting_a_resumed_scan_keeps_both_runs(tmp_path, monkeypatch):
291
+ targets = ["8.8.8.8", "1.1.1.1", "9.9.9.9"]
292
+ _interrupt_first_run(tmp_path, monkeypatch, targets, "1.1.1.1", formats=["jsonl"])
293
+ (partial,) = tmp_path.glob("scan_*.jsonl")
294
+
295
+ monkeypatch.setattr(
296
+ "ipmg.services.scan_service.load_targets", lambda _source: ["8.8.8.8", "9.9.9.9", "1.1.1.1"]
297
+ )
298
+ with pytest.raises(KeyboardInterrupt):
299
+ run_scan(_scan_args(tmp_path, formats=["jsonl"], resume=str(partial)))
300
+
301
+ resumed = load_partial_report(str(partial))
302
+ assert sorted(resumed.scanned) == ["8.8.8.8", "9.9.9.9"]
303
+
304
+
305
+ def test_resume_drops_hosts_no_longer_targeted(tmp_path, monkeypatch):
306
+ partial = tmp_path / "scan_20260917_120000.jsonl"
307
+ partial.write_text(
308
+ "".join(
309
+ json.dumps({"IP Address": ip, "Status": "Active", "Batch Timestamp": BATCH}) + "\n"
310
+ for ip in ("8.8.8.8", "10.0.0.1")
311
+ ),
312
+ encoding="utf-8",
313
+ )
314
+ monkeypatch.setattr(
315
+ "ipmg.services.scan_service.load_targets", lambda _source: ["8.8.8.8", "1.1.1.1"]
316
+ )
317
+ monkeypatch.setattr("ipmg.core.engine.ping_ip", _pinged([]))
318
+
319
+ run_scan(_scan_args(tmp_path, formats=["jsonl"], resume=str(partial)))
320
+
321
+ rows = [json.loads(line) for line in partial.read_text(encoding="utf-8").splitlines()]
322
+ assert sorted(row["IP Address"] for row in rows) == ["1.1.1.1", "8.8.8.8"]
323
+ assert {row["Batch Timestamp"] for row in rows} == {BATCH}
324
+
325
+
326
+ def test_load_drops_a_line_cut_off_mid_write(tmp_path):
327
+ partial = tmp_path / "scan_20260917_120000.jsonl"
328
+ partial.write_text(
329
+ json.dumps({"IP Address": "8.8.8.8", "Status": "Active", "Scan Duration (s)": 4.5})
330
+ + '\n{"IP Address": "1.1.1.1", "Sta',
331
+ encoding="utf-8",
332
+ )
333
+
334
+ loaded = load_partial_report(str(partial))
335
+
336
+ assert [result.ip for result in loaded.results] == ["8.8.8.8"]
337
+ assert loaded.elapsed_s == 4.5
338
+ assert loaded.base == str(tmp_path / "scan")
339
+ assert loaded.timestamp == "20260917_120000"
340
+
341
+
342
+ @pytest.mark.parametrize("fmt", ["csv", "xlsx", "json"])
343
+ def test_load_round_trips_every_field(tmp_path, fmt):
344
+ original = HostResult(
345
+ ip="8.8.8.8",
346
+ status="Active",
347
+ latency=12.5,
348
+ hostname="=evil.example",
349
+ open_ports=(22, 443),
350
+ )
351
+ with _report(tmp_path, [fmt]) as report:
352
+ report.record(original)
353
+ report.record(_result("1.1.1.1", "Timeout", None))
354
+ report.snapshot()
355
+
356
+ loaded = load_partial_report(report.path_for(fmt))
357
+
358
+ # Spreadsheet formats store the hostname escaped; the scan gets it back as-is.
359
+ assert loaded.results[0] == original
360
+ assert loaded.results[1].latency is None
361
+ assert str(loaded.batch_timestamp) == BATCH
362
+
363
+
364
+ @pytest.mark.parametrize(
365
+ "name, message",
366
+ [
367
+ ("scan_20260917_120000.md", "Cannot resume from a .md report"),
368
+ ("notes.jsonl", "expected a report named like"),
369
+ ("scan_20260917_999999.csv", "not found"),
370
+ ],
371
+ )
372
+ def test_load_rejects_what_it_cannot_resume(tmp_path, name, message):
373
+ if not name.endswith(".csv"):
374
+ (tmp_path / name).write_text("", encoding="utf-8")
375
+
376
+ with pytest.raises(FileIOError, match=message):
377
+ load_partial_report(str(tmp_path / name))
378
+
379
+
380
+ def test_find_prefers_the_most_complete_format(tmp_path):
381
+ for name in (
382
+ "scan_20260917_120000.xlsx",
383
+ "scan_20260917_120000.jsonl",
384
+ "other_20990101_000000.csv",
385
+ ):
386
+ (tmp_path / name).write_text("", encoding="utf-8")
387
+
388
+ assert find_partial_report(str(tmp_path / "scan")) == str(
389
+ tmp_path / "scan_20260917_120000.jsonl"
390
+ )
@@ -55,3 +55,13 @@ def test_parser_accepts_streaming_flags():
55
55
 
56
56
  assert args.stream_all is True
57
57
  assert args.stream_refresh == 1.0
58
+
59
+
60
+ def test_parser_resume_takes_an_optional_report():
61
+ parser = build_parser()
62
+
63
+ assert parser.parse_args([]).resume is None
64
+ assert parser.parse_args(["--resume"]).resume == ""
65
+ assert parser.parse_args(["--resume", "scan_20260917_120000.csv"]).resume == (
66
+ "scan_20260917_120000.csv"
67
+ )
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes