#!/usr/bin/env python3
# Copyright (c) 2024-2025, Anders Andersen, UiT The Arctic University
# of Norway. All rights reserved.
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions
# are met:
# - Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# - Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in
# the documentation and/or other materials provided with the
# distribution.
# - Neither the name of the copyright holder nor the names of its
# contributors may be used to endorse or promote products derived
# from this software without specific prior written permission.
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
# COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
# Modules, help values and help functions
#
# Import standard modules
import sys, os, json
# File path mainipulation from standard module pathlib
from pathlib import Path
# Install the 'webinteract' module with 'pip'
import webinteract
# You should also install either 'keyring' or 'onepw' with 'pip' (to
# store account password in a safe way)
# If used interactivly, Qt and the module PySide6 have to be installed
# Current version of program
version = "1.26"
# Valid file types (from mobileprint web page)
acceptable_file_types = [
"All files (*)",
"Portable Document Format (*.pdf)",
"Microsoft Word document (*.doc)",
"Microsoft Word document (*.docx)",
"Microsoft Word document (*.docm)",
"Microsoft Word template (*.dot)",
"Microsoft Word template (*.dotx)",
"Microsoft Word template (*.dotm)",
"Rich Text Format (*.rtf)",
"Microsoft Excel workbook (*.xls)",
"Microsoft Excel workbook (*.xlsx)",
"Microsoft Excel workbook (*.xlsm)",
"Microsoft Excel workbook (*.xlsb)",
"Microsoft Excel template (*.xltx)",
"Microsoft Excel template (*.xltm)",
"Comma Separated Values (*.csv)",
"Microsoft PowerPoint slideshow (*.ppt)",
"Microsoft PowerPoint slideshow (*.pptx)",
"Microsoft PowerPoint slideshow (*.pps)",
"Microsoft PowerPoint slideshow (*.ppsx)",
"Microsoft PowerPoint template (*.pot)",
"Microsoft PowerPoint template (*.potx)",
"OpenDocument presentation (*.odp)",
"OpenDocument spreadsheet (*.ods)",
"OpenDocument document (*.odt)",
"OpenDocument Text template (*.ott)",
"Hypertext Markup Language (*.html)",
"Hypertext Markup Language (*.htm)",
"MIME Hypertext Markup Language (*.mhtml)",
"Hypertext Markup Language (*.xhtml)",
"Joint Photographic Expert Group image (*.jpg)",
"Joint Photographic Expert Group image (*.jpeg)",
"Portable Network Graphics (*.png)",
"Bitmap image (*.bmp)",
"Graphical Interchange Format (*.gif)",
"Tag Image File Format (*.tif)",
"Tag Image File Format (*.tiff)",
"Icon images (*.ico)",
"Windows Metafile (*.wmf)",
"Enhanced Metafile (*.emf)",
"Scalable Vector Graphics (*.svg)",
"Text format (*.txt)",
"Extensible Markup Language (*.xml)",
"XSL Formatting Objects (*.fo)",
"XML Paper Specification (*.xps)",
"Electronic Publication format (*.epub)"]
# A backup wia script if the `uitprint.wia` file is not found
wia_script = """
#WIASCRIPT
visit(url)
fill(account, "name", "username")
fill(pw, "name", "password")
click("id", "log-in")
verify(is_text_present("Upload job"), True, \
'The "Upload job" button is not present')
click_link("partial_text", "Upload job")
verify(is_present("id", "tap-area", wait_time=20), True, \
'The "Drop file area" is not present')
attach_file(path, "id", "add-file")
verify(is_text_present("unsupported file type"), False, \
'The file type is not supported')
verify(is_present("id", "uploading-widget", wait_time=20), True, \
'The "Uploading widget" element is not present')
check("xpath", "//input[@class='duplex']", 1)
click("id", "upload")
verify(is_text_present("Job uploaded", wait_time=20), True, \
'The "Job uploaded" text is not shown')
"""
# Messages (errors)
def announce(msg, error=True, terminate=True, wait=False):
if error:
out=sys.stderr
err=1
else:
out=sys.stdout
err=0
print(msg, file=out)
if wait:
dummy = input("Press return ")
if terminate:
sys.exit(err)
#
# Read configuration from the config file
#
# Config file name (in home directory)
confile = ".uitprintconfig.json"
# The valid str content of the config file
confcontent = ["account", "service", "url", "driver"]
# Some default values
config = {
"service": "UiTprint",
"url": "https://uit.no/mobilprint"}
# Read the config file and handle different errors
estatus = ""
try:
cfp = Path.home().joinpath(confile)
with open(cfp) as configfile:
config.update(json.load(configfile))
except FileNotFoundError:
pass # config file is optional
except json.decoder.JSONDecodeError:
announce(f'Unable to parse JSON config file "{str(cfp)}"\n')
except Exception as err:
announce(f'Error reading "{str(cfp)}": {err}\n')
# Add placeholders for missing values
for conf_key in confcontent:
if not conf_key in config:
config[conf_key] = ""
#
# Parse and verify the arguments
#
# Create argument parser
import argparse
parser = argparse.ArgumentParser(
description='Print a file using UiT print service.')
parser.add_argument(
"file", nargs='?', type=argparse.FileType('r'),
help="file to be printed")
parser.add_argument(
"-V", "--version", action="version",
version=f"%(prog)s " + version)
parser.add_argument(
"-s", "--service", default=config["service"],
help="service name used fecthing the password from the keychain " + \
f'(default "{config["service"]}")')
parser.add_argument(
"-a", "--account",
help="account name used fecthing the password from the keychain")
if ("account" in config) and config["account"]:
parser.set_defaults(account=config["account"])
parser.add_argument(
"-u", "--url", default=config["url"],
help="the address of the UiT print service " + \
f'(default "{config["url"]}")')
parser.add_argument(
"--one-password", action="store_true", default=False,
help="get password (and account) from 1Password")
parser.add_argument(
"--driver", default=config["driver"],
help=f'the web driver (default "{config["driver"]}")')
parser.add_argument(
"-d", "--duplex", action="store_true", default=True,
help="duplex printing")
parser.add_argument(
"-i", "--interactive", action="store_true", default=False,
help="run program interactive (with gui)")
parser.add_argument(
"--visible", action="store_true", default=False,
help="show the browser window")
# Parse arguments
args = parser.parse_args()
# Verify arguments
if not args.interactive:
if not args.file:
announce(f"""
>>> File missing: if not interactiv, the "file" argument is needed:
""", terminate=False)
parser.print_help()
announce("")
#
# Grab other information needed (password)
#
# Get the password (and maybe account) from 1Password
if args.one_password:
try:
import onepw
except ModuleNotFoundError:
announce(f"""
>>> Error: Install module 'onepw' when 1Password is used:
pip install onepw
""")
op = onepw.OnePW()
try:
args.pw = op.get(args.service, field="password")
except onepw.OnePWError:
announce(f"""
>>> Error: Unable to get password from 1Password ({args.service})
""")
# If not provided, get account (username) from 1Password
if not "account" in args or not args.account:
try:
args.account = op.get(args.service, field="username")
except onepw.OnePWError:
announce(f"""
>>> Error: Unable to get account from 1Password ({args.service})
""")
# If not provided, get also the url from 1Password
if not "url" in args or not args.url:
try:
urls = op.get(args.service, info="urls")
except onepw.OnePWError:
announce(f"""
>>> Error: Unable to get account from 1Password ({args.service})
""")
if urls:
args.url = list(urls.values())[0]
else:
announce(f"""
>>> Error: Unable to get url from 1Password ({args.service})
""")
# Get password from keyring
elif "account" in args and args.account:
try:
import keyring
except ModuleNotFoundError:
announce(f"""
>>> Error: Install module 'keyring' to be able to fetch password:
pip install keyring
""")
args.pw = keyring.get_password(args.service, args.account)
if not args.pw:
announce(f"""
>>> Error: No password found in keychain for {args.account} ({args.service}).
>>> Add password for {args.account} ({args.service}) with this command:
keyring set {args.service} {args.account}
""")
# Account missing
else:
announce(f"""
>>> Error: Unable to get password: no account (username) given
""")
#
# Get the file name (path)
#
# Interactive file dialog?
if args.interactive:
# We use Qt for the GUI part
from PySide6.QtWidgets import QApplication, QFileDialog, QMainWindow
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
args.path, selected_filter = QFileDialog.getOpenFileName(
self, dir=os.path.expanduser('~'),
selectedFilter=acceptable_file_types[0],
filter=(";;").join(acceptable_file_types))
app = QApplication(sys.argv)
window = MainWindow()
# Passed by argument
else:
# Set file path
args.path = os.path.realpath(args.file.name)
# Verify that we have a path to a file
if not args.path:
announce("""
>>> No file name given (no file to print)
""")
#
# Interact with the web page
#
# Create named arguments
kw = {"headless": (not args.visible)}
if args.driver:
kw["driver"] = args.driver
# Open browser
try:
web = webinteract.WebInteraction(**kw)
except Exception as err:
announce(f"""
>>> Error: Could be a problem with the web driver. Try another using the
'--driver' argument:
--driver "chrome"
--driver "firefox"
--driver "edge"
See https://blog.pg12.org/web-page-interaction-in-python for more
information.
>>> Error message:
{err}\n""")
# Add `url`, `account`, `pw`, `path` and `duplex` to the namespace of the script
ns = {k: vars(args)[k] for k in ('url', 'account', 'pw', 'path', 'duplex')}
web.update(ns)
# Try to locate `wia` script (file) first
wia_file_path = os.path.dirname(os.path.abspath(__file__)) + "/uitprint.wia"
if os.path.isfile(wia_file_path) and os.access(wia_file_path, os.R_OK):
wia_file = open(wia_file_path)
else:
from io import StringIO
wia_file = StringIO(wia_script)
# Perform web interaction actions (the script)
try:
web(wia_file)
except webinteract.WebInteractionError as err:
announce(f"""
>>> Web interaction error:
{err}
""")
except Exception as err:
announce(f"""
>>> Other error:
{err}
""")
else:
web.quit()