diff --git a/pyipptool/__init__.py b/pyipptool/__init__.py index 3692238..70ed8d3 100644 --- a/pyipptool/__init__.py +++ b/pyipptool/__init__.py @@ -9,6 +9,7 @@ create_printer_subscription = wrapper. create_printer_subscription cancel_job = wrapper.cancel_job release_job = wrapper.release_job +create_job = wrapper.create_job create_printer_subscription = wrapper.create_printer_subscription cups_add_modify_class = wrapper.cups_add_modify_class cups_add_modify_printer = wrapper.cups_add_modify_printer @@ -26,7 +27,9 @@ get_subscriptions = wrapper.get_subscriptions get_notifications = wrapper.get_notifications pause_printer = wrapper.pause_printer +print_job = wrapper.print_job resume_printer = wrapper.resume_printer +send_document = wrapper.send_document hold_new_jobs = wrapper.hold_new_jobs release_held_new_jobs = wrapper.release_held_new_jobs cancel_subscription = wrapper.cancel_subscription diff --git a/pyipptool/core.py b/pyipptool/core.py index 116fe40..1ee3043 100644 --- a/pyipptool/core.py +++ b/pyipptool/core.py @@ -1,6 +1,7 @@ import functools import os import plistlib +import shutil import subprocess import tempfile import time @@ -11,6 +12,7 @@ from .forms import (cancel_job_form, release_job_form, + create_job_form, create_job_subscription_form, create_printer_subscription_form, cups_add_modify_class_form, @@ -29,7 +31,9 @@ get_subscriptions_form, get_notifications_form, pause_printer_form, + print_job_form, resume_printer_form, + send_document_form, hold_new_jobs_form, release_held_new_jobs_form, cancel_subscription_form, @@ -60,6 +64,7 @@ def pyipptool_coroutine(method): will take care to consume the generator synchronously. """ method.ipptool_caller = True + @functools.wraps(method) def sync_coroutine_consumer(*args, **kw): gen = method(*args, **kw) @@ -75,6 +80,54 @@ def sync_coroutine_consumer(*args, **kw): return sync_coroutine_consumer +def _get_filename_for_content(content): + """ + Return the name of a file based on type of content + - already a file ? + - does he have a name ? + take its name + - else + copy to temp file and return its name + - binary content ? + copy to temp file and return its name + + if a temp file is created the caller is responsible to + destroy the file. the flag delete is meant for it. + """ + file_ = None + delete = False + if isinstance(content, file): + # regular file + file_ = content + if isinstance(getattr(content, 'file', None), file): + # tempfile + file_ = content + if (hasattr(content, 'read') and hasattr(content, 'read') and + hasattr(content, 'tell')): + # most likely a file like object + file_ = content + if file_ is not None: + if file_.name: + name = file_.name + else: + with tempfile.NamedTemporaryFile(delete=False, + mode='rb') as tmp: + delete = True + shutil.copyfileobj(file_, tmp) + name = tmp.name + elif isinstance(content, basestring): + with tempfile.NamedTemporaryFile(delete=False, mode='w') as tmp: + delete = True + tmp.write(content) + name = tmp.name + else: + raise NotImplementedError( + 'Got unknow document\'s content type {}'.format( + type(content))) + + return name, delete + + class MetaAsyncShifter(type): """ Based on async flage defined on IPPToolWrapper @@ -94,7 +147,6 @@ def __new__(cls, name, bases, attrs): return klass - class IPPToolWrapper(object): __metaclass__ = MetaAsyncShifter async = False @@ -127,7 +179,6 @@ def timeout_handler(self, process, future): break time.sleep(.1) - def _call_ipptool(self, uri, request): with tempfile.NamedTemporaryFile(delete=False) as temp_file: temp_file.write(request) @@ -179,6 +230,49 @@ def cancel_job(self, uri, response = yield self._call_ipptool(uri, request) raise Return(response) + @pyipptool_coroutine + def create_job(self, uri, + printer_uri=None, + auth_info=colander.null, + job_billing=colander.null, + job_sheets=colander.null, + media=colander.null): + kw = {'header': + {'operation_attributes': + {'printer_uri': printer_uri}}, + 'auth_info': auth_info, + 'job_billing': job_billing, + 'job_sheets': job_sheets, + 'media': media} + request = create_job_form.render(kw) + response = yield self._call_ipptool(uri, request) + raise Return(response) + + @pyipptool_coroutine + def print_job(self, uri, + printer_uri=None, + auth_info=colander.null, + job_billing=colander.null, + job_sheets=colander.null, + media=colander.null, + document_content=None): + filename, delete = _get_filename_for_content(document_content) + kw = {'header': + {'operation_attributes': + {'printer_uri': printer_uri}}, + 'auth_info': auth_info, + 'job_billing': job_billing, + 'job_sheets': job_sheets, + 'media': media, + 'file': filename} + request = print_job_form.render(kw) + try: + response = yield self._call_ipptool(uri, request) + raise Return(response) + finally: + if delete: + os.unlink(filename) + @pyipptool_coroutine def create_job_subscription(self, uri, @@ -588,6 +682,44 @@ def release_held_new_jobs(self, *args, **kw): return self._hold_or_release_new_jobs(release_held_new_jobs_form, *args, **kw) + @pyipptool_coroutine + def send_document(self, uri, + job_uri=colander.null, + printer_uri=colander.null, + job_id=colander.null, + requesting_user_name=None, + document_name=colander.null, + compression=colander.null, + document_format='application/pdf', + document_natural_language=colander.null, + last_document=True, + document_content=None, + ): + """ + :param document_content: Binary Content or Named File + """ + delete = False + filename, delete = _get_filename_for_content(document_content) + kw = {'header': + {'operation_attributes': + {'job_uri': job_uri, + 'printer_uri': printer_uri, + 'job_id': job_id, + 'requesting_user_name': requesting_user_name, + 'document_name': document_name, + 'compression': compression, + 'document_format': document_format, + 'document_natural_language': document_natural_language, + 'last_document': last_document}}, + 'file': filename} + request = send_document_form.render(kw) + try: + response = yield self._call_ipptool(uri, request) + raise Return(response) + finally: + if delete: + os.unlink(filename) + class AsyncIPPToolWrapper(IPPToolWrapper): async = True diff --git a/pyipptool/forms.py b/pyipptool/forms.py index b3ba630..184a08c 100644 --- a/pyipptool/forms.py +++ b/pyipptool/forms.py @@ -4,6 +4,7 @@ from .schemas import (cancel_job_schema, release_job_schema, + create_job_schema, create_job_subscription_schema, create_printer_subscription_schema, cups_add_modify_class_schema, @@ -22,7 +23,9 @@ get_subscriptions_schema, get_notifications_schema, pause_printer_schema, + print_job_schema, resume_printer_schema, + send_document_schema, hold_new_jobs_schema, release_held_new_jobs_schema, cancel_subscription_schema, @@ -36,6 +39,7 @@ cancel_job_form = deform.Form(cancel_job_schema) release_job_form = deform.Form(release_job_schema) +create_job_form = deform.Form(create_job_schema) create_job_subscription_form = deform.Form( create_job_subscription_schema) create_printer_subscription_form = deform.Form( @@ -56,7 +60,9 @@ get_subscriptions_form = deform.Form(get_subscriptions_schema) get_notifications_form = deform.Form(get_notifications_schema) pause_printer_form = deform.Form(pause_printer_schema) +print_job_form = deform.Form(print_job_schema) resume_printer_form = deform.Form(resume_printer_schema) +send_document_form = deform.Form(send_document_schema) hold_new_jobs_form = deform.Form(hold_new_jobs_schema) release_held_new_jobs_form = deform.Form(release_held_new_jobs_schema) cancel_subscription_form = deform.Form(cancel_subscription_schema) diff --git a/pyipptool/schemas.py b/pyipptool/schemas.py index 672dc2e..4a3dee0 100644 --- a/pyipptool/schemas.py +++ b/pyipptool/schemas.py @@ -1,6 +1,7 @@ import colander -from .widgets import (IPPAttributeWidget, IPPBodyWidget, IPPGroupWidget, - IPPNameWidget, IPPTupleWidget, IPPConstantTupleWidget) +from .widgets import (IPPAttributeWidget, IPPBodyWidget, IPPFileWidget, + IPPGroupWidget, IPPNameWidget, IPPTupleWidget, + IPPConstantTupleWidget) class IntegerOrTuple(colander.List): @@ -40,6 +41,10 @@ class Language(colander.String): pass +class MimeMediaType(colander.String): + pass + + class Name(colander.String): pass @@ -235,6 +240,21 @@ class GetPrinterAttributesOperationAttributes( widget=IPPAttributeWidget()) +class SendDocumentOperationAttribute(JobOperationAttributes): + requesting_user_name = colander.SchemaNode(Name(), + widget=IPPAttributeWidget()) + document_name = colander.SchemaNode(Name(), widget=IPPAttributeWidget()) + compression = colander.SchemaNode(Keyword(), widget=IPPAttributeWidget()) + document_format = colander.SchemaNode(MimeMediaType(), + widget=IPPAttributeWidget()) + document_natural_language = colander.SchemaNode( + NaturalLanguage(), + widget=IPPAttributeWidget()) + last_document = colander.SchemaNode( + colander.Boolean(false_val=0, true_val=1), + widget=IPPAttributeWidget()) + + class HeaderIPPSchema(colander.Schema): name = colander.SchemaNode(colander.String(), widget=IPPNameWidget()) operation = colander.SchemaNode(colander.String(), widget=IPPNameWidget()) @@ -284,7 +304,8 @@ class BaseCupsAddModifyIPPSchema(BaseIPPSchema): printer_info = colander.SchemaNode(Text(), widget=IPPAttributeWidget()) printer_location = colander.SchemaNode(Text(), widget=IPPAttributeWidget()) printer_more_info = colander.SchemaNode(Uri(), widget=IPPAttributeWidget()) - printer_op_policy = colander.SchemaNode(Name(), widget=IPPAttributeWidget()) + printer_op_policy = colander.SchemaNode(Name(), + widget=IPPAttributeWidget()) printer_state = colander.SchemaNode(Enum(), widget=IPPAttributeWidget()) printer_state_message = colander.SchemaNode( Text(), @@ -380,6 +401,19 @@ class CupsRejectJobsSchema(BaseIPPSchema): widget=IPPAttributeWidget()) +class CreateJobSchema(BaseIPPSchema): + name = 'Create Job' + operation = 'Create-Job' + header = HeaderIPPSchema(widget=IPPConstantTupleWidget()) + header['operation_attributes'] = OperationAttributesWithPrinterUri( + widget=IPPTupleWidget()) + object_attributes_tag = 'job-attributes-tag' + auth_info = colander.SchemaNode(Text(), widget=IPPAttributeWidget()) + job_billing = colander.SchemaNode(Text(), widget=IPPAttributeWidget()) + job_sheets = colander.SchemaNode(Keyword(), widget=IPPAttributeWidget()) + media = colander.SchemaNode(Keyword(), widget=IPPAttributeWidget()) + + class CreateSubscriptionSchema(BaseIPPSchema): header = HeaderIPPSchema(widget=IPPConstantTupleWidget()) header['operation_attributes'] = SubscriptionOperationAttributes( @@ -470,6 +504,13 @@ class PausePrinterSchema(BaseIPPSchema): object_attributes_tag = colander.null +class PrintJobSchema(CreateJobSchema): + name = 'Print Job' + operation = 'Print-Job' + object_attributes_tag = 'document-attributes-tag' + file = colander.SchemaNode(colander.String(), widget=IPPFileWidget()) + + class ResumePrinterSchema(PausePrinterSchema): name = 'Resume Printer' operation = 'Resume-Printer' @@ -479,6 +520,16 @@ class ResumePrinterSchema(PausePrinterSchema): object_attributes_tag = colander.null +class SendDocumentSchema(BaseIPPSchema): + name = 'Send Document' + operation = 'Send-Document' + header = HeaderIPPSchema(widget=IPPConstantTupleWidget()) + header['operation_attributes'] = SendDocumentOperationAttribute( + widget=IPPTupleWidget()) + object_attributes_tag = 'document-attributes-tag' + file = colander.SchemaNode(colander.String(), widget=IPPFileWidget()) + + class HoldNewJobsSchema(PausePrinterSchema): name = 'Hold New Jobs' operation = 'Hold-New-Jobs' @@ -508,6 +559,8 @@ class CancelSubscriptionSchema(BaseIPPSchema): create_job_subscription_schema = CreateJobSubscriptionSchema( widget=IPPBodyWidget()) +create_job_schema = CreateJobSchema(widget=IPPBodyWidget()) + create_printer_subscription_schema = CreatePrinterSubscriptionSchema( widget=IPPBodyWidget()) @@ -548,8 +601,12 @@ class CancelSubscriptionSchema(BaseIPPSchema): pause_printer_schema = PausePrinterSchema(widget=IPPBodyWidget()) +print_job_schema = PrintJobSchema(widget=IPPBodyWidget()) + resume_printer_schema = ResumePrinterSchema(widget=IPPBodyWidget()) +send_document_schema = SendDocumentSchema(widget=IPPBodyWidget()) + hold_new_jobs_schema = HoldNewJobsSchema(widget=IPPBodyWidget()) release_held_new_jobs_schema = ReleaseHeldNewJobsSchema(widget=IPPBodyWidget()) diff --git a/pyipptool/widgets.py b/pyipptool/widgets.py index aff8a59..7b485da 100644 --- a/pyipptool/widgets.py +++ b/pyipptool/widgets.py @@ -16,6 +16,14 @@ def serialize(self, field, cstruct=None, readonly=False): return '{} "{}"'.format(name.upper(), value) +class IPPFileWidget(Widget): + def serialize(self, field, cstruct=None, readonly=False): + if not isinstance(cstruct, basestring): + raise ValueError('Wrong value provided for field {!r}'.format( + field.name)) + return 'FILE {}'.format(cstruct) + + class IPPGroupWidget(Widget): def serialize(self, field, cstruct=None, readonly=False): name = field.name diff --git a/tests/hello.pdf b/tests/hello.pdf new file mode 100644 index 0000000..5e5a982 Binary files /dev/null and b/tests/hello.pdf differ diff --git a/tests/test_form.py b/tests/test_form.py index f660acc..2726777 100644 --- a/tests/test_form.py +++ b/tests/test_form.py @@ -493,3 +493,71 @@ def test_cancel_subscription_form(): assert 'ATTR uri printer-uri ipp://server:port/printers/name' in request assert 'ATTR name requesting-user-name yoda' in request assert 'ATTR integer notify-subscription-id 5' in request, request + + +def test_create_job_form(): + from pyipptool.forms import create_job_form + request = create_job_form.render( + {'header': + {'operation_attributes': + {'printer_uri': 'ipp://server:port/printers/name'}}, + 'auth_info': 'michael', + 'job_billing': 'no-idea', + 'job_sheets': 'none', + 'media': 'media-default'}) + assert 'NAME "Create Job"' in request + assert 'OPERATION "Create-Job"' in request + assert ('ATTR uri printer-uri ipp://server:port/printers/name' in + request), request + assert 'ATTR text auth-info "michael"' in request, request + assert 'ATTR text job-billing "no-idea"' in request, request + assert 'ATTR keyword job-sheets none' in request, request + assert 'ATTR keyword media media-default' in request + + +def test_print_job_form(): + from pyipptool.forms import print_job_form + request = print_job_form.render( + {'header': + {'operation_attributes': + {'printer_uri': 'ipp://server:port/printers/name'}}, + 'auth_info': 'michael', + 'job_billing': 'no-idea', + 'job_sheets': 'none', + 'media': 'media-default', + 'file': '/path/to/file.txt'}) + assert 'NAME "Print Job"' in request + assert 'OPERATION "Print-Job"' in request + assert ('ATTR uri printer-uri ipp://server:port/printers/name' in + request), request + assert 'ATTR text auth-info "michael"' in request, request + assert 'ATTR text job-billing "no-idea"' in request, request + assert 'ATTR keyword job-sheets none' in request, request + assert 'ATTR keyword media media-default' in request + assert 'FILE /path/to/file.txt' in request + + +def test_send_document_form(): + from pyipptool.forms import send_document_form + + request = send_document_form.render( + {'header': + {'operation_attributes': + {'job_uri': 'http://cups:631/jobs/2', + 'requesting_user_name': 'sweet', + 'document_name': 'python.pdf', + 'compression': 'gzip', + 'document_format': 'application/pdf', + 'document_natural_language': 'en', + 'last_document': True}}, + 'file': '/path/to/a/file.pdf'}) + assert 'NAME "Send Document"' in request + assert 'OPERATION "Send-Document"' in request + assert 'ATTR uri job-uri http://cups:631/jobs/2' in request + assert 'ATTR name requesting-user-name sweet' in request + assert 'ATTR name document-name python.pdf' in request + assert 'ATTR keyword compression gzip' in request + assert 'ATTR mimeMediaType document-format application/pdf' in request + assert 'ATTR naturalLanguage document-natural-language en' in request + assert 'ATTR boolean last-document 1' in request, request + assert 'FILE /path/to/a/file.pdf' in request, request diff --git a/tests/test_highlevel.py b/tests/test_highlevel.py index 3741c87..bb56071 100644 --- a/tests/test_highlevel.py +++ b/tests/test_highlevel.py @@ -1,6 +1,8 @@ import BaseHTTPServer +import os.path import SocketServer import socket +import tempfile import threading import time @@ -325,3 +327,52 @@ def test_cancel_subscription(_call_ipptool): printer_uri='', notify_subscription_id=3) assert _call_ipptool._mock_mock_calls[0][1][0] == 'https://localhost:631/' + + +@mock.patch.object(pyipptool.wrapper, '_call_ipptool') +def test_create_job(_call_ipptool): + from pyipptool import create_job + _call_ipptool.return_value = {'Tests': [{}]} + create_job('https://localhost:631/', + printer_uri='') + assert _call_ipptool._mock_mock_calls[0][1][0] == 'https://localhost:631/' + + +@mock.patch.object(pyipptool.wrapper, '_call_ipptool') +def test_print_job(_call_ipptool): + from pyipptool import print_job + _call_ipptool.return_value = {'Tests': [{}]} + with open(os.path.join(os.path.dirname(__file__), + 'hello.pdf'), 'rb') as tmp: + print_job('https://localhost:631/', + printer_uri='', + document_content=tmp.read()) + assert _call_ipptool._mock_mock_calls[0][1][0] == 'https://localhost:631/' + assert 'FILE /tmp/' in _call_ipptool._mock_mock_calls[0][1][-1] + + +@mock.patch.object(pyipptool.wrapper, '_call_ipptool') +def test_send_document_with_file(_call_ipptool): + from pyipptool import send_document + _call_ipptool.return_value = {'Tests': [{}]} + + with tempfile.NamedTemporaryFile('rb') as tmp: + send_document('https://localhost:631/', + document_content=tmp) + assert (_call_ipptool._mock_mock_calls[0][1][0] == + 'https://localhost:631/') + assert ('FILE {}'.format(tmp.name) + in _call_ipptool._mock_mock_calls[0][1][-1]) + + +@mock.patch.object(pyipptool.wrapper, '_call_ipptool') +def test_send_document_with_binary(_call_ipptool): + from pyipptool import send_document + _call_ipptool.return_value = {'Tests': [{}]} + + with open(os.path.join(os.path.dirname(__file__), + 'hello.pdf'), 'rb') as tmp: + send_document('https://localhost:631/', + document_content=tmp.read()) + assert _call_ipptool._mock_mock_calls[0][1][0] == 'https://localhost:631/' + assert 'FILE /tmp/' in _call_ipptool._mock_mock_calls[0][1][-1] diff --git a/tests/test_widget.py b/tests/test_widget.py index 0e2ad97..3cc73bc 100644 --- a/tests/test_widget.py +++ b/tests/test_widget.py @@ -81,3 +81,24 @@ def test_ipp_group_widget(): response = widget.serialize(sub_field, 'world') assert response == 'GROUP FOO' + + +def test_ipp_file_widget(): + from pyipptool.widgets import IPPFileWidget + + widget = IPPFileWidget() + rendrer = DummyRenderer() + + field = DummyField(None, renderer=rendrer) + response = widget.serialize(field, '/path/to/file.txt') + assert response == 'FILE /path/to/file.txt' + +def test_ipp_file_widgeti_with_None(): + from pyipptool.widgets import IPPFileWidget + + widget = IPPFileWidget() + rendrer = DummyRenderer() + + field = DummyField(None, renderer=rendrer) + with pytest.raises(ValueError): + widget.serialize(field, None)