├── .nvmrc ├── src └── pyipp │ ├── py.typed │ ├── exceptions.py │ ├── __init__.py │ ├── const.py │ ├── tags.py │ ├── serializer.py │ ├── ipp.py │ ├── enums.py │ ├── models.py │ └── parser.py ├── tests ├── fixtures │ ├── get-printer-attributes-hp6830.bin │ ├── get-jobs-kyocera-ecosys-m2540dn-000.bin │ ├── get-printer-attributes-epsonxp6000.bin │ ├── get-printer-attributes-error-0x0503.bin │ ├── get-printer-attributes-brother-mfcj5320dw.bin │ ├── get-printer-attributes-kyocera-ecosys-m2540dn-001.bin │ ├── get-printer-attributes-response-000.bin │ ├── get-printer-attributes-empty-attribute-group.bin │ └── serializer │ │ └── get-printer-attributes-request-000.bin ├── ruff.toml ├── test_serializer.py ├── test_interface.py ├── test_parser.py ├── __init__.py ├── test_client.py ├── test_models.py └── __snapshots__ │ └── test_parser.ambr ├── examples ├── ruff.toml ├── printer.py ├── print_example.py ├── execute.py └── debug.py ├── .editorconfig ├── package.json ├── .github ├── workflows │ ├── release-drafter.yml │ ├── labels.yaml │ ├── pr-labels.yaml │ ├── typing.yaml │ ├── release.yml │ ├── tests.yaml │ └── linting.yaml ├── release-drafter.yml └── labels.yml ├── LICENSE ├── .yamllint ├── README.md ├── renovate.json ├── .gitignore ├── .devcontainer └── devcontainer.json ├── .pre-commit-config.yaml └── pyproject.toml /.nvmrc: -------------------------------------------------------------------------------- 1 | 20.19.2 2 | -------------------------------------------------------------------------------- /src/pyipp/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/fixtures/get-printer-attributes-hp6830.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ctalkington/python-ipp/HEAD/tests/fixtures/get-printer-attributes-hp6830.bin -------------------------------------------------------------------------------- /tests/fixtures/get-jobs-kyocera-ecosys-m2540dn-000.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ctalkington/python-ipp/HEAD/tests/fixtures/get-jobs-kyocera-ecosys-m2540dn-000.bin -------------------------------------------------------------------------------- /tests/fixtures/get-printer-attributes-epsonxp6000.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ctalkington/python-ipp/HEAD/tests/fixtures/get-printer-attributes-epsonxp6000.bin -------------------------------------------------------------------------------- /tests/fixtures/get-printer-attributes-error-0x0503.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ctalkington/python-ipp/HEAD/tests/fixtures/get-printer-attributes-error-0x0503.bin -------------------------------------------------------------------------------- /tests/fixtures/get-printer-attributes-brother-mfcj5320dw.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ctalkington/python-ipp/HEAD/tests/fixtures/get-printer-attributes-brother-mfcj5320dw.bin -------------------------------------------------------------------------------- /tests/fixtures/get-printer-attributes-kyocera-ecosys-m2540dn-001.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ctalkington/python-ipp/HEAD/tests/fixtures/get-printer-attributes-kyocera-ecosys-m2540dn-001.bin -------------------------------------------------------------------------------- /examples/ruff.toml: -------------------------------------------------------------------------------- 1 | # This extend our general Ruff rules specifically for the examples 2 | extend = "../pyproject.toml" 3 | 4 | lint.extend-ignore = [ 5 | "T201", # Allow the use of print() in examples 6 | ] 7 | -------------------------------------------------------------------------------- /tests/fixtures/get-printer-attributes-response-000.bin: -------------------------------------------------------------------------------- 1 |  Gattributes-charsetutf-8Hattributes-natural-languageen-USE printer-uri'ipp://printer.example.com:361/ipp/printBrequesting-user-name PythonIPP -------------------------------------------------------------------------------- /tests/fixtures/get-printer-attributes-empty-attribute-group.bin: -------------------------------------------------------------------------------- 1 |  Gattributes-charsetutf-8Hattributes-natural-languageen-USE printer-uri'ipp://printer.example.com:361/ipp/printBrequesting-user-name PythonIPP -------------------------------------------------------------------------------- /tests/fixtures/serializer/get-printer-attributes-request-000.bin: -------------------------------------------------------------------------------- 1 |  Gattributes-charsetutf-8Hattributes-natural-languageen-USE printer-uri'ipp://printer.example.com:361/ipp/printBrequesting-user-name PythonIPP -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_style = space 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | indent_size = 4 10 | 11 | [*.md] 12 | indent_size = 2 13 | trim_trailing_whitespace = false 14 | 15 | [*.json] 16 | indent_size = 2 17 | 18 | [{.gitignore,.gitkeep,.editorconfig}] 19 | indent_size = 2 20 | 21 | [Makefile] 22 | indent_style = tab 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "python-ipp", 3 | "version": "0.0.0", 4 | "private": true, 5 | "description": "Asynchronous Python client for Internet Printing Protocol (IPP)", 6 | "scripts": { 7 | "prettier": "prettier --write **/*.{json,js,md,yml,yaml} *.{json,js,md,yml,yaml}" 8 | }, 9 | "author": "Chris Talkington ", 10 | "license": "MIT", 11 | "devDependencies": { 12 | "prettier": "3.5.3" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release Drafter 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | push: 7 | branches: 8 | - master 9 | workflow_dispatch: 10 | 11 | jobs: 12 | update_release_draft: 13 | name: Draft release 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Run Release Drafter 17 | uses: release-drafter/release-drafter@v6.1.0 18 | env: 19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 20 | -------------------------------------------------------------------------------- /tests/ruff.toml: -------------------------------------------------------------------------------- 1 | # This extend our general Ruff rules specifically for tests 2 | extend = "../pyproject.toml" 3 | 4 | lint.extend-select = [ 5 | "PT", # Use @pytest.fixture without parentheses 6 | ] 7 | 8 | lint.extend-ignore = [ 9 | "S101", # Use of assert detected. As these are tests... 10 | "SLF001", # Tests will access private/protected members... 11 | "PLC1901", # Tests may compare to empty strings... 12 | "TCH002", # pytest doesn't like this one... 13 | "E501", # string length doesn't matter for tests... 14 | ] 15 | -------------------------------------------------------------------------------- /.github/workflows/labels.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Sync labels 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | push: 7 | branches: 8 | - main 9 | paths: 10 | - .github/labels.yml 11 | workflow_dispatch: 12 | 13 | jobs: 14 | labels: 15 | name: Sync labels 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Check out code from GitHub 19 | uses: actions/checkout@v4 20 | - name: Run Label Syncer 21 | uses: micnncim/action-label-syncer@v1.3.0 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | -------------------------------------------------------------------------------- /src/pyipp/exceptions.py: -------------------------------------------------------------------------------- 1 | """Exceptions for IPP.""" 2 | 3 | 4 | class IPPError(Exception): 5 | """Generic IPP exception.""" 6 | 7 | 8 | class IPPConnectionError(IPPError): 9 | """IPP connection exception.""" 10 | 11 | 12 | class IPPConnectionUpgradeRequired(IPPError): # noqa: N818 13 | """IPP connection upgrade requested.""" 14 | 15 | 16 | class IPPParseError(IPPError): 17 | """IPP parse exception.""" 18 | 19 | 20 | class IPPResponseError(IPPError): 21 | """IPP response exception.""" 22 | 23 | 24 | class IPPVersionNotSupportedError(IPPError): 25 | """IPP version not supported.""" 26 | -------------------------------------------------------------------------------- /examples/printer.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=W0621 2 | """Asynchronous Python client for IPP.""" 3 | import asyncio 4 | 5 | from pyipp import IPP 6 | 7 | 8 | async def main() -> None: 9 | """Show example of connecting to your IPP print server.""" 10 | async with IPP("ipps://EPSON761251.local:631/ipp/print") as ipp: 11 | # Get Printer Info 12 | printer = await ipp.printer() 13 | print(printer) 14 | 15 | async with IPP("ipp://hp6830.local:631/ipp/print") as ipp: 16 | # Get Printer Info 17 | printer = await ipp.printer() 18 | print(printer) 19 | 20 | 21 | if __name__ == "__main__": 22 | loop = asyncio.get_event_loop() 23 | loop.run_until_complete(main()) 24 | -------------------------------------------------------------------------------- /src/pyipp/__init__.py: -------------------------------------------------------------------------------- 1 | """Asynchronous Python client for IPP.""" 2 | from .exceptions import ( 3 | IPPConnectionError, 4 | IPPConnectionUpgradeRequired, 5 | IPPError, 6 | IPPParseError, 7 | IPPResponseError, 8 | IPPVersionNotSupportedError, 9 | ) 10 | from .ipp import IPP 11 | from .models import ( 12 | Info, 13 | Marker, 14 | Printer, 15 | State, 16 | Uri, 17 | ) 18 | 19 | __all__ = [ 20 | "Info", 21 | "Marker", 22 | "Printer", 23 | "State", 24 | "Uri", 25 | "IPP", 26 | "IPPConnectionError", 27 | "IPPConnectionUpgradeRequired", 28 | "IPPError", 29 | "IPPParseError", 30 | "IPPResponseError", 31 | "IPPVersionNotSupportedError", 32 | ] 33 | -------------------------------------------------------------------------------- /.github/workflows/pr-labels.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: PR Labels 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | pull_request_target: 7 | types: [opened, labeled, unlabeled, synchronize] 8 | 9 | jobs: 10 | pr_labels: 11 | name: Verify 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Verify PR has a valid label 15 | uses: jesusvasquez333/verify-pr-label-action@657d111bbbe13e22bbd55870f1813c699bde1401 # v1.4.0 16 | with: 17 | github-token: "${{ secrets.GITHUB_TOKEN }}" 18 | pull-request-number: "${{ github.event.pull_request.number }}" 19 | valid-labels: >- 20 | breaking-change, bugfix, documentation, enhancement, 21 | refactor, performance, new-feature, maintenance, ci, dependencies 22 | disable-reviews: true 23 | -------------------------------------------------------------------------------- /examples/print_example.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=W0621 2 | """Asynchronous Python client for IPP.""" 3 | import asyncio 4 | 5 | from pyipp import IPP 6 | from pyipp.enums import IppOperation 7 | 8 | 9 | async def main() -> None: 10 | """Show example of printing via IPP print server.""" 11 | pdf_file = "/path/to/pdf.pfd" 12 | with open(pdf_file, "rb") as f: # noqa: PTH123, ASYNC230 13 | content = f.read() 14 | 15 | async with IPP("ipp://192.168.1.92:631/ipp/print") as ipp: 16 | response = await ipp.execute( 17 | IppOperation.PRINT_JOB, 18 | { 19 | "operation-attributes-tag": { 20 | "requesting-user-name": "Me", 21 | "job-name": "My Test Job", 22 | "document-format": "application/pdf", 23 | }, 24 | "data": content, 25 | }, 26 | ) 27 | 28 | print(response) 29 | 30 | 31 | if __name__ == "__main__": 32 | loop = asyncio.get_event_loop() 33 | loop.run_until_complete(main()) 34 | -------------------------------------------------------------------------------- /.github/workflows/typing.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Typing 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | push: 7 | branches: 8 | - master 9 | pull_request: 10 | workflow_dispatch: 11 | 12 | env: 13 | DEFAULT_PYTHON: "3.10" 14 | 15 | jobs: 16 | mypy: 17 | name: mypy 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Check out code from GitHub 21 | uses: actions/checkout@v4.2.2 22 | - name: Set up Poetry 23 | run: pipx install poetry 24 | - name: Set up Python ${{ env.DEFAULT_PYTHON }} 25 | id: python 26 | uses: actions/setup-python@v5.6.0 27 | with: 28 | python-version: ${{ env.DEFAULT_PYTHON }} 29 | cache: "poetry" 30 | - name: Install workflow dependencies 31 | run: | 32 | poetry config virtualenvs.create true 33 | poetry config virtualenvs.in-project true 34 | - name: Install dependencies 35 | run: poetry install --no-interaction 36 | - name: Run mypy 37 | run: poetry run mypy examples src tests 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Chris Talkington 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/pyipp/const.py: -------------------------------------------------------------------------------- 1 | """Constants for IPP.""" 2 | 3 | DEFAULT_CHARSET = "utf-8" 4 | DEFAULT_CHARSET_LANGUAGE = "en-US" 5 | 6 | DEFAULT_CLASS_ATTRIBUTES = ["printer-name", "member-names"] 7 | 8 | DEFAULT_JOB_ATTRIBUTES = [ 9 | "job-id", 10 | "job-name", 11 | "printer-uri", 12 | "job-state", 13 | "job-state-reasons", 14 | "job-hold-until", 15 | "job-media-progress", 16 | "job-k-octets", 17 | "number-of-documents", 18 | "copies", 19 | "job-originating-user-name", 20 | ] 21 | 22 | DEFAULT_PRINTER_ATTRIBUTES = [ 23 | "printer-device-id", 24 | "printer-name", 25 | "printer-type", 26 | "printer-location", 27 | "printer-info", 28 | "printer-make-and-model", 29 | "printer-state", 30 | "printer-state-message", 31 | "printer-state-reasons", 32 | "printer-supply", 33 | "printer-up-time", 34 | "printer-uri-supported", 35 | "device-uri", 36 | "printer-is-shared", 37 | "printer-more-info", 38 | "printer-firmware-string-version", 39 | "marker-colors", 40 | "marker-high-levels", 41 | "marker-levels", 42 | "marker-low-levels", 43 | "marker-names", 44 | "marker-types", 45 | ] 46 | 47 | DEFAULT_PORT = 631 48 | DEFAULT_PROTO_VERSION = (2, 0) 49 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name-template: "$RESOLVED_VERSION" 3 | tag-template: "$RESOLVED_VERSION" 4 | change-template: "- $TITLE @$AUTHOR (#$NUMBER)" 5 | sort-direction: ascending 6 | 7 | categories: 8 | - title: "Breaking changes" 9 | labels: 10 | - "breaking-change" 11 | - title: "New features" 12 | labels: 13 | - "new-feature" 14 | - title: "Bug fixes" 15 | labels: 16 | - "bugfix" 17 | - title: "Enhancements" 18 | labels: 19 | - "enhancement" 20 | - "refactor" 21 | - "performance" 22 | - title: "Maintenance" 23 | labels: 24 | - "maintenance" 25 | - "ci" 26 | - title: "Documentation" 27 | labels: 28 | - "documentation" 29 | - title: "Dependency updates" 30 | labels: 31 | - "dependencies" 32 | 33 | version-resolver: 34 | major: 35 | labels: 36 | - "major" 37 | - "breaking-change" 38 | minor: 39 | labels: 40 | - "minor" 41 | - "new-feature" 42 | patch: 43 | labels: 44 | - "bugfix" 45 | - "chore" 46 | - "ci" 47 | - "dependencies" 48 | - "documentation" 49 | - "enhancement" 50 | - "performance" 51 | - "refactor" 52 | default: patch 53 | 54 | template: | 55 | ## What’s changed 56 | 57 | $CHANGES 58 | -------------------------------------------------------------------------------- /.yamllint: -------------------------------------------------------------------------------- 1 | --- 2 | ignore: 3 | - .venv 4 | rules: 5 | braces: 6 | level: error 7 | min-spaces-inside: 0 8 | max-spaces-inside: 1 9 | min-spaces-inside-empty: -1 10 | max-spaces-inside-empty: -1 11 | brackets: 12 | level: error 13 | min-spaces-inside: 0 14 | max-spaces-inside: 0 15 | min-spaces-inside-empty: -1 16 | max-spaces-inside-empty: -1 17 | colons: 18 | level: error 19 | max-spaces-before: 0 20 | max-spaces-after: 1 21 | commas: 22 | level: error 23 | max-spaces-before: 0 24 | min-spaces-after: 1 25 | max-spaces-after: 1 26 | comments: 27 | level: error 28 | require-starting-space: true 29 | min-spaces-from-content: 1 30 | comments-indentation: 31 | level: error 32 | document-end: 33 | level: error 34 | present: false 35 | document-start: 36 | level: error 37 | present: true 38 | empty-lines: 39 | level: error 40 | max: 1 41 | max-start: 0 42 | max-end: 1 43 | hyphens: 44 | level: error 45 | max-spaces-after: 1 46 | indentation: 47 | level: error 48 | spaces: 2 49 | indent-sequences: true 50 | check-multi-line-strings: false 51 | key-duplicates: 52 | level: error 53 | line-length: 54 | level: warning 55 | max: 120 56 | allow-non-breakable-words: true 57 | allow-non-breakable-inline-mappings: true 58 | new-line-at-end-of-file: 59 | level: error 60 | new-lines: 61 | level: error 62 | type: unix 63 | trailing-spaces: 64 | level: error 65 | truthy: 66 | level: error 67 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | release: 7 | types: 8 | - published 9 | 10 | env: 11 | DEFAULT_PYTHON: "3.10" 12 | 13 | jobs: 14 | release: 15 | name: Releasing to PyPi 16 | runs-on: ubuntu-latest 17 | environment: 18 | name: release 19 | url: https://pypi.org/p/pyipp 20 | permissions: 21 | id-token: write 22 | steps: 23 | - name: Check out code from GitHub 24 | uses: actions/checkout@v4.2.2 25 | - name: Set up Poetry 26 | run: pipx install poetry 27 | - name: Set up Python ${{ env.DEFAULT_PYTHON }} 28 | id: python 29 | uses: actions/setup-python@v5.6.0 30 | with: 31 | python-version: ${{ env.DEFAULT_PYTHON }} 32 | cache: "poetry" 33 | - name: Install workflow dependencies 34 | run: | 35 | poetry config virtualenvs.create true 36 | poetry config virtualenvs.in-project true 37 | - name: Install dependencies 38 | run: poetry install --no-interaction 39 | - name: Set package version 40 | run: | 41 | version="${{ github.event.release.tag_name }}" 42 | version="${version,,}" 43 | version="${version#v}" 44 | poetry version --no-interaction "${version}" 45 | - name: Build package 46 | run: poetry build --no-interaction 47 | - name: Publish to PyPi 48 | uses: pypa/gh-action-pypi-publish@v1.12.4 49 | with: 50 | verbose: true 51 | print-hash: true 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python: Internet Printing Protocol (IPP) Client 2 | 3 | Asynchronous Python client for Internet Printing Protocol (IPP). 4 | 5 | ## About 6 | 7 | This package allows you to monitor printers that support the Internet Printing Protocol (IPP) programmatically. 8 | 9 | ## Installation 10 | 11 | ```bash 12 | pip install pyipp 13 | ``` 14 | 15 | ## Usage 16 | 17 | ```python 18 | import asyncio 19 | 20 | from pyipp import IPP, Printer 21 | 22 | 23 | async def main(): 24 | """Show example of connecting to your IPP print server.""" 25 | async with IPP("ipps://EPSON123456.local:631/ipp/print") as ipp: 26 | printer: Printer = await ipp.printer() 27 | print(printer) 28 | 29 | 30 | if __name__ == "__main__": 31 | loop = asyncio.get_event_loop() 32 | loop.run_until_complete(main()) 33 | ``` 34 | 35 | ## Setting up development environment 36 | 37 | This Python project is fully managed using the [Poetry](https://python-poetry.org) dependency 38 | manager. But also relies on the use of NodeJS for certain checks during 39 | development. 40 | 41 | You need at least: 42 | 43 | - Python 3.9+ 44 | - [Poetry](https://python-poetry.org/docs/#installation) 45 | - NodeJS 20+ (including NPM) 46 | 47 | To install all packages, including all development requirements: 48 | 49 | ```bash 50 | npm install 51 | poetry install 52 | ``` 53 | 54 | As this repository uses the [pre-commit](https://pre-commit.com/) framework, all changes 55 | are linted and tested with each commit. You can run all checks and tests 56 | manually, using the following command: 57 | 58 | ```bash 59 | poetry run pre-commit run --all-files 60 | ``` 61 | 62 | To run just the Python tests: 63 | 64 | ```bash 65 | poetry run pytest 66 | ``` 67 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended", 5 | "schedule:daily" 6 | ], 7 | "timezone": "America/Chicago", 8 | "labels": [ 9 | "dependencies" 10 | ], 11 | "lockFileMaintenance": { 12 | "enabled": true, 13 | "automerge": true 14 | }, 15 | "packageRules": [ 16 | { 17 | "matchManagers": [ 18 | "poetry" 19 | ], 20 | "addLabels": [ 21 | "python" 22 | ] 23 | }, 24 | { 25 | "matchManagers": [ 26 | "poetry" 27 | ], 28 | "matchDepTypes": [ 29 | "dev" 30 | ], 31 | "rangeStrategy": "pin" 32 | }, 33 | { 34 | "matchManagers": [ 35 | "poetry" 36 | ], 37 | "matchUpdateTypes": [ 38 | "minor", 39 | "patch" 40 | ], 41 | "automerge": true 42 | }, 43 | { 44 | "matchManagers": [ 45 | "npm", 46 | "nvm" 47 | ], 48 | "addLabels": [ 49 | "javascript" 50 | ], 51 | "rangeStrategy": "pin" 52 | }, 53 | { 54 | "matchManagers": [ 55 | "npm", 56 | "nvm" 57 | ], 58 | "matchUpdateTypes": [ 59 | "minor", 60 | "patch" 61 | ], 62 | "automerge": true 63 | }, 64 | { 65 | "matchManagers": [ 66 | "github-actions" 67 | ], 68 | "addLabels": [ 69 | "github_actions" 70 | ], 71 | "rangeStrategy": "pin" 72 | }, 73 | { 74 | "matchManagers": [ 75 | "github-actions" 76 | ], 77 | "matchUpdateTypes": [ 78 | "minor", 79 | "patch" 80 | ], 81 | "automerge": true 82 | } 83 | ] 84 | } 85 | -------------------------------------------------------------------------------- /examples/execute.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=W0621 2 | """Asynchronous Python client for IPP.""" 3 | import asyncio 4 | 5 | from pyipp import IPP 6 | from pyipp.enums import IppOperation 7 | 8 | 9 | async def main() -> None: 10 | """Show example of executing operation against your IPP print server.""" 11 | async with IPP("ipps://192.168.1.92:631/ipp/print") as ipp: 12 | response = await ipp.execute( 13 | IppOperation.GET_PRINTER_ATTRIBUTES, 14 | { 15 | "version": (2, 0), # try (1, 1) for older devices 16 | "operation-attributes-tag": { 17 | "requested-attributes": [ 18 | "printer-device-id", 19 | "printer-name", 20 | "printer-type", 21 | "printer-location", 22 | "printer-info", 23 | "printer-make-and-model", 24 | "printer-state", 25 | "printer-state-message", 26 | "printer-state-reasons", 27 | "printer-supply", 28 | "printer-up-time", 29 | "printer-uri-supported", 30 | "device-uri", 31 | "printer-is-shared", 32 | "printer-more-info", 33 | "printer-firmware-string-version", 34 | "marker-colors", 35 | "marker-high-levels", 36 | "marker-levels", 37 | "marker-low-levels", 38 | "marker-names", 39 | "marker-types", 40 | ], 41 | }, 42 | }, 43 | ) 44 | 45 | print(response) 46 | 47 | 48 | if __name__ == "__main__": 49 | loop = asyncio.get_event_loop() 50 | loop.run_until_complete(main()) 51 | -------------------------------------------------------------------------------- /examples/debug.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=W0621 2 | """Asynchronous Python client for IPP.""" 3 | import asyncio 4 | 5 | from pyipp import IPP 6 | from pyipp.enums import IppOperation 7 | 8 | 9 | async def main() -> None: 10 | """Show example of connecting to your IPP print server.""" 11 | async with IPP("ipps://192.168.1.92:631/ipp/print") as ipp: 12 | response = await ipp.raw( 13 | IppOperation.GET_PRINTER_ATTRIBUTES, 14 | { 15 | "version": (2, 0), # try (1, 1) for older devices 16 | "operation-attributes-tag": { 17 | "requested-attributes": [ 18 | "printer-device-id", 19 | "printer-name", 20 | "printer-type", 21 | "printer-location", 22 | "printer-info", 23 | "printer-make-and-model", 24 | "printer-state", 25 | "printer-state-message", 26 | "printer-state-reasons", 27 | "printer-supply", 28 | "printer-up-time", 29 | "printer-uri-supported", 30 | "device-uri", 31 | "printer-is-shared", 32 | "printer-more-info", 33 | "printer-firmware-string-version", 34 | "marker-colors", 35 | "marker-high-levels", 36 | "marker-levels", 37 | "marker-low-levels", 38 | "marker-names", 39 | "marker-types", 40 | ], 41 | }, 42 | }, 43 | ) 44 | 45 | with open("printer-attributes.bin", "wb") as file: # noqa: PTH123, ASYNC230 46 | file.write(response) 47 | file.close() 48 | 49 | 50 | if __name__ == "__main__": 51 | loop = asyncio.get_event_loop() 52 | loop.run_until_complete(main()) 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # OSX useful to ignore 7 | *.DS_Store 8 | .AppleDouble 9 | .LSOverride 10 | 11 | # Thumbnails 12 | ._* 13 | 14 | # Files that might appear in the root of a volume 15 | .DocumentRevisions-V100 16 | .fseventsd 17 | .Spotlight-V100 18 | .TemporaryItems 19 | .Trashes 20 | .VolumeIcon.icns 21 | .com.apple.timemachine.donotpresent 22 | 23 | # Directories potentially created on remote AFP share 24 | .AppleDB 25 | .AppleDesktop 26 | Network Trash Folder 27 | Temporary Items 28 | .apdisk 29 | 30 | # C extensions 31 | *.so 32 | 33 | # Distribution / packaging 34 | .Python 35 | env/ 36 | build/ 37 | develop-eggs/ 38 | dist/ 39 | downloads/ 40 | eggs/ 41 | .eggs/ 42 | lib/ 43 | lib64/ 44 | parts/ 45 | sdist/ 46 | var/ 47 | *.egg-info/ 48 | .installed.cfg 49 | *.egg 50 | 51 | # PyInstaller 52 | # Usually these files are written by a python script from a template 53 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 54 | *.manifest 55 | *.spec 56 | 57 | # Installer logs 58 | pip-log.txt 59 | pip-delete-this-directory.txt 60 | 61 | # Unit test / coverage reports 62 | htmlcov/ 63 | .tox/ 64 | .coverage 65 | .coverage.* 66 | .cache 67 | nosetests.xml 68 | coverage.xml 69 | *,cover 70 | .hypothesis/ 71 | .pytest_cache/ 72 | 73 | # Translations 74 | *.mo 75 | *.pot 76 | 77 | # Django stuff: 78 | *.log 79 | 80 | # Sphinx documentation 81 | docs/_build/ 82 | 83 | # pyenv 84 | .python-version 85 | 86 | # virtualenv 87 | .venv 88 | venv/ 89 | ENV/ 90 | 91 | # mypy 92 | .mypy_cache/ 93 | 94 | # Visual Studio Code 95 | .vscode 96 | 97 | # IntelliJ Idea family of suites 98 | .idea 99 | *.iml 100 | 101 | ## File-based project format: 102 | *.ipr 103 | *.iws 104 | 105 | ## mpeltonen/sbt-idea plugin 106 | .idea_modules/ 107 | 108 | # PyBuilder 109 | target/ 110 | 111 | # Cookiecutter 112 | output/ 113 | python_boilerplate/ 114 | 115 | # Node 116 | node_modules/ 117 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "containerEnv": { 3 | "POETRY_VIRTUALENVS_IN_PROJECT": "true" 4 | }, 5 | "customizations": { 6 | "codespaces": { 7 | "openFiles": ["README.md", "src/pyipp/ipp.py", "src/pyipp/models.py"] 8 | }, 9 | "vscode": { 10 | "extensions": [ 11 | "ms-python.python", 12 | "redhat.vscode-yaml", 13 | "esbenp.prettier-vscode", 14 | "GitHub.vscode-pull-request-github", 15 | "charliermarsh.ruff", 16 | "GitHub.vscode-github-actions", 17 | "ryanluker.vscode-coverage-gutters" 18 | ], 19 | "settings": { 20 | "[python]": { 21 | "editor.codeActionsOnSave": { 22 | "source.fixAll": true, 23 | "source.organizeImports": true 24 | } 25 | }, 26 | "coverage-gutters.customizable.context-menu": true, 27 | "coverage-gutters.customizable.status-bar-toggler-watchCoverageAndVisibleEditors-enabled": true, 28 | "coverage-gutters.showGutterCoverage": false, 29 | "coverage-gutters.showLineCoverage": true, 30 | "coverage-gutters.xmlname": "coverage.xml", 31 | "python.analysis.extraPaths": ["${workspaceFolder}/src"], 32 | "python.defaultInterpreterPath": ".venv/bin/python", 33 | "python.formatting.provider": "black", 34 | "python.linting.enabled": true, 35 | "python.linting.mypyEnabled": true, 36 | "python.linting.pylintEnabled": true, 37 | "python.testing.cwd": "${workspaceFolder}", 38 | "python.testing.pytestArgs": ["--cov-report=xml"], 39 | "python.testing.pytestEnabled": true, 40 | "ruff.importStrategy": "fromEnvironment", 41 | "ruff.interpreter": [".venv/bin/python"], 42 | "terminal.integrated.defaultProfile.linux": "zsh" 43 | } 44 | } 45 | }, 46 | "features": { 47 | "ghcr.io/devcontainers-contrib/features/poetry:2": {}, 48 | "ghcr.io/devcontainers/features/github-cli:1": {}, 49 | "ghcr.io/devcontainers/features/node:1": {}, 50 | "ghcr.io/devcontainers/features/python:1": { 51 | "installTools": false 52 | } 53 | }, 54 | "image": "mcr.microsoft.com/devcontainers/base:ubuntu", 55 | "name": "Async Python client for IPP", 56 | "updateContentCommand": ". ${NVM_DIR}/nvm.sh && nvm install && nvm use && npm install && poetry install", 57 | "postStartCommand": "git config --global --add safe.directory ${containerWorkspaceFolder} && poetry run pre-commit install" 58 | } 59 | -------------------------------------------------------------------------------- /src/pyipp/tags.py: -------------------------------------------------------------------------------- 1 | """Attribute Tags for IPP.""" 2 | 3 | from .enums import IppTag 4 | 5 | ATTRIBUTE_TAG_MAP = { 6 | "attributes-charset": IppTag.CHARSET, 7 | "attributes-natural-language": IppTag.LANGUAGE, 8 | "document-number": IppTag.INTEGER, 9 | "printer-uri": IppTag.URI, 10 | "requesting-user-name": IppTag.NAME, 11 | "job-id": IppTag.INTEGER, 12 | "document-name": IppTag.NAME, 13 | "job-name": IppTag.NAME, 14 | "document-format": IppTag.MIME_TYPE, 15 | "last-document": IppTag.BOOLEAN, 16 | "copies": IppTag.INTEGER, 17 | "job-cancel-after": IppTag.INTEGER, 18 | "job-hold-until": IppTag.KEYWORD, 19 | "job-k-octets": IppTag.INTEGER, 20 | "job-impressions-completed": IppTag.INTEGER, 21 | "job-media-sheets-completed": IppTag.INTEGER, 22 | "job-originating-host-name": IppTag.NAME, 23 | "job-originating-user-name": IppTag.NAME, 24 | "job-printer-state-message": IppTag.TEXT, 25 | "job-printer-state-reasons": IppTag.KEYWORD, 26 | "job-priority": IppTag.INTEGER, 27 | "number-up": IppTag.INTEGER, 28 | "job-sheets": IppTag.NAME, 29 | "job-uri": IppTag.URI, 30 | "job-state": IppTag.ENUM, 31 | "job-state-reasons": IppTag.KEYWORD, 32 | "job-uuid": IppTag.URI, 33 | "requested-attributes": IppTag.KEYWORD, 34 | "member-uris": IppTag.URI, 35 | "operations-supported": IppTag.ENUM, 36 | "ppd-name": IppTag.NAME, 37 | "printer-is-shared": IppTag.BOOLEAN, 38 | "printer-error-policy": IppTag.NAME, 39 | "printer-geo-location": IppTag.URI, 40 | "printer-info": IppTag.TEXT, 41 | "printer-organization": IppTag.TEXT, 42 | "printer-organizational-unit": IppTag.TEXT, 43 | "which-jobs": IppTag.KEYWORD, 44 | "my-jobs": IppTag.BOOLEAN, 45 | "purge-jobs": IppTag.BOOLEAN, 46 | "hold-job-until": IppTag.KEYWORD, 47 | "job-printer-uri": IppTag.URI, 48 | "printer-location": IppTag.TEXT, 49 | "printer-state": IppTag.ENUM, 50 | "printer-state-reasons": IppTag.KEYWORD, 51 | "printer-up-time": IppTag.INTEGER, 52 | "printer-uri-supported": IppTag.URI, 53 | "document-state": IppTag.ENUM, 54 | "device-uri": IppTag.URI, 55 | "ipp-attribute-fidelity": IppTag.BOOLEAN, 56 | "finishings": IppTag.ENUM, 57 | "orientation-requested": IppTag.ENUM, 58 | "print-quality": IppTag.ENUM, 59 | "time-at-creation": IppTag.INTEGER, 60 | "time-at-processing": IppTag.INTEGER, 61 | "time-at-completed": IppTag.INTEGER, 62 | "media": IppTag.NAME, 63 | "center-of-pixel": IppTag.BOOLEAN, 64 | "sides": IppTag.KEYWORD, 65 | } 66 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Testing 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | push: 7 | branches: 8 | - master 9 | pull_request: 10 | workflow_dispatch: 11 | 12 | env: 13 | DEFAULT_PYTHON: "3.10" 14 | 15 | jobs: 16 | pytest: 17 | name: Python ${{ matrix.python }} 18 | runs-on: ubuntu-latest 19 | strategy: 20 | matrix: 21 | python: ["3.9", "3.10", "3.11"] 22 | steps: 23 | - name: Check out code from GitHub 24 | uses: actions/checkout@v4.2.2 25 | - name: Set up Poetry 26 | run: pipx install poetry 27 | - name: Set up Python ${{ matrix.python }} 28 | id: python 29 | uses: actions/setup-python@v5.6.0 30 | with: 31 | python-version: ${{ matrix.python }} 32 | cache: "poetry" 33 | - name: Install workflow dependencies 34 | run: | 35 | poetry config virtualenvs.create true 36 | poetry config virtualenvs.in-project true 37 | - name: Install dependencies 38 | run: poetry install --no-interaction 39 | - name: Run pytest 40 | run: poetry run pytest --cov pyipp tests 41 | - name: Upload coverage artifact 42 | uses: actions/upload-artifact@v4.6.2 43 | with: 44 | name: coverage-${{ matrix.python }} 45 | path: .coverage 46 | include-hidden-files: true 47 | 48 | coverage: 49 | runs-on: ubuntu-latest 50 | needs: pytest 51 | steps: 52 | - name: Check out code from GitHub 53 | uses: actions/checkout@v4.2.2 54 | with: 55 | fetch-depth: 0 56 | - name: Download coverage data 57 | uses: actions/download-artifact@v4.3.0 58 | - name: Set up Poetry 59 | run: pipx install poetry 60 | - name: Set up Python ${{ env.DEFAULT_PYTHON }} 61 | id: python 62 | uses: actions/setup-python@v5.6.0 63 | with: 64 | python-version: ${{ env.DEFAULT_PYTHON }} 65 | cache: "poetry" 66 | - name: Install workflow dependencies 67 | run: | 68 | poetry config virtualenvs.create true 69 | poetry config virtualenvs.in-project true 70 | - name: Install dependencies 71 | run: poetry install --no-interaction 72 | - name: Process coverage results 73 | run: | 74 | poetry run coverage combine coverage*/.coverage* 75 | poetry run coverage xml -i 76 | - name: Upload coverage report 77 | uses: codecov/codecov-action@v5.4.3 78 | with: 79 | token: ${{ secrets.CODECOV_TOKEN }} 80 | -------------------------------------------------------------------------------- /tests/test_serializer.py: -------------------------------------------------------------------------------- 1 | """Tests for Serializer.""" 2 | from pyipp import serializer 3 | from pyipp.const import DEFAULT_CHARSET, DEFAULT_CHARSET_LANGUAGE, DEFAULT_PROTO_VERSION 4 | from pyipp.enums import IppOperation, IppTag 5 | 6 | from . import load_fixture_binary 7 | 8 | 9 | def test_construct_attribute_values() -> None: 10 | """Test the construct_attribute_values method.""" 11 | result = serializer.construct_attribute_values( 12 | IppTag.INTEGER, 13 | IppOperation.GET_PRINTER_ATTRIBUTES, 14 | ) 15 | assert result == b"\x00\x04\x00\x00\x00\x0b" 16 | 17 | result = serializer.construct_attribute_values( 18 | IppTag.ENUM, 19 | IppOperation.GET_PRINTER_ATTRIBUTES, 20 | ) 21 | assert result == b"\x00\x04\x00\x00\x00\x0b" 22 | 23 | result = serializer.construct_attribute_values( 24 | IppTag.BOOLEAN, 25 | "0", 26 | ) 27 | assert result == b"\x00\x01\x01" 28 | 29 | result = serializer.construct_attribute_values( 30 | IppTag.URI, 31 | "ipps://localhost:631", 32 | ) 33 | assert result == b"\x00\x14ipps://localhost:631" 34 | 35 | 36 | def test_construct_attribute() -> None: 37 | """Test the construct_attribute method.""" 38 | result = serializer.construct_attribute("attributes-charset", DEFAULT_CHARSET) 39 | assert result == b"G\x00\x12attributes-charset\x00\x05utf-8" 40 | 41 | result = serializer.construct_attribute( 42 | "operations-supported", 43 | [IppOperation.GET_PRINTER_ATTRIBUTES], 44 | ) 45 | assert result == b"#\x00\x14operations-supported\x00\x04\x00\x00\x00\x0b" 46 | 47 | 48 | def test_construct_attribute_no_tag_unmapped() -> None: 49 | """Test the construct_attribute method with no tag and unmapped attribute name.""" 50 | result = serializer.construct_attribute( 51 | "no-tag-unmapped", 52 | None, 53 | ) 54 | 55 | assert result == b"" 56 | 57 | 58 | def test_encode_dict() -> None: 59 | """Test the encode_dict method.""" 60 | result = serializer.encode_dict( 61 | { 62 | "version": DEFAULT_PROTO_VERSION, 63 | "operation": IppOperation.GET_PRINTER_ATTRIBUTES, 64 | "request-id": 1, 65 | "operation-attributes-tag": { 66 | "attributes-charset": DEFAULT_CHARSET, 67 | "attributes-natural-language": DEFAULT_CHARSET_LANGUAGE, 68 | "printer-uri": "ipp://printer.example.com:361/ipp/print", 69 | "requesting-user-name": "PythonIPP", 70 | }, 71 | }, 72 | ) 73 | 74 | assert result == load_fixture_binary( 75 | "serializer/get-printer-attributes-request-000.bin", 76 | ) 77 | -------------------------------------------------------------------------------- /.github/labels.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: "breaking-change" 3 | color: ee0701 4 | description: "A breaking change for existing users." 5 | - name: "bugfix" 6 | color: ee0701 7 | description: "Inconsistencies or issues which will cause a problem for users or implementers." 8 | - name: "documentation" 9 | color: 0052cc 10 | description: "Solely about the documentation of the project." 11 | - name: "enhancement" 12 | color: 1d76db 13 | description: "Enhancement of the code, not introducing new features." 14 | - name: "refactor" 15 | color: 1d76db 16 | description: "Improvement of existing code, not introducing new features." 17 | - name: "performance" 18 | color: 1d76db 19 | description: "Improving performance, not introducing new features." 20 | - name: "new-feature" 21 | color: 0e8a16 22 | description: "New features or options." 23 | - name: "maintenance" 24 | color: 2af79e 25 | description: "Generic maintenance tasks." 26 | - name: "ci" 27 | color: 1d76db 28 | description: "Work that improves the continue integration." 29 | - name: "dependencies" 30 | color: 1d76db 31 | description: "Upgrade or downgrade of project dependencies." 32 | 33 | - name: "in-progress" 34 | color: fbca04 35 | description: "Issue is currently being resolved by a developer." 36 | - name: "stale" 37 | color: fef2c0 38 | description: "There has not been activity on this issue or PR for quite some time." 39 | - name: "no-stale" 40 | color: fef2c0 41 | description: "This issue or PR is exempted from the stable bot." 42 | 43 | - name: "security" 44 | color: ee0701 45 | description: "Marks a security issue that needs to be resolved asap." 46 | - name: "incomplete" 47 | color: fef2c0 48 | description: "Marks a PR or issue that is missing information." 49 | - name: "invalid" 50 | color: fef2c0 51 | description: "Marks a PR or issue that is missing information." 52 | 53 | - name: "beginner-friendly" 54 | color: 0e8a16 55 | description: "Good first issue for people wanting to contribute to the project." 56 | - name: "help-wanted" 57 | color: 0e8a16 58 | description: "We need some extra helping hands or expertise in order to resolve this." 59 | 60 | - name: "priority-critical" 61 | color: ee0701 62 | description: "This should be dealt with ASAP. Not fixing this issue would be a serious error." 63 | - name: "priority-high" 64 | color: b60205 65 | description: "After critical issues are fixed, these should be dealt with before any further issues." 66 | - name: "priority-medium" 67 | color: 0e8a16 68 | description: "This issue may be useful, and needs some attention." 69 | - name: "priority-low" 70 | color: e4ea8a 71 | description: "Nice addition, maybe... someday..." 72 | 73 | - name: "major" 74 | color: b60205 75 | description: "This PR causes a major version bump in the version number." 76 | - name: "minor" 77 | color: 0e8a16 78 | description: "This PR causes a minor version bump in the version number." 79 | -------------------------------------------------------------------------------- /tests/test_interface.py: -------------------------------------------------------------------------------- 1 | """Tests for IPP public interface.""" 2 | import pytest 3 | from aiohttp import ClientSession 4 | from aresponses import ResponsesMockServer 5 | 6 | from pyipp import IPP, Printer 7 | from pyipp.const import DEFAULT_PRINTER_ATTRIBUTES 8 | from pyipp.enums import IppOperation 9 | 10 | from . import ( 11 | DEFAULT_PRINTER_HOST, 12 | DEFAULT_PRINTER_PATH, 13 | DEFAULT_PRINTER_PORT, 14 | DEFAULT_PRINTER_URI, 15 | load_fixture_binary, 16 | ) 17 | 18 | MATCH_DEFAULT_HOST = f"{DEFAULT_PRINTER_HOST}:{DEFAULT_PRINTER_PORT}" 19 | 20 | 21 | @pytest.mark.asyncio 22 | async def test_printer(aresponses: ResponsesMockServer) -> None: 23 | """Test getting IPP printer information.""" 24 | aresponses.add( 25 | MATCH_DEFAULT_HOST, 26 | DEFAULT_PRINTER_PATH, 27 | "POST", 28 | aresponses.Response( 29 | status=200, 30 | headers={"Content-Type": "application/ipp"}, 31 | body=load_fixture_binary("get-printer-attributes-epsonxp6000.bin"), 32 | ), 33 | ) 34 | 35 | async with ClientSession() as session: 36 | ipp = IPP(DEFAULT_PRINTER_URI, session=session) 37 | printer = await ipp.printer() 38 | 39 | assert printer 40 | assert isinstance(printer, Printer) 41 | 42 | 43 | @pytest.mark.asyncio 44 | async def test_printer_update_logic(aresponses: ResponsesMockServer) -> None: 45 | """Test getting updated IPP printer information.""" 46 | aresponses.add( 47 | MATCH_DEFAULT_HOST, 48 | DEFAULT_PRINTER_PATH, 49 | "POST", 50 | aresponses.Response( 51 | status=200, 52 | headers={"Content-Type": "application/ipp"}, 53 | body=load_fixture_binary("get-printer-attributes-epsonxp6000.bin"), 54 | ), 55 | repeat=2, 56 | ) 57 | 58 | async with ClientSession() as session: 59 | ipp = IPP(DEFAULT_PRINTER_URI, session=session) 60 | printer = await ipp.printer() 61 | 62 | assert printer 63 | assert isinstance(printer, Printer) 64 | 65 | printer = await ipp.printer() 66 | assert printer 67 | assert isinstance(printer, Printer) 68 | 69 | 70 | @pytest.mark.asyncio 71 | async def test_raw(aresponses: ResponsesMockServer) -> None: 72 | """Test raw method is handled correctly.""" 73 | aresponses.add( 74 | MATCH_DEFAULT_HOST, 75 | DEFAULT_PRINTER_PATH, 76 | "POST", 77 | aresponses.Response( 78 | status=200, 79 | headers={"Content-Type": "application/ipp"}, 80 | body=load_fixture_binary("get-printer-attributes-epsonxp6000.bin"), 81 | ), 82 | ) 83 | 84 | async with ClientSession() as session: 85 | ipp = IPP(DEFAULT_PRINTER_URI, session=session) 86 | response = await ipp.raw( 87 | IppOperation.GET_PRINTER_ATTRIBUTES, 88 | { 89 | "operation-attributes-tag": { 90 | "requested-attributes": DEFAULT_PRINTER_ATTRIBUTES, 91 | }, 92 | }, 93 | ) 94 | 95 | assert response 96 | assert isinstance(response, bytes) 97 | -------------------------------------------------------------------------------- /src/pyipp/serializer.py: -------------------------------------------------------------------------------- 1 | """Data Serializer for IPP.""" 2 | from __future__ import annotations 3 | 4 | import logging 5 | import random 6 | import struct 7 | from typing import Any 8 | 9 | from .const import DEFAULT_PROTO_VERSION 10 | from .enums import IppTag 11 | from .tags import ATTRIBUTE_TAG_MAP 12 | 13 | _LOGGER = logging.getLogger(__name__) 14 | 15 | 16 | def construct_attribute_values(tag: IppTag, value: Any) -> bytes: 17 | """Serialize the attribute values into IPP format.""" 18 | byte_str = b"" 19 | 20 | if tag in (IppTag.INTEGER, IppTag.ENUM): 21 | byte_str += struct.pack(">h", 4) 22 | byte_str += struct.pack(">i", value) 23 | elif tag == IppTag.BOOLEAN: 24 | byte_str += struct.pack(">h", 1) 25 | byte_str += struct.pack(">?", value) 26 | else: 27 | encoded_value = value.encode("utf-8") 28 | byte_str += struct.pack(">h", len(encoded_value)) 29 | byte_str += encoded_value 30 | 31 | return byte_str 32 | 33 | 34 | def construct_attribute(name: str, value: Any, tag: IppTag | None = None) -> bytes: 35 | """Serialize the attribute into IPP format.""" 36 | byte_str = b"" 37 | 38 | if not tag and not (tag := ATTRIBUTE_TAG_MAP.get(name, None)): 39 | _LOGGER.debug("Unknown IppTag for %s", name) 40 | return byte_str 41 | 42 | if isinstance(value, (list, tuple, set)): 43 | for index, list_value in enumerate(value): 44 | byte_str += struct.pack(">b", tag.value) 45 | 46 | if index == 0: 47 | byte_str += struct.pack(">h", len(name)) 48 | byte_str += name.encode("utf-8") 49 | else: 50 | byte_str += struct.pack(">h", 0) 51 | 52 | byte_str += construct_attribute_values(tag, list_value) 53 | else: 54 | byte_str = struct.pack(">b", tag.value) 55 | 56 | byte_str += struct.pack(">h", len(name)) 57 | byte_str += name.encode("utf-8") 58 | 59 | byte_str += construct_attribute_values(tag, value) 60 | 61 | return byte_str 62 | 63 | 64 | def encode_dict(data: dict[str, Any]) -> bytes: 65 | """Serialize a dictionary of data into IPP format.""" 66 | version = data["version"] or DEFAULT_PROTO_VERSION 67 | operation = data["operation"] 68 | 69 | if (request_id := data.get("request-id")) is None: 70 | request_id = random.choice(range(10000, 99999)) # nosec # noqa: S311 71 | 72 | encoded = struct.pack(">bb", *version) 73 | encoded += struct.pack(">h", operation.value) 74 | encoded += struct.pack(">i", request_id) 75 | 76 | encoded += struct.pack(">b", IppTag.OPERATION.value) 77 | 78 | if isinstance(data.get("operation-attributes-tag"), dict): 79 | for attr, value in data["operation-attributes-tag"].items(): 80 | encoded += construct_attribute(attr, value) 81 | 82 | if isinstance(data.get("job-attributes-tag"), dict): 83 | encoded += struct.pack(">b", IppTag.JOB.value) 84 | 85 | for attr, value in data["job-attributes-tag"].items(): 86 | encoded += construct_attribute(attr, value) 87 | 88 | if isinstance(data.get("printer-attributes-tag"), dict): 89 | encoded += struct.pack(">b", IppTag.PRINTER.value) 90 | 91 | for attr, value in data["printer-attributes-tag"].items(): 92 | encoded += construct_attribute(attr, value) 93 | 94 | encoded += struct.pack(">b", IppTag.END.value) 95 | 96 | if "data" in data: 97 | encoded += data["data"] 98 | 99 | return encoded 100 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | repos: 3 | - repo: local 4 | hooks: 5 | - id: ruff 6 | name: Ruff 7 | language: system 8 | types: [python] 9 | entry: poetry run ruff check --fix 10 | require_serial: true 11 | stages: [commit, push, manual] 12 | - id: black 13 | name: Format using black 14 | language: system 15 | types: [python] 16 | entry: poetry run black 17 | require_serial: true 18 | - id: blacken-docs 19 | name: Format documentation examples using black 20 | language: system 21 | files: '\.(rst|md|markdown|py|tex)$' 22 | entry: poetry run blacken-docs 23 | require_serial: true 24 | - id: check-ast 25 | name: Check Python AST 26 | language: system 27 | types: [python] 28 | entry: poetry run check-ast 29 | - id: check-case-conflict 30 | name: Check for case conflicts 31 | language: system 32 | entry: poetry run check-case-conflict 33 | - id: check-docstring-first 34 | name: Check docstring is first 35 | language: system 36 | types: [python] 37 | entry: poetry run check-docstring-first 38 | - id: check-executables-have-shebangs 39 | name: Check that executables have shebangs 40 | language: system 41 | types: [text, executable] 42 | entry: poetry run check-executables-have-shebangs 43 | stages: [commit, push, manual] 44 | - id: check-json 45 | name: Check JSON files 46 | language: system 47 | types: [json] 48 | entry: poetry run check-json 49 | - id: check-merge-conflict 50 | name: Check for merge conflicts 51 | language: system 52 | types: [text] 53 | entry: poetry run check-merge-conflict 54 | - id: check-symlinks 55 | name: Check for broken symlinks 56 | language: system 57 | types: [symlink] 58 | entry: poetry run check-symlinks 59 | - id: check-toml 60 | name: Check TOML files 61 | language: system 62 | types: [toml] 63 | entry: poetry run check-toml 64 | - id: check-xml 65 | name: Check XML files 66 | entry: check-xml 67 | language: system 68 | types: [xml] 69 | - id: check-yaml 70 | name: Check YAML files 71 | language: system 72 | types: [yaml] 73 | entry: poetry run check-yaml 74 | - id: codespell 75 | name: Check code for common misspellings 76 | language: system 77 | types: [text] 78 | exclude: ^poetry\.lock$ 79 | entry: poetry run codespell 80 | - id: detect-private-key 81 | name: Detect Private Keys 82 | language: system 83 | types: [text] 84 | entry: poetry run detect-private-key 85 | - id: end-of-file-fixer 86 | name: Fix End of Files 87 | language: system 88 | types: [text] 89 | entry: poetry run end-of-file-fixer 90 | stages: [commit, push, manual] 91 | - id: mypy 92 | name: Static type checking using mypy 93 | language: system 94 | types: [python] 95 | entry: poetry run mypy 96 | require_serial: true 97 | - id: no-commit-to-branch 98 | name: Don't commit to main branch 99 | language: system 100 | entry: poetry run no-commit-to-branch 101 | pass_filenames: false 102 | always_run: true 103 | args: 104 | - --branch=master 105 | - id: poetry 106 | name: Check pyproject with Poetry 107 | language: system 108 | entry: poetry check 109 | pass_filenames: false 110 | always_run: true 111 | - id: prettier 112 | name: Ensuring files are prettier 113 | language: system 114 | types: [yaml, json, markdown] 115 | entry: npm run prettier 116 | pass_filenames: false 117 | - id: pylint 118 | name: Starring code with pylint 119 | language: system 120 | types: [python] 121 | entry: poetry run pylint 122 | - id: pytest 123 | name: Running tests and test coverage with pytest 124 | language: system 125 | types: [python] 126 | entry: poetry run pytest 127 | pass_filenames: false 128 | - id: trailing-whitespace 129 | name: Trim Trailing Whitespace 130 | language: system 131 | types: [text] 132 | entry: poetry run trailing-whitespace-fixer 133 | stages: [commit, push, manual] 134 | - id: yamllint 135 | name: Check YAML files with yamllint 136 | language: system 137 | types: [yaml] 138 | entry: poetry run yamllint 139 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "pyipp" 3 | description = "Asynchronous Python client for Internet Printing Protocol (IPP)" 4 | authors = ["Chris Talkington "] 5 | classifiers = [ 6 | "Development Status :: 4 - Beta", 7 | "Framework :: AsyncIO", 8 | "Intended Audience :: Developers", 9 | "Natural Language :: English", 10 | "Programming Language :: Python :: 3.9", 11 | "Programming Language :: Python :: 3.10", 12 | "Programming Language :: Python :: 3", 13 | "Topic :: Software Development :: Libraries :: Python Modules", 14 | ] 15 | keywords = ["ipp", "api", "async", "client", "printer"] 16 | documentation = "https://github.com/ctalkington/python-ipp" 17 | homepage = "https://github.com/ctalkington/python-ipp" 18 | license = "MIT" 19 | maintainers = ["Chris Talkington "] 20 | packages = [ 21 | {include = "pyipp", from = "src"}, 22 | ] 23 | readme = "README.md" 24 | repository = "https://github.com/ctalkington/python-ipp" 25 | version = "0.0.0" 26 | 27 | [tool.poetry.dependencies] 28 | aiohttp = ">=3.0.0" 29 | async-timeout = { version = "5.0.1", python = "<3.11" } 30 | awesomeversion = ">=21.10.1" 31 | backoff = ">=2.2.0" 32 | deepmerge = ">=1.1.0" 33 | python = "^3.9" 34 | yarl = ">=1.6.0" 35 | 36 | [tool.poetry.dev-dependencies] 37 | aresponses = "3.0.0" 38 | black = "25.1.0" 39 | blacken-docs = "1.19.1" 40 | codespell = "2.4.1" 41 | covdefaults = "2.3.0" 42 | coverage = {version = "7.9.1", extras = ["toml"]} 43 | mypy = "1.16.0" 44 | pre-commit = "4.2.0" 45 | pre-commit-hooks = "5.0.0" 46 | pylint = "3.3.7" 47 | pytest = "8.4.0" 48 | pytest-asyncio = "1.0.0" 49 | pytest-cov = "6.2.1" 50 | ruff = "0.6.9" 51 | safety = "3.5.2" 52 | syrupy = "4.9.1" 53 | types-cachetools = "^5.3.0" 54 | yamllint = "1.37.1" 55 | 56 | [tool.poetry.urls] 57 | "Bug Tracker" = "https://github.com/ctalkington/python-ipp/issues" 58 | Changelog = "https://github.com/ctalkington/python-ipp/releases" 59 | 60 | [tool.coverage.report] 61 | show_missing = true 62 | fail_under = 53 63 | 64 | [tool.coverage.run] 65 | plugins = ["covdefaults"] 66 | source = ["pyipp"] 67 | 68 | [tool.mypy] 69 | # Specify the target platform details in config, so your developers are 70 | # free to run mypy on Windows, Linux, or macOS and get consistent 71 | # results. 72 | platform = "linux" 73 | python_version = 3.9 74 | 75 | # show error messages from unrelated files 76 | follow_imports = "normal" 77 | 78 | # suppress errors about unsatisfied imports 79 | ignore_missing_imports = true 80 | 81 | # be strict 82 | check_untyped_defs = true 83 | disallow_any_generics = true 84 | disallow_incomplete_defs = true 85 | disallow_subclassing_any = true 86 | disallow_untyped_calls = true 87 | disallow_untyped_decorators = true 88 | disallow_untyped_defs = true 89 | no_implicit_optional = true 90 | no_implicit_reexport = true 91 | strict_optional = true 92 | warn_incomplete_stub = true 93 | warn_no_return = true 94 | warn_redundant_casts = true 95 | warn_return_any = true 96 | warn_unused_configs = true 97 | warn_unused_ignores = true 98 | 99 | [[tool.mypy.overrides]] 100 | follow_imports = "skip" 101 | module = "mypy-aiohttp.*" 102 | 103 | [tool.pylint.MASTER] 104 | extension-pkg-whitelist = [ 105 | "pydantic", 106 | ] 107 | ignore = [ 108 | "examples", 109 | "tests", 110 | ] 111 | 112 | [tool.pylint.BASIC] 113 | good-names = [ 114 | "_", 115 | "ex", 116 | "fp", 117 | "i", 118 | "id", 119 | "j", 120 | "k", 121 | "on", 122 | "Run", 123 | "T", 124 | "wv", 125 | ] 126 | 127 | [tool.pylint."MESSAGES CONTROL"] 128 | disable = [ 129 | "too-few-public-methods", 130 | "too-many-arguments", 131 | "duplicate-code", 132 | "format", 133 | "unsubscriptable-object", 134 | ] 135 | 136 | [tool.pylint.SIMILARITIES] 137 | ignore-imports = true 138 | 139 | [tool.pylint.FORMAT] 140 | max-line-length = 88 141 | 142 | [tool.pylint.DESIGN] 143 | max-attributes = 20 144 | 145 | [tool.pyright] 146 | include = ["src"] 147 | 148 | [tool.pytest.ini_options] 149 | addopts = "--cov" 150 | asyncio_mode = "auto" 151 | 152 | [tool.ruff.lint] 153 | select = ["ALL"] 154 | ignore = [ 155 | "ANN101", # Self... explanatory 156 | "ANN401", # Opinionated warning on disallowing dynamically typed expressions 157 | "D203", # Conflicts with other rules 158 | "D213", # Conflicts with other rules 159 | "D417", # False positives in some occasions 160 | "PLR2004", # Just annoying, not really useful 161 | "TRY003", 162 | "EM101", 163 | "EXE002", 164 | ] 165 | 166 | [tool.ruff.lint.flake8-pytest-style] 167 | mark-parentheses = false 168 | fixture-parentheses = false 169 | 170 | [tool.ruff.lint.isort] 171 | known-first-party = ["pyipp"] 172 | 173 | [tool.ruff.lint.mccabe] 174 | max-complexity = 25 175 | 176 | [build-system] 177 | build-backend = "poetry.core.masonry.api" 178 | requires = ["poetry-core>=1.0.0"] 179 | -------------------------------------------------------------------------------- /tests/test_parser.py: -------------------------------------------------------------------------------- 1 | """Tests for Parser.""" 2 | import pytest 3 | from syrupy.assertion import SnapshotAssertion 4 | 5 | from pyipp import IPPParseError, parser 6 | from pyipp.const import DEFAULT_CHARSET, DEFAULT_CHARSET_LANGUAGE, DEFAULT_PROTO_VERSION 7 | from pyipp.enums import IppOperation 8 | 9 | from . import load_fixture_binary 10 | 11 | RESPONSE_GET_PRINTER_ATTRIBUTES = load_fixture_binary( 12 | "get-printer-attributes-response-000.bin", 13 | ) 14 | 15 | MOCK_IEEE1284_DEVICE_ID = "MFG:EPSON;CMD:ESCPL2,BDC,D4,D4PX,ESCPR7,END4,GENEP,URF;MDL:XP-6000 Series;CLS:PRINTER;DES:EPSON XP-6000 Series;CID:EpsonRGB;FID:FXN,DPA,WFA,ETN,AFN,DAN,WRA;RID:20;DDS:022500;ELG:1000;SN:583434593035343012;URF:CP1,PQ4-5,OB9,OFU0,RS360,SRGB24,W8,DM3,IS1-7-6,V1.4,MT1-3-7-8-10-11-12;" 16 | 17 | 18 | def test_parse() -> None: 19 | """Test the parse method.""" 20 | result = parser.parse(RESPONSE_GET_PRINTER_ATTRIBUTES) 21 | assert result == { 22 | "data": b"", 23 | "jobs": [], 24 | "operation-attributes": { 25 | "attributes-charset": DEFAULT_CHARSET, 26 | "attributes-natural-language": DEFAULT_CHARSET_LANGUAGE, 27 | "printer-uri": "ipp://printer.example.com:361/ipp/print", 28 | "requesting-user-name": "PythonIPP", 29 | }, 30 | "printers": [], 31 | "request-id": 1, 32 | "status-code": IppOperation.GET_PRINTER_ATTRIBUTES, 33 | "unsupported-attributes": [], 34 | "version": DEFAULT_PROTO_VERSION, 35 | } 36 | 37 | 38 | def test_parse_attribute() -> None: 39 | """Test the parse_attribute method.""" 40 | result = parser.parse_attribute(RESPONSE_GET_PRINTER_ATTRIBUTES, 9) 41 | assert result == ( 42 | { 43 | "name": "attributes-charset", 44 | "name-length": 18, 45 | "tag": 71, 46 | "value": "utf-8", 47 | "value-length": 5, 48 | }, 49 | 37, 50 | ) 51 | 52 | 53 | def test_parse_attribute_reserved_string() -> None: 54 | """Test the parse_attribute method when provided a reserved string.""" 55 | result = parser.parse_attribute(b"C\x00\x0freserved-string\x00\x04yoda", 0) 56 | assert result == ( 57 | { 58 | "name": "reserved-string", 59 | "name-length": 15, 60 | "tag": 67, 61 | "value": "yoda", 62 | "value-length": 4, 63 | }, 64 | 24, 65 | ) 66 | 67 | result = parser.parse_attribute(b"C\x00\x0freserved-string\x00\x00", 0) 68 | assert result == ( 69 | { 70 | "name": "reserved-string", 71 | "name-length": 15, 72 | "tag": 67, 73 | "value": None, 74 | "value-length": 0, 75 | }, 76 | 20, 77 | ) 78 | 79 | 80 | def test_parse_attribute_invalid_date() -> None: 81 | """Test the parse_attribute method when provided an invalid date.""" 82 | invalid = b"1\x00\x14printer-current-time\x00\x0299" 83 | 84 | with pytest.raises(IPPParseError): 85 | parser.parse_attribute(invalid, 0) 86 | 87 | 88 | def test_parse_ieee1284_device_id() -> None: 89 | """Test the parse_ieee1284_device_id method.""" 90 | result = parser.parse_ieee1284_device_id(MOCK_IEEE1284_DEVICE_ID) 91 | 92 | assert result 93 | assert result["MFG"] == "EPSON" 94 | assert result["MDL"] == "XP-6000 Series" 95 | assert result["SN"] == "583434593035343012" 96 | assert result["CMD"] == "ESCPL2,BDC,D4,D4PX,ESCPR7,END4,GENEP,URF" 97 | 98 | assert result["MANUFACTURER"] == result["MFG"] 99 | assert result["MODEL"] == result["MDL"] 100 | assert result["COMMAND SET"] == result["CMD"] 101 | 102 | 103 | def test_parse_ieee1284_device_id_manufacturer_only() -> None: 104 | """Test the parse_ieee1284_device_id method with only a manufacturer.""" 105 | result = parser.parse_ieee1284_device_id("MANUFACTURER:EPSON") 106 | 107 | assert result == { 108 | "MANUFACTURER": "EPSON", 109 | } 110 | 111 | 112 | def test_parse_ieee1284_device_id_empty() -> None: 113 | """Test the parse_ieee1284_device_id method with empty string.""" 114 | result = parser.parse_ieee1284_device_id("") 115 | 116 | assert isinstance(result, dict) 117 | 118 | 119 | def test_parse_make_and_model() -> None: 120 | """Test the parse_make_and_model method.""" 121 | result = parser.parse_make_and_model("") 122 | assert result == ("Unknown", "Unknown") 123 | 124 | # generic fallback for unknown brands 125 | result = parser.parse_make_and_model("IPP") 126 | assert result == ("IPP", "Unknown") 127 | 128 | result = parser.parse_make_and_model("IPP Printer") 129 | assert result == ("IPP", "Printer") 130 | 131 | # known brands 132 | result = parser.parse_make_and_model("EPSON XP-6000 Series") 133 | assert result == ("EPSON", "XP-6000 Series") 134 | 135 | result = parser.parse_make_and_model("HP Officejet Pro 6830") 136 | assert result == ("HP", "Officejet Pro 6830") 137 | 138 | result = parser.parse_make_and_model("HP Photosmart D110 Series") 139 | assert result == ("HP", "Photosmart D110 Series") 140 | 141 | 142 | def test_parse_brother_mfcj5320dw(snapshot: SnapshotAssertion) -> None: 143 | """Test the parse method against response from Brother MFC-J5320DW.""" 144 | response = load_fixture_binary("get-printer-attributes-brother-mfcj5320dw.bin") 145 | 146 | result = parser.parse(response) 147 | assert result == snapshot 148 | 149 | 150 | def test_parse_epson_xp6000(snapshot: SnapshotAssertion) -> None: 151 | """Test the parse method against response from Epson XP-6000 Series.""" 152 | response = load_fixture_binary("get-printer-attributes-epsonxp6000.bin") 153 | 154 | result = parser.parse(response) 155 | assert result == snapshot 156 | 157 | 158 | def test_parse_kyocera_ecosys_m2540dn(snapshot: SnapshotAssertion) -> None: 159 | """Test the parse method against response from Kyocera Ecosys M2540DN.""" 160 | response = load_fixture_binary( 161 | "get-printer-attributes-kyocera-ecosys-m2540dn-001.bin", 162 | ) 163 | 164 | result = parser.parse(response) 165 | assert result == snapshot 166 | 167 | 168 | def test_parse_empty_attribute_group(snapshot: SnapshotAssertion) -> None: 169 | """Test the parse method against a sample response with an empty attribute group.""" 170 | response = load_fixture_binary( 171 | "get-printer-attributes-empty-attribute-group.bin", 172 | ) 173 | 174 | result = parser.parse(response) 175 | assert result == snapshot 176 | -------------------------------------------------------------------------------- /.github/workflows/linting.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Linting 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | push: 7 | branches: 8 | - master 9 | pull_request: 10 | workflow_dispatch: 11 | 12 | env: 13 | DEFAULT_PYTHON: "3.11" 14 | 15 | jobs: 16 | codespell: 17 | name: codespell 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Check out code from GitHub 21 | uses: actions/checkout@v4.2.2 22 | - name: Set up Poetry 23 | run: pipx install poetry 24 | - name: Set up Python ${{ env.DEFAULT_PYTHON }} 25 | id: python 26 | uses: actions/setup-python@v5.6.0 27 | with: 28 | python-version: ${{ env.DEFAULT_PYTHON }} 29 | cache: "poetry" 30 | - name: Install workflow dependencies 31 | run: | 32 | poetry config virtualenvs.create true 33 | poetry config virtualenvs.in-project true 34 | - name: Install Python dependencies 35 | run: poetry install --no-interaction 36 | - name: Check code for common misspellings 37 | run: poetry run pre-commit run codespell --all-files 38 | 39 | ruff: 40 | name: Ruff 41 | runs-on: ubuntu-latest 42 | steps: 43 | - name: Check out code from GitHub 44 | uses: actions/checkout@v4.2.2 45 | - name: Set up Poetry 46 | run: pipx install poetry 47 | - name: Set up Python ${{ env.DEFAULT_PYTHON }} 48 | id: python 49 | uses: actions/setup-python@v5.6.0 50 | with: 51 | python-version: ${{ env.DEFAULT_PYTHON }} 52 | cache: "poetry" 53 | - name: Install workflow dependencies 54 | run: | 55 | poetry config virtualenvs.create true 56 | poetry config virtualenvs.in-project true 57 | - name: Install Python dependencies 58 | run: poetry install --no-interaction 59 | - name: Run Ruff 60 | run: poetry run ruff check . 61 | 62 | black: 63 | name: black 64 | runs-on: ubuntu-latest 65 | steps: 66 | - name: Check out code from GitHub 67 | uses: actions/checkout@v4.2.2 68 | - name: Set up Poetry 69 | run: pipx install poetry 70 | - name: Set up Python ${{ env.DEFAULT_PYTHON }} 71 | id: python 72 | uses: actions/setup-python@v5.6.0 73 | with: 74 | python-version: ${{ env.DEFAULT_PYTHON }} 75 | cache: "poetry" 76 | - name: Install workflow dependencies 77 | run: | 78 | poetry config virtualenvs.create true 79 | poetry config virtualenvs.in-project true 80 | - name: Install Python dependencies 81 | run: poetry install --no-interaction 82 | - name: Run black on docs 83 | run: poetry run blacken-docs 84 | 85 | pre-commit-hooks: 86 | name: pre-commit-hooks 87 | runs-on: ubuntu-latest 88 | steps: 89 | - name: Check out code from GitHub 90 | uses: actions/checkout@v4.2.2 91 | - name: Set up Poetry 92 | run: pipx install poetry 93 | - name: Set up Python ${{ env.DEFAULT_PYTHON }} 94 | id: python 95 | uses: actions/setup-python@v5.6.0 96 | with: 97 | python-version: ${{ env.DEFAULT_PYTHON }} 98 | cache: "poetry" 99 | - name: Install workflow dependencies 100 | run: | 101 | poetry config virtualenvs.create true 102 | poetry config virtualenvs.in-project true 103 | - name: Install Python dependencies 104 | run: poetry install --no-interaction 105 | - name: Check Python AST 106 | run: poetry run pre-commit run check-ast --all-files 107 | - name: Check for case conflicts 108 | run: poetry run pre-commit run check-case-conflict --all-files 109 | - name: Check docstring is first 110 | run: poetry run pre-commit run check-docstring-first --all-files 111 | - name: Check that executables have shebangs 112 | run: poetry run pre-commit run check-executables-have-shebangs --all-files 113 | - name: Check JSON files 114 | run: poetry run pre-commit run check-json --all-files 115 | - name: Check for merge conflicts 116 | run: poetry run pre-commit run check-merge-conflict --all-files 117 | - name: Check for broken symlinks 118 | run: poetry run pre-commit run check-symlinks --all-files 119 | - name: Check TOML files 120 | run: poetry run pre-commit run check-toml --all-files 121 | - name: Check XML files 122 | run: poetry run pre-commit run check-xml --all-files 123 | - name: Check YAML files 124 | run: poetry run pre-commit run check-yaml --all-files 125 | - name: Check YAML files 126 | run: poetry run pre-commit run check-yaml --all-files 127 | - name: Detect Private Keys 128 | run: poetry run pre-commit run detect-private-key --all-files 129 | - name: Check End of Files 130 | run: poetry run pre-commit run end-of-file-fixer --all-files 131 | - name: Trim Trailing Whitespace 132 | run: poetry run pre-commit run trailing-whitespace --all-files 133 | 134 | pylint: 135 | name: pylint 136 | runs-on: ubuntu-latest 137 | steps: 138 | - name: Check out code from GitHub 139 | uses: actions/checkout@v4.2.2 140 | - name: Set up Poetry 141 | run: pipx install poetry 142 | - name: Set up Python ${{ env.DEFAULT_PYTHON }} 143 | id: python 144 | uses: actions/setup-python@v5.6.0 145 | with: 146 | python-version: ${{ env.DEFAULT_PYTHON }} 147 | cache: "poetry" 148 | - name: Install workflow dependencies 149 | run: | 150 | poetry config virtualenvs.create true 151 | poetry config virtualenvs.in-project true 152 | - name: Install Python dependencies 153 | run: poetry install --no-interaction 154 | - name: Run pylint 155 | run: poetry run pre-commit run pylint --all-files 156 | 157 | yamllint: 158 | name: yamllint 159 | runs-on: ubuntu-latest 160 | steps: 161 | - name: Check out code from GitHub 162 | uses: actions/checkout@v4.2.2 163 | - name: Set up Poetry 164 | run: pipx install poetry 165 | - name: Set up Python ${{ env.DEFAULT_PYTHON }} 166 | id: python 167 | uses: actions/setup-python@v5.6.0 168 | with: 169 | python-version: ${{ env.DEFAULT_PYTHON }} 170 | cache: "poetry" 171 | - name: Install workflow dependencies 172 | run: | 173 | poetry config virtualenvs.create true 174 | poetry config virtualenvs.in-project true 175 | - name: Install Python dependencies 176 | run: poetry install --no-interaction 177 | - name: Run yamllint 178 | run: poetry run yamllint . 179 | 180 | prettier: 181 | name: Prettier 182 | runs-on: ubuntu-latest 183 | steps: 184 | - name: Check out code from GitHub 185 | uses: actions/checkout@v4.2.2 186 | - name: Set up Poetry 187 | run: pipx install poetry 188 | - name: Set up Python ${{ env.DEFAULT_PYTHON }} 189 | id: python 190 | uses: actions/setup-python@v5.6.0 191 | with: 192 | python-version: ${{ env.DEFAULT_PYTHON }} 193 | cache: "poetry" 194 | - name: Install workflow dependencies 195 | run: | 196 | poetry config virtualenvs.create true 197 | poetry config virtualenvs.in-project true 198 | - name: Install Python dependencies 199 | run: poetry install --no-interaction 200 | - name: Set up Node.js 201 | uses: actions/setup-node@v4 202 | with: 203 | node-version-file: ".nvmrc" 204 | cache: "npm" 205 | - name: Install NPM dependencies 206 | run: npm install 207 | - name: Run prettier 208 | run: poetry run pre-commit run prettier --all-files 209 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for IPP.""" 2 | from __future__ import annotations 3 | 4 | import os 5 | from typing import Any 6 | 7 | DEFAULT_PRINTER_HOST = "epson761251.local" 8 | DEFAULT_PRINTER_PORT = 631 9 | DEFAULT_PRINTER_PATH = "/ipp/print" 10 | DEFAULT_PRINTER_URI = ( 11 | f"ipp://{DEFAULT_PRINTER_HOST}:{DEFAULT_PRINTER_PORT}{DEFAULT_PRINTER_PATH}" 12 | ) 13 | 14 | # COMMON (NOT DOCUMENTED) 15 | COMMON_PRINTER_ATTRS: dict[str, Any] = { 16 | "marker-colors": "", 17 | "marker-high-levels": "", 18 | "marker-levels": "", 19 | "marker-low-levels": "", 20 | "marker-names": "", 21 | "marker-types": "", 22 | } 23 | 24 | # RFC 2911 - IPP 1.1 25 | RFC2911_PRINTER_ATTRS: dict[str, Any] = { 26 | "attributes-charset": "", 27 | "attributes-natural-language": "", 28 | "charset-configured": "", 29 | "charset-supported": "", 30 | "color-supported": "", 31 | "compression-supported": "", 32 | "copies-default": "", 33 | "copies-supported": "", 34 | "document-format-default": "", 35 | "document-format-supported": "", 36 | "finishings-default": "", 37 | "finishings-supported": "", 38 | "generated-natural-language-supported": "", 39 | "ipp-versions-supported": "", 40 | "media-default": "", 41 | "media-ready": "", 42 | "media-supported": "", 43 | "operations-supported": "", 44 | "orientation-requested-default": "", 45 | "orientation-requested-supported": "", 46 | "output-bin-default": "", 47 | "output-bin-supported": "", 48 | "pages-per-minute": "", 49 | "pages-per-minute-color": "", 50 | "pdl-override-supported": "", 51 | "print-quality-default": "", 52 | "print-quality-supported": "", 53 | "printer-alert": "", 54 | "printer-alert-description": "", 55 | "printer-device-id": "", 56 | "printer-info": "", 57 | "printer-is-accepting-jobs": "", 58 | "printer-location": "", 59 | "printer-make-and-model": "", # optional 60 | "printer-more-info": "", 61 | "printer-name": "", 62 | "printer-resolution-default": "", 63 | "printer-state": "", 64 | "printer-state-reasons": "", 65 | "printer-up-time": 0, 66 | "printer-uri": "", 67 | "printer-uri-supported": "", 68 | "queued-job-count": "", 69 | "request-id": "", 70 | "requested-attributes": "", 71 | "requesting-user-name": "", 72 | "sides-default": "", 73 | "sides-supported": "", 74 | "status-code": "", 75 | "status-message": "", 76 | "uri-authentication-supported": "", 77 | "uri-security-supported": "", 78 | "version-number": "", 79 | } 80 | 81 | # RFC 3995 - Notifications 82 | RFC3995_PRINTER_ATTRS: dict[str, Any] = { 83 | "notify-events-default": "", 84 | "notify-events-supported": "", 85 | "notify-lease-duration-default": "", 86 | "notify-lease-duration-supported": "", 87 | "notify-max-events-supported": "", 88 | "notify-pull-method-supported": "", 89 | "printer-state-change-date-time": "", 90 | "printer-state-change-time": "", 91 | } 92 | 93 | # PWG 5100.1 - Finishings 94 | PWG51001_PRINTER_ATTRS: dict[str, Any] = { 95 | "finishings-col-default": "", 96 | "finishings-col-ready": "", 97 | } 98 | 99 | # PWG 5100.2 - Output Bins 100 | PWG51002_PRINTER_ATTRS: dict[str, Any] = { 101 | "output-bin-default": "", 102 | "output-bin-supported": "", 103 | } 104 | 105 | # PWG 5100.3 106 | PWG51003_PRINTER_ATTRS: dict[str, Any] = { 107 | "job-account-id-default": "", 108 | "job-account-id-supported": "", 109 | "job-accounting-user-id-default": "", 110 | "job-accounting-user-id-supported": "", 111 | "media-col-default": "", 112 | "media-col-ready": "", 113 | "media-col-supported": "", 114 | "media-type-supported": "", 115 | } 116 | 117 | # PWG 5100.4 118 | PWG51004_PRINTER_ATTRS: dict[str, Any] = { 119 | "pwg-raster-document-resolution-supported": "", 120 | "pwg-raster-document-sheet-back": "", 121 | "pwg-raster-document-type-supported": "", 122 | } 123 | 124 | # PWG 5100.6 - Page Overrides 125 | PWG51006_PRINTER_ATTRS: dict[str, Any] = { 126 | "overrides-supported": "", 127 | } 128 | 129 | # PWG 5100.7 130 | PWG51007_PRINTER_ATTRS: dict[str, Any] = { 131 | "print-content-optimize-default": "", 132 | "print-content-optimize-supported": "", 133 | } 134 | 135 | # PWG 5100.9 - Alerts 136 | PWG51009_PRINTER_ATTRS: dict[str, Any] = { 137 | "printer-alert": "", 138 | "printer-alert-description": "", 139 | } 140 | 141 | # PWG 5100.11 - Extended Options Set 2 142 | PWG510011_PRINTER_ATTRS: dict[str, Any] = { 143 | "feed-orientation-default": "", 144 | "feed-orientation-supported": "", 145 | "job-creation-attributes-supported": "", 146 | "job-ids-supported": "", 147 | "job-password-supported": "", 148 | "job-password-encryption-supported": "", 149 | "media-col-database": "", 150 | "which-jobs-supported": "", 151 | } 152 | 153 | # PWG 5100.13 - Extended Options Sst 3 154 | PWG510013_PRINTER_ATTRS: dict[str, Any] = { 155 | "document-password-supported": "", 156 | "identify-actions-default": "", 157 | "identify-actions-supported": "", 158 | "ipp-features-supported": "", 159 | "job-constraints-supported": "", 160 | "job-preferred-attributes-supported": "", 161 | "job-resolvers-supported": "", 162 | "media-bottom-margin-supported": "", 163 | "media-col-database.media-source-properties": "", 164 | "media-col-ready.media-source-properties": "", 165 | "media-left-margin-supported": "", 166 | "media-right-margin-supported": "", 167 | "media-source-supported": "", 168 | "media-top-margin-supported": "", 169 | "multiple-operation-timeout-action": "", 170 | "print-color-mode-default": "", 171 | "print-color-mode-supported": "", 172 | "print-rendering-intent-default": "", 173 | "print-rendering-intent-supported": "", 174 | "printer-charge-info": "", 175 | "printer-charge-info-url": "", 176 | "printer-config-change-date-time": "", 177 | "printer-config-change-time": "", 178 | "printer-geo-location": "", 179 | "printer-get-attributes-supported": "", 180 | "printer-icc-profiles": "", 181 | "printer-icons": "", 182 | "printer-mandatory-job-attributes": "", 183 | "printer-organization": "", 184 | "printer-organizational-unit": "", 185 | "printer-supply": "", 186 | "printer-supply-description": "", 187 | "printer-supply-info-uri": "", 188 | "printer-uuid": "", 189 | "which-jobs-supported": "", 190 | } 191 | 192 | # PWG 5100.14 - IPP Everywhere v1 193 | IPPE10_PRINTER_ATTRS: dict[str, Any] = { 194 | **RFC2911_PRINTER_ATTRS, # required 195 | **RFC3995_PRINTER_ATTRS, 196 | **PWG51002_PRINTER_ATTRS, 197 | **PWG51003_PRINTER_ATTRS, 198 | **PWG51004_PRINTER_ATTRS, 199 | **PWG51006_PRINTER_ATTRS, 200 | **PWG51007_PRINTER_ATTRS, 201 | **PWG51009_PRINTER_ATTRS, 202 | **PWG510011_PRINTER_ATTRS, 203 | **PWG510013_PRINTER_ATTRS, 204 | **COMMON_PRINTER_ATTRS, 205 | } 206 | 207 | # PWG 5100.12 - IPP 2.0 208 | IPP20_PRINTER_ATTRS: dict[str, Any] = { 209 | **RFC2911_PRINTER_ATTRS, # required 210 | **RFC3995_PRINTER_ATTRS, # optional 211 | **PWG51001_PRINTER_ATTRS, # required 212 | **PWG51002_PRINTER_ATTRS, # required 213 | **PWG51003_PRINTER_ATTRS, # optional 214 | **PWG51004_PRINTER_ATTRS, # optional 215 | **PWG51006_PRINTER_ATTRS, # optional 216 | **PWG51007_PRINTER_ATTRS, # optional 217 | **PWG51009_PRINTER_ATTRS, # recommended 218 | **PWG510011_PRINTER_ATTRS, # optional 219 | **COMMON_PRINTER_ATTRS, 220 | } 221 | 222 | 223 | def load_fixture(filename: str) -> str: 224 | """Load a fixture.""" 225 | path = os.path.join( # noqa: PTH118 226 | os.path.dirname(__file__), # noqa: PTH120 227 | "fixtures", 228 | filename, 229 | ) 230 | with open(path, encoding="utf-8") as fptr: # noqa: PTH123 231 | return fptr.read() 232 | 233 | 234 | def load_fixture_binary(filename: str) -> bytes: 235 | """Load a binary fixture.""" 236 | path = os.path.join( # noqa: PTH118 237 | os.path.dirname(__file__), # noqa: PTH120 238 | "fixtures", 239 | filename, 240 | ) 241 | with open(path, "rb") as fptr: # noqa: PTH123 242 | return fptr.read() 243 | -------------------------------------------------------------------------------- /src/pyipp/ipp.py: -------------------------------------------------------------------------------- 1 | """Asynchronous Python client for IPP.""" 2 | from __future__ import annotations 3 | 4 | import asyncio 5 | import sys 6 | from dataclasses import dataclass 7 | from importlib import metadata 8 | from socket import gaierror 9 | from struct import error as structerror 10 | from typing import TYPE_CHECKING, Any 11 | 12 | import aiohttp 13 | from deepmerge import always_merger 14 | from yarl import URL 15 | 16 | from .const import ( 17 | DEFAULT_CHARSET, 18 | DEFAULT_CHARSET_LANGUAGE, 19 | DEFAULT_PRINTER_ATTRIBUTES, 20 | DEFAULT_PROTO_VERSION, 21 | ) 22 | from .enums import IppOperation, IppStatus 23 | from .exceptions import ( 24 | IPPConnectionError, 25 | IPPConnectionUpgradeRequired, 26 | IPPError, 27 | IPPParseError, 28 | IPPResponseError, 29 | IPPVersionNotSupportedError, 30 | ) 31 | from .models import Printer 32 | from .parser import parse as parse_response 33 | from .serializer import encode_dict 34 | 35 | if TYPE_CHECKING: 36 | from collections.abc import Mapping 37 | 38 | if sys.version_info >= (3, 11): 39 | from asyncio import timeout 40 | else: 41 | from async_timeout import timeout 42 | 43 | VERSION = metadata.version(__package__) 44 | 45 | @dataclass 46 | class IPP: 47 | """Main class for handling connections with IPP servers.""" 48 | 49 | host: str 50 | base_path: str = "/ipp/print" 51 | password: str | None = None 52 | port: int = 631 53 | request_timeout: int = 8 54 | session: aiohttp.client.ClientSession | None = None 55 | tls: bool = False 56 | username: str | None = None 57 | verify_ssl: bool = False 58 | user_agent: str | None = None 59 | ipp_version: tuple[int, int] = DEFAULT_PROTO_VERSION 60 | 61 | _close_session: bool = False 62 | _printer_uri: str = "" 63 | _printer: Printer | None = None 64 | 65 | def __post_init__(self) -> None: 66 | """Initialize connection parameters.""" 67 | if self.host.startswith(("ipp://", "ipps://")): 68 | self._printer_uri = self.host 69 | printer_uri = URL(self.host) 70 | 71 | if printer_uri.host is not None: 72 | self.host = printer_uri.host 73 | 74 | if printer_uri.port is not None: 75 | self.port = printer_uri.port 76 | 77 | self.tls = printer_uri.scheme == "ipps" # pylint: disable=W0143 78 | self.base_path = printer_uri.path 79 | else: 80 | self._printer_uri = self._build_printer_uri() 81 | 82 | if self.user_agent is None: 83 | self.user_agent = f"PythonIPP/{VERSION}" 84 | 85 | async def _request( 86 | self, 87 | uri: str = "", 88 | data: Any | None = None, 89 | params: Mapping[str, str] | None = None, 90 | ) -> bytes: 91 | """Handle a request to an IPP server.""" 92 | scheme = "https" if self.tls else "http" 93 | 94 | method = "POST" 95 | url = URL.build( 96 | scheme=scheme, 97 | host=self.host, 98 | port=self.port, 99 | path=self.base_path, 100 | ).join(URL(uri)) 101 | 102 | auth = None 103 | if self.username and self.password: 104 | auth = aiohttp.BasicAuth(self.username, self.password) 105 | 106 | headers = { 107 | "User-Agent": self.user_agent, 108 | "Content-Type": "application/ipp", 109 | "Accept": "application/ipp, text/plain, */*", 110 | } 111 | 112 | if self.session is None: 113 | self.session = aiohttp.ClientSession() 114 | self._close_session = True 115 | 116 | if isinstance(data, dict): 117 | data = encode_dict(data) 118 | 119 | try: 120 | async with timeout(self.request_timeout): 121 | response = await self.session.request( 122 | method, 123 | url, 124 | auth=auth, 125 | data=data, 126 | params=params, 127 | headers=headers, 128 | ssl=self.verify_ssl, 129 | ) 130 | except asyncio.TimeoutError as exc: 131 | raise IPPConnectionError( 132 | "Timeout occurred while connecting to IPP server.", 133 | ) from exc 134 | except (aiohttp.ClientError, gaierror) as exc: 135 | raise IPPConnectionError( 136 | "Error occurred while communicating with IPP server.", 137 | ) from exc 138 | 139 | if response.status == 426: 140 | raise IPPConnectionUpgradeRequired( 141 | "Connection upgrade required while communicating with IPP server.", 142 | {"upgrade": response.headers.get("Upgrade")}, 143 | ) 144 | 145 | if (response.status // 100) in [4, 5]: 146 | content = await response.read() 147 | response.close() 148 | 149 | raise IPPResponseError( 150 | f"HTTP {response.status}", # noqa: EM102 151 | { 152 | "content-type": response.headers.get("Content-Type"), 153 | "message": content.decode("utf8"), 154 | "status-code": response.status, 155 | }, 156 | ) 157 | 158 | return await response.read() 159 | 160 | def _build_printer_uri(self) -> str: 161 | scheme = "ipps" if self.tls else "ipp" 162 | 163 | return URL.build( 164 | scheme=scheme, 165 | host=self.host, 166 | port=self.port, 167 | path=self.base_path, 168 | ).human_repr() 169 | 170 | def _message(self, operation: IppOperation, msg: dict[str, Any]) -> dict[str, Any]: 171 | """Build a request message to be sent to the server.""" 172 | base = { 173 | "version": self.ipp_version, 174 | "operation": operation, 175 | "request-id": None, # will get added by serializer if one isn't given 176 | "operation-attributes-tag": { # these are required to be in this order 177 | "attributes-charset": DEFAULT_CHARSET, 178 | "attributes-natural-language": DEFAULT_CHARSET_LANGUAGE, 179 | "printer-uri": self._printer_uri, 180 | "requesting-user-name": "PythonIPP", 181 | }, 182 | } 183 | 184 | return always_merger.merge(base, msg) 185 | 186 | async def execute( 187 | self, 188 | operation: IppOperation, 189 | message: dict[str, Any], 190 | ) -> dict[str, Any]: 191 | """Send a request message to the server.""" 192 | message = self._message(operation, message) 193 | response = await self._request(data=message) 194 | 195 | try: 196 | parsed = parse_response(response) 197 | except (structerror, Exception) as exc: # disable=broad-except 198 | raise IPPParseError from exc 199 | 200 | if parsed["status-code"] == IppStatus.ERROR_VERSION_NOT_SUPPORTED: 201 | raise IPPVersionNotSupportedError("IPP version not supported by server") 202 | 203 | if parsed["status-code"] not in range(0x200): 204 | raise IPPError( 205 | "Unexpected printer status code", 206 | {"status-code": parsed["status-code"]}, 207 | ) 208 | 209 | return parsed 210 | 211 | async def raw(self, operation: IppOperation, message: dict[str, Any]) -> bytes: 212 | """Send a request message to the server and return raw response.""" 213 | message = self._message(operation, message) 214 | 215 | return await self._request(data=message) 216 | 217 | async def close(self) -> None: 218 | """Close open client session.""" 219 | if self.session and self._close_session: 220 | await self.session.close() 221 | 222 | async def printer(self) -> Printer: 223 | """Get printer information from server.""" 224 | response_data = await self.execute( 225 | IppOperation.GET_PRINTER_ATTRIBUTES, 226 | { 227 | "operation-attributes-tag": { 228 | "requested-attributes": DEFAULT_PRINTER_ATTRIBUTES, 229 | }, 230 | }, 231 | ) 232 | 233 | parsed: dict[str, Any] = next(iter(response_data["printers"] or []), {}) 234 | 235 | try: 236 | if self._printer is None: 237 | self._printer = Printer.from_dict(parsed) 238 | else: 239 | self._printer.update_from_dict(parsed) 240 | except Exception as exc: 241 | raise IPPParseError from exc 242 | 243 | return self._printer 244 | 245 | async def __aenter__(self) -> IPP: # noqa: PYI034 246 | """Async enter.""" 247 | return self 248 | 249 | async def __aexit__(self, *_exec_info: object) -> None: 250 | """Async exit.""" 251 | await self.close() 252 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | """Tests for IPP Client.""" 2 | import asyncio 3 | 4 | import pytest 5 | from aiohttp import ClientResponse, ClientSession 6 | from aresponses import Response, ResponsesMockServer 7 | 8 | from pyipp import IPP 9 | from pyipp.const import DEFAULT_PRINTER_ATTRIBUTES 10 | from pyipp.enums import IppOperation 11 | from pyipp.exceptions import ( 12 | IPPConnectionError, 13 | IPPConnectionUpgradeRequired, 14 | IPPError, 15 | IPPParseError, 16 | IPPVersionNotSupportedError, 17 | ) 18 | 19 | from . import ( 20 | DEFAULT_PRINTER_HOST, 21 | DEFAULT_PRINTER_PATH, 22 | DEFAULT_PRINTER_PORT, 23 | DEFAULT_PRINTER_URI, 24 | load_fixture_binary, 25 | ) 26 | 27 | MATCH_DEFAULT_HOST = f"{DEFAULT_PRINTER_HOST}:{DEFAULT_PRINTER_PORT}" 28 | NON_STANDARD_PORT = 3333 29 | 30 | 31 | @pytest.mark.asyncio 32 | async def test_ipp_request(aresponses: ResponsesMockServer) -> None: 33 | """Test IPP response is handled correctly.""" 34 | aresponses.add( 35 | MATCH_DEFAULT_HOST, 36 | DEFAULT_PRINTER_PATH, 37 | "POST", 38 | aresponses.Response( 39 | status=200, 40 | headers={"Content-Type": "application/ipp"}, 41 | body=load_fixture_binary("get-printer-attributes-epsonxp6000.bin"), 42 | ), 43 | ) 44 | 45 | async with ClientSession() as session: 46 | ipp = IPP(DEFAULT_PRINTER_URI, session=session) 47 | response = await ipp.execute( 48 | IppOperation.GET_PRINTER_ATTRIBUTES, 49 | { 50 | "operation-attributes-tag": { 51 | "requested-attributes": DEFAULT_PRINTER_ATTRIBUTES, 52 | }, 53 | }, 54 | ) 55 | assert response["status-code"] == 0 56 | 57 | 58 | @pytest.mark.asyncio 59 | async def test_internal_session(aresponses: ResponsesMockServer) -> None: 60 | """Test IPP response is handled correctly.""" 61 | aresponses.add( 62 | MATCH_DEFAULT_HOST, 63 | DEFAULT_PRINTER_PATH, 64 | "POST", 65 | aresponses.Response( 66 | status=200, 67 | headers={"Content-Type": "application/ipp"}, 68 | body=load_fixture_binary("get-printer-attributes-epsonxp6000.bin"), 69 | ), 70 | ) 71 | 72 | async with IPP(DEFAULT_PRINTER_URI) as ipp: 73 | response = await ipp.execute( 74 | IppOperation.GET_PRINTER_ATTRIBUTES, 75 | { 76 | "operation-attributes-tag": { 77 | "requested-attributes": DEFAULT_PRINTER_ATTRIBUTES, 78 | }, 79 | }, 80 | ) 81 | assert response["status-code"] == 0 82 | 83 | 84 | @pytest.mark.asyncio 85 | async def test_request_port(aresponses: ResponsesMockServer) -> None: 86 | """Test the IPP server running on non-standard port.""" 87 | aresponses.add( 88 | f"{DEFAULT_PRINTER_HOST}:{NON_STANDARD_PORT}", 89 | DEFAULT_PRINTER_PATH, 90 | "POST", 91 | aresponses.Response( 92 | status=200, 93 | headers={"Content-Type": "application/ipp"}, 94 | body=load_fixture_binary("get-printer-attributes-epsonxp6000.bin"), 95 | ), 96 | ) 97 | 98 | async with ClientSession() as session: 99 | ipp = IPP( 100 | host=DEFAULT_PRINTER_HOST, 101 | port=NON_STANDARD_PORT, 102 | base_path=DEFAULT_PRINTER_PATH, 103 | session=session, 104 | ) 105 | response = await ipp.execute( 106 | IppOperation.GET_PRINTER_ATTRIBUTES, 107 | { 108 | "operation-attributes-tag": { 109 | "requested-attributes": DEFAULT_PRINTER_ATTRIBUTES, 110 | }, 111 | }, 112 | ) 113 | assert response["status-code"] == 0 114 | 115 | 116 | @pytest.mark.asyncio 117 | async def test_request_tls(aresponses: ResponsesMockServer) -> None: 118 | """Test the IPP server over TLS.""" 119 | aresponses.add( 120 | MATCH_DEFAULT_HOST, 121 | DEFAULT_PRINTER_PATH, 122 | "POST", 123 | aresponses.Response( 124 | status=200, 125 | headers={"Content-Type": "application/ipp"}, 126 | body=load_fixture_binary("get-printer-attributes-epsonxp6000.bin"), 127 | ), 128 | ) 129 | 130 | async with ClientSession() as session: 131 | ipp = IPP( 132 | host=DEFAULT_PRINTER_HOST, 133 | port=DEFAULT_PRINTER_PORT, 134 | tls=True, 135 | base_path=DEFAULT_PRINTER_PATH, 136 | session=session, 137 | ) 138 | response = await ipp.execute( 139 | IppOperation.GET_PRINTER_ATTRIBUTES, 140 | { 141 | "operation-attributes-tag": { 142 | "requested-attributes": DEFAULT_PRINTER_ATTRIBUTES, 143 | }, 144 | }, 145 | ) 146 | assert response["status-code"] == 0 147 | 148 | 149 | @pytest.mark.asyncio 150 | async def test_timeout(aresponses: ResponsesMockServer) -> None: 151 | """Test request timeout from the IPP server.""" 152 | 153 | # Faking a timeout by sleeping 154 | async def response_handler(_: ClientResponse) -> Response: 155 | await asyncio.sleep(2) 156 | return Response(body="Timeout!") 157 | 158 | aresponses.add( 159 | MATCH_DEFAULT_HOST, 160 | DEFAULT_PRINTER_PATH, 161 | "POST", 162 | response_handler, 163 | ) 164 | 165 | async with ClientSession() as session: 166 | ipp = IPP(DEFAULT_PRINTER_URI, session=session, request_timeout=1) 167 | with pytest.raises(IPPConnectionError): 168 | assert await ipp.execute( 169 | IppOperation.GET_PRINTER_ATTRIBUTES, 170 | { 171 | "operation-attributes-tag": { 172 | "requested-attributes": DEFAULT_PRINTER_ATTRIBUTES, 173 | }, 174 | }, 175 | ) 176 | 177 | 178 | @pytest.mark.asyncio 179 | async def test_http_error404(aresponses: ResponsesMockServer) -> None: 180 | """Test HTTP 404 response handling.""" 181 | aresponses.add( 182 | MATCH_DEFAULT_HOST, 183 | DEFAULT_PRINTER_PATH, 184 | "POST", 185 | aresponses.Response(text="Not Found!", status=404), 186 | ) 187 | 188 | async with ClientSession() as session: 189 | ipp = IPP(DEFAULT_PRINTER_URI, session=session) 190 | with pytest.raises(IPPError): 191 | assert await ipp.execute( 192 | IppOperation.GET_PRINTER_ATTRIBUTES, 193 | { 194 | "operation-attributes-tag": { 195 | "requested-attributes": DEFAULT_PRINTER_ATTRIBUTES, 196 | }, 197 | }, 198 | ) 199 | 200 | 201 | @pytest.mark.asyncio 202 | async def test_http_error426(aresponses: ResponsesMockServer) -> None: 203 | """Test HTTP 426 response handling.""" 204 | aresponses.add( 205 | MATCH_DEFAULT_HOST, 206 | DEFAULT_PRINTER_PATH, 207 | "POST", 208 | aresponses.Response( 209 | text="Upgrade Required", 210 | headers={"Upgrade": "TLS/1.0, HTTP/1.1"}, 211 | status=426, 212 | ), 213 | ) 214 | 215 | async with ClientSession() as session: 216 | ipp = IPP(DEFAULT_PRINTER_URI, session=session) 217 | with pytest.raises(IPPConnectionUpgradeRequired): 218 | assert await ipp.execute( 219 | IppOperation.GET_PRINTER_ATTRIBUTES, 220 | { 221 | "operation-attributes-tag": { 222 | "requested-attributes": DEFAULT_PRINTER_ATTRIBUTES, 223 | }, 224 | }, 225 | ) 226 | 227 | 228 | @pytest.mark.asyncio 229 | async def test_unexpected_response(aresponses: ResponsesMockServer) -> None: 230 | """Test unexpected response handling.""" 231 | aresponses.add( 232 | MATCH_DEFAULT_HOST, 233 | DEFAULT_PRINTER_PATH, 234 | "POST", 235 | aresponses.Response(text="Surprise!", status=200), 236 | ) 237 | 238 | async with ClientSession() as session: 239 | ipp = IPP(DEFAULT_PRINTER_URI, session=session) 240 | with pytest.raises(IPPParseError): 241 | assert await ipp.execute( 242 | IppOperation.GET_PRINTER_ATTRIBUTES, 243 | { 244 | "operation-attributes-tag": { 245 | "requested-attributes": DEFAULT_PRINTER_ATTRIBUTES, 246 | }, 247 | }, 248 | ) 249 | 250 | 251 | @pytest.mark.asyncio 252 | async def test_ipp_error_0x0503(aresponses: ResponsesMockServer) -> None: 253 | """Test IPP Error 0x0503 response handling.""" 254 | aresponses.add( 255 | MATCH_DEFAULT_HOST, 256 | DEFAULT_PRINTER_PATH, 257 | "POST", 258 | aresponses.Response( 259 | status=200, 260 | headers={"Content-Type": "application/ipp"}, 261 | body=load_fixture_binary("get-printer-attributes-error-0x0503.bin"), 262 | ), 263 | ) 264 | 265 | async with ClientSession() as session: 266 | ipp = IPP(DEFAULT_PRINTER_URI, session=session) 267 | with pytest.raises(IPPVersionNotSupportedError): 268 | assert await ipp.execute( 269 | IppOperation.GET_PRINTER_ATTRIBUTES, 270 | { 271 | "operation-attributes-tag": { 272 | "requested-attributes": DEFAULT_PRINTER_ATTRIBUTES, 273 | }, 274 | }, 275 | ) 276 | -------------------------------------------------------------------------------- /src/pyipp/enums.py: -------------------------------------------------------------------------------- 1 | """Enumerators for IPP.""" 2 | from enum import IntEnum 3 | 4 | 5 | class IppStatus(IntEnum): 6 | """Represent the ENUMs of a status response.""" 7 | 8 | CUPS_INVALID = -1 9 | OK = 0x0000 10 | OK_IGNORED_OR_SUBSTITUTED = 0x0001 11 | OK_CONFLICTING = 0x0002 12 | OK_IGNORED_SUBSCRIPTIONS = 0x0003 13 | OK_IGNORED_NOTIFICATIONS = 0x0004 14 | OK_TOO_MANY_EVENTS = 0x0005 15 | OK_BUT_CANCEL_SUBSCRIPTION = 0x0006 16 | OK_EVENTS_COMPLETE = 0x0007 17 | REDIRECTION_OTHER_SITE = 0x0200 18 | CUPS_SEE_OTHER = 0x0280 19 | ERROR_BAD_REQUEST = 0x0400 20 | ERROR_FORBIDDEN = 0x0401 21 | ERROR_NOT_AUTHENTICATED = 0x0402 22 | ERROR_NOT_AUTHORIZED = 0x0403 23 | ERROR_NOT_POSSIBLE = 0x0404 24 | ERROR_TIMEOUT = 0x0405 25 | ERROR_NOT_FOUND = 0x0406 26 | ERROR_GONE = 0x0407 27 | ERROR_REQUEST_ENTITY = 0x0408 28 | ERROR_REQUEST_VALUE = 0x0409 29 | ERROR_DOCUMENT_FORMAT_NOT_SUPPORTED = 0x040A 30 | ERROR_ATTRIBUTES_OR_VALUES = 0x040B 31 | ERROR_URI_SCHEME = 0x040C 32 | ERROR_CHARSET = 0x040D 33 | ERROR_CONFLICTING = 0x040E 34 | ERROR_COMPRESSION_ERROR = 0x040F 35 | ERROR_DOCUMENT_FORMAT_ERROR = 0x0410 36 | ERROR_DOCUMENT_ACCESS = 0x0411 37 | ERROR_ATTRIBUTES_NOT_SETTABLE = 0x0412 38 | ERROR_IGNORED_ALL_SUBSCRIPTIONS = 0x0413 39 | ERROR_TOO_MANY_SUBSCRIPTIONS = 0x0414 40 | ERROR_IGNORED_ALL_NOTIFICATIONS = 0x0415 41 | ERROR_PRINT_SUPPORT_FILE_NOT_FOUND = 0x0416 42 | ERROR_DOCUMENT_PASSWORD = 0x0417 43 | ERROR_DOCUMENT_PERMISSION = 0x0418 44 | ERROR_DOCUMENT_SECURITY = 0x0419 45 | ERROR_DOCUMENT_UNPRINTABLE = 0x041A 46 | ERROR_ACCOUNT_INFO_NEEDED = 0x041B 47 | ERROR_ACCOUNT_CLOSED = 0x041C 48 | ERROR_ACCOUNT_LIMIT_REACHED = 0x041D 49 | ERROR_ACCOUNT_AUTHORIZATION_FAILED = 0x041E 50 | ERROR_NOT_FETCHABLE = 0x041F 51 | ERROR_CUPS_ACCOUNT_INFO_NEEDED = 0x049C 52 | ERROR_CUPS_ACCOUNT_CLOSED = 0x049D 53 | ERROR_CUPS_ACCOUNT_LIMIT_REACHED = 0x049E 54 | ERROR_CUPS_ACCOUNT_AUTHORIZATION_FAILED = 0x049F 55 | ERROR_INTERNAL = 0x0500 56 | ERROR_OPERATION_NOT_SUPPORTED = 0x0501 57 | ERROR_SERVICE_UNAVAILABLE = 0x0502 58 | ERROR_VERSION_NOT_SUPPORTED = 0x0503 59 | ERROR_DEVICE = 0x0504 60 | ERROR_TEMPORARY = 0x0505 61 | ERROR_NOT_ACCEPTING_JOBS = 0x0506 62 | ERROR_BUSY = 0x0507 63 | ERROR_JOB_CANCELED = 0x0508 64 | ERROR_MULTIPLE_JOBS_NOT_SUPPORTED = 0x0509 65 | ERROR_PRINTER_IS_DEACTIVATED = 0x050A 66 | ERROR_TOO_MANY_JOBS = 0x050B 67 | ERROR_TOO_MANY_DOCUMENTS = 0x050C 68 | ERROR_CUPS_AUTHENTICATION_CANCELED = 0x1000 69 | ERROR_CUPS_PKI = 0x1001 70 | ERROR_CUPS_UPGRADE_REQUIRED = 0x1002 71 | 72 | 73 | class IppOperation(IntEnum): 74 | """Represent the ENUMs of an operation.""" 75 | 76 | CUPS_INVALID = -0x0001 77 | CUPS_NONE = 0x0000 78 | PRINT_JOB = 0x0002 79 | PRINT_URI = 0x0003 80 | VALIDATE_JOB = 0x0004 81 | CREATE_JOB = 0x0005 82 | SEND_DOCUMENT = 0x0006 83 | SEND_URI = 0x0007 84 | CANCEL_JOB = 0x0008 85 | GET_JOB_ATTRIBUTES = 0x0009 86 | GET_JOBS = 0x000A 87 | GET_PRINTER_ATTRIBUTES = 0x000B 88 | HOLD_JOB = 0x000C 89 | RELEASE_JOB = 0x000D 90 | RESTART_JOB = 0x000E 91 | PAUSE_PRINTER = 0x0010 92 | RESUME_PRINTER = 0x0011 93 | PURGE_JOBS = 0x0012 94 | SET_PRINTER_ATTRIBUTES = 0x0013 95 | SET_JOB_ATTRIBUTES = 0x0014 96 | GET_PRINTER_SUPPORTED_VALUES = 0x0015 97 | CREATE_PRINTER_SUBSCRIPTIONS = 0x0016 98 | CREATE_JOB_SUBSCRIPTIONS = 0x0017 99 | GET_SUBSCRIPTION_ATTRIBUTES = 0x0018 100 | GET_SUBSCRIPTIONS = 0x0019 101 | RENEW_SUBSCRIPTION = 0x001A 102 | CANCEL_SUBSCRIPTION = 0x001B 103 | GET_NOTIFICATIONS = 0x001C 104 | SEND_NOTIFICATIONS = 0x001D 105 | GET_RESOURCE_ATTRIBUTES = 0x001E 106 | GET_RESOURCE_DATA = 0x001F 107 | GET_RESOURCES = 0x0020 108 | GET_PRINT_SUPPORT_FILES = 0x0021 109 | ENABLE_PRINTER = 0x0022 110 | DISABLE_PRINTER = 0x0023 111 | PAUSE_PRINTER_AFTER_CURRENT_JOB = 0x0024 112 | HOLD_NEW_JOBS = 0x0025 113 | RELEASE_HELD_NEW_JOBS = 0x0026 114 | DEACTIVATE_PRINTER = 0x0027 115 | ACTIVATE_PRINTER = 0x0028 116 | RESTART_PRINTER = 0x0029 117 | SHUTDOWN_PRINTER = 0x002A 118 | STARTUP_PRINTER = 0x002B 119 | REPROCESS_JOB = 0x002C 120 | CANCEL_CURRENT_JOB = 0x002D 121 | SUSPEND_CURRENT_JOB = 0x002E 122 | RESUME_JOB = 0x002F 123 | PROMOTE_JOB = 0x0030 124 | SCHEDULE_JOB_AFTER = 0x0031 125 | CANCEL_DOCUMENT = 0x0033 126 | GET_DOCUMENT_ATTRIBUTES = 0x0034 127 | GET_DOCUMENTS = 0x0035 128 | DELETE_DOCUMENT = 0x0036 129 | SET_DOCUMENT_ATTRIBUTES = 0x0037 130 | CANCEL_JOBS = 0x0038 131 | CANCEL_MY_JOBS = 0x0039 132 | RESUBMIT_JOB = 0x003A 133 | CLOSE_JOB = 0x003B 134 | IDENTIFY_PRINTER = 0x003C 135 | VALIDATE_DOCUMENT = 0x003D 136 | ADD_DOCUMENT_IMAGES = 0x003E 137 | ACKNOWLEDGE_DOCUMENT = 0x003F 138 | ACKNOWLEDGE_IDENTIFY_PRINTER = 0x0040 139 | ACKNOWLEDGE_JOB = 0x0041 140 | FETCH_DOCUMENT = 0x0042 141 | FETCH_JOB = 0x0043 142 | GET_OUTPUT_DEVICE_ATTRIBUTES = 0x0044 143 | UPDATE_ACTIVE_JOBS = 0x0045 144 | DEREGISTER_OUTPUT_DEVICE = 0x0046 145 | UPDATE_DOCUMENT_STATUS = 0x0047 146 | UPDATE_JOB_STATUS = 0x0048 147 | UPDATE_OUTPUT_DEVICE_ATTRIBUTES = 0x0049 148 | GET_NEXT_DOCUMENT_DATA = 0x004A 149 | ALLOCATE_PRINTER_RESOURCES = 0x004B 150 | CREATE_PRINTER = 0x004C 151 | DEALLOCATE_PRINTER_RESOURCES = 0x004D 152 | DELETE_PRINTER = 0x004E 153 | GET_PRINTERS = 0x004F 154 | SHUTDOWN_ONE_PRINTER = 0x0050 155 | STARTUP_ONE_PRINTER = 0x0051 156 | CANCEL_RESOURCE = 0x0052 157 | CREATE_RESOURCE = 0x0053 158 | INSTALL_RESOURCE = 0x0054 159 | SEND_RESOURCE_DATA = 0x0055 160 | SET_RESOURCE_ATTRIBUTES = 0x0056 161 | CREATE_RESOURCE_SUBSCRIPTIONS = 0x0057 162 | CREATE_SYSTEM_SUBSCRIPTIONS = 0x0058 163 | DISABLE_ALL_PRINTERS = 0x0059 164 | ENABLE_ALL_PRINTERS = 0x005A 165 | GET_SYSTEM_ATTRIBUTES = 0x005B 166 | GET_SYSTEM_SUPPORTED_VALUES = 0x005C 167 | PAUSE_ALL_PRINTERS = 0x005D 168 | PAUSE_ALL_PRINTERS_AFTER_CURRENT_JOB = 0x005E 169 | REGISTER_OUTPUT_DEVICE = 0x005F 170 | RESTART_SYSTEM = 0x0060 171 | RESUME_ALL_PRINTERS = 0x0061 172 | SET_SYSTEM_ATTRIBUTES = 0x0062 173 | SHUTDOWN_ALL_PRINTER = 0x0063 174 | STARTUP_ALL_PRINTERS = 0x0064 175 | PRIVATE = 0x4000 176 | CUPS_GET_DEFAULT = 0x4001 177 | CUPS_GET_PRINTERS = 0x4002 178 | CUPS_ADD_MODIFY_PRINTER = 0x4003 179 | CUPS_DELETE_PRINTER = 0x4004 180 | CUPS_GET_CLASSES = 0x4005 181 | CUPS_ADD_MODIFY_CLASS = 0x4006 182 | CUPS_DELETE_CLASS = 0x4007 183 | CUPS_ACCEPT_JOBS = 0x4008 184 | CUPS_REJECT_JOBS = 0x4009 185 | CUPS_SET_DEFAULT = 0x400A 186 | CUPS_GET_DEVICES = 0x400B 187 | CUPS_GET_PPDS = 0x400C 188 | CUPS_MOVE_JOB = 0x400D 189 | CUPS_AUTHENTICATE_JOB = 0x400E 190 | CUPS_GET_PPD = 0x400F 191 | CUPS_GET_DOCUMENT = 0x4027 192 | CUPS_CREATE_LOCAL_PRINTER = 0x4028 193 | 194 | 195 | class IppTag(IntEnum): 196 | """Represent the ENUMs of a tag.""" 197 | 198 | CUPS_INVALID = -1 199 | ZERO = 0x00 200 | OPERATION = 0x01 201 | JOB = 0x02 202 | END = 0x03 203 | PRINTER = 0x04 204 | UNSUPPORTED_GROUP = 0x05 205 | SUBSCRIPTION = 0x06 206 | EVENT_NOTIFICATION = 0x07 207 | RESOURCE = 0x08 208 | DOCUMENT = 0x09 209 | SYSTEM = 0x0A 210 | UNSUPPORTED_VALUE = 0x10 211 | DEFAULT = 0x11 212 | UNKNOWN = 0x12 213 | NO_VALUE = 0x013 214 | NOT_SETTABLE = 0x15 215 | DELETE_ATTR = 0x16 216 | ADMIN_DEFINE = 0x17 217 | INTEGER = 0x21 218 | BOOLEAN = 0x22 219 | ENUM = 0x23 220 | STRING = 0x30 221 | DATE = 0x31 222 | RESOLUTION = 0x32 223 | RANGE = 0x33 224 | BEGIN_COLLECTION = 0x34 225 | TEXT_LANG = 0x35 226 | NAME_LANG = 0x36 227 | END_COLLECTION = 0x37 228 | TEXT = 0x41 229 | NAME = 0x42 230 | RESERVED_STRING = 0x43 231 | KEYWORD = 0x44 232 | URI = 0x45 233 | URI_SCHEME = 0x46 234 | CHARSET = 0x47 235 | LANGUAGE = 0x48 236 | MIME_TYPE = 0x49 237 | MEMBER_NAME = 0x4A 238 | EXTENSION = 0x7F 239 | CUPS_MASK = 0x7FFFFFFF 240 | CUPS_CONST = -0x7FFFFFFF - 1 241 | 242 | 243 | class IppJobState(IntEnum): 244 | """Represent the ENUMs of the state of a print job.""" 245 | 246 | PENDING = 0x03 247 | HELD = 0x04 248 | PROCESSING = 0x05 249 | STOPPED = 0x06 250 | CANCELED = 0x07 251 | ABORTED = 0x08 252 | COMPLETED = 0x09 253 | 254 | 255 | class IppDocumentState(IntEnum): 256 | """Represent the ENUMs of the state of a document.""" 257 | 258 | PENDING = 0x03 259 | PROCESSING = 0x05 260 | CANCELED = 0x07 261 | ABORTED = 0x08 262 | COMPLETED = 0x08 263 | 264 | 265 | class IppPrinterState(IntEnum): 266 | """Represent the ENUMs of the state of a printer.""" 267 | 268 | IDLE = 0x0003 269 | PROCESSING = 0x0004 270 | STOPPED = 0x0005 271 | 272 | 273 | class IppFinishing(IntEnum): 274 | """Represent the ENUMs of the finishings attribute.""" 275 | 276 | NONE = 0x0003 277 | STAPLE = 0x0004 278 | PUNCH = 0x0005 279 | COVER = 0x0006 280 | BIND = 0x0007 281 | SADDLE_STITCH = 0x0008 282 | EDGE_STITCH = 0x0009 283 | STAPLE_TOP_LEFT = 0x0014 284 | STAPLE_BOTTOM_LEFT = 0x0015 285 | STAPLE_TOP_RIGHT = 0x0016 286 | STAPLE_BOTTOM_RIGHT = 0x0017 287 | EDGE_STITCH_LEFT = 0x0018 288 | EDGE_STITCH_TOP = 0x0019 289 | EDGE_STITCH_RIGHT = 0x001A 290 | EDGE_STITCH_BOTTOM = 0x001B 291 | STAPLE_DUAL_LEFT = 0x001C 292 | STAPLE_DUAL_TOP = 0x001D 293 | STAPLE_DUAL_RIGHT = 0x001E 294 | STAPLE_DUAL_BOTTOM = 0x001F 295 | TRIM_AFTER_PAGES = 0x003C 296 | TRIM_AFTER_DOCUMENTS = 0x003D 297 | TRIM_AFTER_COPIES = 0x003E 298 | TRIM_AFTER_JOB = 0x003F 299 | 300 | 301 | class IppPrintQuality(IntEnum): 302 | """Represent the ENUMs of the print-quality attribute.""" 303 | 304 | DRAFT = 0x0003 305 | NORMAL = 0x0004 306 | HIGH = 0x0005 307 | 308 | 309 | class IppOrientationRequested(IntEnum): 310 | """Represent the ENUMs of the orientation-requested attribute.""" 311 | 312 | PORTRAIT = 0x0003 313 | LANDSCAPE = 0x0004 314 | REVERSE_LANDSCAPE = 0x0005 315 | REVERSE_PORTRAIT = 0x0006 316 | 317 | 318 | ATTRIBUTE_ENUM_MAP = { 319 | "document-state": IppDocumentState, # PWG5100.5 320 | "finishings": IppFinishing, # RFC8011 321 | "finishings-default": IppFinishing, # RFC8011 322 | "finishings-supported": IppFinishing, # RFC8011 323 | "job-state": IppJobState, # RFC8011 324 | "media-source-feed-orientation": IppOrientationRequested, # PWG5100.7 325 | "operations-supported": IppOperation, # RFC8011 326 | "orientation-requested": IppOrientationRequested, # RFC8011 327 | "orientation-requested-default": IppOrientationRequested, # RFC8011 328 | "orientation-requested-supported": IppOrientationRequested, # RFC8011 329 | "printer-state": IppPrinterState, # RFC8011 330 | "print-quality": IppPrintQuality, # RFC8011 331 | "print-quality-default": IppPrintQuality, # RFC8011 332 | "print-quality-supported": IppPrintQuality, # RFC8011 333 | "status-code": IppStatus, # RFC8011 334 | } 335 | -------------------------------------------------------------------------------- /src/pyipp/models.py: -------------------------------------------------------------------------------- 1 | """Models for IPP.""" 2 | # pylint: disable=R0912,R0915 3 | from __future__ import annotations 4 | 5 | from dataclasses import asdict, dataclass 6 | from datetime import datetime, timedelta, timezone 7 | from typing import Any 8 | 9 | from yarl import URL 10 | 11 | from .parser import parse_ieee1284_device_id, parse_make_and_model 12 | 13 | PRINTER_STATES = {3: "idle", 4: "printing", 5: "stopped"} 14 | 15 | 16 | @dataclass 17 | class Info: 18 | """Object holding information from IPP.""" 19 | 20 | name: str 21 | printer_name: str 22 | printer_uri_supported: list[str] 23 | uptime: int 24 | command_set: str | None = None 25 | location: str | None = None 26 | manufacturer: str | None = None 27 | model: str | None = None 28 | printer_info: str | None = None 29 | serial: str | None = None 30 | uuid: str | None = None 31 | version: str | None = None 32 | more_info: str | None = None 33 | 34 | @staticmethod 35 | def from_dict(data: dict[str, Any]) -> Info: 36 | """Return Info object from IPP response.""" 37 | cmd = None 38 | name = "IPP Printer" 39 | name_parts = [] 40 | serial = None 41 | _printer_name = printer_name = data.get("printer-name", "") 42 | make_model = data.get("printer-make-and-model", "") 43 | device_id = data.get("printer-device-id", "") 44 | uri_supported = data.get("printer-uri-supported", []) 45 | uuid = data.get("printer-uuid") 46 | 47 | if not isinstance(uri_supported, list): 48 | uri_supported = [str(uri_supported)] 49 | 50 | for uri in uri_supported: 51 | if (URL(uri).path.lstrip("/")) == _printer_name.lstrip("/"): 52 | _printer_name = "" 53 | break 54 | 55 | make, model = parse_make_and_model(make_model) 56 | parsed_device_id = parse_ieee1284_device_id(device_id) 57 | 58 | if parsed_device_id.get("MFG") is not None and len(parsed_device_id["MFG"]) > 0: 59 | make = parsed_device_id["MFG"] 60 | name_parts.append(make) 61 | 62 | if parsed_device_id.get("MDL") is not None and len(parsed_device_id["MDL"]) > 0: 63 | model = parsed_device_id["MDL"] 64 | name_parts.append(model) 65 | 66 | if parsed_device_id.get("CMD") is not None and len(parsed_device_id["CMD"]) > 0: 67 | cmd = parsed_device_id["CMD"] 68 | 69 | if parsed_device_id.get("SN") is not None and len(parsed_device_id["SN"]) > 0: 70 | serial = parsed_device_id["SN"] 71 | 72 | if len(make_model) > 0: 73 | name = make_model 74 | elif len(name_parts) == 2: 75 | name = " ".join(name_parts) 76 | elif len(_printer_name) > 0: 77 | name = _printer_name 78 | 79 | return Info( 80 | command_set=cmd, 81 | location=data.get("printer-location", ""), 82 | name=name, 83 | manufacturer=make, 84 | model=model, 85 | printer_name=printer_name, 86 | printer_info=data.get("printer-info"), 87 | printer_uri_supported=uri_supported, 88 | serial=serial, 89 | uptime=data.get("printer-up-time", 0), 90 | uuid=uuid[9:] if uuid else None, # strip urn:uuid: from uuid 91 | version=data.get("printer-firmware-string-version"), 92 | more_info=data.get("printer-more-info"), 93 | ) 94 | 95 | 96 | @dataclass 97 | class Marker: 98 | """Object holding marker (ink) info from IPP.""" 99 | 100 | marker_id: int 101 | marker_type: str 102 | name: str 103 | color: str 104 | level: int 105 | low_level: int 106 | high_level: int 107 | 108 | 109 | @dataclass 110 | class Uri: 111 | """Object holding URI info from IPP.""" 112 | 113 | uri: str 114 | authentication: str | None 115 | security: str | None 116 | 117 | 118 | @dataclass 119 | class State: 120 | """Object holding the IPP printer state.""" 121 | 122 | printer_state: str 123 | reasons: str | None 124 | message: str | None 125 | 126 | @staticmethod 127 | def from_dict(data: dict[str, Any]) -> State: 128 | """Return State object from IPP response.""" 129 | state = data.get("printer-state", 0) 130 | 131 | if (reasons := data.get("printer-state-reasons")) == "none": 132 | reasons = None 133 | 134 | return State( 135 | printer_state=PRINTER_STATES.get(state, state), 136 | reasons=reasons, 137 | message=data.get("printer-state-message"), 138 | ) 139 | 140 | 141 | @dataclass 142 | class Printer: 143 | """Object holding the IPP printer information.""" 144 | 145 | info: Info 146 | markers: list[Marker] 147 | state: State 148 | uris: list[Uri] 149 | booted_at: datetime 150 | 151 | def as_dict(self) -> dict[str, Any]: 152 | """Return dictionary version of this printer.""" 153 | return { 154 | "info": asdict(self.info), 155 | "state": asdict(self.state), 156 | "markers": [asdict(marker) for marker in self.markers], 157 | "uris": [asdict(uri) for uri in self.uris], 158 | "booted_at": self.booted_at, 159 | } 160 | 161 | def update_from_dict(self, data: dict[str, Any]) -> Printer: 162 | """Return updated Printer object from IPP response data.""" 163 | last_uptime = self.info.uptime 164 | 165 | self.info = Info.from_dict(data) 166 | self.markers = Printer.merge_marker_data(data) 167 | self.state = State.from_dict(data) 168 | self.uris = Printer.merge_uri_data(data) 169 | 170 | if self.info.uptime < last_uptime: 171 | self.booted_at = _utcnow() - timedelta(seconds=self.info.uptime) 172 | 173 | return self 174 | 175 | 176 | @staticmethod 177 | def from_dict(data: dict[str, Any]) -> Printer: 178 | """Return Printer object from IPP response data.""" 179 | info = Info.from_dict(data) 180 | 181 | return Printer( 182 | info=info, 183 | markers=Printer.merge_marker_data(data), 184 | state=State.from_dict(data), 185 | uris=Printer.merge_uri_data(data), 186 | booted_at=(_utcnow() - timedelta(seconds=info.uptime)), 187 | ) 188 | 189 | @staticmethod 190 | def merge_marker_data( # noqa: PLR0912, C901 191 | data: dict[str, Any], 192 | ) -> list[Marker]: 193 | """Return Marker data from IPP response.""" 194 | marker_names = [] 195 | marker_colors = [] 196 | marker_levels = [] 197 | marker_types = [] 198 | marker_highs = [] 199 | marker_lows = [] 200 | 201 | if not data.get("marker-names"): 202 | return [] 203 | 204 | if isinstance(data["marker-names"], list): 205 | marker_names = data["marker-names"] 206 | elif isinstance(data["marker-names"], str): 207 | marker_names = [data["marker-names"]] 208 | 209 | if not (mlen := len(marker_names)): 210 | return [] 211 | 212 | for _ in range(mlen): 213 | marker_colors.append("") 214 | marker_levels.append(-2) 215 | marker_types.append("unknown") 216 | marker_highs.append(100) 217 | marker_lows.append(0) 218 | 219 | if isinstance(data.get("marker-colors"), list): 220 | for index, list_value in enumerate(data["marker-colors"]): 221 | if index < mlen: 222 | marker_colors[index] = list_value 223 | elif isinstance(data.get("marker-colors"), str) and mlen == 1: 224 | marker_colors[0] = data["marker-colors"] 225 | 226 | if isinstance(data.get("marker-levels"), list): 227 | for index, list_value in enumerate(data["marker-levels"]): 228 | if index < mlen: 229 | marker_levels[index] = list_value 230 | elif isinstance(data.get("marker-levels"), int) and mlen == 1: 231 | marker_levels[0] = data["marker-levels"] 232 | 233 | if isinstance(data.get("marker-high-levels"), list): 234 | for index, list_value in enumerate(data["marker-high-levels"]): 235 | if index < mlen: 236 | marker_highs[index] = list_value 237 | elif isinstance(data.get("marker-high-levels"), int) and mlen == 1: 238 | marker_highs[0] = data["marker-high-levels"] 239 | 240 | if isinstance(data.get("marker-low-levels"), list): 241 | for index, list_value in enumerate(data["marker-low-levels"]): 242 | if index < mlen: 243 | marker_lows[index] = list_value 244 | elif isinstance(data.get("marker-low-levels"), int) and mlen == 1: 245 | marker_lows[0] = data["marker-low-levels"] 246 | 247 | if isinstance(data.get("marker-types"), list): 248 | for index, list_value in enumerate(data["marker-types"]): 249 | if index < mlen: 250 | marker_types[index] = list_value 251 | elif isinstance(data.get("marker-types"), str) and mlen == 1: 252 | marker_types[0] = data["marker-types"] 253 | 254 | markers = [ 255 | Marker( 256 | marker_id=marker_id, 257 | marker_type=marker_types[marker_id], 258 | name=marker_names[marker_id], 259 | color=marker_colors[marker_id], 260 | level=marker_levels[marker_id], 261 | high_level=marker_highs[marker_id], 262 | low_level=marker_lows[marker_id], 263 | ) 264 | for marker_id in range(mlen) 265 | ] 266 | markers.sort(key=lambda x: x.name) 267 | 268 | return markers 269 | 270 | @staticmethod 271 | def merge_uri_data(data: dict[str, Any]) -> list[Uri]: # noqa: PLR0912 272 | """Return URI data from IPP response.""" 273 | _uris: list[str] = [] 274 | auth: list[str | None] = [] 275 | security: list[str | None] = [] 276 | 277 | if not data.get("printer-uri-supported"): 278 | return [] 279 | 280 | if isinstance(data["printer-uri-supported"], list): 281 | _uris = data["printer-uri-supported"] 282 | elif isinstance(data["printer-uri-supported"], str): 283 | _uris = [data["printer-uri-supported"]] 284 | 285 | if not (ulen := len(_uris)): 286 | return [] 287 | 288 | for _ in range(ulen): 289 | auth.append(None) 290 | security.append(None) 291 | 292 | if isinstance(data.get("uri-authentication-supported"), list): 293 | for k, list_value in enumerate(data["uri-authentication-supported"]): 294 | if k < ulen: 295 | auth[k] = _str_or_none(list_value) 296 | elif isinstance(data.get("uri-authentication-supported"), str) and ulen == 1: 297 | auth[0] = _str_or_none(data["uri-authentication-supported"]) 298 | 299 | if isinstance(data.get("uri-security-supported"), list): 300 | for k, list_value in enumerate(data["uri-security-supported"]): 301 | if k < ulen: 302 | security[k] = _str_or_none(list_value) 303 | elif isinstance(data.get("uri-security-supported"), str) and ulen == 1: 304 | security[0] = _str_or_none(data["uri-security-supported"]) 305 | 306 | return [ 307 | Uri( 308 | uri=_uris[uri_id], 309 | authentication=auth[uri_id], 310 | security=security[uri_id], 311 | ) 312 | for uri_id in range(ulen) 313 | ] 314 | 315 | 316 | def _utcnow() -> datetime: 317 | """Return the current date and time in UTC.""" 318 | return datetime.now(tz=timezone.utc) 319 | 320 | def _str_or_none(value: str) -> str | None: 321 | """Return string while handling string representations of None.""" 322 | if value == "none": 323 | return None 324 | 325 | return value 326 | -------------------------------------------------------------------------------- /src/pyipp/parser.py: -------------------------------------------------------------------------------- 1 | """Response Parser for IPP.""" 2 | from __future__ import annotations 3 | 4 | import logging 5 | import struct 6 | from datetime import datetime, timedelta, timezone 7 | from typing import Any 8 | 9 | from .enums import ATTRIBUTE_ENUM_MAP, IppTag 10 | from .exceptions import IPPParseError 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | 15 | def parse_ieee1284_device_id(device_id: str) -> dict[str, str]: 16 | """Parse IEEE 1284 device id for common device info.""" 17 | if not device_id: 18 | return {} 19 | 20 | device_id = device_id.strip(";") 21 | device_info: dict[str, str] = {} 22 | 23 | for pair in device_id.split(";"): 24 | key, value = pair.split(":", 2) 25 | device_info[key.strip()] = value.strip() 26 | 27 | if not device_info.get("MANUFACTURER") and device_info.get("MFG"): 28 | device_info["MANUFACTURER"] = device_info["MFG"] 29 | 30 | if not device_info.get("MODEL") and device_info.get("MDL"): 31 | device_info["MODEL"] = device_info["MDL"] 32 | 33 | if not device_info.get("COMMAND SET") and device_info.get("CMD"): 34 | device_info["COMMAND SET"] = device_info["CMD"] 35 | 36 | return device_info 37 | 38 | 39 | def parse_collection(data: bytes, offset: int) -> tuple[dict[str, Any], int]: 40 | """Parse member attributes from IPP collection.""" 41 | collection_data: dict[str, Any] = {} 42 | member_name: str = "" 43 | 44 | _LOGGER.debug("#BeginCollectionOffset: %s", offset) 45 | 46 | while struct.unpack_from("b", data, offset)[0] != IppTag.END_COLLECTION.value: 47 | attribute, next_attr_offset = parse_attribute(data, offset, member_name) 48 | 49 | if attribute["tag"] == IppTag.MEMBER_NAME.value: 50 | member_name = attribute["value"] 51 | elif member_name: 52 | collection_data[member_name] = attribute["value"] 53 | 54 | offset = next_attr_offset 55 | 56 | _LOGGER.debug("#EndCollectionOffset: %s", offset) 57 | 58 | # skip over end of collection marker 59 | _, next_attr_offset = parse_attribute(data, offset) 60 | 61 | return collection_data, next_attr_offset 62 | 63 | 64 | # pylint: disable=R0912,R0915 65 | def parse_attribute( # noqa: PLR0912, PLR0915 66 | data: bytes, 67 | offset: int, 68 | prev_attr_name: str = "", 69 | ) -> tuple[dict[str, Any], int]: 70 | """Parse attribute from IPP data. 71 | 72 | 1 byte: Tag - b 73 | 2 byte: Name Length - h 74 | N bytes: Name - direct access 75 | 2 byte: Value Length -h 76 | N bytes: Value - direct access 77 | """ 78 | _LOGGER.debug("Parsing Attribute at offset %s", offset) 79 | 80 | attribute = {"tag": struct.unpack_from(">b", data, offset)[0]} 81 | offset += 1 82 | 83 | attribute["name-length"] = struct.unpack_from(">h", data, offset)[0] 84 | offset += 2 85 | 86 | offset_length = offset + attribute["name-length"] 87 | attribute["name"] = data[offset:offset_length].decode("utf-8") 88 | offset += attribute["name-length"] 89 | 90 | resolved_attr_name = attribute["name"] or prev_attr_name 91 | 92 | if attribute["name"]: 93 | _LOGGER.debug( 94 | "Attribute Name: %s (%s)", attribute["name"], hex(attribute["tag"]), 95 | ) 96 | else: 97 | _LOGGER.debug("Attribute Tag: %s", hex(attribute["tag"])) 98 | 99 | attribute["value-length"] = struct.unpack_from(">h", data, offset)[0] 100 | offset += 2 101 | 102 | _LOGGER.debug("Attribute Value Offset: %s", offset) 103 | _LOGGER.debug("Attribute Value Length: %s", attribute["value-length"]) 104 | 105 | if attribute["tag"] in (IppTag.ENUM.value, IppTag.INTEGER.value): 106 | attribute["value"] = struct.unpack_from(">i", data, offset)[0] 107 | 108 | if ( 109 | attribute["tag"] == IppTag.ENUM.value 110 | and resolved_attr_name in ATTRIBUTE_ENUM_MAP 111 | ): 112 | enum_class = ATTRIBUTE_ENUM_MAP[resolved_attr_name] 113 | attribute["value"] = enum_class(attribute["value"]) 114 | 115 | offset += 4 116 | _LOGGER.debug("Attribute Value: %s", attribute["value"]) 117 | elif attribute["tag"] == IppTag.BOOLEAN.value: 118 | attribute["value"] = struct.unpack_from(">?", data, offset)[0] 119 | offset += 1 120 | _LOGGER.debug("Attribute Value: %s", attribute["value"]) 121 | elif attribute["tag"] == IppTag.DATE.value: 122 | if attribute["value-length"] != 11: 123 | raise IPPParseError( 124 | f'Invalid DATE size {attribute["value-length"]}', # noqa: EM102 125 | ) 126 | 127 | raw_date = dict( 128 | zip( 129 | ( 130 | "year", 131 | "month", 132 | "day", 133 | "hour", 134 | "minute", 135 | "second", 136 | "decisecond", 137 | "tz_dir", 138 | "tz_hour", 139 | "tz_minute", 140 | ), 141 | struct.unpack_from(">hbbbbbbcbb", data, offset), 142 | ), 143 | ) 144 | raw_date["microsecond"] = raw_date.pop("decisecond") * 100_000 145 | raw_date["tzinfo"] = timezone( 146 | {b"+": 1, b"-": -1}[raw_date.pop("tz_dir")] 147 | * timedelta( 148 | hours=raw_date.pop("tz_hour"), 149 | minutes=raw_date.pop("tz_minute"), 150 | ), 151 | ) 152 | 153 | attribute["value"] = datetime(**raw_date) # noqa: DTZ001 154 | offset += attribute["value-length"] 155 | _LOGGER.debug("Attribute Value: %s", attribute["value"]) 156 | elif attribute["tag"] == IppTag.RESERVED_STRING.value: 157 | if attribute["value-length"] > 0: 158 | offset_length = offset + attribute["value-length"] 159 | attribute["value"] = data[offset:offset_length].decode("utf-8") 160 | offset += attribute["value-length"] 161 | else: 162 | attribute["value"] = None 163 | 164 | _LOGGER.debug("Attribute Value: %s", attribute["value"]) 165 | elif attribute["tag"] == IppTag.RANGE.value: 166 | attribute["value"] = [] 167 | for i in range(int(attribute["value-length"] / 4)): 168 | attribute["value"].append(struct.unpack_from(">i", data, offset + i * 4)[0]) 169 | offset += attribute["value-length"] 170 | _LOGGER.debug("Attribute Value: %s", attribute["value"]) 171 | elif attribute["tag"] == IppTag.RESOLUTION.value: 172 | attribute["value"] = struct.unpack_from(">iib", data, offset) 173 | offset += attribute["value-length"] 174 | elif attribute["tag"] in (IppTag.TEXT_LANG.value, IppTag.NAME_LANG.value): 175 | attribute["language-length"] = struct.unpack_from(">h", data, offset)[0] 176 | offset += 2 177 | 178 | offset_length = offset + attribute["language-length"] 179 | attribute["language"] = data[offset:offset_length].decode("utf-8") 180 | offset += attribute["language-length"] 181 | _LOGGER.debug("Attribute Language: %s", attribute["language"]) 182 | 183 | attribute["text-length"] = struct.unpack_from(">h", data, offset)[0] 184 | offset += 2 185 | _LOGGER.debug("Attribute Text Length: %s", attribute["text-length"]) 186 | 187 | offset_length = offset + attribute["text-length"] 188 | attribute["value"] = data[offset:offset_length].decode("utf-8") 189 | offset += attribute["text-length"] 190 | _LOGGER.debug("Attribute Value: %s", attribute["value"]) 191 | elif attribute["tag"] == IppTag.BEGIN_COLLECTION.value: 192 | offset += attribute["value-length"] 193 | collection, new_offset = parse_collection(data, offset) 194 | attribute["value"] = collection 195 | offset = new_offset 196 | _LOGGER.debug("Attribute Value: %s", attribute["value"]) 197 | elif attribute["tag"] == IppTag.END_COLLECTION.value: 198 | offset += attribute["value-length"] 199 | else: 200 | offset_length = offset + attribute["value-length"] 201 | attribute["value"] = data[offset:offset_length] 202 | _LOGGER.debug("Attribute Bytes: %s", attribute["value"]) 203 | 204 | attribute["value"] = attribute["value"].decode("utf-8", "ignore") 205 | offset += attribute["value-length"] 206 | _LOGGER.debug("Attribute Value: %s", attribute["value"]) 207 | 208 | return attribute, offset 209 | 210 | 211 | # pylint: disable=R0912,R0915 212 | def parse( # noqa: PLR0912, PLR0915 213 | raw_data: bytes, 214 | contains_data: bool = False, # noqa: FBT001, FBT002 215 | ) -> dict[str, Any]: 216 | r"""Parse raw IPP data. 217 | 218 | 1 byte: Protocol Major Version - b 219 | 1 byte: Protocol Minor Version - b 220 | 2 byte: Operation ID/Status Code - h 221 | 4 byte: Request ID - i 222 | 223 | 1 byte: Operation Attribute Byte (\0x01) 224 | 225 | N Mal: Attributes 226 | 227 | 1 byte: Attribute End Byte (\0x03) 228 | """ 229 | data: dict[str, Any] = {} 230 | offset = 0 231 | 232 | _LOGGER.debug("Parsing IPP Data") 233 | 234 | data["version"] = struct.unpack_from(">bb", raw_data, offset) 235 | offset += 2 236 | 237 | _LOGGER.debug("IPP Version: %s", data["version"]) 238 | 239 | data["status-code"] = struct.unpack_from(">h", raw_data, offset)[0] 240 | offset += 2 241 | 242 | _LOGGER.debug("IPP Status Code: %s", data["status-code"]) 243 | 244 | data["request-id"] = struct.unpack_from(">i", raw_data, offset)[0] 245 | offset += 4 246 | 247 | data["operation-attributes"] = [] 248 | data["unsupported-attributes"] = [] 249 | data["jobs"] = [] 250 | data["printers"] = [] 251 | data["data"] = b"" 252 | 253 | attribute_key = "" 254 | previous_attribute_name = "" 255 | tmp_data: dict[str, Any] = {} 256 | 257 | while struct.unpack_from("b", raw_data, offset)[0] != IppTag.END.value: 258 | # check for operation, job or printer attribute start byte 259 | # if tmp data and attribute key is set, another operation was sent 260 | # add it and reset tmp data 261 | if struct.unpack_from("b", raw_data, offset)[0] == IppTag.OPERATION.value: 262 | if tmp_data and attribute_key: 263 | data[attribute_key].append(tmp_data) 264 | tmp_data = {} 265 | 266 | attribute_key = "operation-attributes" 267 | offset += 1 268 | elif struct.unpack_from("b", raw_data, offset)[0] == IppTag.JOB.value: 269 | if tmp_data and attribute_key: 270 | data[attribute_key].append(tmp_data) 271 | tmp_data = {} 272 | 273 | attribute_key = "jobs" 274 | offset += 1 275 | elif struct.unpack_from("b", raw_data, offset)[0] == IppTag.PRINTER.value: 276 | if tmp_data and attribute_key: 277 | data[attribute_key].append(tmp_data) 278 | tmp_data = {} 279 | 280 | attribute_key = "printers" 281 | offset += 1 282 | elif ( 283 | struct.unpack_from("b", raw_data, offset)[0] 284 | == IppTag.UNSUPPORTED_GROUP.value 285 | ): 286 | if tmp_data and attribute_key: 287 | data[attribute_key].append(tmp_data) 288 | tmp_data = {} 289 | 290 | attribute_key = "unsupported-attributes" 291 | offset += 1 292 | else: 293 | attribute, new_offset = parse_attribute( 294 | raw_data, 295 | offset, 296 | previous_attribute_name, 297 | ) 298 | 299 | # if attribute has a name -> add it 300 | # if attribute doesn't have a name -> it is part of an array 301 | if attribute["name"]: 302 | tmp_data[attribute["name"]] = attribute["value"] 303 | previous_attribute_name = attribute["name"] 304 | elif previous_attribute_name: 305 | # check if attribute is already an array 306 | # else convert it to an array 307 | if isinstance(tmp_data[previous_attribute_name], list): 308 | tmp_data[previous_attribute_name].append(attribute["value"]) 309 | else: 310 | tmp_value = tmp_data[previous_attribute_name] 311 | tmp_data[previous_attribute_name] = [tmp_value, attribute["value"]] 312 | 313 | offset = new_offset 314 | 315 | if isinstance(data[attribute_key], list): 316 | data[attribute_key].append(tmp_data) 317 | 318 | if isinstance(data["operation-attributes"], list): 319 | data["operation-attributes"] = data["operation-attributes"][0] 320 | 321 | if contains_data: 322 | offset_start = offset + 1 323 | data["data"] = raw_data[offset_start:] 324 | 325 | return data 326 | 327 | 328 | def parse_make_and_model(make_and_model: str) -> tuple[str, str]: 329 | """Parse make and model for separate device make and model.""" 330 | if not (make_and_model := make_and_model.strip()): 331 | return ("Unknown", "Unknown") 332 | 333 | make = "Unknown" 334 | model = "Unknown" 335 | found_make = False 336 | known_makes = [ 337 | "brother", 338 | "canon", 339 | "epson", 340 | "kyocera", 341 | "hp", 342 | "xerox", 343 | ] 344 | 345 | test_against = make_and_model.lower() 346 | for known_make in known_makes: 347 | if test_against.startswith(known_make): 348 | found_make = True 349 | mlen = len(known_make) 350 | make = make_and_model[:mlen] 351 | model = make_and_model[mlen:].strip() 352 | break 353 | 354 | if not found_make: 355 | split = make_and_model.split(None, 1) 356 | make = split[0] 357 | 358 | if len(split) == 2: 359 | model = split[1].strip() 360 | 361 | return (make, model) 362 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | """Tests for IPP Models.""" 2 | # pylint: disable=R0912,R0915 3 | from __future__ import annotations 4 | 5 | from typing import Any, List 6 | 7 | import pytest 8 | 9 | from pyipp import models, parser 10 | 11 | from . import IPPE10_PRINTER_ATTRS, load_fixture_binary 12 | 13 | 14 | @pytest.mark.asyncio 15 | async def test_info() -> None: # noqa: PLR0915 16 | """Test Info model.""" 17 | parsed = parser.parse(load_fixture_binary("get-printer-attributes-epsonxp6000.bin")) 18 | data = parsed["printers"][0] 19 | info = models.Info.from_dict(data) 20 | 21 | assert info 22 | assert info.command_set == "ESCPL2,BDC,D4,D4PX,ESCPR7,END4,GENEP,URF" 23 | assert info.location == "" 24 | assert info.name == "EPSON XP-6000 Series" 25 | assert info.manufacturer == "EPSON" 26 | assert info.model == "XP-6000 Series" 27 | assert info.printer_name == "ipp/print" 28 | assert info.printer_info == "EPSON XP-6000 Series" 29 | assert info.printer_uri_supported == [ 30 | "ipps://192.168.1.92:631/ipp/print", 31 | "ipp://192.168.1.92:631/ipp/print", 32 | ] 33 | assert info.more_info == "http://192.168.1.92:80/PRESENTATION/BONJOUR" 34 | assert info.serial == "583434593035343012" 35 | assert info.uuid == "cfe92100-67c4-11d4-a45f-f8d027761251" 36 | assert info.version == "20.44.NU25M7" 37 | assert info.uptime == 783801 38 | 39 | # no make/model, device id 40 | data["printer-make-and-model"] = "" 41 | info = models.Info.from_dict(data) 42 | 43 | assert info 44 | assert info.name == "EPSON XP-6000 Series" 45 | assert info.printer_name == "ipp/print" 46 | assert info.manufacturer == "EPSON" 47 | assert info.model == "XP-6000 Series" 48 | 49 | # no make/model, no device id, URI name 50 | data["printer-device-id"] = "" 51 | data["printer-make-and-model"] = "" 52 | data["printer-name"] = "ipp/print" 53 | info = models.Info.from_dict(data) 54 | 55 | assert info 56 | assert info.name == "IPP Printer" 57 | assert info.printer_name == "ipp/print" 58 | assert info.manufacturer == "Unknown" 59 | assert info.model == "Unknown" 60 | 61 | # no make/model, no device id, name 62 | data["printer-device-id"] = "" 63 | data["printer-make-and-model"] = "" 64 | data["printer-name"] = "Printy" 65 | info = models.Info.from_dict(data) 66 | 67 | assert info 68 | assert info.name == "Printy" 69 | assert info.printer_name == "Printy" 70 | assert info.manufacturer == "Unknown" 71 | assert info.model == "Unknown" 72 | 73 | # no make/model, no device id, no name 74 | data["printer-device-id"] = "" 75 | data["printer-make-and-model"] = "" 76 | data["printer-name"] = "" 77 | info = models.Info.from_dict(data) 78 | 79 | assert info 80 | assert info.name == "IPP Printer" 81 | assert info.printer_name == "" 82 | assert info.manufacturer == "Unknown" 83 | assert info.model == "Unknown" 84 | 85 | 86 | @pytest.mark.asyncio 87 | async def test_state() -> None: 88 | """Test State model.""" 89 | data: dict[str, Any] = { 90 | "printer-state": 4, 91 | "printer-state-reasons": "none", 92 | } 93 | 94 | state = models.State.from_dict(data) 95 | 96 | assert state 97 | assert state.printer_state == "printing" 98 | assert state.reasons is None 99 | assert state.message is None 100 | 101 | 102 | @pytest.mark.asyncio 103 | async def test_printer() -> None: # noqa: PLR0915 104 | """Test Printer model.""" 105 | parsed = parser.parse(load_fixture_binary("get-printer-attributes-epsonxp6000.bin")) 106 | printer = models.Printer.from_dict(parsed["printers"][0]) 107 | 108 | assert printer 109 | 110 | assert printer.info 111 | assert printer.info.command_set == "ESCPL2,BDC,D4,D4PX,ESCPR7,END4,GENEP,URF" 112 | assert printer.info.location == "" 113 | assert printer.info.name == "EPSON XP-6000 Series" 114 | assert printer.info.manufacturer == "EPSON" 115 | assert printer.info.model == "XP-6000 Series" 116 | assert printer.info.printer_name == "ipp/print" 117 | assert printer.info.printer_info == "EPSON XP-6000 Series" 118 | assert printer.info.printer_uri_supported == [ 119 | "ipps://192.168.1.92:631/ipp/print", 120 | "ipp://192.168.1.92:631/ipp/print", 121 | ] 122 | assert printer.info.more_info == "http://192.168.1.92:80/PRESENTATION/BONJOUR" 123 | assert printer.info.serial == "583434593035343012" 124 | assert printer.info.uuid == "cfe92100-67c4-11d4-a45f-f8d027761251" 125 | assert printer.info.version == "20.44.NU25M7" 126 | assert printer.info.uptime == 783801 127 | 128 | assert printer.state 129 | assert printer.state.printer_state == "idle" 130 | assert printer.state.reasons == "marker-supply-low-warning" 131 | assert printer.state.message is None 132 | 133 | assert printer.markers 134 | assert isinstance(printer.markers, list) 135 | assert len(printer.markers) == 5 136 | 137 | assert printer.markers[0] 138 | assert printer.markers[0].marker_id == 4 139 | assert printer.markers[0].marker_type == "ink-cartridge" 140 | assert printer.markers[0].name == "Black ink" 141 | assert printer.markers[0].color == "#000000" 142 | assert printer.markers[0].level == 64 143 | assert printer.markers[0].low_level == 15 144 | assert printer.markers[0].high_level == 100 145 | 146 | assert printer.markers[1] 147 | assert printer.markers[1].marker_id == 1 148 | assert printer.markers[1].marker_type == "ink-cartridge" 149 | assert printer.markers[1].name == "Cyan ink" 150 | assert printer.markers[1].color == "#00FFFF" 151 | assert printer.markers[1].level == 99 152 | assert printer.markers[1].low_level == 15 153 | assert printer.markers[1].high_level == 100 154 | 155 | assert printer.markers[2] 156 | assert printer.markers[2].marker_id == 2 157 | assert printer.markers[2].marker_type == "ink-cartridge" 158 | assert printer.markers[2].name == "Magenta ink" 159 | assert printer.markers[2].color == "#FF00FF" 160 | assert printer.markers[2].level == 83 161 | assert printer.markers[2].low_level == 15 162 | assert printer.markers[2].high_level == 100 163 | 164 | assert printer.markers[3] 165 | assert printer.markers[3].marker_id == 0 166 | assert printer.markers[3].marker_type == "ink-cartridge" 167 | assert printer.markers[3].name == "Photo Black ink" 168 | assert printer.markers[3].color == "#000000" 169 | assert printer.markers[3].level == 27 170 | assert printer.markers[3].low_level == 15 171 | assert printer.markers[3].high_level == 100 172 | 173 | assert printer.markers[4] 174 | assert printer.markers[4].marker_id == 3 175 | assert printer.markers[4].marker_type == "ink-cartridge" 176 | assert printer.markers[4].name == "Yellow ink" 177 | assert printer.markers[4].color == "#FFFF00" 178 | assert printer.markers[4].level == 6 179 | assert printer.markers[4].low_level == 15 180 | assert printer.markers[4].high_level == 100 181 | 182 | assert printer.uris 183 | assert isinstance(printer.uris, list) 184 | assert len(printer.uris) == 2 185 | 186 | assert printer.uris[0] 187 | assert printer.uris[0].uri == "ipps://192.168.1.92:631/ipp/print" 188 | assert printer.uris[0].authentication is None 189 | assert printer.uris[0].security == "tls" 190 | 191 | assert printer.uris[1] 192 | assert printer.uris[1].uri == "ipp://192.168.1.92:631/ipp/print" 193 | assert printer.uris[1].authentication is None 194 | assert printer.uris[1].security is None 195 | 196 | def test_printer_as_dict() -> None: 197 | """Test the dictionary version of Printer.""" 198 | parsed = parser.parse(load_fixture_binary("get-printer-attributes-epsonxp6000.bin")) 199 | printer = models.Printer.from_dict(parsed["printers"][0]) 200 | 201 | assert printer 202 | 203 | printer_dict = printer.as_dict() 204 | assert printer_dict 205 | assert isinstance(printer_dict, dict) 206 | assert isinstance(printer_dict["info"], dict) 207 | assert isinstance(printer_dict["state"], dict) 208 | assert isinstance(printer_dict["markers"], List) 209 | assert len(printer_dict["markers"]) == 5 210 | assert isinstance(printer_dict["uris"], List) 211 | assert len(printer_dict["uris"]) == 2 212 | 213 | def test_printer_update_from_dict() -> None: 214 | """Test updating data of Printer.""" 215 | parsed = parser.parse(load_fixture_binary("get-printer-attributes-epsonxp6000.bin")) 216 | printer = models.Printer.from_dict(parsed["printers"][0]) 217 | 218 | assert printer 219 | assert printer.info 220 | assert printer.info.uptime == 783801 221 | 222 | parsed["printer-up-time"] = 2 223 | printer.update_from_dict(parsed) 224 | 225 | assert printer 226 | assert printer.info 227 | assert printer.info.uptime == 2 228 | 229 | @pytest.mark.asyncio 230 | async def test_printer_with_single_marker() -> None: 231 | """Test Printer model with single marker.""" 232 | data = IPPE10_PRINTER_ATTRS.copy() 233 | data["marker-names"] = "Black" 234 | data["marker-types"] = "ink-cartridge" 235 | data["marker-colors"] = "#FF0000" 236 | data["marker-levels"] = 77 237 | data["marker-high-levels"] = 100 238 | data["marker-low-levels"] = 0 239 | 240 | printer = models.Printer.from_dict(data) 241 | assert printer 242 | assert printer.markers[0] 243 | assert printer.markers[0].name == "Black" 244 | assert printer.markers[0].color == "#FF0000" 245 | assert printer.markers[0].level == 77 246 | assert printer.markers[0].high_level == 100 247 | assert printer.markers[0].low_level == 0 248 | assert printer.markers[0].marker_type == "ink-cartridge" 249 | 250 | 251 | @pytest.mark.asyncio 252 | async def test_printer_with_single_marker_empty_strings() -> None: 253 | """Test Printer model with single marker with empty string values.""" 254 | data = IPPE10_PRINTER_ATTRS.copy() 255 | data["marker-names"] = "" 256 | data["marker-types"] = "" 257 | data["marker-colors"] = "" 258 | data["marker-levels"] = "" 259 | data["marker-low-levels"] = "" 260 | data["marker-high-levels"] = "" 261 | 262 | printer = models.Printer.from_dict(data) 263 | assert printer 264 | assert len(printer.markers) == 0 265 | 266 | 267 | @pytest.mark.asyncio 268 | async def test_printer_with_single_marker_invalid() -> None: 269 | """Test Printer model with single invalid marker name.""" 270 | data = IPPE10_PRINTER_ATTRS.copy() 271 | data["marker-names"] = -1 272 | 273 | printer = models.Printer.from_dict(data) 274 | assert printer 275 | assert len(printer.markers) == 0 276 | 277 | 278 | @pytest.mark.asyncio 279 | async def test_printer_with_extra_marker_data() -> None: 280 | """Test Printer model with extra marker data.""" 281 | data = IPPE10_PRINTER_ATTRS.copy() 282 | data["marker-names"] = ["Black"] 283 | data["marker-types"] = ["ink-cartridge", "ink"] 284 | data["marker-colors"] = ["#FF0000", "#FF1111"] 285 | data["marker-levels"] = [99, 33] 286 | data["marker-low-levels"] = [0, 10] 287 | data["marker-high-levels"] = [99, 100] 288 | 289 | printer = models.Printer.from_dict(data) 290 | assert printer 291 | assert len(printer.markers) == 1 292 | assert printer.markers[0] 293 | assert printer.markers[0].name == "Black" 294 | assert printer.markers[0].color == "#FF0000" 295 | assert printer.markers[0].level == 99 296 | assert printer.markers[0].high_level == 99 297 | assert printer.markers[0].low_level == 0 298 | assert printer.markers[0].marker_type == "ink-cartridge" 299 | 300 | 301 | @pytest.mark.asyncio 302 | async def test_printer_with_single_supported_uri() -> None: 303 | """Test Printer model with single supported uri.""" 304 | data = IPPE10_PRINTER_ATTRS.copy() 305 | data["printer-uri-supported"] = "ipp://10.104.12.95:631/ipp/print" 306 | data["uri-authentication-supported"] = "none" 307 | data["uri-security-supported"] = "none" 308 | 309 | printer = models.Printer.from_dict(data) 310 | assert printer 311 | assert printer.uris[0] 312 | assert printer.uris[0].uri == "ipp://10.104.12.95:631/ipp/print" 313 | assert printer.uris[0].authentication is None 314 | assert printer.uris[0].security is None 315 | 316 | 317 | @pytest.mark.asyncio 318 | async def test_printer_with_single_supported_uri_extra_data() -> None: 319 | """Test Printer model with single supported uri with extra data.""" 320 | data = IPPE10_PRINTER_ATTRS.copy() 321 | data["printer-uri-supported"] = "ipp://10.104.12.95:631/ipp/print" 322 | data["uri-authentication-supported"] = ["none", "basic"] 323 | data["uri-security-supported"] = ["none", "tls"] 324 | 325 | printer = models.Printer.from_dict(data) 326 | assert printer 327 | assert printer.uris[0] 328 | assert printer.uris[0].uri == "ipp://10.104.12.95:631/ipp/print" 329 | assert printer.uris[0].authentication is None 330 | assert printer.uris[0].security is None 331 | 332 | 333 | @pytest.mark.asyncio 334 | async def test_printer_with_single_supported_uri_invalid_uri() -> None: 335 | """Test Printer model with single invalid supported uri.""" 336 | data = IPPE10_PRINTER_ATTRS.copy() 337 | data["printer-uri-supported"] = -1 338 | data["uri-authentication-supported"] = "none" 339 | data["uri-security-supported"] = "none" 340 | 341 | printer = models.Printer.from_dict(data) 342 | assert printer 343 | assert len(printer.uris) == 0 344 | 345 | 346 | @pytest.mark.asyncio 347 | async def test_printer_with_single_supported_uri_with_security() -> None: 348 | """Test Printer model with multiple markers.""" 349 | data = IPPE10_PRINTER_ATTRS.copy() 350 | data["printer-uri-supported"] = "ipps://10.104.12.95:631/ipp/print" 351 | data["uri-authentication-supported"] = "basic" 352 | data["uri-security-supported"] = "tls" 353 | 354 | printer = models.Printer.from_dict(data) 355 | assert printer 356 | assert printer.uris[0] 357 | assert printer.uris[0].uri == "ipps://10.104.12.95:631/ipp/print" 358 | assert printer.uris[0].authentication == "basic" 359 | assert printer.uris[0].security == "tls" 360 | -------------------------------------------------------------------------------- /tests/__snapshots__/test_parser.ambr: -------------------------------------------------------------------------------- 1 | # serializer version: 1 2 | # name: test_parse_brother_mfcj5320dw 3 | dict({ 4 | 'data': b'', 5 | 'jobs': list([ 6 | ]), 7 | 'operation-attributes': dict({ 8 | 'attributes-charset': 'utf-8', 9 | 'attributes-natural-language': 'de', 10 | }), 11 | 'printers': list([ 12 | dict({ 13 | 'charset-configured': 'utf-8', 14 | 'charset-supported': 'utf-8', 15 | 'color-supported': True, 16 | 'compression-supported': 'none', 17 | 'copies-default': 1, 18 | 'copies-supported': list([ 19 | 1, 20 | 99, 21 | ]), 22 | 'document-format-default': 'application/octet-stream', 23 | 'document-format-supported': list([ 24 | 'application/octet-stream', 25 | 'application/vnd.brother-hbp', 26 | 'image/pwg-raster', 27 | 'image/urf', 28 | 'image/jpeg', 29 | ]), 30 | 'finishings-default': , 31 | 'finishings-supported': , 32 | 'generated-natural-language-supported': 'de', 33 | 'identify-actions-default': list([ 34 | 'flash', 35 | 'sound', 36 | ]), 37 | 'identify-actions-supported': list([ 38 | 'flash', 39 | 'sound', 40 | ]), 41 | 'ipp-features-supported': list([ 42 | 'airprint-1.3', 43 | 'faxout', 44 | 'wfds-print-1.0', 45 | ]), 46 | 'ipp-versions-supported': list([ 47 | '1.0', 48 | '1.1', 49 | '2.0', 50 | ]), 51 | 'job-creation-attributes-supported': list([ 52 | 'copies', 53 | 'finishings', 54 | 'ipp-attribute-fidelity', 55 | 'job-name', 56 | 'media', 57 | 'media-col', 58 | 'orientation-requested', 59 | 'output-bin', 60 | 'output-mode', 61 | 'print-quality', 62 | 'printer-resolution', 63 | 'requesting-user-name', 64 | 'sides', 65 | 'print-color-mode', 66 | ]), 67 | 'job-impressions-supported': list([ 68 | 1, 69 | 999, 70 | ]), 71 | 'jpeg-k-octets-supported': list([ 72 | 0, 73 | 12288, 74 | ]), 75 | 'jpeg-x-dimension-supported': list([ 76 | 0, 77 | 8192, 78 | ]), 79 | 'jpeg-y-dimension-supported': list([ 80 | 1, 81 | 20000, 82 | ]), 83 | 'landscape-orientation-requested-preferred': 5, 84 | 'marker-colors': list([ 85 | '#FF00FF', 86 | '#00FFFF', 87 | '#FFFF00', 88 | '#000000', 89 | ]), 90 | 'marker-high-levels': list([ 91 | 100, 92 | 100, 93 | 100, 94 | 100, 95 | ]), 96 | 'marker-levels': list([ 97 | 11, 98 | 9, 99 | 45, 100 | 11, 101 | ]), 102 | 'marker-low-levels': list([ 103 | 18, 104 | 18, 105 | 18, 106 | 18, 107 | ]), 108 | 'marker-names': list([ 109 | 'M', 110 | 'C', 111 | 'Y', 112 | 'BK', 113 | ]), 114 | 'marker-types': list([ 115 | 'ink-cartridge', 116 | 'ink-cartridge', 117 | 'ink-cartridge', 118 | 'ink-cartridge', 119 | ]), 120 | 'media-bottom-margin-supported': list([ 121 | 300, 122 | 0, 123 | 2200, 124 | 2200, 125 | 1200, 126 | ]), 127 | 'media-col-default': dict({ 128 | 'media-bottom-margin': 300, 129 | 'media-left-margin': 300, 130 | 'media-right-margin': 300, 131 | 'media-size': dict({ 132 | 'x-dimension': 21000, 133 | 'y-dimension': 29700, 134 | }), 135 | 'media-source': 'main', 136 | 'media-source-properties': dict({ 137 | 'media-source-feed-direction': 'long-edge-first', 138 | 'media-source-feed-orientation': , 139 | }), 140 | 'media-top-margin': 300, 141 | 'media-type': 'stationery', 142 | }), 143 | 'media-col-ready': list([ 144 | dict({ 145 | 'media-bottom-margin': 300, 146 | 'media-left-margin': 300, 147 | 'media-right-margin': 300, 148 | 'media-size': dict({ 149 | 'x-dimension': 21000, 150 | 'y-dimension': 29700, 151 | }), 152 | 'media-source': 'main', 153 | 'media-source-properties': dict({ 154 | 'media-source-feed-direction': 'long-edge-first', 155 | 'media-source-feed-orientation': , 156 | }), 157 | 'media-top-margin': 300, 158 | 'media-type': 'stationery', 159 | }), 160 | dict({ 161 | 'media-bottom-margin': 0, 162 | 'media-left-margin': 0, 163 | 'media-right-margin': 0, 164 | 'media-size': dict({ 165 | 'x-dimension': 21000, 166 | 'y-dimension': 29700, 167 | }), 168 | 'media-source': 'main', 169 | 'media-source-properties': dict({ 170 | 'media-source-feed-direction': 'long-edge-first', 171 | 'media-source-feed-orientation': , 172 | }), 173 | 'media-top-margin': 0, 174 | 'media-type': 'stationery', 175 | }), 176 | ]), 177 | 'media-col-supported': list([ 178 | 'media-type', 179 | 'media-size', 180 | 'media-top-margin', 181 | 'media-left-margin', 182 | 'media-right-margin', 183 | 'media-bottom-margin', 184 | 'media-source', 185 | ]), 186 | 'media-default': 'iso_a4_210x297mm', 187 | 'media-left-margin-supported': list([ 188 | 300, 189 | 0, 190 | 300, 191 | 300, 192 | 300, 193 | ]), 194 | 'media-ready': 'iso_a4_210x297mm', 195 | 'media-right-margin-supported': list([ 196 | 300, 197 | 0, 198 | 300, 199 | 300, 200 | 300, 201 | ]), 202 | 'media-size-supported': list([ 203 | dict({ 204 | 'x-dimension': 21000, 205 | 'y-dimension': 29700, 206 | }), 207 | dict({ 208 | 'x-dimension': 21590, 209 | 'y-dimension': 27940, 210 | }), 211 | dict({ 212 | 'x-dimension': 18415, 213 | 'y-dimension': 26670, 214 | }), 215 | dict({ 216 | 'x-dimension': 14800, 217 | 'y-dimension': 21000, 218 | }), 219 | dict({ 220 | 'x-dimension': 10500, 221 | 'y-dimension': 14800, 222 | }), 223 | dict({ 224 | 'x-dimension': 10160, 225 | 'y-dimension': 15240, 226 | }), 227 | dict({ 228 | 'x-dimension': 12700, 229 | 'y-dimension': 20320, 230 | }), 231 | dict({ 232 | 'x-dimension': 8890, 233 | 'y-dimension': 12700, 234 | }), 235 | dict({ 236 | 'x-dimension': 12700, 237 | 'y-dimension': 17780, 238 | }), 239 | dict({ 240 | 'x-dimension': 10477, 241 | 'y-dimension': 24130, 242 | }), 243 | dict({ 244 | 'x-dimension': 11000, 245 | 'y-dimension': 22000, 246 | }), 247 | dict({ 248 | 'x-dimension': 9842, 249 | 'y-dimension': 19050, 250 | }), 251 | dict({ 252 | 'x-dimension': 21590, 253 | 'y-dimension': 35560, 254 | }), 255 | dict({ 256 | 'x-dimension': 16200, 257 | 'y-dimension': 22900, 258 | }), 259 | dict({ 260 | 'x-dimension': 21590, 261 | 'y-dimension': 33020, 262 | }), 263 | dict({ 264 | 'x-dimension': 29700, 265 | 'y-dimension': 42000, 266 | }), 267 | dict({ 268 | 'x-dimension': 27940, 269 | 'y-dimension': 43180, 270 | }), 271 | dict({ 272 | 'x-dimension': list([ 273 | 8890, 274 | 28700, 275 | ]), 276 | 'y-dimension': list([ 277 | 12700, 278 | 43180, 279 | ]), 280 | }), 281 | ]), 282 | 'media-source-supported': list([ 283 | 'auto', 284 | 'main', 285 | ]), 286 | 'media-supported': list([ 287 | 'iso_a4_210x297mm', 288 | 'na_letter_8.5x11in', 289 | 'na_executive_7.25x10.5in', 290 | 'iso_a5_148x210mm', 291 | 'iso_a6_105x148mm', 292 | 'na_index-4x6_4x6in', 293 | 'na_index-5x8_5x8in', 294 | 'oe_photo-l_3.5x5in', 295 | 'na_5x7_5x7in', 296 | 'na_number-10_4.125x9.5in', 297 | 'iso_dl_110x220mm', 298 | 'na_monarch_3.875x7.5in', 299 | 'na_legal_8.5x14in', 300 | 'iso_c5_162x229mm', 301 | 'na_foolscap_8.5x13in', 302 | 'iso_a3_297x420mm', 303 | 'na_ledger_11x17in', 304 | 'custom_min_88.9x127mm', 305 | 'custom_max_287x431.8mm', 306 | ]), 307 | 'media-top-margin-supported': list([ 308 | 300, 309 | 0, 310 | 1200, 311 | 2200, 312 | 2200, 313 | ]), 314 | 'media-type-supported': list([ 315 | 'stationery', 316 | 'stationery-inkjet', 317 | 'photographic-glossy', 318 | ]), 319 | 'multiple-document-jobs-supported': False, 320 | 'multiple-operation-time-out': 150, 321 | 'natural-language-configured': 'de', 322 | 'operations-supported': list([ 323 | , 324 | , 325 | , 326 | , 327 | , 328 | , 329 | , 330 | , 331 | , 332 | ]), 333 | 'orientation-requested-default': , 334 | 'orientation-requested-supported': list([ 335 | , 336 | , 337 | ]), 338 | 'output-bin-default': 'face-up', 339 | 'output-bin-supported': 'face-up', 340 | 'output-mode-default': 'color', 341 | 'output-mode-supported': list([ 342 | 'color', 343 | 'auto', 344 | 'monochrome', 345 | ]), 346 | 'pages-per-minute': 35, 347 | 'pages-per-minute-color': 27, 348 | 'pdf-versions-supported': 'none', 349 | 'pdl-override-supported': 'attempted', 350 | 'print-color-mode-default': 'color', 351 | 'print-color-mode-supported': list([ 352 | 'auto', 353 | 'color', 354 | 'monochrome', 355 | ]), 356 | 'print-content-optimize-default': 'auto', 357 | 'print-content-optimize-supported': 'auto', 358 | 'print-quality-default': , 359 | 'print-quality-supported': list([ 360 | , 361 | , 362 | ]), 363 | 'print-scaling-default': 'auto', 364 | 'print-scaling-supported': list([ 365 | 'auto', 366 | 'auto-fit', 367 | 'fill', 368 | 'fit', 369 | 'none', 370 | ]), 371 | 'printer-device-id': 'MFG:Brother;CMD:HBP,BRPJL,URF;MDL:MFC-J5320DW;CLS:PRINTER;CID:Brother Generic Jpeg Type1;URF:SRGB24,W8,CP1,IS1,MT1-8-11,OB9,PQ4-5,RS200-300,OFU0,V1.3,DM4;', 372 | 'printer-dns-sd-name': 'Brother MFC-J5320DW', 373 | 'printer-geo-location': '', 374 | 'printer-icons': list([ 375 | 'http://192.168.11.20/ipp/printer-icons-128.png', 376 | 'http://192.168.11.20/ipp/printer-icons-512.png', 377 | ]), 378 | 'printer-info': 'Brother MFC-J5320DW', 379 | 'printer-is-accepting-jobs': True, 380 | 'printer-kind': list([ 381 | 'document', 382 | 'envelope', 383 | 'photo', 384 | ]), 385 | 'printer-location': '', 386 | 'printer-make-and-model': 'Brother MFC-J5320DW', 387 | 'printer-more-info': 'http://192.168.11.20/net/net/airprint.html', 388 | 'printer-name': 'brother-printer', 389 | 'printer-resolution-default': tuple( 390 | 300, 391 | 300, 392 | 3, 393 | ), 394 | 'printer-resolution-supported': tuple( 395 | 300, 396 | 300, 397 | 3, 398 | ), 399 | 'printer-state': , 400 | 'printer-state-reasons': 'marker-supply-low-warning', 401 | 'printer-supply-info-uri': 'http://192.168.11.20/general/status.html', 402 | 'printer-up-time': 1326249, 403 | 'printer-uri-supported': 'ipp://192.168.11.20/ipp/print', 404 | 'printer-uuid': 'urn:uuid:e3248000-80ce-11db-8000-30055ce13be2', 405 | 'pwg-raster-document-resolution-supported': tuple( 406 | 300, 407 | 300, 408 | 3, 409 | ), 410 | 'pwg-raster-document-sheet-back-supported': 'Rotated', 411 | 'pwg-raster-document-type-supported': list([ 412 | 'srgb_8', 413 | 'sgray_8', 414 | ]), 415 | 'queued-job-count': 0, 416 | 'sides-default': 'one-sided', 417 | 'sides-supported': list([ 418 | 'one-sided', 419 | 'two-sided-long-edge', 420 | 'two-sided-short-edge', 421 | ]), 422 | 'urf-supported': list([ 423 | 'SRGB24', 424 | 'W8', 425 | 'CP1', 426 | 'IS1', 427 | 'MT1-8-11', 428 | 'OB9', 429 | 'PQ4-5', 430 | 'RS200-300', 431 | 'OFU0', 432 | 'V1.3', 433 | 'DM4', 434 | ]), 435 | 'uri-authentication-supported': 'none', 436 | 'uri-security-supported': 'none', 437 | }), 438 | ]), 439 | 'request-id': 93687, 440 | 'status-code': 0, 441 | 'unsupported-attributes': list([ 442 | ]), 443 | 'version': tuple( 444 | 2, 445 | 0, 446 | ), 447 | }) 448 | # --- 449 | # name: test_parse_empty_attribute_group 450 | dict({ 451 | 'data': b'', 452 | 'jobs': list([ 453 | ]), 454 | 'operation-attributes': dict({ 455 | 'attributes-charset': 'utf-8', 456 | 'attributes-natural-language': 'en-US', 457 | 'printer-uri': 'ipp://printer.example.com:361/ipp/print', 458 | 'requesting-user-name': 'PythonIPP', 459 | }), 460 | 'printers': list([ 461 | ]), 462 | 'request-id': 1, 463 | 'status-code': 11, 464 | 'unsupported-attributes': list([ 465 | dict({ 466 | }), 467 | ]), 468 | 'version': tuple( 469 | 2, 470 | 0, 471 | ), 472 | }) 473 | # --- 474 | # name: test_parse_epson_xp6000 475 | dict({ 476 | 'data': b'', 477 | 'jobs': list([ 478 | ]), 479 | 'operation-attributes': dict({ 480 | 'attributes-charset': 'utf-8', 481 | 'attributes-natural-language': 'en', 482 | }), 483 | 'printers': list([ 484 | dict({ 485 | 'charset-configured': 'utf-8', 486 | 'charset-supported': 'utf-8', 487 | 'color-supported': True, 488 | 'compression-supported': list([ 489 | 'none', 490 | 'gzip', 491 | ]), 492 | 'copies-default': 1, 493 | 'copies-supported': list([ 494 | 1, 495 | 99, 496 | ]), 497 | 'document-format-default': 'application/octet-stream', 498 | 'document-format-preferred': 'image/urf', 499 | 'document-format-supported': list([ 500 | 'application/octet-stream', 501 | 'image/pwg-raster', 502 | 'image/urf', 503 | 'image/jpeg', 504 | ]), 505 | 'document-format-varying-attributes': list([ 506 | 'copies', 507 | 'sides', 508 | ]), 509 | 'finishings-default': , 510 | 'finishings-supported': , 511 | 'generated-natural-language-supported': 'en', 512 | 'identify-actions-default': 'flash', 513 | 'identify-actions-supported': 'flash', 514 | 'ipp-features-supported': list([ 515 | 'wfds-print-1.0', 516 | 'airprint-1.7', 517 | ]), 518 | 'ipp-versions-supported': list([ 519 | '1.0', 520 | '1.1', 521 | '2.0', 522 | ]), 523 | 'job-creation-attributes-supported': list([ 524 | 'copies', 525 | 'finishings', 526 | 'ipp-attribute-fidelity', 527 | 'job-name', 528 | 'media', 529 | 'media-col', 530 | 'orientation-requested', 531 | 'output-bin', 532 | 'print-quality', 533 | 'printer-resolution', 534 | 'sides', 535 | 'print-color-mode', 536 | 'print-content-optimize', 537 | 'print-scaling', 538 | 'job-mandatory-attributes', 539 | ]), 540 | 'jpeg-features-supported': 'none', 541 | 'jpeg-k-octets-supported': list([ 542 | 0, 543 | 16384, 544 | ]), 545 | 'jpeg-x-dimension-supported': list([ 546 | 0, 547 | 15000, 548 | ]), 549 | 'jpeg-y-dimension-supported': list([ 550 | 1, 551 | 15000, 552 | ]), 553 | 'landscape-orientation-requested-preferred': 5, 554 | 'marker-colors': list([ 555 | '#000000', 556 | '#00FFFF', 557 | '#FF00FF', 558 | '#FFFF00', 559 | '#000000', 560 | ]), 561 | 'marker-high-levels': list([ 562 | 100, 563 | 100, 564 | 100, 565 | 100, 566 | 100, 567 | ]), 568 | 'marker-levels': list([ 569 | 27, 570 | 99, 571 | 83, 572 | 6, 573 | 64, 574 | ]), 575 | 'marker-low-levels': list([ 576 | 15, 577 | 15, 578 | 15, 579 | 15, 580 | 15, 581 | ]), 582 | 'marker-names': list([ 583 | 'Photo Black ink', 584 | 'Cyan ink', 585 | 'Magenta ink', 586 | 'Yellow ink', 587 | 'Black ink', 588 | ]), 589 | 'marker-types': list([ 590 | 'ink-cartridge', 591 | 'ink-cartridge', 592 | 'ink-cartridge', 593 | 'ink-cartridge', 594 | 'ink-cartridge', 595 | ]), 596 | 'media-bottom-margin-supported': list([ 597 | 0, 598 | 300, 599 | ]), 600 | 'media-col-default': dict({ 601 | 'media-bottom-margin': 300, 602 | 'media-left-margin': 300, 603 | 'media-right-margin': 300, 604 | 'media-size': dict({ 605 | 'x-dimension': 21590, 606 | 'y-dimension': 27940, 607 | }), 608 | 'media-source': 'main', 609 | 'media-top-margin': 300, 610 | 'media-type': 'stationery', 611 | }), 612 | 'media-col-ready': list([ 613 | dict({ 614 | 'media-bottom-margin': 300, 615 | 'media-left-margin': 300, 616 | 'media-right-margin': 300, 617 | 'media-size': dict({ 618 | 'x-dimension': 21590, 619 | 'y-dimension': 27940, 620 | }), 621 | 'media-source': 'main', 622 | 'media-top-margin': 300, 623 | 'media-type': 'stationery', 624 | }), 625 | dict({ 626 | 'media-bottom-margin': 300, 627 | 'media-left-margin': 300, 628 | 'media-right-margin': 300, 629 | 'media-size': dict({ 630 | 'x-dimension': 10160, 631 | 'y-dimension': 15240, 632 | }), 633 | 'media-source': 'photo', 634 | 'media-top-margin': 300, 635 | 'media-type': 'photographic', 636 | }), 637 | dict({ 638 | 'media-bottom-margin': 0, 639 | 'media-left-margin': 0, 640 | 'media-right-margin': 0, 641 | 'media-size': dict({ 642 | 'x-dimension': 10160, 643 | 'y-dimension': 15240, 644 | }), 645 | 'media-source': 'photo', 646 | 'media-top-margin': 0, 647 | 'media-type': 'photographic', 648 | }), 649 | dict({ 650 | 'media-bottom-margin': 0, 651 | 'media-left-margin': 0, 652 | 'media-right-margin': 0, 653 | 'media-size': dict({ 654 | 'x-dimension': 12000, 655 | 'y-dimension': 12000, 656 | }), 657 | 'media-source': 'disc', 658 | 'media-top-margin': 0, 659 | 'media-type': 'disc', 660 | }), 661 | ]), 662 | 'media-col-supported': list([ 663 | 'media-size', 664 | 'media-top-margin', 665 | 'media-left-margin', 666 | 'media-right-margin', 667 | 'media-bottom-margin', 668 | 'media-type', 669 | 'media-source', 670 | ]), 671 | 'media-default': 'na_letter_8.5x11in', 672 | 'media-left-margin-supported': list([ 673 | 0, 674 | 300, 675 | ]), 676 | 'media-ready': list([ 677 | 'na_letter_8.5x11in', 678 | 'na_index-4x6_4x6in', 679 | 'disc_12cm_18x120mm', 680 | ]), 681 | 'media-right-margin-supported': list([ 682 | 0, 683 | 300, 684 | ]), 685 | 'media-size-supported': list([ 686 | dict({ 687 | 'x-dimension': 21590, 688 | 'y-dimension': 27940, 689 | }), 690 | dict({ 691 | 'x-dimension': 10160, 692 | 'y-dimension': 15240, 693 | }), 694 | dict({ 695 | 'x-dimension': 12700, 696 | 'y-dimension': 17780, 697 | }), 698 | dict({ 699 | 'x-dimension': 20320, 700 | 'y-dimension': 25400, 701 | }), 702 | dict({ 703 | 'x-dimension': 10160, 704 | 'y-dimension': 18060, 705 | }), 706 | dict({ 707 | 'x-dimension': 21000, 708 | 'y-dimension': 29700, 709 | }), 710 | dict({ 711 | 'x-dimension': 10500, 712 | 'y-dimension': 14800, 713 | }), 714 | dict({ 715 | 'x-dimension': 21590, 716 | 'y-dimension': 35560, 717 | }), 718 | dict({ 719 | 'x-dimension': 8890, 720 | 'y-dimension': 12700, 721 | }), 722 | dict({ 723 | 'x-dimension': 13970, 724 | 'y-dimension': 21590, 725 | }), 726 | dict({ 727 | 'x-dimension': 10477, 728 | 'y-dimension': 24130, 729 | }), 730 | dict({ 731 | 'x-dimension': 21590, 732 | 'y-dimension': 33020, 733 | }), 734 | dict({ 735 | 'x-dimension': 12000, 736 | 'y-dimension': 12000, 737 | }), 738 | dict({ 739 | 'x-dimension': list([ 740 | 8900, 741 | 21590, 742 | ]), 743 | 'y-dimension': list([ 744 | 12700, 745 | 111760, 746 | ]), 747 | }), 748 | ]), 749 | 'media-source-supported': list([ 750 | 'auto', 751 | 'main', 752 | 'photo', 753 | 'disc', 754 | ]), 755 | 'media-supported': list([ 756 | 'na_letter_8.5x11in', 757 | 'na_index-4x6_4x6in', 758 | 'na_5x7_5x7in', 759 | 'na_govt-letter_8x10in', 760 | 'om_hivision_101.6x180.6mm', 761 | 'iso_a4_210x297mm', 762 | 'iso_a6_105x148mm', 763 | 'na_legal_8.5x14in', 764 | 'oe_photo-l_3.5x5in', 765 | 'na_invoice_5.5x8.5in', 766 | 'na_number-10_4.125x9.5in', 767 | 'na_foolscap_8.5x13in', 768 | 'disc_12cm_18x120mm', 769 | 'custom_min_89x127mm', 770 | 'custom_max_215.9x1117.6mm', 771 | ]), 772 | 'media-top-margin-supported': list([ 773 | 0, 774 | 300, 775 | ]), 776 | 'media-type-supported': list([ 777 | 'stationery', 778 | 'photographic-high-gloss', 779 | 'photographic', 780 | 'photographic-semi-gloss', 781 | 'photographic-glossy', 782 | 'photographic-matte', 783 | 'com.epson-luster', 784 | 'envelope', 785 | 'stationery-coated', 786 | 'disc', 787 | ]), 788 | 'mopria-certified': 'mopria-certified 1.3', 789 | 'multiple-document-jobs-supported': False, 790 | 'multiple-operation-time-out': 120, 791 | 'multiple-operation-time-out-action': 'abort-job', 792 | 'natural-language-configured': 'en', 793 | 'operations-supported': list([ 794 | , 795 | , 796 | , 797 | , 798 | , 799 | , 800 | , 801 | , 802 | , 803 | , 804 | ]), 805 | 'orientation-requested-default': , 806 | 'orientation-requested-supported': , 807 | 'output-bin-default': 'face-up', 808 | 'output-bin-supported': 'face-up', 809 | 'pages-per-minute': 9, 810 | 'pages-per-minute-color': 9, 811 | 'pdf-versions-supported': 'none', 812 | 'pdl-override-supported': 'attempted', 813 | 'print-color-mode-default': 'auto', 814 | 'print-color-mode-supported': list([ 815 | 'color', 816 | 'monochrome', 817 | 'auto-monochrome', 818 | 'process-monochrome', 819 | 'auto', 820 | ]), 821 | 'print-content-optimize-default': 'auto', 822 | 'print-content-optimize-supported': 'auto', 823 | 'print-quality-default': , 824 | 'print-quality-supported': list([ 825 | , 826 | , 827 | ]), 828 | 'print-scaling-default': 'auto', 829 | 'print-scaling-supported': list([ 830 | 'auto', 831 | 'auto-fit', 832 | 'fill', 833 | 'fit', 834 | 'none', 835 | ]), 836 | 'printer-alert': 'code=other', 837 | 'printer-alert-description': 'feed roller needed soon', 838 | 'printer-config-change-date-time': '', 839 | 'printer-config-change-time': 25, 840 | 'printer-current-time': datetime.datetime(2022, 10, 4, 2, 21, 58, tzinfo=datetime.timezone.utc), 841 | 'printer-device-id': 'MFG:EPSON;CMD:ESCPL2,BDC,D4,D4PX,ESCPR7,END4,GENEP,URF;MDL:XP-6000 Series;CLS:PRINTER;DES:EPSON XP-6000 Series;CID:EpsonRGB;FID:FXN,DPA,WFA,ETN,AFN,DAN,WRA;RID:20;DDS:022500;ELG:1000;SN:583434593035343012;URF:CP1,PQ4-5,OB9,OFU0,RS360,SRGB24,W8,DM3,IS1-7-6,V1.4,MT1-3-7-8-10-11-12;', 842 | 'printer-dns-sd-name': 'EPSON XP-6000 Series', 843 | 'printer-firmware-name': 'Firmware', 844 | 'printer-firmware-string-version': '20.44.NU25M7', 845 | 'printer-firmware-version': '000020440000M7250000000000000000', 846 | 'printer-geo-location': '', 847 | 'printer-get-attributes-supported': 'document-format', 848 | 'printer-icons': list([ 849 | 'https://192.168.1.92:443/PRESENTATION/AIRPRINT/PRINTER_128.PNG', 850 | 'https://192.168.1.92:443/PRESENTATION/AIRPRINT/PRINTER_512.PNG', 851 | ]), 852 | 'printer-info': 'EPSON XP-6000 Series', 853 | 'printer-input-tray': list([ 854 | 'type=other;dimunit=micrometers;mediafeed=279400;mediaxfeed=215900;maxcapacity=-2;level=-2;status=0;name=Sheet feeder bin 1;', 855 | 'type=sheetFeedAutoNonRemovableTray;dimunit=micrometers;mediafeed=279400;mediaxfeed=215900;maxcapacity=-2;level=-2;status=0;name=Sheet feeder bin 1;', 856 | 'type=sheetFeedAutoNonRemovableTray;dimunit=micrometers;mediafeed=152400;mediaxfeed=101600;maxcapacity=-2;level=-2;status=0;name=Sheet feeder bin 2;', 857 | 'type=other;dimunit=micrometers;mediafeed=120000;mediaxfeed=120000;maxcapacity=-2;level=-2;status=5;name=Disc;', 858 | ]), 859 | 'printer-is-accepting-jobs': True, 860 | 'printer-kind': list([ 861 | 'document', 862 | 'envelope', 863 | 'photo', 864 | 'disc', 865 | ]), 866 | 'printer-location': '', 867 | 'printer-make-and-model': 'EPSON XP-6000 Series', 868 | 'printer-more-info': 'http://192.168.1.92:80/PRESENTATION/BONJOUR', 869 | 'printer-name': 'ipp/print', 870 | 'printer-organization': '', 871 | 'printer-organizational-unit': '', 872 | 'printer-output-tray': 'type=unRemovableBin;maxcapacity=50;remaining=-3;status=0;name=Face-up Tray;stackingorder=lastToFirst;pagedelivery=faceUp;', 873 | 'printer-resolution-default': tuple( 874 | 360, 875 | 360, 876 | 3, 877 | ), 878 | 'printer-resolution-supported': list([ 879 | tuple( 880 | 360, 881 | 360, 882 | 3, 883 | ), 884 | tuple( 885 | 720, 886 | 720, 887 | 3, 888 | ), 889 | tuple( 890 | 5760, 891 | 1440, 892 | 3, 893 | ), 894 | ]), 895 | 'printer-state': , 896 | 'printer-state-change-date-time': datetime.datetime(2022, 9, 27, 3, 47, 19, tzinfo=datetime.timezone.utc), 897 | 'printer-state-change-time': 184119, 898 | 'printer-state-reasons': 'marker-supply-low-warning', 899 | 'printer-strings-languages-supported': list([ 900 | 'en', 901 | 'es-mx', 902 | 'pt', 903 | 'fr', 904 | ]), 905 | 'printer-strings-uri': 'http://192.168.1.92:80/LANGUAGES/IPP?LANG=en', 906 | 'printer-supply-info-uri': 'http://192.168.1.92:80/PRESENTATION/HTML/TOP/PRTINFO.HTML', 907 | 'printer-up-time': 783801, 908 | 'printer-uri-supported': list([ 909 | 'ipps://192.168.1.92:631/ipp/print', 910 | 'ipp://192.168.1.92:631/ipp/print', 911 | ]), 912 | 'printer-uuid': 'urn:uuid:cfe92100-67c4-11d4-a45f-f8d027761251', 913 | 'pwg-raster-document-resolution-supported': tuple( 914 | 360, 915 | 360, 916 | 3, 917 | ), 918 | 'pwg-raster-document-sheet-back': 'rotated', 919 | 'pwg-raster-document-type-supported': list([ 920 | 'sgray_8', 921 | 'srgb_8', 922 | ]), 923 | 'queued-job-count': 0, 924 | 'sides-default': 'one-sided', 925 | 'sides-supported': list([ 926 | 'one-sided', 927 | 'two-sided-short-edge', 928 | 'two-sided-long-edge', 929 | ]), 930 | 'urf-supported': list([ 931 | 'CP1', 932 | 'PQ4-5', 933 | 'OB9', 934 | 'OFU0', 935 | 'RS360', 936 | 'SRGB24', 937 | 'W8', 938 | 'DM3', 939 | 'IS1-7-6', 940 | 'V1.4', 941 | 'MT1-3-7-8-10-11-12', 942 | ]), 943 | 'uri-authentication-supported': list([ 944 | 'none', 945 | 'none', 946 | ]), 947 | 'uri-security-supported': list([ 948 | 'tls', 949 | 'none', 950 | ]), 951 | 'which-jobs-supported': list([ 952 | 'completed', 953 | 'not-completed', 954 | ]), 955 | }), 956 | ]), 957 | 'request-id': 66306, 958 | 'status-code': 0, 959 | 'unsupported-attributes': list([ 960 | ]), 961 | 'version': tuple( 962 | 2, 963 | 0, 964 | ), 965 | }) 966 | # --- 967 | # name: test_parse_kyocera_ecosys_m2540dn 968 | dict({ 969 | 'data': b'', 970 | 'jobs': list([ 971 | ]), 972 | 'operation-attributes': dict({ 973 | 'attributes-charset': 'utf-8', 974 | 'attributes-natural-language': 'en-us', 975 | }), 976 | 'printers': list([ 977 | dict({ 978 | 'printer-info': 'mfu00-0365', 979 | 'printer-location': '8409', 980 | 'printer-make-and-model': 'ECOSYS M2540dn', 981 | 'printer-name': 'mfu00-0365', 982 | 'printer-state': , 983 | 'printer-state-message': 'Sleeping... ', 984 | 'printer-uri-supported': list([ 985 | 'ipps://10.104.12.95:443/ipp/print', 986 | 'ipp://10.104.12.95:631/ipp/print', 987 | ]), 988 | }), 989 | ]), 990 | 'request-id': 47131, 991 | 'status-code': 1, 992 | 'unsupported-attributes': list([ 993 | dict({ 994 | 'requested-attributes': list([ 995 | 'printer-type', 996 | 'printer-state-reason', 997 | 'device-uri', 998 | 'printer-is-shared', 999 | ]), 1000 | }), 1001 | ]), 1002 | 'version': tuple( 1003 | 2, 1004 | 0, 1005 | ), 1006 | }) 1007 | # --- 1008 | --------------------------------------------------------------------------------