py-printer-server 0.0.0__tar.gz → 0.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.
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.log
3
+ dist/
4
+ build/
5
+ *.egg-info/
6
+ spool/
7
+ jobs/
8
+ config.json
9
+
10
+ # Temporary files
11
+ SCRATCHPAD.md
12
+ scratchpad/
@@ -0,0 +1,31 @@
1
+ # Disclaimer
2
+
3
+ py-printer-server is provided **AS IS**, without warranty of any kind, express
4
+ or implied. **Use it entirely at your own risk.**
5
+
6
+ This tool binds a network port on the machine it runs on and lets anyone who
7
+ can log in upload files and send them to a physical printer attached to that
8
+ machine. Used carelessly it can spend a stranger's paper and ink, expose
9
+ uploaded files to your network, or leave a printer's settings changed if a job
10
+ is interrupted.
11
+
12
+ The author accepts no liability for data loss, data corruption, wasted
13
+ consumables, hardware damage, unauthorised access or disclosure of files,
14
+ business interruption, or any other direct or consequential damages arising
15
+ from the use of this software.
16
+
17
+ You are responsible for:
18
+
19
+ - setting `ADMIN_PASSWORD` to a strong value; the server refuses to start
20
+ without it;
21
+ - restricting the server to a network you trust (it binds all interfaces);
22
+ - verifying the printer and paper tray before printing anything you did not
23
+ author yourself;
24
+ - keeping tested backups of anything uploaded through the spool;
25
+ - being authorised to print the files you are printing.
26
+
27
+ This software is not certified for regulated, forensic, safety-critical or
28
+ high-assurance use.
29
+
30
+ `LICENSE` is the governing legal text and prevails wherever it and this
31
+ plain-language summary differ.
@@ -0,0 +1,126 @@
1
+ Metadata-Version: 2.5
2
+ Name: py-printer-server
3
+ Version: 0.1.0
4
+ Summary: Remote print server for a USB-connected printer. No third-party dependencies.
5
+ Author-email: Pandiyaraj Karuppasamy <pandiyarajk@live.com>
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Keywords: http,print-server,printer,spool,windows
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Operating System :: Microsoft :: Windows
11
+ Classifier: Programming Language :: Python :: 3
12
+ Requires-Python: >=3.11
13
+ Description-Content-Type: text/markdown
14
+
15
+ # py-printer-server
16
+
17
+ [![PyPI](https://img.shields.io/pypi/v/py-printer-server)](https://pypi.org/project/py-printer-server/)
18
+ [![Python versions](https://img.shields.io/pypi/pyversions/py-printer-server)](https://pypi.org/project/py-printer-server/)
19
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
20
+ [![Dependencies: none](https://img.shields.io/badge/dependencies-none-brightgreen)](pyproject.toml)
21
+
22
+ Remote print server for a USB-connected printer. Python 3.11+, no third-party
23
+ dependencies. Runs on the machine your printer is plugged into; phones and
24
+ laptops on the same network upload files to it and print them.
25
+
26
+ > **Disclaimer.** Provided **AS IS**, without warranty of any kind, express or
27
+ > implied. **Use entirely at your own risk.** This tool binds a network port on
28
+ > the machine it runs on and lets an authenticated user upload files and send
29
+ > them to a physical printer attached to that machine. The author accepts no
30
+ > liability for data loss, wasted paper or ink, hardware damage, unauthorised
31
+ > access or disclosure, business interruption, or consequential damages. You are
32
+ > responsible for setting a strong `ADMIN_PASSWORD` (the server refuses to start
33
+ > without one), limiting the server to a network you trust, verifying the
34
+ > printer and paper tray before printing, and being authorised to print the
35
+ > files you upload. Not certified for regulated, forensic, safety-critical or
36
+ > high-assurance use. See [DISCLAIMER.md](DISCLAIMER.md);
37
+ > [LICENSE](LICENSE) is the governing text and prevails where the two differ.
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ pip install py-printer-server
43
+ ```
44
+
45
+ ## Run
46
+
47
+ ```powershell
48
+ $env:ADMIN_PASSWORD = "your-strong-password"
49
+ py-printer-server
50
+ ```
51
+
52
+ `pps` is installed as a shorter alias for the same command. No `ADMIN_PASSWORD`
53
+ set? Get a suggestion with:
54
+
55
+ ```powershell
56
+ py-printer-server --generate-password
57
+ ```
58
+
59
+ The server prints its LAN URL on startup, e.g. `http://192.168.1.24:8114` --
60
+ open that from a phone on the same Wi-Fi, log in, and upload a file.
61
+
62
+ ### Options
63
+
64
+ ```
65
+ --port PORT Port (default: 8114)
66
+ --spool DIR Spool folder for uploaded files (default: ./spool)
67
+ --jobs DIR Archive folder for printed jobs (default: ./jobs)
68
+ --list-printers List installed printers (hardware vs virtual) and exit
69
+ --dry-run Log what would be printed instead of sending jobs to a printer
70
+ --generate-password Print a strong random ADMIN_PASSWORD suggestion and exit
71
+ ```
72
+
73
+ ## How it works
74
+
75
+ 1. Upload one or more files from any device on the network -- they land in a
76
+ single flat spool folder. There is no folder browsing; uploading is the
77
+ whole interaction.
78
+ 2. Tick the files you want (or "Print all"), pick a printer, colour/mono, A4,
79
+ copies and duplex, and press Print.
80
+ 3. Printed files are **moved**, not deleted, into
81
+ `jobs\print-job-<timestamp>-<id>\`, alongside a `job.json` recording what
82
+ was printed and how -- so nothing needs re-uploading to print again.
83
+ A file that failed or could not be printed **stays in the spool** so you
84
+ can retry it; only successfully printed files are archived.
85
+
86
+ A job is reported `done` only when every file actually printed. A mixed result
87
+ is `partial`, and the jobs panel shows the reason next to each file that did
88
+ not print.
89
+
90
+ Only real hardware printers are shown by default (a "Show all printers"
91
+ checkbox reveals software sinks like Microsoft Print to PDF).
92
+
93
+ ## Supported files
94
+
95
+ | Type | Path |
96
+ |---|---|
97
+ | Plain text / code (`.txt .log .csv .md .py .json ...`) | Printed directly by this tool -- full control over colour, copies and duplex. |
98
+ | PDF and images (`.pdf .png .jpg .tif .bmp .gif`) | Handed to Windows' registered handler for that file type via the shell's print verb. |
99
+ | Office documents (`.docx .xlsx .pptx .doc .xls .ppt`) | Same shell handoff, via Word/Excel/PowerPoint. |
100
+
101
+ **Colour, copies and duplex apply fully only to the text path.** PDFs, images
102
+ and Office documents are printed by whichever application Windows has
103
+ associated with them, and that application builds its own print settings, so
104
+ those files follow the printer's standing defaults instead. This is a real
105
+ limitation of printing without a bundled renderer, not a bug.
106
+
107
+ ## No third-party dependencies
108
+
109
+ Everything, including Win32 printer access, uses only the Python standard
110
+ library (`ctypes` bindings to `winspool.drv`/`shell32.dll`, `http.server`,
111
+ `socketserver`). This is a deliberate choice, matching the companion project
112
+ [py-file-server](https://pypi.org/project/py-file-server/): fewer things that
113
+ can go missing or need a security update on a machine you may not touch again
114
+ for a year.
115
+
116
+ ## Security
117
+
118
+ - The server refuses to start unless `ADMIN_PASSWORD` is set.
119
+ - Login attempts are rate-limited (5 failures locks an IP out for 5 minutes).
120
+ - Sessions use `HttpOnly`, `SameSite=Strict` cookies with CSRF tokens on every
121
+ state-changing request.
122
+ - Windows-only: printer access is unavailable on any other OS.
123
+
124
+ ## License
125
+
126
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,112 @@
1
+ # py-printer-server
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/py-printer-server)](https://pypi.org/project/py-printer-server/)
4
+ [![Python versions](https://img.shields.io/pypi/pyversions/py-printer-server)](https://pypi.org/project/py-printer-server/)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+ [![Dependencies: none](https://img.shields.io/badge/dependencies-none-brightgreen)](pyproject.toml)
7
+
8
+ Remote print server for a USB-connected printer. Python 3.11+, no third-party
9
+ dependencies. Runs on the machine your printer is plugged into; phones and
10
+ laptops on the same network upload files to it and print them.
11
+
12
+ > **Disclaimer.** Provided **AS IS**, without warranty of any kind, express or
13
+ > implied. **Use entirely at your own risk.** This tool binds a network port on
14
+ > the machine it runs on and lets an authenticated user upload files and send
15
+ > them to a physical printer attached to that machine. The author accepts no
16
+ > liability for data loss, wasted paper or ink, hardware damage, unauthorised
17
+ > access or disclosure, business interruption, or consequential damages. You are
18
+ > responsible for setting a strong `ADMIN_PASSWORD` (the server refuses to start
19
+ > without one), limiting the server to a network you trust, verifying the
20
+ > printer and paper tray before printing, and being authorised to print the
21
+ > files you upload. Not certified for regulated, forensic, safety-critical or
22
+ > high-assurance use. See [DISCLAIMER.md](DISCLAIMER.md);
23
+ > [LICENSE](LICENSE) is the governing text and prevails where the two differ.
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ pip install py-printer-server
29
+ ```
30
+
31
+ ## Run
32
+
33
+ ```powershell
34
+ $env:ADMIN_PASSWORD = "your-strong-password"
35
+ py-printer-server
36
+ ```
37
+
38
+ `pps` is installed as a shorter alias for the same command. No `ADMIN_PASSWORD`
39
+ set? Get a suggestion with:
40
+
41
+ ```powershell
42
+ py-printer-server --generate-password
43
+ ```
44
+
45
+ The server prints its LAN URL on startup, e.g. `http://192.168.1.24:8114` --
46
+ open that from a phone on the same Wi-Fi, log in, and upload a file.
47
+
48
+ ### Options
49
+
50
+ ```
51
+ --port PORT Port (default: 8114)
52
+ --spool DIR Spool folder for uploaded files (default: ./spool)
53
+ --jobs DIR Archive folder for printed jobs (default: ./jobs)
54
+ --list-printers List installed printers (hardware vs virtual) and exit
55
+ --dry-run Log what would be printed instead of sending jobs to a printer
56
+ --generate-password Print a strong random ADMIN_PASSWORD suggestion and exit
57
+ ```
58
+
59
+ ## How it works
60
+
61
+ 1. Upload one or more files from any device on the network -- they land in a
62
+ single flat spool folder. There is no folder browsing; uploading is the
63
+ whole interaction.
64
+ 2. Tick the files you want (or "Print all"), pick a printer, colour/mono, A4,
65
+ copies and duplex, and press Print.
66
+ 3. Printed files are **moved**, not deleted, into
67
+ `jobs\print-job-<timestamp>-<id>\`, alongside a `job.json` recording what
68
+ was printed and how -- so nothing needs re-uploading to print again.
69
+ A file that failed or could not be printed **stays in the spool** so you
70
+ can retry it; only successfully printed files are archived.
71
+
72
+ A job is reported `done` only when every file actually printed. A mixed result
73
+ is `partial`, and the jobs panel shows the reason next to each file that did
74
+ not print.
75
+
76
+ Only real hardware printers are shown by default (a "Show all printers"
77
+ checkbox reveals software sinks like Microsoft Print to PDF).
78
+
79
+ ## Supported files
80
+
81
+ | Type | Path |
82
+ |---|---|
83
+ | Plain text / code (`.txt .log .csv .md .py .json ...`) | Printed directly by this tool -- full control over colour, copies and duplex. |
84
+ | PDF and images (`.pdf .png .jpg .tif .bmp .gif`) | Handed to Windows' registered handler for that file type via the shell's print verb. |
85
+ | Office documents (`.docx .xlsx .pptx .doc .xls .ppt`) | Same shell handoff, via Word/Excel/PowerPoint. |
86
+
87
+ **Colour, copies and duplex apply fully only to the text path.** PDFs, images
88
+ and Office documents are printed by whichever application Windows has
89
+ associated with them, and that application builds its own print settings, so
90
+ those files follow the printer's standing defaults instead. This is a real
91
+ limitation of printing without a bundled renderer, not a bug.
92
+
93
+ ## No third-party dependencies
94
+
95
+ Everything, including Win32 printer access, uses only the Python standard
96
+ library (`ctypes` bindings to `winspool.drv`/`shell32.dll`, `http.server`,
97
+ `socketserver`). This is a deliberate choice, matching the companion project
98
+ [py-file-server](https://pypi.org/project/py-file-server/): fewer things that
99
+ can go missing or need a security update on a machine you may not touch again
100
+ for a year.
101
+
102
+ ## Security
103
+
104
+ - The server refuses to start unless `ADMIN_PASSWORD` is set.
105
+ - Login attempts are rate-limited (5 failures locks an IP out for 5 minutes).
106
+ - Sessions use `HttpOnly`, `SameSite=Strict` cookies with CSRF tokens on every
107
+ state-changing request.
108
+ - Windows-only: printer access is unavailable on any other OS.
109
+
110
+ ## License
111
+
112
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,10 @@
1
+ """py-printer-server: remote print server for a USB-connected printer.
2
+
3
+ Author: Pandiyaraj Karuppasamy
4
+ Date: Sep-14-2026
5
+ """
6
+
7
+ from py_printer_server.server import main
8
+
9
+ __version__ = "0.1.0"
10
+ __all__ = ["main", "__version__"]
@@ -0,0 +1,9 @@
1
+ """Entry point for `python -m py_printer_server`.
2
+
3
+ Author: Pandiyaraj Karuppasamy
4
+ Date: Sep-14-2026
5
+ """
6
+
7
+ from py_printer_server import main
8
+
9
+ raise SystemExit(main())
@@ -0,0 +1,150 @@
1
+ """Runtime configuration for py-printer-server.
2
+
3
+ Author: Pandiyaraj Karuppasamy
4
+ Date: Sep-14-2026
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import threading
12
+
13
+ DEFAULT_CONFIG = {
14
+ "max_upload_mb": 100,
15
+ "blocked_extensions": [".exe", ".bat", ".cmd", ".ps1", ".sh"],
16
+ "default_printer": "",
17
+ "default_color": True,
18
+ "default_paper": "A4",
19
+ "default_duplex": False,
20
+ "default_copies": 1,
21
+ "show_virtual_printers": False,
22
+ }
23
+
24
+
25
+ class Config:
26
+ """
27
+ Runtime settings editable by the admin via the settings page.
28
+
29
+ Settings are loaded from config.json (inside the spool directory) at
30
+ startup and saved back on every admin update, so they survive server
31
+ restarts. Ported from file-share's Config class, with upload keys kept
32
+ and print-default keys added.
33
+ """
34
+
35
+ _lock = threading.Lock()
36
+ _config_file: str | None = None
37
+
38
+ max_upload_mb: int = DEFAULT_CONFIG["max_upload_mb"]
39
+ blocked_extensions: set = set(DEFAULT_CONFIG["blocked_extensions"])
40
+ default_printer: str = DEFAULT_CONFIG["default_printer"]
41
+ default_color: bool = DEFAULT_CONFIG["default_color"]
42
+ default_paper: str = DEFAULT_CONFIG["default_paper"]
43
+ default_duplex: bool = DEFAULT_CONFIG["default_duplex"]
44
+ default_copies: int = DEFAULT_CONFIG["default_copies"]
45
+ show_virtual_printers: bool = DEFAULT_CONFIG["show_virtual_printers"]
46
+
47
+ @classmethod
48
+ def set_config_file(cls, path: str) -> None:
49
+ """Point the config at a specific file (called once at startup)."""
50
+ cls._config_file = path
51
+
52
+ @classmethod
53
+ def _apply(cls, data: dict) -> None:
54
+ with cls._lock:
55
+ cls.max_upload_mb = int(data.get("max_upload_mb", DEFAULT_CONFIG["max_upload_mb"]))
56
+ cls.blocked_extensions = {
57
+ e.lower() for e in data.get("blocked_extensions", DEFAULT_CONFIG["blocked_extensions"])
58
+ }
59
+ cls.default_printer = str(data.get("default_printer", DEFAULT_CONFIG["default_printer"]))
60
+ cls.default_color = bool(data.get("default_color", DEFAULT_CONFIG["default_color"]))
61
+ cls.default_paper = str(data.get("default_paper", DEFAULT_CONFIG["default_paper"]))
62
+ cls.default_duplex = bool(data.get("default_duplex", DEFAULT_CONFIG["default_duplex"]))
63
+ cls.default_copies = max(1, int(data.get("default_copies", DEFAULT_CONFIG["default_copies"])))
64
+ cls.show_virtual_printers = bool(
65
+ data.get("show_virtual_printers", DEFAULT_CONFIG["show_virtual_printers"])
66
+ )
67
+
68
+ @classmethod
69
+ def ensure_config(cls) -> None:
70
+ """Create config.json with defaults if it does not exist."""
71
+ if cls._config_file is None or os.path.isfile(cls._config_file):
72
+ return
73
+ try:
74
+ with open(cls._config_file, "w", encoding="utf-8") as f:
75
+ json.dump(DEFAULT_CONFIG, f, indent=2)
76
+ f.write("\n")
77
+ except OSError as exc:
78
+ print(f"Warning: could not create {cls._config_file}: {exc}")
79
+
80
+ @classmethod
81
+ def load(cls) -> None:
82
+ """Load settings from the config file, creating it with defaults if missing."""
83
+ cls.ensure_config()
84
+ if cls._config_file is None:
85
+ cls._apply(DEFAULT_CONFIG)
86
+ return
87
+ try:
88
+ with open(cls._config_file, encoding="utf-8") as f:
89
+ cls._apply(json.load(f))
90
+ except (OSError, ValueError) as exc:
91
+ print(f"Warning: could not load {cls._config_file}: {exc}")
92
+ cls._apply(DEFAULT_CONFIG)
93
+
94
+ @classmethod
95
+ def save(cls) -> None:
96
+ """Persist current settings to the config file."""
97
+ if cls._config_file is None:
98
+ return
99
+ with cls._lock:
100
+ data = {
101
+ "max_upload_mb": cls.max_upload_mb,
102
+ "blocked_extensions": sorted(cls.blocked_extensions),
103
+ "default_printer": cls.default_printer,
104
+ "default_color": cls.default_color,
105
+ "default_paper": cls.default_paper,
106
+ "default_duplex": cls.default_duplex,
107
+ "default_copies": cls.default_copies,
108
+ "show_virtual_printers": cls.show_virtual_printers,
109
+ }
110
+ try:
111
+ with open(cls._config_file, "w", encoding="utf-8") as f:
112
+ json.dump(data, f, indent=2)
113
+ f.write("\n")
114
+ except OSError as exc:
115
+ print(f"Warning: could not save {cls._config_file}: {exc}")
116
+
117
+ @classmethod
118
+ def update(cls, **kwargs) -> None:
119
+ """Apply new settings and persist them immediately."""
120
+ with cls._lock:
121
+ for key, value in kwargs.items():
122
+ if hasattr(cls, key):
123
+ setattr(cls, key, value)
124
+ cls.save()
125
+
126
+ # Multipart bodies carry part headers and boundaries on top of the file
127
+ # itself, so the body cap is the per-file cap plus a fixed allowance.
128
+ BODY_OVERHEAD = 1024 * 1024
129
+
130
+ @classmethod
131
+ def max_body_bytes(cls) -> int:
132
+ """Largest request body to accept, or 0 when uploads are uncapped."""
133
+ with cls._lock:
134
+ if not cls.max_upload_mb:
135
+ return 0
136
+ return cls.max_upload_mb * 1024 * 1024 + cls.BODY_OVERHEAD
137
+
138
+ @classmethod
139
+ def check_upload(cls, filename: str, size: int) -> str | None:
140
+ """
141
+ Validate a pending upload against current restrictions.
142
+ Returns an error message string if rejected, or None if allowed.
143
+ """
144
+ with cls._lock:
145
+ if cls.max_upload_mb and size > cls.max_upload_mb * 1024 * 1024:
146
+ return f"File too large (max {cls.max_upload_mb} MB)"
147
+ ext = os.path.splitext(filename)[1].lower()
148
+ if ext in cls.blocked_extensions:
149
+ return f"Extension '{ext}' is blocked"
150
+ return None
@@ -0,0 +1,110 @@
1
+ """Printer discovery and the hardware/virtual split.
2
+
3
+ Author: Pandiyaraj Karuppasamy
4
+ Date: Sep-14-2026
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from dataclasses import dataclass
11
+
12
+ from py_printer_server import winspool
13
+
14
+ # Heuristic, not a hardcoded name list: OEM virtual-printer drivers vary too
15
+ # much (PDFCreator, CutePDF, Foxit, novaPDF, ...) to enumerate exhaustively,
16
+ # but they consistently either use a "prompt"/null/software port, or carry
17
+ # one of these words in their driver or printer name.
18
+ _VIRTUAL_PORT_PREFIXES = ("PORTPROMPT:", "NUL:", "SHRFAX:", "XPSPORT", "ONENOTE")
19
+ _VIRTUAL_NAME_PATTERN = re.compile(
20
+ r"print to pdf|xps document writer|onenote|fax|pdfcreator|cutepdf|"
21
+ r"foxit.*pdf|novapdf|document writer|send to.*driver",
22
+ re.IGNORECASE,
23
+ )
24
+ # A real USB/parallel/serial/network printer reports one of these port shapes.
25
+ _HARDWARE_PORT_PATTERN = re.compile(r"^(USB|LPT|COM|WSD-|\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})", re.IGNORECASE)
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class PrinterInfo:
30
+ name: str
31
+ port: str
32
+ driver: str
33
+ is_virtual: bool
34
+ is_default: bool
35
+ supports_color: bool
36
+
37
+
38
+ def is_virtual_printer(name: str, port: str, driver: str) -> bool:
39
+ """Decide whether a printer is a virtual/software sink rather than
40
+ physical hardware, so the UI can hide "Microsoft Print to PDF" etc. by
41
+ default without a maintained blocklist of every OEM driver name."""
42
+ port_upper = (port or "").upper()
43
+ if any(port_upper.startswith(p) for p in _VIRTUAL_PORT_PREFIXES):
44
+ return True
45
+ if _HARDWARE_PORT_PATTERN.match(port or ""):
46
+ return False
47
+ haystack = f"{name} {driver}"
48
+ return bool(_VIRTUAL_NAME_PATTERN.search(haystack))
49
+
50
+
51
+ def supports_color(name: str, port: str) -> bool:
52
+ """Return True if the driver reports colour capability.
53
+
54
+ Used to grey out the colour toggle for a mono-only printer rather than
55
+ silently ignoring the setting -- DeviceCapabilitiesW returns -1 on error,
56
+ which is treated as "assume colour" (the safer default: a toggle that
57
+ exists but has no effect is less confusing than one that is missing when
58
+ it should be there).
59
+ """
60
+ result = winspool.device_capabilities(name, port, winspool.DC_COLORDEVICE)
61
+ if result < 0:
62
+ return True
63
+ # DC_COLORDEVICE's contract is "nonzero means colour-capable", not
64
+ # specifically 1 -- some drivers (confirmed on an HP inkjet) return other
65
+ # nonzero values, so treat any nonzero as True rather than matching 1.
66
+ return result != 0
67
+
68
+
69
+ def list_printers(show_virtual: bool = False) -> list[PrinterInfo]:
70
+ """Enumerate installed printers, hardware first, alphabetically.
71
+
72
+ Args:
73
+ show_virtual: include software sinks (Print to PDF, XPS, OneNote,
74
+ Fax, ...) in the result. Default False, matching the UI's
75
+ "hardware only" default.
76
+ """
77
+ buf, count = winspool.enum_printers_raw(
78
+ winspool.PRINTER_ENUM_LOCAL | winspool.PRINTER_ENUM_CONNECTIONS
79
+ )
80
+ raw = winspool.parse_printer_info_2w(buf, count)
81
+ default_name = winspool.get_default_printer()
82
+
83
+ printers = []
84
+ for p in raw:
85
+ name = p.pPrinterName or ""
86
+ port = p.pPortName or ""
87
+ driver = p.pDriverName or ""
88
+ if not name:
89
+ continue
90
+ virtual = is_virtual_printer(name, port, driver)
91
+ if virtual and not show_virtual:
92
+ continue
93
+ printers.append(
94
+ PrinterInfo(
95
+ name=name,
96
+ port=port,
97
+ driver=driver,
98
+ is_virtual=virtual,
99
+ is_default=(name == default_name),
100
+ supports_color=supports_color(name, port),
101
+ )
102
+ )
103
+
104
+ printers.sort(key=lambda p: (p.is_virtual, p.name.lower()))
105
+ return printers
106
+
107
+
108
+ def get_default_printer_name() -> str | None:
109
+ """Return the system default printer's name, or None if unset."""
110
+ return winspool.get_default_printer()