Own the network: details, VPN, enterprise Wi-Fi, and a firewall that can also allow
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -17,6 +17,9 @@ says so rather than pretending.
|
||||
panama-printers set-default NAME
|
||||
panama-printers pause NAME | resume NAME
|
||||
panama-printers cancel JOB_ID
|
||||
panama-printers hold JOB_ID | release JOB_ID
|
||||
panama-printers get-options NAME
|
||||
panama-printers set-option NAME KEY VALUE
|
||||
panama-printers test-page NAME
|
||||
"""
|
||||
|
||||
@@ -41,6 +44,24 @@ QUEUE = re.compile(r"^[A-Za-z0-9_.-]{1,127}$")
|
||||
# is checkable rather than scattered.
|
||||
DRIVERLESS_MODEL = "everywhere"
|
||||
|
||||
# The printer defaults this page will change, and every value it will accept.
|
||||
#
|
||||
# Closed on purpose. `lpadmin -o anything=anything` is a passthrough into a
|
||||
# daemon running as root, and the two settings people actually reach for --
|
||||
# paper size and double-siding -- are worth exactly two dropdowns. Anything not
|
||||
# in this table is refused rather than forwarded, so what this page can do to a
|
||||
# print queue is readable in one place.
|
||||
OPTION_VOCABULARY: dict[str, tuple[str, ...]] = {
|
||||
"media": ("Letter", "A4", "Legal"),
|
||||
"sides": ("one-sided", "two-sided-long-edge", "two-sided-short-edge"),
|
||||
}
|
||||
|
||||
# How each choice looks when the printer names it, rather than when a PPD does.
|
||||
# IPP spells paper sizes as self-describing keywords ("na_letter_8.5x11in"),
|
||||
# PPDs as short names ("Letter"), and a driverless queue can report either --
|
||||
# so a choice is recognised by the token both spellings share.
|
||||
OPTION_TOKENS = {"Letter": "letter", "A4": "a4", "Legal": "legal"}
|
||||
|
||||
|
||||
class BoundaryError(RuntimeError):
|
||||
"""A user-visible validation or CUPS failure."""
|
||||
@@ -235,6 +256,86 @@ def cancel(job_id: str) -> None:
|
||||
raise BoundaryError("That job could not be cancelled.") from error
|
||||
|
||||
|
||||
def matches(choice: str, reported: str) -> bool:
|
||||
"""Whether a value the printer reported is the choice this page offers."""
|
||||
token = OPTION_TOKENS.get(choice, choice).lower()
|
||||
return token in str(reported).lower()
|
||||
|
||||
|
||||
def get_options(name: str) -> dict:
|
||||
"""The two defaults this page can change, and which values are on offer.
|
||||
|
||||
Asked of the printer rather than assumed: a queue that cannot do two-sided
|
||||
should not be offered a two-sided dropdown, and a printer loaded with A4
|
||||
should not have to be told it is A4 every time. When the printer reports no
|
||||
opinion, the whole vocabulary is offered -- an empty dropdown is worse than
|
||||
a choice that might be refused.
|
||||
"""
|
||||
require_queue(name)
|
||||
cups, connection = connect()
|
||||
try:
|
||||
attributes = connection.getPrinterAttributes(name)
|
||||
except Exception as error: # noqa: BLE001
|
||||
raise BoundaryError("That printer's settings could not be read.") from error
|
||||
|
||||
options: dict[str, str] = {}
|
||||
choices: dict[str, list[str]] = {}
|
||||
raw: dict[str, str] = {}
|
||||
for key, vocabulary in OPTION_VOCABULARY.items():
|
||||
supported = attributes.get(f"{key}-supported") or []
|
||||
if isinstance(supported, (str, bytes)):
|
||||
supported = [supported]
|
||||
supported = [str(entry) for entry in supported]
|
||||
offered = [choice for choice in vocabulary
|
||||
if any(matches(choice, entry) for entry in supported)]
|
||||
choices[key] = offered or list(vocabulary)
|
||||
|
||||
current = str(attributes.get(f"{key}-default") or "")
|
||||
raw[key] = current
|
||||
options[key] = next(
|
||||
(choice for choice in vocabulary if current and matches(choice, current)), "")
|
||||
|
||||
return {"printer": name, "options": options, "choices": choices,
|
||||
"reported": raw, "error": ""}
|
||||
|
||||
|
||||
def set_option(name: str, key: str, value: str) -> None:
|
||||
"""One printer default, from the closed table above and nowhere else."""
|
||||
require_queue(name)
|
||||
vocabulary = OPTION_VOCABULARY.get(key)
|
||||
if vocabulary is None:
|
||||
raise BoundaryError("That is not a setting this page changes.")
|
||||
if value not in vocabulary:
|
||||
raise BoundaryError("That is not a value this setting accepts.")
|
||||
|
||||
cups, connection = connect()
|
||||
try:
|
||||
connection.addPrinterOptionDefault(name, key, value)
|
||||
except Exception as error: # noqa: BLE001
|
||||
message = str(error).lower()
|
||||
if "not-authorized" in message or "forbidden" in message:
|
||||
raise BoundaryError("Changing that setting was not authorized.") from error
|
||||
raise BoundaryError("That setting could not be changed.") from error
|
||||
|
||||
|
||||
def set_held(job_id: str, held: bool) -> None:
|
||||
"""Hold a job where it is, or let it go.
|
||||
|
||||
A held job stays in the queue rather than leaving it, which is the whole
|
||||
point: cancelling to stop a print and then reprinting is how a fifty-page
|
||||
document gets printed twice.
|
||||
"""
|
||||
if not job_id.isdigit():
|
||||
raise BoundaryError("That is not a job.")
|
||||
cups, connection = connect()
|
||||
try:
|
||||
connection.setJobHoldUntil(int(job_id), "indefinite" if held else "no-hold")
|
||||
except Exception as error: # noqa: BLE001
|
||||
raise BoundaryError(
|
||||
"That job could not be held." if held
|
||||
else "That job could not be released.") from error
|
||||
|
||||
|
||||
def test_page(name: str) -> None:
|
||||
require_queue(name)
|
||||
result = run(["lp", "-d", name, "/usr/share/cups/data/testprint"], timeout=30)
|
||||
@@ -251,6 +352,26 @@ def main(arguments: list[str]) -> int:
|
||||
print(json.dumps(discover(), separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
# Read-only, and about one printer, so it answers with its own shape.
|
||||
if len(arguments) == 2 and arguments[0] == "get-options":
|
||||
try:
|
||||
answer = get_options(arguments[1])
|
||||
except BoundaryError as error:
|
||||
answer = {"printer": arguments[1], "options": {}, "choices": {},
|
||||
"reported": {}, "error": str(error)}
|
||||
print(json.dumps(answer, separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
# A mutation, so it answers with the fresh snapshot -- carrying the
|
||||
# printer's re-read options alongside, so the dropdown that made the
|
||||
# change updates from the reply rather than from a second round trip.
|
||||
if len(arguments) == 4 and arguments[0] == "set-option":
|
||||
set_option(arguments[1], arguments[2], arguments[3])
|
||||
state = snapshot()
|
||||
state["options"] = get_options(arguments[1])
|
||||
print(json.dumps(state, separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
if len(arguments) == 3 and arguments[0] == "add":
|
||||
add(arguments[1], arguments[2])
|
||||
elif len(arguments) == 2 and arguments[0] == "remove":
|
||||
@@ -263,12 +384,18 @@ def main(arguments: list[str]) -> int:
|
||||
set_paused(arguments[1], False)
|
||||
elif len(arguments) == 2 and arguments[0] == "cancel":
|
||||
cancel(arguments[1])
|
||||
elif len(arguments) == 2 and arguments[0] == "hold":
|
||||
set_held(arguments[1], True)
|
||||
elif len(arguments) == 2 and arguments[0] == "release":
|
||||
set_held(arguments[1], False)
|
||||
elif len(arguments) == 2 and arguments[0] == "test-page":
|
||||
test_page(arguments[1])
|
||||
else:
|
||||
raise BoundaryError(
|
||||
"Usage: panama-printers snapshot | discover | add URI NAME | remove NAME | "
|
||||
"set-default NAME | pause NAME | resume NAME | cancel JOB_ID | test-page NAME")
|
||||
"set-default NAME | pause NAME | resume NAME | cancel JOB_ID | "
|
||||
"hold JOB_ID | release JOB_ID | get-options NAME | "
|
||||
"set-option NAME KEY VALUE | test-page NAME")
|
||||
except BoundaryError as error:
|
||||
try:
|
||||
state = snapshot()
|
||||
|
||||
Reference in New Issue
Block a user