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.
- py_printer_server-0.1.0/.gitignore +12 -0
- py_printer_server-0.1.0/DISCLAIMER.md +31 -0
- py_printer_server-0.1.0/PKG-INFO +126 -0
- py_printer_server-0.1.0/README.md +112 -0
- py_printer_server-0.1.0/py_printer_server/__init__.py +10 -0
- py_printer_server-0.1.0/py_printer_server/__main__.py +9 -0
- py_printer_server-0.1.0/py_printer_server/config.py +150 -0
- py_printer_server-0.1.0/py_printer_server/printers.py +110 -0
- py_printer_server-0.1.0/py_printer_server/printing.py +474 -0
- py_printer_server-0.1.0/py_printer_server/printroute.py +105 -0
- py_printer_server-0.1.0/py_printer_server/server.py +1322 -0
- py_printer_server-0.1.0/py_printer_server/winspool.py +447 -0
- py_printer_server-0.1.0/pyproject.toml +35 -0
- py_printer_server-0.0.0/PKG-INFO +0 -40
- py_printer_server-0.0.0/README.md +0 -6
- py_printer_server-0.0.0/py_printer_server/__init__.py +0 -8
- py_printer_server-0.0.0/pyproject.toml +0 -21
- {py_printer_server-0.0.0 → py_printer_server-0.1.0}/LICENSE +0 -0
|
@@ -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
|
+
[](https://pypi.org/project/py-printer-server/)
|
|
18
|
+
[](https://pypi.org/project/py-printer-server/)
|
|
19
|
+
[](LICENSE)
|
|
20
|
+
[](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
|
+
[](https://pypi.org/project/py-printer-server/)
|
|
4
|
+
[](https://pypi.org/project/py-printer-server/)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](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,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()
|