ipmg 2.0.0__tar.gz → 2.1.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 (71) hide show
  1. {ipmg-2.0.0/src/ipmg.egg-info → ipmg-2.1.0}/PKG-INFO +63 -6
  2. {ipmg-2.0.0 → ipmg-2.1.0}/README.md +59 -3
  3. {ipmg-2.0.0 → ipmg-2.1.0}/pyproject.toml +13 -3
  4. {ipmg-2.0.0 → ipmg-2.1.0}/src/ipmg/__init__.py +1 -1
  5. {ipmg-2.0.0 → ipmg-2.1.0}/src/ipmg/cli/parser.py +26 -2
  6. {ipmg-2.0.0 → ipmg-2.1.0}/src/ipmg/core/engine.py +6 -0
  7. {ipmg-2.0.0 → ipmg-2.1.0}/src/ipmg/infrastructure/file_io.py +45 -15
  8. ipmg-2.1.0/src/ipmg/infrastructure/incremental.py +276 -0
  9. {ipmg-2.0.0 → ipmg-2.1.0}/src/ipmg/services/scan_service.py +89 -6
  10. {ipmg-2.0.0 → ipmg-2.1.0/src/ipmg.egg-info}/PKG-INFO +63 -6
  11. {ipmg-2.0.0 → ipmg-2.1.0}/src/ipmg.egg-info/SOURCES.txt +2 -0
  12. {ipmg-2.0.0 → ipmg-2.1.0}/src/ipmg.egg-info/requires.txt +2 -1
  13. {ipmg-2.0.0 → ipmg-2.1.0}/tests/test_engine.py +1 -3
  14. ipmg-2.1.0/tests/test_incremental.py +227 -0
  15. {ipmg-2.0.0 → ipmg-2.1.0}/tests/test_scan_service.py +11 -11
  16. {ipmg-2.0.0 → ipmg-2.1.0}/LICENSE +0 -0
  17. {ipmg-2.0.0 → ipmg-2.1.0}/setup.cfg +0 -0
  18. {ipmg-2.0.0 → ipmg-2.1.0}/src/ipmg/__main__.py +0 -0
  19. {ipmg-2.0.0 → ipmg-2.1.0}/src/ipmg/cli/__init__.py +0 -0
  20. {ipmg-2.0.0 → ipmg-2.1.0}/src/ipmg/cli/commands.py +0 -0
  21. {ipmg-2.0.0 → ipmg-2.1.0}/src/ipmg/core/__init__.py +0 -0
  22. {ipmg-2.0.0 → ipmg-2.1.0}/src/ipmg/core/diff.py +0 -0
  23. {ipmg-2.0.0 → ipmg-2.1.0}/src/ipmg/core/discovery.py +0 -0
  24. {ipmg-2.0.0 → ipmg-2.1.0}/src/ipmg/core/ping.py +0 -0
  25. {ipmg-2.0.0 → ipmg-2.1.0}/src/ipmg/core/portscan.py +0 -0
  26. {ipmg-2.0.0 → ipmg-2.1.0}/src/ipmg/core/security.py +0 -0
  27. {ipmg-2.0.0 → ipmg-2.1.0}/src/ipmg/exceptions.py +0 -0
  28. {ipmg-2.0.0 → ipmg-2.1.0}/src/ipmg/infrastructure/__init__.py +0 -0
  29. {ipmg-2.0.0 → ipmg-2.1.0}/src/ipmg/infrastructure/database.py +0 -0
  30. {ipmg-2.0.0 → ipmg-2.1.0}/src/ipmg/reporting/__init__.py +0 -0
  31. {ipmg-2.0.0 → ipmg-2.1.0}/src/ipmg/reporting/diff_report.py +0 -0
  32. {ipmg-2.0.0 → ipmg-2.1.0}/src/ipmg/reporting/frames.py +0 -0
  33. {ipmg-2.0.0 → ipmg-2.1.0}/src/ipmg/reporting/live.py +0 -0
  34. {ipmg-2.0.0 → ipmg-2.1.0}/src/ipmg/reporting/summary.py +0 -0
  35. {ipmg-2.0.0 → ipmg-2.1.0}/src/ipmg/reporting/ui.py +0 -0
  36. {ipmg-2.0.0 → ipmg-2.1.0}/src/ipmg/services/__init__.py +0 -0
  37. {ipmg-2.0.0 → ipmg-2.1.0}/src/ipmg/services/history_service.py +0 -0
  38. {ipmg-2.0.0 → ipmg-2.1.0}/src/ipmg/utils/__init__.py +0 -0
  39. {ipmg-2.0.0 → ipmg-2.1.0}/src/ipmg/utils/helpers.py +0 -0
  40. {ipmg-2.0.0 → ipmg-2.1.0}/src/ipmg/web/__init__.py +0 -0
  41. {ipmg-2.0.0 → ipmg-2.1.0}/src/ipmg/web/app.py +0 -0
  42. {ipmg-2.0.0 → ipmg-2.1.0}/src/ipmg/web/db.py +0 -0
  43. {ipmg-2.0.0 → ipmg-2.1.0}/src/ipmg/web/manager.py +0 -0
  44. {ipmg-2.0.0 → ipmg-2.1.0}/src/ipmg/web/server.py +0 -0
  45. {ipmg-2.0.0 → ipmg-2.1.0}/src/ipmg/web/static/css/app.css +0 -0
  46. {ipmg-2.0.0 → ipmg-2.1.0}/src/ipmg/web/static/index.html +0 -0
  47. {ipmg-2.0.0 → ipmg-2.1.0}/src/ipmg/web/static/js/api.js +0 -0
  48. {ipmg-2.0.0 → ipmg-2.1.0}/src/ipmg/web/static/js/app.js +0 -0
  49. {ipmg-2.0.0 → ipmg-2.1.0}/src/ipmg/web/static/js/charts.js +0 -0
  50. {ipmg-2.0.0 → ipmg-2.1.0}/src/ipmg/web/static/js/demo.js +0 -0
  51. {ipmg-2.0.0 → ipmg-2.1.0}/src/ipmg/web/static/js/views.js +0 -0
  52. {ipmg-2.0.0 → ipmg-2.1.0}/src/ipmg.egg-info/dependency_links.txt +0 -0
  53. {ipmg-2.0.0 → ipmg-2.1.0}/src/ipmg.egg-info/entry_points.txt +0 -0
  54. {ipmg-2.0.0 → ipmg-2.1.0}/src/ipmg.egg-info/top_level.txt +0 -0
  55. {ipmg-2.0.0 → ipmg-2.1.0}/tests/test_commands.py +0 -0
  56. {ipmg-2.0.0 → ipmg-2.1.0}/tests/test_database_history.py +0 -0
  57. {ipmg-2.0.0 → ipmg-2.1.0}/tests/test_diff.py +0 -0
  58. {ipmg-2.0.0 → ipmg-2.1.0}/tests/test_diff_report.py +0 -0
  59. {ipmg-2.0.0 → ipmg-2.1.0}/tests/test_discover.py +0 -0
  60. {ipmg-2.0.0 → ipmg-2.1.0}/tests/test_file_io.py +0 -0
  61. {ipmg-2.0.0 → ipmg-2.1.0}/tests/test_history_service.py +0 -0
  62. {ipmg-2.0.0 → ipmg-2.1.0}/tests/test_live.py +0 -0
  63. {ipmg-2.0.0 → ipmg-2.1.0}/tests/test_parser.py +0 -0
  64. {ipmg-2.0.0 → ipmg-2.1.0}/tests/test_ping.py +0 -0
  65. {ipmg-2.0.0 → ipmg-2.1.0}/tests/test_ping_command.py +0 -0
  66. {ipmg-2.0.0 → ipmg-2.1.0}/tests/test_portscan.py +0 -0
  67. {ipmg-2.0.0 → ipmg-2.1.0}/tests/test_ui.py +0 -0
  68. {ipmg-2.0.0 → ipmg-2.1.0}/tests/test_utils.py +0 -0
  69. {ipmg-2.0.0 → ipmg-2.1.0}/tests/test_web_api.py +0 -0
  70. {ipmg-2.0.0 → ipmg-2.1.0}/tests/test_web_db.py +0 -0
  71. {ipmg-2.0.0 → ipmg-2.1.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.0.0
3
+ Version: 2.1.0
4
4
  Summary: IP Management & Ping Monitoring CLI Tool
5
5
  Author: Sameer Alam
6
6
  Maintainer-email: Sameer Alam <sameeralam3127@gmail.com>
@@ -10,7 +10,7 @@ Project-URL: Documentation, https://github.com/sameeralam3127/ipmg#readme
10
10
  Project-URL: Repository, https://github.com/sameeralam3127/ipmg
11
11
  Project-URL: Issues, https://github.com/sameeralam3127/ipmg/issues
12
12
  Project-URL: Changelog, https://github.com/sameeralam3127/ipmg/releases
13
- Keywords: ip,network,ping,monitoring,cli,port-scan
13
+ Keywords: ip,network,ping,monitoring,cli,port-scan,network-scanner,ping-sweep,subnet-scanner,homelab,sysadmin
14
14
  Classifier: Programming Language :: Python :: 3
15
15
  Classifier: Programming Language :: Python :: 3 :: Only
16
16
  Classifier: Programming Language :: Python :: 3.9
@@ -29,7 +29,7 @@ Classifier: Environment :: Console
29
29
  Requires-Python: >=3.9
30
30
  Description-Content-Type: text/markdown
31
31
  License-File: LICENSE
32
- Requires-Dist: pandas>=2.0
32
+ Requires-Dist: pandas>=2.2.2
33
33
  Requires-Dist: openpyxl>=3.1
34
34
  Requires-Dist: rich>=13.0
35
35
  Requires-Dist: fastapi>=0.110
@@ -38,6 +38,7 @@ Requires-Dist: websockets>=12
38
38
  Requires-Dist: python-multipart>=0.0.9
39
39
  Provides-Extra: dev
40
40
  Requires-Dist: pytest>=7.4; extra == "dev"
41
+ Requires-Dist: pytest-cov>=5.0; extra == "dev"
41
42
  Requires-Dist: ruff>=0.4; extra == "dev"
42
43
  Requires-Dist: pre-commit>=3.5.0; extra == "dev"
43
44
  Requires-Dist: python-semantic-release>=10.5.3; extra == "dev"
@@ -58,8 +59,19 @@ can send to someone: Excel, CSV, JSON, or Markdown. It works from the command
58
59
  line or from IPMG Web, a local browser UI, and it remembers every scan so it can tell
59
60
  you what moved.
60
61
 
62
+ <p align="center">
63
+ <img src="https://raw.githubusercontent.com/sameeralam3127/ipmg/main/docs/assets/ipmg-demo.gif" alt="ipmg scanning 13 hosts in parallel: live results with reverse DNS names and latency, then a summary of 10 active and 3 timed out" width="820">
64
+ </p>
65
+
66
+ **Why not `nmap -sn`, `fping`, or Angry IP Scanner?** They tell you what is up
67
+ right now. IPMG also remembers every scan and tells you what changed since the
68
+ last one: the host that dropped off, the device that appeared, the latency that
69
+ doubled. It writes the report you would otherwise build by hand.
70
+ [How it compares](#how-it-compares)
71
+
61
72
  **Website:** [sameeralam3127.github.io/ipmg](https://sameeralam3127.github.io/ipmg/) ·
62
- **Live demo:** [IPMG Web with sample data](https://sameeralam3127.github.io/ipmg/demo/)
73
+ **Live demo:** [IPMG Web with sample data](https://sameeralam3127.github.io/ipmg/demo/) ·
74
+ **Command builder:** [pick what you want, copy the command](https://sameeralam3127.github.io/ipmg/#builder)
63
75
 
64
76
  ```bash
65
77
  pip install ipmg
@@ -71,6 +83,7 @@ ipmg --discover # scan the network you are on, right now
71
83
 
72
84
  **Contents**
73
85
 
86
+ [How it compares](#how-it-compares) ·
74
87
  [Install](#install) ·
75
88
  [Your first scan](#your-first-scan) ·
76
89
  [Common tasks](#common-tasks) ·
@@ -86,6 +99,26 @@ ipmg --discover # scan the network you are on, right now
86
99
 
87
100
  ---
88
101
 
102
+ ## How it compares
103
+
104
+ | | IPMG | `nmap -sn` | `fping` | Angry IP Scanner |
105
+ | --- | --- | --- | --- | --- |
106
+ | Parallel ping sweep | Yes | Yes | Yes | Yes |
107
+ | Scan history and "what changed" | Built in | Save XML, compare with `ndiff` | No | No |
108
+ | Reports | Excel, CSV, JSON, Markdown | XML, grepable text | Plain text | CSV, TXT, XML |
109
+ | Browser UI | IPMG Web, local | No (Zenmap is a desktop app) | No | Desktop app (Java) |
110
+ | Port checks | Common TCP ports | Full port and OS scanner | No | Via fetchers |
111
+
112
+ Reach for nmap when you need a real port or OS scanner. Reach for IPMG when you
113
+ look after a network and need to know what moved since yesterday, with a report
114
+ you can hand to someone.
115
+
116
+ <p align="center">
117
+ <img src="https://raw.githubusercontent.com/sameeralam3127/ipmg/main/docs/assets/ipmg-web.png" alt="IPMG Web dashboard: scan totals, a status donut of 16 active, 1 timeout and 1 inactive host, a latency trend chart, and a list of recent scans" width="820">
118
+ </p>
119
+
120
+ ---
121
+
89
122
  ## Install
90
123
 
91
124
  **Linux and macOS — one command, works on every distribution:**
@@ -280,6 +313,19 @@ exist yet. A file you name with `--input` must already exist.
280
313
 
281
314
  ## Common tasks
282
315
 
316
+ Not sure which flags you need? The
317
+ [command builder](https://sameeralam3127.github.io/ipmg/#builder) on the
318
+ website puts the command together as you pick what you want to know, and
319
+ explains every flag it adds.
320
+
321
+ <p align="center">
322
+ <a href="https://sameeralam3127.github.io/ipmg/#builder">
323
+ <img src="https://raw.githubusercontent.com/sameeralam3127/ipmg/main/docs/assets/ipmg-builder.png" alt="IPMG command builder: choose Scan, Compare, History, or Web, enter a target such as 192.168.1.0/24, switch on options like live results or hostnames, and copy the generated ipmg command with each flag explained" width="820">
324
+ </a>
325
+ </p>
326
+
327
+ Or pick a ready-made command:
328
+
283
329
  | I want to… | Command |
284
330
  | --- | --- |
285
331
  | Scan the network I am on | `ipmg --discover` |
@@ -478,7 +524,16 @@ Status is one of `Active`, `Inactive`, `Timeout`, `Unreachable`, `Invalid IP`,
478
524
  or `Error`. `Hostname` is filled in when you pass `--resolve`.
479
525
 
480
526
  The `md` format produces a shareable Markdown report with a status summary
481
- table — handy for tickets, handoffs, and incident timelines.
527
+ table — handy for tickets, handoffs, and incident timelines. `jsonl` writes one
528
+ JSON object per line, which streams into `jq` and log pipelines.
529
+
530
+ **Reports survive an interrupted scan.** A scan writes its report as it goes,
531
+ so pressing Ctrl+C halfway through a /16 leaves a valid report of everything
532
+ scanned so far instead of nothing at all. `csv` and `jsonl` are appended per
533
+ host; `xlsx`, `json`, and `md` are re-saved every `--autosave` seconds (30 by
534
+ default). A finished scan overwrites those files with the complete report, so
535
+ the file names and contents are the same as they always were. Use
536
+ `--no-incremental` to go back to writing only at the end.
482
537
 
483
538
  **Open ports.** `Open Ports` is only populated when `--scan-ports` is set: for
484
539
  each host that answers, IPMG probes a list of common TCP ports (SSH, HTTP,
@@ -507,7 +562,9 @@ them — so piping IPMG into a file or a log gives you clean text.
507
562
  | `--input` | `ip_list.xlsx` | What to scan: a file (`.xlsx`, `.xls`, `.csv`, `.txt`, `.list`), a single IP, a CIDR block, or a range (`10.0.0.1-10.0.0.50`) |
508
563
  | `--discover` | off | Auto-detect and scan the local subnet instead |
509
564
  | `--output` | `results` | Report file name prefix |
510
- | `--formats` | `xlsx` | One or more of `xlsx`, `csv`, `json`, `md` |
565
+ | `--formats` | `xlsx` | One or more of `xlsx`, `csv`, `json`, `jsonl`, `md` |
566
+ | `--no-incremental` | off | Only write the report once the scan has finished |
567
+ | `--autosave` | `30` | How often a running scan re-saves `xlsx`, `json`, and `md` |
511
568
 
512
569
  **Speed and accuracy**
513
570
 
@@ -12,8 +12,19 @@ can send to someone: Excel, CSV, JSON, or Markdown. It works from the command
12
12
  line or from IPMG Web, a local browser UI, and it remembers every scan so it can tell
13
13
  you what moved.
14
14
 
15
+ <p align="center">
16
+ <img src="https://raw.githubusercontent.com/sameeralam3127/ipmg/main/docs/assets/ipmg-demo.gif" alt="ipmg scanning 13 hosts in parallel: live results with reverse DNS names and latency, then a summary of 10 active and 3 timed out" width="820">
17
+ </p>
18
+
19
+ **Why not `nmap -sn`, `fping`, or Angry IP Scanner?** They tell you what is up
20
+ right now. IPMG also remembers every scan and tells you what changed since the
21
+ last one: the host that dropped off, the device that appeared, the latency that
22
+ doubled. It writes the report you would otherwise build by hand.
23
+ [How it compares](#how-it-compares)
24
+
15
25
  **Website:** [sameeralam3127.github.io/ipmg](https://sameeralam3127.github.io/ipmg/) ·
16
- **Live demo:** [IPMG Web with sample data](https://sameeralam3127.github.io/ipmg/demo/)
26
+ **Live demo:** [IPMG Web with sample data](https://sameeralam3127.github.io/ipmg/demo/) ·
27
+ **Command builder:** [pick what you want, copy the command](https://sameeralam3127.github.io/ipmg/#builder)
17
28
 
18
29
  ```bash
19
30
  pip install ipmg
@@ -25,6 +36,7 @@ ipmg --discover # scan the network you are on, right now
25
36
 
26
37
  **Contents**
27
38
 
39
+ [How it compares](#how-it-compares) ·
28
40
  [Install](#install) ·
29
41
  [Your first scan](#your-first-scan) ·
30
42
  [Common tasks](#common-tasks) ·
@@ -40,6 +52,26 @@ ipmg --discover # scan the network you are on, right now
40
52
 
41
53
  ---
42
54
 
55
+ ## How it compares
56
+
57
+ | | IPMG | `nmap -sn` | `fping` | Angry IP Scanner |
58
+ | --- | --- | --- | --- | --- |
59
+ | Parallel ping sweep | Yes | Yes | Yes | Yes |
60
+ | Scan history and "what changed" | Built in | Save XML, compare with `ndiff` | No | No |
61
+ | Reports | Excel, CSV, JSON, Markdown | XML, grepable text | Plain text | CSV, TXT, XML |
62
+ | Browser UI | IPMG Web, local | No (Zenmap is a desktop app) | No | Desktop app (Java) |
63
+ | Port checks | Common TCP ports | Full port and OS scanner | No | Via fetchers |
64
+
65
+ Reach for nmap when you need a real port or OS scanner. Reach for IPMG when you
66
+ look after a network and need to know what moved since yesterday, with a report
67
+ you can hand to someone.
68
+
69
+ <p align="center">
70
+ <img src="https://raw.githubusercontent.com/sameeralam3127/ipmg/main/docs/assets/ipmg-web.png" alt="IPMG Web dashboard: scan totals, a status donut of 16 active, 1 timeout and 1 inactive host, a latency trend chart, and a list of recent scans" width="820">
71
+ </p>
72
+
73
+ ---
74
+
43
75
  ## Install
44
76
 
45
77
  **Linux and macOS — one command, works on every distribution:**
@@ -234,6 +266,19 @@ exist yet. A file you name with `--input` must already exist.
234
266
 
235
267
  ## Common tasks
236
268
 
269
+ Not sure which flags you need? The
270
+ [command builder](https://sameeralam3127.github.io/ipmg/#builder) on the
271
+ website puts the command together as you pick what you want to know, and
272
+ explains every flag it adds.
273
+
274
+ <p align="center">
275
+ <a href="https://sameeralam3127.github.io/ipmg/#builder">
276
+ <img src="https://raw.githubusercontent.com/sameeralam3127/ipmg/main/docs/assets/ipmg-builder.png" alt="IPMG command builder: choose Scan, Compare, History, or Web, enter a target such as 192.168.1.0/24, switch on options like live results or hostnames, and copy the generated ipmg command with each flag explained" width="820">
277
+ </a>
278
+ </p>
279
+
280
+ Or pick a ready-made command:
281
+
237
282
  | I want to… | Command |
238
283
  | --- | --- |
239
284
  | Scan the network I am on | `ipmg --discover` |
@@ -432,7 +477,16 @@ Status is one of `Active`, `Inactive`, `Timeout`, `Unreachable`, `Invalid IP`,
432
477
  or `Error`. `Hostname` is filled in when you pass `--resolve`.
433
478
 
434
479
  The `md` format produces a shareable Markdown report with a status summary
435
- table — handy for tickets, handoffs, and incident timelines.
480
+ table — handy for tickets, handoffs, and incident timelines. `jsonl` writes one
481
+ JSON object per line, which streams into `jq` and log pipelines.
482
+
483
+ **Reports survive an interrupted scan.** A scan writes its report as it goes,
484
+ so pressing Ctrl+C halfway through a /16 leaves a valid report of everything
485
+ scanned so far instead of nothing at all. `csv` and `jsonl` are appended per
486
+ host; `xlsx`, `json`, and `md` are re-saved every `--autosave` seconds (30 by
487
+ default). A finished scan overwrites those files with the complete report, so
488
+ the file names and contents are the same as they always were. Use
489
+ `--no-incremental` to go back to writing only at the end.
436
490
 
437
491
  **Open ports.** `Open Ports` is only populated when `--scan-ports` is set: for
438
492
  each host that answers, IPMG probes a list of common TCP ports (SSH, HTTP,
@@ -461,7 +515,9 @@ them — so piping IPMG into a file or a log gives you clean text.
461
515
  | `--input` | `ip_list.xlsx` | What to scan: a file (`.xlsx`, `.xls`, `.csv`, `.txt`, `.list`), a single IP, a CIDR block, or a range (`10.0.0.1-10.0.0.50`) |
462
516
  | `--discover` | off | Auto-detect and scan the local subnet instead |
463
517
  | `--output` | `results` | Report file name prefix |
464
- | `--formats` | `xlsx` | One or more of `xlsx`, `csv`, `json`, `md` |
518
+ | `--formats` | `xlsx` | One or more of `xlsx`, `csv`, `json`, `jsonl`, `md` |
519
+ | `--no-incremental` | off | Only write the report once the scan has finished |
520
+ | `--autosave` | `30` | How often a running scan re-saves `xlsx`, `json`, and `md` |
465
521
 
466
522
  **Speed and accuracy**
467
523
 
@@ -13,7 +13,7 @@ build-backend = "setuptools.build_meta"
13
13
 
14
14
  [project]
15
15
  name = "ipmg"
16
- version = "2.0.0" # Managed automatically by semantic-release
16
+ version = "2.1.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"
@@ -28,7 +28,7 @@ maintainers = [
28
28
  { name = "Sameer Alam", email = "sameeralam3127@gmail.com" }
29
29
  ]
30
30
 
31
- keywords = ["ip", "network", "ping", "monitoring", "cli", "port-scan"]
31
+ keywords = ["ip", "network", "ping", "monitoring", "cli", "port-scan", "network-scanner", "ping-sweep", "subnet-scanner", "homelab", "sysadmin"]
32
32
 
33
33
  classifiers = [
34
34
  "Programming Language :: Python :: 3",
@@ -49,7 +49,7 @@ classifiers = [
49
49
  ]
50
50
 
51
51
  dependencies = [
52
- "pandas>=2.0",
52
+ "pandas>=2.2.2",
53
53
  "openpyxl>=3.1",
54
54
  "rich>=13.0",
55
55
  "fastapi>=0.110",
@@ -68,6 +68,7 @@ Changelog = "https://github.com/sameeralam3127/ipmg/releases"
68
68
  [project.optional-dependencies]
69
69
  dev = [
70
70
  "pytest>=7.4",
71
+ "pytest-cov>=5.0",
71
72
  "ruff>=0.4",
72
73
  "pre-commit>=3.5.0",
73
74
  "python-semantic-release>=10.5.3",
@@ -85,6 +86,7 @@ ipmg = "ipmg.cli.commands:run"
85
86
  [dependency-groups]
86
87
  dev = [
87
88
  "pytest>=7.4",
89
+ "pytest-cov>=5.0",
88
90
  "ruff>=0.4",
89
91
  "pre-commit>=3.5.0",
90
92
  "python-semantic-release>=10.5.3",
@@ -130,6 +132,14 @@ where = ["src"]
130
132
  testpaths = ["tests"]
131
133
  pythonpath = ["src"]
132
134
 
135
+ [tool.coverage.run]
136
+ source = ["ipmg"]
137
+ branch = true
138
+
139
+ [tool.coverage.report]
140
+ # Current coverage is ~95%; this floor catches a large untested addition.
141
+ fail_under = 90
142
+
133
143
 
134
144
  # ==========================================================
135
145
  # Ruff
@@ -2,4 +2,4 @@
2
2
  ipmg - IP Management & Ping Monitoring Tool
3
3
  """
4
4
 
5
- __version__ = "2.0.0"
5
+ __version__ = "2.1.0"
@@ -7,6 +7,12 @@ import argparse
7
7
  from ipmg import __version__
8
8
  from ipmg.core.portscan import DEFAULT_PORTS, parse_port_list
9
9
  from ipmg.infrastructure.file_io import DEFAULT_INPUT_FILE
10
+ from ipmg.infrastructure.incremental import (
11
+ DEFAULT_AUTOSAVE_S,
12
+ MAX_AUTOSAVE_S,
13
+ MIN_AUTOSAVE_S,
14
+ REPORT_FORMATS,
15
+ )
10
16
  from ipmg.reporting.diff_report import DIFF_FORMATS
11
17
  from ipmg.reporting.live import DEFAULT_REFRESH_S, MAX_REFRESH_S, MIN_REFRESH_S
12
18
 
@@ -135,9 +141,9 @@ def build_parser() -> argparse.ArgumentParser:
135
141
  "--formats",
136
142
  nargs="+",
137
143
  default=["xlsx"],
138
- choices=["xlsx", "csv", "json", "md"],
144
+ choices=list(REPORT_FORMATS),
139
145
  metavar="FORMAT",
140
- help="One or more report formats: xlsx, csv, json, md (default: xlsx).",
146
+ help=f"One or more report formats: {', '.join(REPORT_FORMATS)} (default: xlsx).",
141
147
  )
142
148
  parser.add_argument(
143
149
  "--discover",
@@ -196,6 +202,24 @@ def build_parser() -> argparse.ArgumentParser:
196
202
  help="Connect timeout per port when --scan-ports is set (default: 1).",
197
203
  )
198
204
 
205
+ reports = parser.add_argument_group("report writing")
206
+ reports.add_argument(
207
+ "--no-incremental",
208
+ action="store_true",
209
+ help="Only write the report once the scan has finished.",
210
+ )
211
+ reports.add_argument(
212
+ "--autosave",
213
+ type=float,
214
+ default=DEFAULT_AUTOSAVE_S,
215
+ metavar="SECONDS",
216
+ help=(
217
+ "How often a running scan re-saves the xlsx, json, and md reports "
218
+ f"(default: {DEFAULT_AUTOSAVE_S:g}, range: {MIN_AUTOSAVE_S:g}-{MAX_AUTOSAVE_S:g}). "
219
+ "csv and jsonl are written per host regardless."
220
+ ),
221
+ )
222
+
199
223
  live = parser.add_argument_group("live output")
200
224
  live.add_argument(
201
225
  "--stream",
@@ -95,6 +95,12 @@ def execute_scan(
95
95
 
96
96
  if on_result is not None:
97
97
  on_result(result, len(results), len(ips))
98
+ except BaseException:
99
+ # Ctrl+C must not be followed by minutes of queued pings: drop the
100
+ # hosts that have not started, and let the finally below wait only
101
+ # for the handful already in flight.
102
+ executor.shutdown(wait=False, cancel_futures=True)
103
+ raise
98
104
  finally:
99
105
  executor.shutdown(wait=True)
100
106
 
@@ -1,3 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ import io
1
4
  import ipaddress
2
5
  from pathlib import Path
3
6
  from typing import Iterable
@@ -6,6 +9,7 @@ import pandas as pd
6
9
 
7
10
  from ipmg.core.ping import validate_ip
8
11
  from ipmg.exceptions import FileIOError
12
+ from ipmg.infrastructure.incremental import atomic_write_bytes, jsonl_record
9
13
  from ipmg.reporting import ui
10
14
  from ipmg.utils.helpers import markdown_cell, spreadsheet_escape, timestamp_str
11
15
 
@@ -270,24 +274,50 @@ def build_markdown_report(df: pd.DataFrame) -> str:
270
274
  return "\n".join(lines)
271
275
 
272
276
 
273
- def save_results(df, base: str, formats: list[str]) -> list[str]:
274
- ts = timestamp_str()
277
+ def write_report(df: pd.DataFrame, path: str, fmt: str) -> None:
278
+ """Write ``df`` to ``path`` in one format, replacing the file atomically.
279
+
280
+ The whole file is rendered in memory and swapped into place, so a reader
281
+ — or an incremental snapshot interrupted halfway — never finds a report
282
+ that is only partly written. Raises :class:`ValueError` for an unknown
283
+ format so callers can tell "not written" from "silently skipped".
284
+ """
285
+ if fmt == "xlsx":
286
+ buffer = io.BytesIO()
287
+ sanitize_export_frame(df).to_excel(buffer, index=False)
288
+ data = buffer.getvalue()
289
+ elif fmt == "csv":
290
+ data = sanitize_export_frame(df).to_csv(index=False).encode("utf-8")
291
+ elif fmt == "json":
292
+ data = df.to_json(orient="records").encode("utf-8")
293
+ elif fmt == "jsonl":
294
+ # Rendered row by row rather than through pandas, so the finished file
295
+ # is written exactly like the one a running scan appends to.
296
+ rows = df.astype(object).where(pd.notna(df), None).to_dict(orient="records")
297
+ data = "".join(jsonl_record(row) for row in rows).encode("utf-8")
298
+ elif fmt == "md":
299
+ data = build_markdown_report(df).encode("utf-8")
300
+ else:
301
+ raise ValueError(f"Unsupported report format: {fmt}")
302
+
303
+ atomic_write_bytes(path, data)
304
+
305
+
306
+ def save_results(df, base: str, formats: list[str], timestamp: str | None = None) -> list[str]:
307
+ """Write the finished report in every requested format.
308
+
309
+ ``timestamp`` is passed in when a scan has already been writing reports
310
+ incrementally, so the final write lands on those same files instead of
311
+ creating a second, differently named set.
312
+ """
313
+ ts = timestamp or timestamp_str()
275
314
  saved_paths: list[str] = []
276
315
 
277
316
  for fmt in formats:
278
- if fmt == "xlsx":
279
- output_path = f"{base}_{ts}.xlsx"
280
- sanitize_export_frame(df).to_excel(output_path, index=False)
281
- elif fmt == "csv":
282
- output_path = f"{base}_{ts}.csv"
283
- sanitize_export_frame(df).to_csv(output_path, index=False)
284
- elif fmt == "json":
285
- output_path = f"{base}_{ts}.json"
286
- df.to_json(output_path, orient="records")
287
- elif fmt == "md":
288
- output_path = f"{base}_{ts}.md"
289
- Path(output_path).write_text(build_markdown_report(df), encoding="utf-8")
290
- else:
317
+ output_path = f"{base}_{ts}.{fmt}"
318
+ try:
319
+ write_report(df, output_path, fmt)
320
+ except ValueError:
291
321
  continue
292
322
 
293
323
  saved_paths.append(output_path)