pax_global_header00006660000000000000000000000064146612155510014520gustar00rootroot0000000000000052 comment=8cd8c22353adf85795a62687044526c5537fa516 transmission-rpc-7.0.11/000077500000000000000000000000001466121555100151215ustar00rootroot00000000000000transmission-rpc-7.0.11/.github/000077500000000000000000000000001466121555100164615ustar00rootroot00000000000000transmission-rpc-7.0.11/.github/codeql/000077500000000000000000000000001466121555100177305ustar00rootroot00000000000000transmission-rpc-7.0.11/.github/codeql/codeql-config.yaml000066400000000000000000000001121466121555100233200ustar00rootroot00000000000000name: "CodeQL config" paths-ignore: - .venv paths: - transmission_rpc transmission-rpc-7.0.11/.github/renovate.json000066400000000000000000000001501466121555100211730ustar00rootroot00000000000000{ "extends": [ "github>Trim21/renovate-config", "github>Trim21/renovate-config:monthly" ] } transmission-rpc-7.0.11/.github/workflows/000077500000000000000000000000001466121555100205165ustar00rootroot00000000000000transmission-rpc-7.0.11/.github/workflows/ci.yaml000066400000000000000000000025371466121555100220040ustar00rootroot00000000000000name: test on: push: branches: - master pull_request: branches: - master jobs: test: runs-on: ubuntu-22.04 strategy: matrix: transmission: ["version-3.00-r8", "4.0.5"] python: ["3.8", "3.9", "3.10", "3.11", "3.12"] services: transmission: image: linuxserver/transmission:${{ matrix.transmission }} ports: - 8080:9091 - 6881:6881 env: UID: "1000" TZ: Etc/UTC USER: admin PASS: my-secret-password steps: - uses: actions/checkout@v4 with: fetch-depth: 2 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} cache: pip - run: pip install -e '.[dev]' - name: test run: coverage run -m pytest env: TR_PORT: 8080 TR_USER: admin TR_PASSWORD: my-secret-password - uses: codecov/codecov-action@v4 with: flags: "${{ matrix.python }}" token: ${{ secrets.CODECOV_TOKEN }} dist-files: runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.x" - run: pipx run flit build - name: Check Files run: pipx run twine check --strict dist/* transmission-rpc-7.0.11/.github/workflows/codeql-analysis.yaml000066400000000000000000000015431466121555100244750ustar00rootroot00000000000000name: CodeQL on: push: branches: [master] pull_request: branches: [master] schedule: - cron: 0 3 * * 5 jobs: analyze: name: Analyze runs-on: ubuntu-22.04 strategy: fail-fast: false matrix: language: [python] # Override automatic language detection by changing the below list # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] steps: - name: Checkout repository uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.11" - name: Initialize CodeQL uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} config-file: ./.github/codeql/codeql-config.yaml - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v3 transmission-rpc-7.0.11/.github/workflows/lint.yaml000066400000000000000000000020211466121555100223430ustar00rootroot00000000000000name: lint on: push: branches: - master pull_request: branches: - master jobs: mypy: runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.10" cache: pip - uses: liskin/gh-problem-matcher-wrap@v3 with: action: add linters: mypy - run: pip install -e '.[dev]' - name: mypy run: mypy --show-column-numbers transmission_rpc pre-commit: runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.x" - run: pip install -e '.[dev]' - uses: trim21/actions/pre-commit@master sphinx: runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.10" cache: pip - run: pip install -e '.[dev]' - run: sphinx-build -W docs/ dist/ transmission-rpc-7.0.11/.github/workflows/pr.yaml000066400000000000000000000006601466121555100220250ustar00rootroot00000000000000name: Check PR on: pull_request_target: types: - opened - reopened - edited - synchronize - converted_to_draft - ready_for_review permissions: pull-requests: write statuses: write jobs: lint: runs-on: ubuntu-22.04 steps: - uses: amannn/action-semantic-pull-request@v5 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: wip: true transmission-rpc-7.0.11/.github/workflows/release.yaml000066400000000000000000000021521466121555100230220ustar00rootroot00000000000000name: release on: push: tags: - v* jobs: release: runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: '3.x' - run: pipx run flit publish env: FLIT_USERNAME: '__token__' FLIT_PASSWORD: ${{ secrets.PYPI_TOKEN }} - name: Get Previous Tag id: tag uses: trim21/changelog-previous-tag@master with: token: ${{ github.token }} version-spec: pep440 - name: Update CHANGELOG id: changelog uses: requarks/changelog-action@v1 with: token: ${{ github.token }} fromTag: ${{ github.ref_name }} toTag: ${{ env.previousTag }} restrictToTypes: feat,fix,revert - name: Upload Github Release run: gh release create "${GITHUB_REF}" --notes "${CHANGELOG}" $EXTRA_OPTS env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CHANGELOG: "${{ steps.changelog.outputs.changes }}" EXTRA_OPTS: "${{ env.preRelease == 'true' && '-p' || '' }}" transmission-rpc-7.0.11/.gitignore000066400000000000000000000025341466121555100171150ustar00rootroot00000000000000node_modules/ .task/ # Created by .ignore support plugin (hsz.mobi) ### Python template # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python docs/source/_build/ docs/build/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt pip-wheel-metadata # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ # IDE .vscode/ .idea/ app.py transmission-rpc-7.0.11/.pre-commit-config.yaml000066400000000000000000000024511466121555100214040ustar00rootroot00000000000000default_stages: [commit] repos: - repo: https://github.com/abravalheri/validate-pyproject rev: v0.18 hooks: - id: validate-pyproject # Optional extra validations from SchemaStore: additional_dependencies: ["validate-pyproject-schema-store[all]"] - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks rev: v2.14.0 hooks: - id: pretty-format-yaml args: [--autofix, --indent, '2', '--preserve-quotes', --offset, '2'] - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.6.0 hooks: - id: check-case-conflict - id: check-ast - id: check-builtin-literals - id: check-byte-order-marker - id: check-toml - id: check-yaml - id: check-json - id: check-docstring-first - id: check-merge-conflict - id: check-added-large-files # check for file bigger than 500kb - id: debug-statements - id: trailing-whitespace - id: mixed-line-ending args: [--fix=lf] - id: end-of-file-fixer - id: fix-encoding-pragma args: [--remove] - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.5.5 hooks: - id: ruff args: [--fix] - repo: https://github.com/psf/black rev: 24.4.2 hooks: - id: black transmission-rpc-7.0.11/LICENSE000066400000000000000000000021471466121555100161320ustar00rootroot00000000000000MIT License Copyright (c) 2018-2023 Trim21 Copyright (c) 2008-2014 Erik Svensson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. transmission-rpc-7.0.11/README.md000066400000000000000000000042051466121555100164010ustar00rootroot00000000000000# Transmission-rpc Readme [![PyPI](https://img.shields.io/pypi/v/transmission-rpc)](https://pypi.org/project/transmission-rpc/) [![Documentation Status](https://readthedocs.org/projects/transmission-rpc/badge/)](https://transmission-rpc.readthedocs.io/) [![ci](https://github.com/Trim21/transmission-rpc/workflows/ci/badge.svg)](https://github.com/Trim21/transmission-rpc/actions) [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/transmission-rpc)](https://pypi.org/project/transmission-rpc/) [![Codecov branch](https://img.shields.io/codecov/c/github/Trim21/transmission-rpc/master)](https://codecov.io/gh/Trim21/transmission-rpc/branch/master) ## Introduction `transmission-rpc` is a python wrapper on top of [transmission](https://github.com/transmission/transmission) JSON RPC protocol, hosted on GitHub at [github.com/trim21/transmission-rpc](https://github.com/trim21/transmission-rpc) Support 2.40 (released 2011-10-05) <= transmission version <= 4.0.6 (released 2024-05-29), should works fine with newer rpc version but some new feature may be missing. ## versioning `transmission-rpc` follow [Semantic Versioning](https://semver.org/), report an issue if you found unexpected API break changes at same major version. ## Install ```console pip install transmission-rpc -U ``` ## Documents ## Contributing All kinds of PRs (docs, feature, bug fixes and eta...) are most welcome. ### Setup Local Development Environment At first, you need to install [python>=3.10](https://python.org/), and [task](https://taskfile.dev/) (or you can also run command in `taskfile.yaml` directly). It's recommended to python3.10 as local development python version. ```shell python -m venv .venv source .venv/bin/activate pip install -e '.[dev]' # install git pre-commit hooks pre-commit install ``` ### Lint ```shell task lint ``` ### Testing You need to have a transmission daemon running then add a `.env` file ```shell export TR_HOST="..." export TR_PORT="..." export TR_USER="..." export TR_PASS="..." ``` ```shell task test ``` ## License `transmission-rpc` is licensed under the MIT license. transmission-rpc-7.0.11/Taskfile.yaml000066400000000000000000000013261466121555100175510ustar00rootroot00000000000000version: '3' dotenv: - .env tasks: default: cmds: - task --list-all silent: true bump: vars: VERSION: sh: yq '.project.version' pyproject.toml cmds: - git add pyproject.toml - 'git commit -m "bump: {{.VERSION}}"' - 'git tag "v{{.VERSION}}" -m "v{{.VERSION}}"' lint: cmds: - pre-commit run -a - mypy --show-column-numbers transmission_rpc test: cmds: - pytest dev:docs: cmds: - sphinx-autobuild -W --watch transmission_rpc ./docs/ ./dist/ build:docs: sources: - docs/**/* - transmission_rpc/**/* generates: - dist/**/* cmds: - rm -rf dist/ - sphinx-build -W ./docs/ ./dist/ transmission-rpc-7.0.11/docs/000077500000000000000000000000001466121555100160515ustar00rootroot00000000000000transmission-rpc-7.0.11/docs/.readthedocs.yaml000066400000000000000000000002771466121555100213060ustar00rootroot00000000000000version: 2 build: os: "ubuntu-22.04" tools: python: "3.10" python: install: - method: pip path: . extra_requirements: [dev] sphinx: configuration: docs/conf.py transmission-rpc-7.0.11/docs/client.rst000066400000000000000000000014031466121555100200570ustar00rootroot00000000000000Client ======== Client is the class handling the Transmission JSON-RPC client protocol. Torrent ids ------------ Many functions in Client takes torrent id. You can find torrent-ids spec in `official docs `_ .. note:: It's recommended that you use torrent's ``info_hash`` as torrent id. The torrent's ``info_hash`` will never change. .. automodule:: transmission_rpc .. autofunction:: from_url .. autoclass:: Client :members: Timeouts -------- Since most methods results in HTTP requests against Transmission, it is possible to provide a argument called ``timeout``. Default timeout is 30 seconds. .. toctree:: :maxdepth: 2 :caption: Contents: transmission-rpc-7.0.11/docs/conf.py000066400000000000000000000104261466121555100173530ustar00rootroot00000000000000# # Configuration file for the Sphinx documentation builder. # # This file does only contain a selection of the most common options. For a # full list see the documentation: # http://www.sphinx-doc.org/en/master/config import os import sys from sphinx_github_style.utils.linkcode import get_linkcode_resolve # -- Path setup -------------------------------------------------------------- # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # # -- Project information ----------------------------------------------------- project = "transmission-rpc" copyright = "2018-2023, Trim21 " author = "Trim21 " # -- General configuration --------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. # # needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ "sphinx.ext.autodoc", "sphinx.ext.napoleon", "sphinx_copybutton", "furo.sphinxext", "sphinx.ext.linkcode", "sphinx_new_tab_link", ] napoleon_numpy_docstring = False # Add any paths that contain templates here, relative to this directory. templates_path = [] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] source_suffix = ".rst" # The master toctree document. master_doc = "index" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. language = "en" # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. exclude_patterns = [] # The name of the Pygments (syntax highlighting) style to use. pygments_style = None # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # html_theme = "furo" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. # html_theme_options = { "source_edit_link": "https://github.com/trim21/transmission-rpc/blob/master/docs/{filename}", # "source_view_link": "https://github.com/trim21/transmission-rpc/blob/master/{filename}", "source_repository": "https://github.com/trim21/transmission-rpc/", "source_branch": "master", "source_directory": "docs/", } html_copy_source = False # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = [] autodoc_member_order = "bysource" autodoc_class_signature = "separated" autodoc_typehints = "signature" autodoc_default_options = { "special-members": "", "exclude-members": "__new__", } # Custom sidebar templates, must be a dictionary that maps document names # to template names. # # The default sidebars (for documents that don't match any pattern) are # defined by theme itself. Builtin themes are using these templates by # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', # 'searchbox.html']``. # # html_sidebars = {} # -- Options for HTMLHelp output --------------------------------------------- # Output file base name for HTML help builder. htmlhelp_basename = "transmission-rpc doc" ref = "master" if os.environ.get("READTHEDOCS"): sys.path.insert(0, os.path.normpath("..")) if os.environ["READTHEDOCS_VERSION_TYPE"] == "tag": ref = os.environ["READTHEDOCS_GIT_IDENTIFIER"] else: ref = os.environ["READTHEDOCS_GIT_COMMIT_HASH"] linkcode_resolve = get_linkcode_resolve( "https://github.com/trim21/transmission-rpc/blob/" + ref + "/{filepath}#L{linestart}" ) transmission-rpc-7.0.11/docs/enum.rst000066400000000000000000000003021466121555100175420ustar00rootroot00000000000000Enum ==== .. automodule:: transmission_rpc :no-index: .. autoclass:: RatioLimitMode :members: .. autoclass:: IdleMode :members: .. toctree:: :maxdepth: 2 :caption: Contents: transmission-rpc-7.0.11/docs/errors.rst000066400000000000000000000002371466121555100201210ustar00rootroot00000000000000Errors ============================================ .. automodule:: transmission_rpc.error :members: .. toctree:: :maxdepth: 2 :caption: Contents: transmission-rpc-7.0.11/docs/examples/000077500000000000000000000000001466121555100176675ustar00rootroot00000000000000transmission-rpc-7.0.11/docs/examples/change_torrent_file.py000066400000000000000000000005421466121555100242430ustar00rootroot00000000000000from transmission_rpc import Client client = Client() t = client.get_torrent(0) client.change_torrent( t.hashString, files_unwanted=[f.id for f in t.get_files() if f.name.endswith(".txt")], priority_high=[f.id for f in t.get_files() if f.name.endswith(".mp4")], priority_low=[f.id for f in t.get_files() if f.name.endswith(".txt")], ) transmission-rpc-7.0.11/docs/examples/move_torrent_data.py000066400000000000000000000002501466121555100237520ustar00rootroot00000000000000from transmission_rpc import Client client = Client() t = client.get_torrent(0) client.move_torrent_data(t.hashString, location="/home/trim21/downloads/completed/") transmission-rpc-7.0.11/docs/examples/quick-start.py000066400000000000000000000015421466121555100225120ustar00rootroot00000000000000import requests from transmission_rpc import Client torrent_url = "https://github.com/trim21/transmission-rpc/raw/v4.1.0/tests/fixtures/iso.torrent" c = Client(host="localhost", port=9091, username="transmission", password="password") c.add_torrent(torrent_url) ######## c = Client(username="transmission", password="password") torrent_url = "magnet:?xt=urn:btih:e84213a794f3ccd890382a54a64ca68b7e925433&dn=ubuntu-18.04.1-desktop-amd64.iso" c.add_torrent(torrent_url) ######## c = Client(username="trim21", password="123456") torrent_url = "https://github.com/trim21/transmission-rpc/raw/v4.1.0/tests/fixtures/iso.torrent" r = requests.get(torrent_url) # client will base64 the torrent content for you. c.add_torrent(r.content) # or use a file-like object with open("a", "wb") as f: f.write(r.content) with open("a", "rb") as f: c.add_torrent(f) transmission-rpc-7.0.11/docs/examples/set_download_upload_speed.py000066400000000000000000000003171466121555100254500ustar00rootroot00000000000000from transmission_rpc import Client client = Client() client.change_torrent( 0, upload_limited=True, # don't forget this upload_limit=100, download_limited=True, download_limit=100, ) transmission-rpc-7.0.11/docs/index.rst000066400000000000000000000034331466121555100177150ustar00rootroot00000000000000.. transmission-rpc documentation master file, created by sphinx-quickstart on Fri Oct 5 09:29:21 2018. You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. Welcome to transmission-rpc's documentation! ============================================ :code:`transmission-rpc` is a python3 library to help your control your transmission daemon remotely. quick start ------------------------- .. literalinclude:: examples/quick-start.py .. seealso:: :py:meth:`transmission_rpc.Client.add_torrent` Example ======= Filter files .. literalinclude:: examples/change_torrent_file.py Move Torrent Data .. literalinclude:: examples/move_torrent_data.py Set Upload/Download Speed Limit .. literalinclude:: examples/set_download_upload_speed.py Arguments ------------------- Each method has it own arguments. You can pass arguments as kwargs when you call methods. But in python, :code:`-` can't be used in a variable name, so you need to replace :code:`-` with :code:`_`. For example, :code:`torrent-add` method support arguments :code:`download-dir`, you should call method like this. .. code-block :: python from transmission_rpc import Client Client().add_torrent(torrent_url, download_dir='/path/to/download/dir') :code:`transmission-rpc` will put :code:`{"download-dir": "/path/to/download/dir"}` in arguments. you can find rpc version by transmission version from `transmission rpc docs `_ .. toctree:: :maxdepth: 2 :caption: Contents: client.rst torrent.rst enum.rst session.rst errors.rst utils.rst Indices and tables ================== * :ref:`genindex` * :ref:`modindex` * :ref:`search` transmission-rpc-7.0.11/docs/session.rst000066400000000000000000000005671466121555100202760ustar00rootroot00000000000000Session ======= .. automodule:: transmission_rpc :no-index: .. autoclass:: Session :inherited-members: get :members: :undoc-members: :exclude-members: __new__, __init__ .. autoclass:: SessionStats :inherited-members: get :members: :undoc-members: :exclude-members: __new__, __init__ .. toctree:: :maxdepth: 2 :caption: Contents: transmission-rpc-7.0.11/docs/torrent.rst000066400000000000000000000011321466121555100202750ustar00rootroot00000000000000Torrent ============================================ .. automodule:: transmission_rpc :no-index: .. autoclass:: Torrent :members: .. autoclass:: Status :members: .. autoclass:: FileStat :members: :undoc-members: :inherited-members: :exclude-members: __init__, __new__ .. autoclass:: Tracker :members: :undoc-members: :inherited-members: :exclude-members: __init__, __new__ .. autoclass:: TrackerStats :members: :undoc-members: :inherited-members: :exclude-members: __init__, __new__ .. toctree:: :maxdepth: 2 :caption: Contents: transmission-rpc-7.0.11/docs/utils.rst000066400000000000000000000006011466121555100177400ustar00rootroot00000000000000.. transmission-rpc documentation master file, created by sphinx-quickstart on Fri Oct 5 09:29:21 2018. You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. Utils ============================================ .. automodule:: transmission_rpc.utils :members: .. toctree:: :maxdepth: 2 :caption: Contents: transmission-rpc-7.0.11/pyproject.toml000066400000000000000000000056211466121555100200410ustar00rootroot00000000000000[build-system] requires = ["setuptools"] build-backend = "setuptools.build_meta" [project] name = "transmission-rpc" version = "7.0.11" description = "Python module that implements the Transmission bittorent client JSON-RPC protocol" authors = [ { name = "trim21", email = "trim21me@gmail.com" }, ] readme = 'README.md' requires-python = "~=3.8" license = { text = 'MIT' } keywords = ['transmission', 'rpc'] classifiers = [ 'Intended Audience :: Developers', 'Development Status :: 4 - Beta', 'License :: OSI Approved :: MIT License', 'Programming Language :: Python :: 3 :: Only', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', 'Programming Language :: Python :: Implementation :: CPython', 'Programming Language :: Python :: Implementation :: PyPy', ] dependencies = [ 'requests~=2.23', 'typing-extensions>=4.5.0', ] [project.urls] Homepage = 'https://github.com/Trim21/transmission-rpc' [project.optional-dependencies] dev = [ # lint 'pre-commit==3.8.0; python_version >= "3.9"', # tests 'yarl==1.9.4', 'pytest==8.3.2', 'pytest-github-actions-annotate-failures==0.2.0', 'coverage==7.6.0', # types 'mypy==1.11.1', 'types-requests==2.32.0.20240712', # docs 'sphinx>=7,<=8; python_version >= "3.9"', 'furo==2024.7.18; python_version >= "3.9"', 'sphinx-copybutton==0.5.2 ; python_version >= "3.9"', 'sphinx-new-tab-link==0.5.2; python_version >= "3.9"', 'sphinx-github-style==1.2.2 ; python_version >= "3.9"', 'sphinx-autobuild==2024.4.16 ; python_version >= "3.9"', ] [tool.pytest.ini_options] addopts = '-rav -Werror' [tool.mypy] python_version = "3.8" strict = true disallow_untyped_defs = true ignore_missing_imports = true warn_return_any = false warn_unused_configs = true show_error_codes = true [tool.black] line-length = 120 target-version = ['py38'] [tool.ruff] target-version = "py38" extend-exclude = ["docs"] line-length = 120 [tool.ruff.lint] select = [ "B", "C", "E", "F", "G", "I", "N", "Q", "S", "W", "BLE", "EXE", "ICN", "INP", "ISC", "NPY", "PD", "PGH", "PIE", "PL", "PT", "PYI", "RET", "RSE", "RUF", "SIM", "SLF", "TCH", "TID", "TRY", "YTT", "UP", "FA100", "FA102" ] extend-fixable = ['UP'] ignore = [ 'PLR0911', 'INP001', 'N806', 'N802', 'N803', 'E501', 'BLE001', 'RUF002', 'S301', 'S314', 'S101', 'N815', 'S104', 'C901', 'ISC003', 'PLR0913', 'RUF001', 'SIM108', 'TCH003', 'RUF003', 'RET504', 'TCH001', 'TRY300', 'TRY003', 'TRY201', 'TRY301', 'PLR0912', 'PLR0915', 'PLR2004', 'PGH003', ] transmission-rpc-7.0.11/tests/000077500000000000000000000000001466121555100162635ustar00rootroot00000000000000transmission-rpc-7.0.11/tests/__init__.py000066400000000000000000000000001466121555100203620ustar00rootroot00000000000000transmission-rpc-7.0.11/tests/conftest.py000066400000000000000000000013351466121555100204640ustar00rootroot00000000000000import os import secrets import pytest from transmission_rpc import LOGGER from transmission_rpc.client import Client HOST = os.getenv("TR_HOST", "127.0.0.1") PORT = int(os.getenv("TR_PORT", "9091")) USER = os.getenv("TR_USER", "admin") PASSWORD = os.getenv("TR_PASSWORD", "password") @pytest.fixture() def tr_client(): LOGGER.setLevel("INFO") with Client(host=HOST, port=PORT, username=USER, password=PASSWORD) as c: for torrent in c.get_torrents(): c.remove_torrent(torrent.id, delete_data=True) yield c for torrent in c.get_torrents(): c.remove_torrent(torrent.id, delete_data=True) @pytest.fixture() def fake_hash_factory(): return lambda: secrets.token_hex(20) transmission-rpc-7.0.11/tests/fixtures/000077500000000000000000000000001466121555100201345ustar00rootroot00000000000000transmission-rpc-7.0.11/tests/fixtures/iso.torrent000066400000000000000000002221541466121555100223530ustar00rootroot00000000000000d8:announce39:http://torrent.ubuntu.com:6969/announce13:announce-listll39:http://torrent.ubuntu.com:6969/announceel44:http://ipv6.torrent.ubuntu.com:6969/announceee7:comment29:Ubuntu CD releases.ubuntu.com13:creation datei1532624126e4:infod6:lengthi1953349632e4:name32:ubuntu-18.04.1-desktop-amd64.iso12:piece lengthi524288e6:pieces74520: -0%ǫXyvc_RLXU>l$̩`灾~)v'>fhkddxPhe)dsY-n)#YjRW,G+T {-m4vvlw ryS^u㤿x xejkpC .y^̿az'=K. xW6IIg-aySئ({'Ҵ>jF|F]-ҟ}ARw@n*KC!\JqF`)Q=eœ&%aIג]Ji7Ad%MDm-ħN™3PSqכ$3j[O9JrGqgnVKzWQpZ+7x?\VG OFN4a70@AD&SC=~qsX83h"澣~UܝFJ'ҳ YBV&Wl bda?t&c,UN?.mkmezi]?m}X:0X[FLwo߯KjI?""Em<]j2cdi4a/qMKţ@Q.,nB^Pc#aux%;Tݎw, 8H](["y>ft%lw  WMmQgf|1c@vW3Yg3EhlԞ[< [++_) 9 N}ox3 !,Ik3yi] 6'OE7ɭgrݍ7)U0\>A0ʖUehV~(GbnlS4:nL3!#OUVf\ɱ詧/7,"g" N"-L~Q\ 2M ))6C۴Ng}\d7WOwP7t3S X r 17x't fԡgX}BY?]-yC0_1ǐ(̴k F|.0&6x\('[rE*fJ\sbVnKgڭiY$b;sWr!tĄf1jZEud~B0>Bt[9XYvEj4)}<.Z9}@i.(WCZ4r]ȡXA6DẢCZtCRmj I0wۨJk_ ?HD!-@'kSõQ\)9I dYi2#Xax#l -xZ0Sa[h;,5^ugfܐsYm:S)'(N l]/؏Qe$FՈqqJJQ)U_:ȇl ?\3ЉD9b#k gIH'M8X 6mhmĬ(WܓI{CdhXqbX[ r&Y״\"& FV5az*4j\%nP(HĞf4pp~}:V C5iNj%!.JU)G-ǁ&0$,*>H!ƫ^h:Pak;"b 2^>!zd,,5θ(v+zrZ(w&(wr+RrΚʬD{lQS;1f# ^Ql-> dUA;x|f?mF#Rқ5Ż)I}q1񵧮 we&~LyW8<DK,跣0iAـś% Hɏ $XP BEfViП3 ɑC+&wr "h9ܧi}ibo4>6JO4T 6Qek[92z™U9m]S>&KtmT_ HyON7b-M(Iړc <̖ Xr} -J m*u& 𻤨-!tl"Óu '5,.Vu?yW{ fq4"x׿ٜiu3;hNξ䘻q|Pm<֏BvT<6 3B5 >Jh 8Sh&X=93PF{) 6JlqRcjtw tR>1 .E,9+/9WKW7%3\C`B5T`'֡q{Ќ˒MO1hȎ#6G@|7PO`ۘ:L0^P nkpk_L.x5{9^rqܥFa#_J߲Q 76OnPY{x# ~DkN%QVB?y "cj߻*Mt)9_~ o>~7Fh+ahbN]DSK1DwӺ0;oJg[4p@qKBX/pgkLq.Q> ?'f.tqz#y&}6MsBB?t$o}%C?F[A ɓ.3_P*/2`b@h4w@aWqoheҞr;`fAGŒ*{Z+2͹Xkn.F}y]y:Ϸti (QM6%|<= o$y/nJ7 QyH^bVBs(cHVlwP>|}xοK;k,&cI2?հ,NloC%; aL<cϧ`Ő`ZoO>QT"YAx+rf0dP5(8w8%"O]gZ*1X`(^xAfaG-ϔR-m(B'5=y<ɿ#$O~??Hׯ]@N7 Nb=(}iFZ: "dG*gV4cK'f":'M<^mI %Ț߲.@)$植]2Fc_O'H"n$lqyQlE~P T> %cz rK-$3n|[u#mwB0Q:^k:Yɮf̺\.FTfRȑgvIY%g><5.+x'[+IcgL@6(K0 $? -qJm%E| i0El;6z9ۿ\)&`#=J|4%Bs{G:i~noT'п5C/)Tg^ElUMTӰ5suNĶs ?o˪ jSONwI礹p u1]㤮+eٔ kx%E1"~"KzS+ZێX1(JO~̈́_p{U|ǙaibZ%3tV͑M9ny_}I"wlns0}|U7d Q )y:J0r I&Am UM!VQ]z=Mzwn'J>5e%q}kyVׂՙȠ0zКK:pVnZY:sxdRާCCM#:R-h6]6%?LBh@J.&Ȩ 8Ϧe4s,PʗRztcxS )xJ8Ժu}8U8f('^CFċ2>{=n'{PZ}%.%ѡ7XZ63*TM@Gn~hs"\yYǞR0(r(鐊!ttQυJԈ1*>G [I?u^tm[pWVՒ T*ADF<ĻU.HĴ,x5^XNa X=B^j di/x&pLWY{ |~ռh5t5a`Sk΀Tڥ?<>yPR^F\B }W@k.9J9`Pݨx& dW܍,cc4-F@k¿J") a~~}ԬVc=m&OkŘq==l?!O8'T{%6Gb3%AP(ucp*MrDLV[Łˉ۹~{Hi%pX|MO|ܼ <hq(ïTx&!1h^C/BaA `"X ӿ5ی7#O>3^Zӯ7Π?YEfnp\Tckʸ9q6ܲձ{7b'ę۰("j 6ɷbQ(gM|Xx*WYss\w("Nn>{\텫I:\I0υJa_Մo"x/ #Ѐeʇy[P%Zo[XkR3|{$<;}H5|sihsIt|6zm!`9QH­*,uU*yoOJ+}F{J)cuxVvTf3di;qk`Yj\KaO8]p-WhK}$IkuAӾrrΞ0ę1X﬘u&V|̵!U(cx[|YP|sZ0rffZ^UEDl:v&uv% Č DNb3Z&^ Ax<ѐ+ GqW|b 5)P`,mOtL)X6)0 7h ߘSL,n|Vκ ߑa.}7lԁ2fA<iB: Jv*քb16>Yj:MD;}9ž;+G$螻V 41Zrt 1k:B:O~]_4]ب?݋ QN!B>_^ x%CUٶ&`gևn۟CR9p34cӡǗM\.4Ft8O#Lp4D~‰+eKGgoe9z78$&Άz;6&1K}7Qa ~>)('TK}j@s%C8$Ze_4:rۻJg2CMm }&WZM8cgA!*k9vZ¼WsjMN i6}83"En7ƻ9)ꔼ0Sg`7.zMAw}_zRhg񰏋${L~Τ2GtTQs"D'Eb|1Kv?T f-ec)%H*Lnxiۛ%mgdԮkF1tw/n~2꿧"S.o3-n-Z#KV )D˺#۷wU&-+&}1rZ%IuRƾj RAGߎ纺ib9,A"tt2Sǯ" zbEMRCCDި儤Gb`lOTV+ [[ūP,BeLUOǵ݄ xͳV ڡ\POWĔ柍1MnǬo[ @ cV>jV쇶.Fky/_ճuZg$sP'ug`ĹJwIsZJRY \ '\Q?>?NB`}ywUAT*FLN;S#/a2\G0Br-9 R Bspv#spۺl 8 h8Fڏµܔ2FYؔ PqR Ъ'h0"֪sQ7SP{OJL ō!D @l}C 5~l&:izՕmY^=UQA~RI583\iggm&BVVѾ4RR1˅ 4땕IeYQqYc$IUAINpl*i0^MC8Pϐ@+ܶvW 1:yp+VZ,-p/a$!b-MtOܠu I%k~ ,B/^){'L"6K[ 3`> B%koUāݺqFJR_9TV2ۘA MliE.W*t?;dsȄ-ήU Q#ݱ:Fƅ]k<+.]=Kcx,!O~~U=dq'֒~1 th!Jq{ ]*"rkK ։j؂헾omN O,uBd:RuVaAM|9}F[]efh—ạZ"*_uR }B,{ֽiK޷<(Z )!'`Qs_#Ho!Tx`v)DEsy`[eAS#3=˝CPS[`'oNįqe`U5[) qSp\+ w)MS챩\Rnz=QCNݾJEE~$\/6luFB5gD^1d%97XtES)sr WDR 7 c{ﰲu1vq$x):[:eJL }?+ѼfG#Wp"wVMP~y`v6 1RV+FcSp6>r'\]z|1la)޶t%yzfW ⓟg3% t_όI3@ ZQPo*TeS*vW8y5#9ʬ;gJP8&Hs2! dS)" '<*߶e4_dɢ0܅azEZ_u=<]alz)<5Tdr2 ˧@? Qܺ( 1ƹi9Ū[gd>D;o׌Vj)yX/:ROͲpnBRq5pB1avt ZÐ[qep r%qxevfq$Ps7$}=ˆK6S5OQa (qk0z PbNP;߈<վe!2 kZ2tBcW /[3Ց۷XRL=pIPBFIq[lF# 12y#xfٞz\{ec?#L*LazxV[%vk^PxǠCY,_4َQ|9-V8Vadw18$8 n h w `Ӌ0f}jQmRi>ԞixoDs oܯdřE[9ǸHoܾL%Ɗcacu2$|pƙ#M @)N5Cym%%H!qr+Cl~  sо8uGׅo?ҧn!b( J-wn󀅧7$b3l$^A(߁ywF+UfHKML+Xd.q:VT @AժX>[׭%UcS-n^/2HP4Zp0RpP ʤNFmwVH&X/hivg؞9~SwGMמD-$x,I҈D՝ eofʶN4K{k$=?dRQp զ6f>bƩ$)$mڤW%,Fˋu%ΕSid8c#n^/zIb/ڷM ؼ™"?Hx̥7Vm'Xg)f:e9 g3wh. BHʃ"/*KH0hZJ3TiCs"SJf(ΡoZ&s߆SS6u ڂɊ/o!nEd145_ %,רAj%q HNp-!8\()>AS7|=,5$A}19}EwmaAS{тHM;w)>ж+YW'e9B9YE/M3˽]|, } \ }@దA:p!=wXWbZeݥ* Zchbs*%}gғ(H/^j*ܢ.yS)j#aԮPg 0 PQD(y<}jGzd³Kq&#J_(5K `JLEtIymC|j4ifJ1}qk\|䲚kLB* .2uI+ַ^) ֙YKf%bY#?5JAT&z= 6G0xoKQA61dD}PH|m󰻱 MAd̪BInE]?fx(bZ&ڃPz(C>RRU+fҌOH ץ= hNBܻy1S /K֕]|V=oWSkܪ }i&Ը}ߝ|VW] NJ$.mqu\/ m' v?%}5ƔE3#JhA`,ءlKI%Ϛp#S$!${(|{Dį,IJd[c<hqk{>nѩ? !g[ng=|T( ~2~EfXQ% dH-'h3s-;pmCeeh,g"s0mX^ e$BψyK dp ?f$XurnՇn^˟ݫafl6us!Q{Ty^#lOb!OdLDQܗm.:mIJْ4clќO||M|OprLGi2>sqZ gzZ7H4rK~YsFւ#wt046ك(O`dgx#%U?ΊZoz: U׳dvdbD~Jw1O <#$ qJ;ADȰ)`mg-b[|sK\0le͛n3xAA/f0!yS*EDY(=ΟZL2=¶Tl%rPDG%Qm3+a d&b߾YߏGYV9n7 -(F+Zo6r%:MAޔBqD0NF3%ɔk7T$!dϩ 270ZXPj4wS*m(-lXJr!ve !e bB%( Y{R>;/lm?WF֣(w݊Wl:96$m58I}*'!u??/}\X4S@ȡRŐ3 ~eO}-j,9`d̝e: GvY")@0sU /-!^K^%Uy!<*O 51R+ajXJ5 ƠӔ/M( C5# ï `r?=d㖂F)Ǘ :& aZNi,zHR$t1T8S 2<{e+/|`_,Xr|LH| pXZN\]",L~K%f x$`[aUJg<i)ϟO~w%rš~2#w 3?3:rSWwBJ]V?ՅU[?FhrLIt/ߙ%,N)ׅwWԇ|۶70&+ ^mcKLCFۂۼLo &EYX>k^H'FuSgGÐ ޺N0/PDC_QƗr76/7yAO dE~mVAI1GL.K\'!= K OٷA}#1& 1!'_}gU&`9RP3 7FEb;1?I Zm«) I>߰a]@.f<<[? /D2xuY ش9Emޢ!8sP# eFI?:jp/}]MeT-#ՑLe顶686k(gsWb={΃~X3-r?k㨇; 6 yE6ɿs_{d C9t05\ [M~-0'y;!+[~Au[ %ݘn-7Uj|5!WrM=oU &P;e_@؆bxesfk0&ǹ6Ba V<:eOg)hap3h}.7'ako5kfb9P0aD7E؈Ƙ`q">w /G؈1axf<%xt %b|e@WyunzFkՅ橌C]Ϯh@tNg`8c`s-^ጀ*H!ph :ZP .?T _LCJn@F!?c[vۨW8fਫ<.0\2 U)A'8tѐqr6d?&==˂#{򐱡nM,E݌^^p oD`yipIOKWY>{QHHM\xK5&7pBΘ(dE j pM[:~lD']sn4;a(lg: Kb9OHH?_1Jٳ%LJ*7s]ܑil]=,VȎJ\.VD/SڪY xY )+E~J͂d>x{?a7+t|-[ؚ.B8?UzTC!&8P?"&'P{rVgzhy˅Mi"9I?%5ܭv{:7't̅Vzh\" J{eŤȜ  d~3k:={=~ U`̬(- o|KY$Yj_p 0d,;d 1ع B(zX3K%ò8L6|V_T뭰 MwE;ܥxqo?ܱ'- z클<5(#*.7$e7UL,f=C2 \v?j)jP42C#*$\i'Š x^״[6keZbkgFRrU5LS bDfPqUMI&@&eFRK"ϋǝwOE՞ bEeM?:x~ஜ0sU+N{'cHud:#m@*6n_9Y&&8rU" &$UZz5vKVy~'.S۶Ƨ56FJNLz:X!7;_z _X,"л/mTLqF*Qzb}KN. _g&KZPধփ5˭iRme3 WZxۥ1V#;?[tTXUQRۭ)Qkap4ko{Iz $_ h$e'{QDr ]y@}q© bq{@n˲v9vEݼK ޯ&b1D}\xLW/>[a6vԇ[eD\q&RQ'F)Mkn:>Q e yylHkj]"'g(#F/Z΄M4} NKEuū%ŷj|#T-a=~V9h|mp4@Yz㺴 )DvSl jE Z@~1)x"qkWpm؆?h{0{MBGz׿r4To ZF Ӂ;*NgxYbd(wU'R{ЌiGI:^bƆoi|Qq+ 볤gUAp.HBhus1X2[fiwl8wd ؞ _;}͊$YŰPzT\Zrqa߁4J(:0@գH\-5H^~DLRG<CHԌ9`)Wi2~/=Ĉ՟?D V:S)8N} :lm.t ه˃AJ.PE(rݿ%A!N+÷a^˔aIl"RnvSFNoW\nrx*@f=R*VCA*$g3u9Uɥ쌃FYSE4].gjxA'؛ldFe/IP^zs7$>ߛ] |BP^J%fb^SGVÿ_o4e; FQ9tL폃Hu_fUBo`t}55 }"XT=4YmiECS<DSDx8N)n@27 g)ǣITP]J0?b7b_K{DDG]TSu08l|MNIU@dտ/-fG˿HT(4cLdq8ΛWw]n uFHH3QziH'JeLQzŏGݿj agJ7.2h9B³"ΕcB=2,"ܭM@Xr-<֯d iƠ۰Lh*yL}P/oi̎yc~wbc93c[KQTqk㈃jGٍo+'Tl6퀆q?t3㵬Ӹ=Y$~@]ӮK uڜص' xÄqv_O~Xpo*"D&q{A`-}K@Lh~Ls>pVsD-<)]?#K| $0<ۮF189?\>';B.J(Ieܯ7SUCsy&J˵,֎yāxe1W]Դ1i495 n8$l:: ex_ :]@(twRR:\E ԇ=9~'S]̝.Z5 wM |ɡgs5o$Fȯ`YPiL1ތ)ڴʹӟg.!҂qYl1[%fEM:5V;5kH7o s1X] g/@FrrܗQp2W?묱 7CqU3k05*]1U{%~c Ȇ :H!7B`Q(md,۪[/̿e[Pben?3aD:7+ ~4{d6 :eDf8FexV{B)&f-R7š&4?H{M=(.mL;h0k e_iԃui:Sun{ DpFRz*3h/rZe#ǣ{<"%+MijrK+?I =o[.E*L:ֵvs\j-(E52o 9l h"3Q.wQ96œS4m)g 85guB * yLX"<)[9Uk9,gڄQ0]9Š.GF@E2t~$L Ԯh~41Q>%V.Xf^]+D2Ǭqg1JFXg{hdAȼ }їWvzɰ}9pOP%1ZKRb'_C eV{3n9WZ0K# յT='|ղ/{~QW= нU`lS-Ynꆀ y̤/ 8unC?:LSy(c;/ MP8>&PȮE֢u|M۱n: 5f\/ok'IH%UZ^I6%Bo|Py>7^l6?Atoiґgr2!Ēp BrN|8*SB28#/e4B;  -J"n_ŕOAǟ}6 b_p Ѕ3ؖ]d) 2 L({I09(4ax7Տ aּ%Sis%k74/ҺX&-%fMLڀɷ/{e{|*.-PA3fbU ]O1q%җF+m|gyFik#l7?_4636 +qOȓ}P<|j1Й5iٻPwɛ#?տi0DAy̱p[ƌs>.->VUgEiUv:O}ˁ6ΪxUf1 [~c:ɗ(\<[F?jD}x1 wpoB6J6>`fz fU)eNW_Ə)l22lRA2&g՟C2u6;$^mӾqo # 5r!z1ZPNAYvSkZ$׶l6v16ⱦ݂Ėfp=A׾?K'NĜ6 ) l uJBx^Võⶋ|x _289 zN#Fc|u.)pmOzx{Fy`gd+)5[G|QcKm=\=*L)fUEzASDB8q9+$}O"Vpj3Da#֟"huխqZJ*t )˵w̠iQSލx3l֢Ǵ$H+X"DW!kUeޱ4M>RUbF ŶRwi",̼xy}:_-~rP%2`8riHk"Z'l 1t4- ߤee^7+8Zo<wZM: ]FoyeNCH3@1O ex i:N)Bpn*@e-a]䇻+tg)\e@YBY< H8:s.31o ߊPD@ i?ʹ%G<98瀀3UcIć))! {:oZѤCODt#eFsǕPLJEff)cTTB+lYH; PHK%}\O e ~MëLZcѩbȱ> (tB!wEpf_H@_bv^F҇>5w'5U._q&K Ou_cqzqŃwk B#3arXӓJNJyD%њE76A‹lL [;WNaGR& |*13wضpfY R[}#jO?h?[1=8k1C>ͭ(:pPz3=陌[Xi/)t"a’Yt0vHUbV{`uX]+ykrЩ^B 8yQ ( !c'1tS=2H\"/wQ^;AIxgfS_ҫ=":B7u}:n2G{O%޺yoD0*Qx)PTZAXjek$/JwFp ė)3&>kO/~;bAؔg_ͣ ,2ȁF!Gki+ 0֕$p(h*th/\uwȹH$}XBbatU:z ܚftx~9PLC*ҿ+A]3ⓘ~mߡ^RKq46h^ru|éSx܆L=2AfY/F0uPq.~ISR+&APyNE#[*S)Zd9.`"8+F60 6k,A֊p~zʲqSyP;\pF+b(=#wS(Wо+Á,GR,in ؐPRɈ'iѼ"ZM JSd}'ӕ!al>L4XGdutLܿr@^P&P<{B3p[xJWd]^s,AEǮBaA0h ]1-8 H'm-B]z V%78-rB&jK b uOc:ƨ0sv9C$bn!Tߕa+gk_ۈ90mrtW@_~9b PIINr`QbGC&Ff܊?JzρnpE (%eMu2‹լ )UX]͛$o5HmVh1wLwnd24PgΡm+}H8b0khz,/8SA8S@Pڍw:(BI >'c$4cgIO#WM-\fĄcy /3AXUU%>E1v$O<o Eb*מNŢhZY:7I+ke(7V Tྖ0 'n`w81[gFl5&9)MUtg]e{.RvSX C_WVgrIVWo<‰sd֛*9aj Bmn !:C>a>(Oo^L>' q+F.$V-%Z|t K82xIj6<)>ե ( :8;o'o5ܥ03CwV R42D2-)H<(ĕǍ wi]߯6%2-C6Ś蝨HV,ez#YD2Bk. & f$<>\iK\Y膻:w(2^brtE!:g>  ߽-hIu6b.0Xq#&ΚM>a24&@YM~|p/»TvM*@"2sK gnqj<%y+`LRƉ1W.L&*A*.n5* “CQL^!BB!Vs2y-~Zf+Cee42p|2"#Π}v$mQRLVy2;S?㤊Z)\-m]Py+Ц!q;yC0we7yGݗ˯}A=duH˞=$ڝ[ׁ? .0.@i3֝;[d#V|^%ָ~>`aJgBi3 xO YDxR, hAtCbl+ɶH ى ׻v N$Չ]1q#NEh/9df@ VO!{KES5UkC&ˢQЙs W/WXT il=UG0bYmJ52Q-'".(ZO%i0<&\1і =j*o/ (`Fӫc[062LbNSXװsq)yMA:zL\d\0*>Z2&Y>Q s G쇠{@:qwCoFUIXT*^AI[GG(k**%ͺ 2G#)0LWH*eátoH1T]w,d~"nOBQb ڼ,Tz"{)pRXm,'gLxԇ*c/P/W /܀]U^L;)pAa׹e8pm֋H~+DO3!WDG_L+ Ѱx ]+A}Uמ6|N^DNgsЮ=JSE&+ , |Uůo9KOmIpVneM`!G P‘gNɀ{UH!(J4)L@}=^!AxJo/zYvTOX9.«qf2}_#Vp܇ àWWy=y PLpI l1K-& 6݀[6 ᖅ[UxrI ~ jnYXCeG^hC? PlEӝ'vἱp2(mO #;FK%gSZHj#ķ<+GW;Ϯ Q9YڵFmHZgJk֐ ,I?? JG* f'?"mQNܞMȞiJm+̽JjtL-8{T^lH{oB)7>ѳu}64w$Q"P`n1փ:_8jtoٓ]gyrK,R Qk_HkrB  1/cGh311 'NrhpOA>T,. 2U]6Ij:,yGq8X:o:9ه=!xOy`˶2$>ݍf$[_.>EU֧l6DwAzg;[3(@cڹl4^7} _ i$RC!,`ڛ8S/<"|wz) xڣx$܉A-:cBϟNiܰ vEUWyY*b\8ڝ1*yM"ΛFWkATB34zrPB+4V2 F R--X,.$̛+TsV SAWB(RqWq<ҟBxC,S gEwөfƞo6>Յ0]uTOn1yQTa],J1]&y}[+(Td0aQV;񍈖iGCzr )miò 8T*"*Sj|dz;N әDZu@w1 .bUzpFH7`Z]/x~r{D8FyX:2g֗!@$g5$΍\0v#3 WmV ^m!6BJtcQ: :I1ō3\vwUGLTY+CFn#ՍŶؠbuFFSBsYsPϤ,=;],z'W_iIdWq1QnǖW4!.y>(<@=` q~")7O8p[(YK)iL/x9=7ɸ<{W }Loj&y.3eŠ{s,4/ꅛPѱ=2%s˅Hv{Q"(jͲ׈ix_kV> vwFEMtwtKRV.p-B: Y  | v= 2g&ԑLI:)^NOnWcUWjOL6h$4 !Y`_O1)މmjTy}F@8 ?]A 7APTV6 9 Vï /Cןem $(:XY  ⑸4["a&Gb.٠֘+{ HY Zu1DKtp^!Wܚ7[J.<|YSʠ3q `xH܂C)"Ckf_E Wϗ22)ȇac=Nu,b}ZD I$A@J\/̄|K)v|A1-К[d"81E9'J'HOȩ,Hou̾R- [mr\*q VAfO$ ҀQ:i`Mx V "F^ܼ_>>+6 4}ka]gmqGx;iC#4H:]'4ՏN럘 rv`>|BԮHS@;T*ASvs5% h&a_ b|95t]ӇuFgjqb( CG;ƪD{2R{1 in?CX!oNb^lXdo ZoU]^)󇹎-=<?n4)Wl\-%kʬ$#SS*eUi7At#n*(ČwήFO=.H+ r$~j,W#|0 ۚE7ZT-b$ Lメ YFE.f@-׃VFP;Ms@Nw)8`Ev,XsU: 4PTQ8O|Ǒ!z9GZO};&*wE,PN_ǒ_0Ҍ? : 2H}[F? TȀ2rKJM-S {W O[A1~ \|. O_i4&1rrHB^<N fv(2套 D<.D݋ ui: k]y<.@[}-CQA !?tj>EYɸ5]d 吏AQo` mC"DX4BZh +`&V4~qO` -xF"τ}vQ{zZLʠD`ZMԬ_b~ 0Q0z?`ʭ"p( l-eyssykjium~ylUlB8@_R+ 0>'ZG3Y42kZT_50ze d$$TꕙƿX,f2RLj.9$ꀲ*w@@`Ð:qJ20-s 2/+:ZE(nBޢmݭ ,/(slbZ͋+BI%/^>fO5%z{qCם_)u_ҩ.Fvvq+pkV~Yr?yҹiҫ 3/P RV0;ɸ htdne!a;@23s / ZF0<}?MӠmPGMjKM&(2>Y u4\՜\̓OH_&,FQG?ʠmQ2" Jk8t p']m L5.^í<vWc҅˃†g9p <!/**qqm DQJyWzH<шԱ{\=f'Ԓs-c%?gbxI3&7p]0P<*VO/ì{D0zʴ1-#-| (:gzBpmčf'azEXIMGN[Y3fZoŌJQE׆ehBΛ`z8ɋ-78ei0x5M- ~O:.Ԏz% R,Un_IHբ^= z>գ%??Q3qFcRJ5ONǭxdꪧ(Gu:^j&XpmtH"s}NC|Iհ5"WOφkO":ʡl׺dmmEuh\Hr}ϾЧ9ӬA&rHe͞+;fx wBqejN|uܷq=2m!zJDX0(Ȟ8w%U(]ЭfxgKᤅ;NmWœuVl61(_%kjS뉋6o㬰=D~O2GٰC5Q;E0vDŽ&9pk|Rk?x_C3i>k6XUn6̕lrKx>QJrj.BLU'㈖&OcR^ϼI(#~>VX5D'K˵#SϬ4x|ܹ|$8zI,6te-iM`VyJmH h{LOxB{U^T8Mj;[Q1q }[gq1F}hRs7Lu':>Wu80}J(oB3Ynwh_—+~C!D I7`5*.&w4B|wq>c꒘F}2b=r+gd͇/ݝ]cEOYPmgpG38Ea_ sNaT93rK{ ݐEխa2o6`%Ccq3D@ծ/!ӝSF=;{C:oxxBDBuEXPB:k $j1DYlX%uWY])rUsܵ0/0=R΢%=ݏᴹiD?j,mԾNW2V[?Xbs%L˳򂶝?H5ɽ+HǬ밠iL$jV Y$GG pyzr855]L,~Px'5:0wP~G=wAaΐEyi/Sl.EW]Le:RY~fqOcWVҠ'wf闆!^f!O)_y{Slr 8 5A Q0.FQťJ L7r>X 0s-RCa#WgNS[{x-e8|x&GJejB{z\ZOH1w$Ů= 5!͆NtR]Pg1w*񬉛}(JvJSF?'`rՇwf(e-R'TxxqL@wWD:N:Ft39Y_~$g@>4kQ ʞG+cLg:bp]O|R;BjeXb>PxwH`-tݬ*p@gC@s*񥀿0Jg Õ0C&ն|:خ[sơrlLM,^P6qjDL6 ZFV|{E@IS_pX@I%r(_N}alv]N8pa|aNLdl=$D6v=fdy|-O'06{b 0"*0ӅD_@ 6 8RT&r;"Vr @M5e, &:+Ȗ{P\X'A9B Hpwyvg{KA܈nN*Fw!-1Asc@fJ>|\oNlRus?\J~007;[|&(+ׇI[(Q~>t1*DМuDfaoWlkFl|qEPԹ{N;{6==R #j :鄯*%E}B!`.J,t*$y8ƘaoA !Hh~p51=G|$&j&uYvST"T9x³p^Ng-X^G-;juoV[ܔ)vDBndU*_TƳV,&X>F_keFu5~bw D6cm;Xn95Gzdy!v{ݳZ΂fKGa% zYf uu6 AIh^`>{Jt~P"C7:|&j_4mrƱKqoǎ g"~Bjz@॥'ʿ[Q=tFNcii _?X*&u.^s 'adGkc+}PA]AvꂤZ11" oBw-C}Pc|$I`=Oqs Vk] !~an+>@2@BO,":G8jlԯ`S,6ӌW?~\R~5iOiiJY.y4wll A}628nQs>v 3)z 4}O6LkEGSxco%Peos4 Bj0nG_[U"ESVj5aҺcВݸ};AbIu=)B;wwLGFn=ƨ >/ks{SSҜw92&JD_:I$ Oʂ6[+ָss #KlM'W3烞tsE$5FtMlff߶N>Vf8)lMt2UWPOZ}}AYAhN ֙؁W4.dz '-2jx˨ZzT7NO3@d~ ZMMFT/ JWi0UyH Jbi1EoSAVE/Nn:8Ũu(C ߠԅAg4}k734gSʴ\p,/q7-"ozuֻL;P FJD~tЯbYlHE9@:ޗ3_'5ot,/\p c3ܿ-@kjstdk|:Qm1SsaIob8@=H0ST]֬bRnPⵇx]4>6J7Du :sK9X)ŃL 3BWOEk!IS]5Iݬ%@}e>x;#&]} jD{~ Iz6[ /;Q_c`,x[FXRu.MJ{6gD3E PO]vN\C-9lb0BR0 D\YD:~'BH@ )\8QN;˷< `aFC|sWNsSEj?v& ),_,H r;=S'ۥUv|q;Qr;)}CKXFݏXʸ\}l/724ŧɒ> ^$m|Sp$w yYRu^tBm9τi݁W5T5E@q ]08Dr/woj6uUO&i(\nC8v& X$[w+zi5rFT. ":r"PD'VƢ1}+WAy XRRǑo*)I{&w~ha/])WБT;2Wj&92+7vGr`Ȯp3LU  NVLNp{"S5۠M% yΥmc?Z#8S4y}dk 6KM8NOqljLΌqconZk3hSA[rPWL[ nEDR7юkP܆ayAW ZVϊӪ#9!JJ)Wt4ZNfHe3r\P^mOu%AXq}%[JLhQ'ԯ7+Sɏ@wJ3>nW(;CKUBҏj`_zƸrb7q5UA)c7<])9ԕ;}e;$ 8g(췚F&l| ; _ En2&ZX$c0C=dXh12{jѨ w,yAp*ҽܒ4@ ߿#Db`RGds#]_)|%7s>-);n(kj2qKt.y_~x@8[^+~tʠ0(/͍^y݀>C/w,Of=qMK#s} ' 2 ?atUGu֓HdȔOwLdIC Qb̢B;_"܇=b) mN >PzSZ«],J&X^Ib.(lUip$ f,ٔj-vv|V 8grF6&F#6Gg:± 9ߩZ~kQ5`TM[ݿkmSf c4ݑp󤂔䭒Ľ]GF,<h=/ϊVe'X\{ojKI3Y3 u5F ,JrF2' DLAbhm\ ɲa+aݘ)GU0tͪ,)MQ]w'U8ԻGN7qjcj4/H^D{s$ ۼ^\ڏ EϔnĄ]T=>coC@C5nɌ<(AtyGn̸,uÿ/SAy>$XՆ n."S1Gat<(4^$\x7R cYT0 4olAJ:ٮY%fPXCWmfA GwßD  X^y:<ةo'HD6SO\ce[ϯ1H[8^)eH8(Aw\u7LJ'&ykl`zu J/4y%i$JzrqΟ%I_q8&hL>W{Xgq_LJ%[` =_@[M4|--q9; }@9mn("[Ź'Yi=0-!2#ЩSem0l& jnm4FjB|!&y. \&* @٩J>n4[6V # ,ist %`E-*S7hŅ\B]W(HTF3h82uAG'uf{pXJ/9)iVy5yɋ`EsTETqͻB([A:O3 _yu'5"aS[P7ouؘmVb6FLaۓՏ|,+`IYb- . Btq !\Ge&s6zP2+pHM$w;8 ι nx@ΙZø$$|uk h1ȋ|#]BXX f**Kt't)(PBևi0G ( A AcD_?`sfL3u^DF |xK'!,]匕wOisy: }=Yt ɀ.U-pShR vkYVJI o/yT-!|yp-_)fU@)7.891o+/K*p\y!UYrNS\ԎP 5{L6õԪ(X#aY(Wc2(#4c9-櫃IxRX)cW {!:yι+[$'3o7/% 8" 3k]Iqm9ē XŬSS2L$*R^WBk^r]knݵ$Uc_1&Y,];DDi.WBmn9pX!ބ;Hv#Wx\M"bt\Xi$@w=18琢-~DA@p,aDx>407fN.֝\-ċ:L)ՌUbṍЪJj3, w)0 %9%%ō>"P<ŵ"4zJBd¤ H_~uiy2. n훲dV4 bW[=Ϣv=6OSg[I&vY2Kljl6𸵒<53^,eE&]%ԍ/Zd8rb7gU00 >GrskT,{;LscD 0IzJ1r: \ΝW!] wYBn&frXtP2M$x"[aOq|[Ypõm3.6]a~|UdޘN42`(WU,؊T?:T͒umwev5ۂs(έVQh#$/Ę>Mb(0#C6, u w= .R5lniHeF+͕`tg6+ s͓?0 Q~χ'*2P0ږ <^ILYo4r[ƼDJ]b=?t4;aC'8+ovqMc@ KPvwd_m) ׶t6s?myBBSZ+:^ҖJfDbGf_ҟզ+'Pxf!**\(LP kI3CYi.g`!Ev7ZLTE:M}?0xhXs, n~,dU}Ou Ӌ(.UI:%ܰ>RK[KXxZ+v4^}0E2;:V>#6NÒ޾mı]Mpfd˜!xӛmT'g鶓#(STj6qmjp%YΰEP.oTw+Lh|ϻ[2q5+А!Pv';]%P"9>_ՁW}'|Zx4mV$BLؓKҍ|N'.Z`kyP {ʗ~D6K*gWΩ@fz~Sh%sarߍhls JO ;:' ب&%bo_ lN Ӻ|{暽-ߣ`氆w:93`NIp⑘B$h7lbQl55E/FЪT}%te&zZEM`ȠC|p&k6'Uf %X1\koS%IsB;5|J +l\8Dž۠SQZ߿VSJ:, ;(.9;pE;v/?Jx)plkw3=.(!*2J3E 5wv`یOR ^U2I]zF\lQ̴:UѪ;EUi6~c{a|'(D"i/%>!*'Tiq͌TVw䩩G:F$el먽<3c3r.88O;\0T*L!}@#0uT[AV ʇ<jyzڷp#_=5r>w?=sNd .s}kم$C&QVH n p&uŇ9.B~7?_\bʔHEEh{Ho-jG>SM' ܂8,=V}:*:<07+2U4ɱJ⁇Y>M_4 9BB.б|%\X+N :0D'`n=opI+Zxsʯl풅Z-H%Ԍ#m8ӏ9$Q$a, WWPϛd ?z|el}IjrgL ;Cd.Q;C!J }:v-5qo4r0Ovׯap׷ `(A93VC`$ԿLLB:TH3%l";ھxۜpԹSWso@ R|El|J?%,ņ֢k-xaém ,a[{"vblB+2ʾC4^uRug0%;Bt%>4iyg! 8{3cwǐ2³֍KC gqM.С3p #:G|֎\PFFL"q-Jܘ<$C~р)m CMc뜦0ݎ>0gi<.#z3t{H.%(Fz]YcC_Qx΋mXY M]pG璑[:-~IU_O|%IVS@#XS<ЇH^LM#jNd>|*]?@UIA[V]ẖz=$Fnޓids®~p`MyJߵ2c&DK0#K@Si"NC׷ocfKe]SnFҵ ^]13tVP7Jk9Ag֠`H dG 駊n\GijEix= h) bcޠO!5_Dqn}smox3 ӖA`*@}nD3/6͹ AXO3%q-qm=C^W_9mgy4DOT< z b|5ʊsi˰: (|ڐ%Q[/^Ѐc{# $gE}/u>(^U($ߏ[z/+,C1JqW Z_ L h1zpߙB9];9%ҥnN6_2ɾB6/{&ybm7Tf.h?v*/,.Z6 +Ӗp%ڸf(^F5N#,J>ݲD4&-o(oKwOOc[HIv't\9PPY99LJ5@jȗZ*DO+Ry 5T%d|?.[4*!gIeBPz=_bIg8[msskl+"`QWtƮ@F 2ØEWSw/J@wP覣f!QP's8T ho!;'ػHV*ݷrCeh \1)SwλHfŲzvnP1z˽v"()Ww" |:iB}$q*!FK;*3&G9qi EPc`7zYw_8rw~6N21ZI FΡ$7PN9R[m0 -( ޒN?0%KD*BL! =g1dJ/)dx:=DD*Щ?H O$a`ڣ:Uu9'y cb=H?$iՃCFSsܭא4 > xvp]3cюC[LrYWp3.7b¯-leO4D檣0/w9{yn 5B2Wޢ+Dcޛ )dM@O;rpv.1lUqk{`y=x}c>ej*DU̢&t0J2nT`9: [Yzz22$P=u\U/4p$(jώﳁ&."ULwofrcPLA)x{;KSIyA%C"r["|{UC!MEm:#$<&R$cw;(~x/®rBWźug'  /˚Bvȍp8f^iN vBi*==xi.*X;ѩ{ҠQ%6Yns߆`K'  N41/5[-8:RߘӓſUQTZwSDeӧihH]L, -IQ߶g[rMQ]+VU}}PflHb9,-ߤM891gp|<Tfm>. Ѩ&PF ΆOwP%H?Ǡk9w' kR:#@.g/".ttWpMW9(~W+f(_5".~! 9jaQ (,5daiVpZ< )j=xB: ^veCǂ4)EE2Ro.WpKg{^%fpݗP õzQd$0e ^wֺvz?N3 $$}{>my>\p,?,!Z'KE24sR[l38\< @y!@T|4yhHzCa Xc'}?bGفpgx]͒O`v. kvxB]&Ahc~c)3(}4)X kOy$HfoM`RwY+QؼAx˘)2qNq5W3kVI\: 1`~`<K*6d#)Saʇ5NY`>@vC3,Z[Gej }&LVh3/_wWyF0̛X:|2cR_< ~WC$)~@tZ^/5"xTq&"h3]dJk<(DW޿$UD&t[;8 ŃA_(wHؽq|dYcs(k)Mn,eD@A130 .2u H 󀎏ridYRhZyYKJD"`$ |_/Z#\W Ć]1bD&B~Q:ز !ȶa d~vT>r^Q.cO>z,^C p[-¤8Ogؘv)vIEt_/ØPލTJ?<-Ƿ%tT7dQQ5>#Q=ɺ|BOn~-c%\+'iWkgjz;(<;d̸a?a*v9,$}Fp[T7l%^@skR}JMz"'N"o㧾n PzqsCDA'5] y޳r8@cM(ܚ7BK-ƞ4DGj_;=5 5C naQkUDTKa1~K0Pg>sU{P懄QR>x>,ϻj YM٨ˀ}gI@Q%A %@4PPc3o J/@8  0[y{dEMZU٩UE0pXQilG 5Kre쏌,+dڄ#Fцp cfנo% rhm*ۚR( UtRUl3OIWNm♓ڵn2 `m=ФcfYD z4Cp&i 1Ed,rF4gX)d5h` W+̰lI4l DN1It8mPH jh$Q+L8_HQM~Rf~bn9|%0\Po-quVG`h́KP1P&Nח, 2.tK&ԷC'jhpYWEgS:OBs-?'q!ӸmyCG~Կ&e></2PS5k.D]ܡ,%Ui0aR^-\)֏Po)voGvEḮ㬥q0;>ҚwEFlD?pK xy?(#$:hYkN4B|)Ś.~ʣn%( ƂH|ATʉ~>9dԢmY./IȉwI [Uv@GYjt Q~jϞ AhL_;Dg3-?Mc|j1iP iTp5Ko q[q_j1U^)E& 7k^G@<<4w6MWZ$@4{7sn1CY6u_ "9>"G^XH4`& J&o㦒{d^0ToC=ejYZڐ~bS:W:7l[IJ'2ew`ץdd\<@Ty/mG2N@)js^0]ޯ<ؙ#XgS!) bg zLh|^7(Y CmN&е<i6۪gM nmiU?F*[|[9 tznbHSr@n t5`=*:4}27 x#[^C5pVcPݻ(Q"%`iaǥem]x_Cd*=cyߣa3w5Pw31ˑM?+~g8oJ<4֜ ܟvQl?q]]qehf*,:mٕܧPcd?xw `ÞkE4qwH`ts)څ,qyS~J!+юXF"ʎ G*o_ⵃ={>2zR2'+Z3 3iVsw`5VQC(ݬ1\1Ow# w7f`{>/v=Q=kLJ Tocv Ua)kpgoZx !moc޶geuZX56RaIR])+t萃Z$'6E؅rit}TAG[AkG8ݷ| hVp/ت sK!7_޼g}ԕ+c/~̡=gI/ 4۽( /tHt|hY6+A]8p ScOWdlד* D`Vթߜ~؞!6Th 裍XZIRi!فu5[?q!Χnnt$]".kxibwH$S֭n5Y5,B5ZjMKؤ/"ݹa[a*d N9Z뾟LfMnGnF)HYGk8un{ "e?%Ԉ烾 Z245;4&49K}9#/RRkz 3Pnk,^i'!xq|!x`EZKQG ml])mTc%'EBWoa8-#1 HMt\|xA9͝ks\c1٦yDpgqmYV_$ $*+0&Ǚ0OfSXK+'!a~8|'וI3Lf']0D r dpMlxa^؋|W6 g{'~ƨ9kWP޺kJuJ{Bb;,Zs3Ee{jJٟMN؄weثFbO{g*Ĺ# Z,BKe TB.8'A jÓ%&VL55U19WQ#H@16jQ:;D`NoЃkn=g -Q9*nN")I}6H˜8 S\7bRzsoIz:Q@R1HF66N( 8VLWEf'4m: rLf;AAfPTjVDi'2 B_t[?@Vm?jo+np6D㿃 wtgs)[Vփ__5D)0ԅZӨf.4_7^5CͳcR qЊԴ 5H'cbw]tgYO>=@ZAfB oY26k2KGB ܽtZBg Oд8ɴA4w:L m(bAl,>AQJR\Q8.Do-r\,nxѕCnbcNݘ=X 4t9{qo q?lDdlƛ a@q/5\W# PRJ9+y \;}9^5ꋃ[HKkK;[Nca[)d ZMz)di&lM># Q,*= 6(• z!2 z23MOo{C 0whQz3A^$9;|vb&(4ٟeVPPrT{ԃiZ ];}…A0(j˂V4rp2ΙTAd+YbORGzwbi#SŒ,FǂmxId*j(0Sc6A`(8mtml3=aM#!426x u\rh{3uA;ߖw ]VpݍkN Gd]%Ǖ?h :YÅ.Tv8:Y6@]-ݻ@`PgXQ$03L(FA@OT.O(sތ.i-F+qMhnwFծEKؒݍ{"+ _xՓ$#N위(Le &wx;ioM1Tr8@:f/S+[rb-?u9YzFޔ x@M4 dqlWu"B||mBӍhH@ hYI0'SȎy&#Kj (`JS_\?^tLJŬEo טϳ>X|AxW 8p$qc%e-"Ebr7l8k@FA\Ipb4əa$Ν,vo}iq@=͡`;Z~ ن\ȇ^z`Y6-@Q7lV42~WI`M)Ɋb l%qa"֔⍣R= 4dJf0cIEnc/]yZ28CCz\koX E őŔ~/YBW{A#h|6:>ˤ=ꇉQ'5o_i;8!9t$- Pũԥ4f{!,'57) ''Q?I4Ԁ ; + cg99ܚ12pWuW+Zd"O]ۿ)m)yvb6}|_>.GTl7Y(N@ҁd MVyjXVOjkDk" fF/ƒ~8Rԟuؑ;䮦d;mꐘ~ݰd8BԨ!O@*M(( #hU/[1ϪSn36Is)I"3l/VH8MS_D2Wװ*/,[7bF="3xNrT*xwCh:HXf.'x̺=OK+W?6A bXUͣ 'kөm J &l3u/[+=z (^bXʩmJ?5i">bBgYbە =bZRlGhW%v'ga&N4{Jf6tuTSG<%na=c!EQq;iىBg`JPEt%\R|_K? iȲhhgF{3ez}EU0=P=vAƛ.-tw ٹf䀋r2a"8W=SUȿ9-jݸ(" 'ane4\dIڲ5LՄ-J5'mrW.Cl Is,:x}nq5u]HR^fvLtC6Xj6y߶ *ɽ@{Ho: Wխ!'=aah0^8S\O7 [C8)wЊa4ҡeV!E[5ȘNs}YMt,[lǻ PW1m; τCDn8kYY?sjxETbUۜ]ngp Max'N Fr#J̸E#01'JU[Kw O~K9(w-V߈:sukrmEGtzXNo;xJ7XdI- YkHXQ:2aܪjag {RLBts>nĶ0i}/`Ed1Ii{{j/N|fp.HJ ܀ D .,װ쭆K¶1%n!W =F)J6` T'T )EYnEI=RH< bKX-x::ZD ԝ*RY6g~YjV?S /+#tvP ZѲxA^1] ),a< !sT҄ь8='W|8ja=MÓ1tJK;]Fqa` MlNO^:J[F! {HTK):w~S`0ZWF%xtixlrTMݎOB \q IO.nEE^aB+CDGp]I?HX )[zL*HPNp#V*q]:Y"9ER>>#VÐ T߲ ߧ]ImiSJ"a!8HG-RNBmYJ?Y}qxar}%ќP +&9ME;øxPuK^5B>-z" TURQQAG$veY٪V\GOG rz&FIJi/d yCDCgF?(K5^[BsGׂj-,R2m_~5b E{O!ZT:ΤIr$j7YX_`SCjTgJ±@zJ4 M̟hp]񮗅r]foqԹ"$r{h6( at§JYV-bm\UΞRkT@`jn'.] ֯A1hpH ה%YUzCM~RP,} X%1YLL(-4 Oc07X>1gA ^v jF{9{3TsBp6Jg*Z*ܱ,|#)Đ~[e}6]c"Kf HS13;!KUX2nH. L-s9~Lf; Mf ]Y ~奓^tH/GX 'q/?G 1Z8h%.b".g8y/Cp2Ķo82,T@Ӧ"h">$|~a3y̲ty0r\#*I|!Cd6ոGiq<],,Q-u !]dc'2谦۷]BR: 3_6# U=D-qo<Ҳl%Qx}.X݁V 4m^CqFI(Z$>v)m+Hf7EN3FͰ}ũqIC)H9(s\QHR; ∨aJVQ|cGWZyi.{N)Zd5uWXwkGLDM3.4G* ?$퓍Jtbu[ŘOfIɣ4~MBl:㞚l7jS&@vMpw-;Kagu.AƈeV+)Fb*  w$!-H5vaK)l0遀Y}=oJ 3}p,pKLnɔ.@+9**` \p'r oBL Ji \sԓxW*ӳ,`´3 &}nw_R QofKMiE~kF|{H22) S<<Qȏ<ĜO3lQm( u\kFn 6?@lcX<#/fBQm]8HA:o[|~Bv!2$74 ! `\̉}f(;W+A|ť:fE$w.?V;HFJi<;Z ncqgAݷ&R6kp KyHλqS9@V)e3#۸ ekgx2eҒR|jȻHӜUA_sL{z*^ϩ{͞nb2'~äwt Ib8h>:;}l{{u_ a+esmPt525TӝtM4 7Ih~a0n+ 7VtSs Da.מ G6kVe svJ? $gŸKU^fWٖ߫AҖ4WhD/c*1к{1(wiMqbJņ4N@eɾSVEVksO]RL9b|_§Iz~ܜ;x~ƫS` 2 ǾB^y(}FrZkht#DPX7I vJ]&CS푒~)7t;\5𪰫N5X`öF2襴ǂCrSoj&wBSy Zv}:aȼ< .\(55Vsu*P#+hLCZsˎy+],869<->jXP'i _?7ٳ0wd#Rl6+S ?z{m_a6۶ x+Gul^.3B ottL9EJ)+XH $ɍMHvău8t[]νׂTTсDu?tΐ Ȃj(ˢ_Kޮ{{sUnтLgUĚ;=Rgw)~VRA @!I S'0v7Q%@1ny̅i7pC[mjCPc7V~ĆBǕK =[9I-RۄʒV|k#0>6s5胳 돼ԗ-}߳NdJ nQd,!7qe؀kZџvPEC)d3cf4L+% 2^Z>ژad$I/?sfɖ[$fH(LeFyi?5t? w$ 8XGl.bVIUc lAgʄ2_s!E#_u[~ЦjcЦ@[9pCȭnP ޥfnFMgRwH("dE:27oW鹈rmAzǃO'uol4#WT MiGN" ̱鍶M; Cwq6w0M˵ h 'Vq!ωc꟨9Vs/iAU/aqmw=IaltkVwC}3Y@*4c6rzK `36=>lq/6;c/&f2'맴d]/*EȔ74Sz&'▼Z!"5&b:R"Y3I_lfw]wn{Cu#|,Rŗ~fAFËrkGdBjkI9AMSoZGr R-qhf̈́FQ1cC^0)sfyVP^3x>gc&7qe oU=dmMwd` KRFԖ賊fhӜЄFlh<ư;g3'dMbY&Hl҉&^R~2sWw< ?׍ywi|o=$ 'V`wU: E Va5l݅N@) Ivam4 ] caDsǁ7I놫p kv}٦X0c ߸>G'T`(Dl-91P&1#/^=߲q`s!gD2[ }p\+ҲtpkwJ\𺦝_ tӍN>ۧIM}zqz"cgG^Uţ߆qAxpԩ1irZ FP݊qΈ 0Έp7 ۹7|&=#װJrMwlIQF2`pcb} >]x|uR8D IS|?@XdYm Vw.48?w@o&YgVܼj:Z'FgKmc+ebDϱb4Sޗ]5>OKM#*̩Q9kĀ mCt"9PM> 8DOto+Y>lPrѸ6o%u`l"xFx :Sn(կxtf\E3]ǧE2y~X3GCz4rGGuLD fQOoSiNX|@#M_1*ScK2c]5,v&rR9tbTDY}W Y1DtJ$i ӵmK=odwL% "UN BZ#/4}h q+|(u7Mi8 dwξ.8 /,{; jʛ]3͒D2\ڨzhֽ$X挢B n#pL%lmԍT#`-Qoأ}Eߕb92/4) tc\,d0,2I8 m%Y~0DgsXk{˞-]KE15#(}VvV^QOvi*@'ĐuWωL;|7hifs?4b8꜡RGS| ROf[MY3jrI΍>ԢЉ gPZdS.!c2ܴ/pQH]oߤ64@,Q0EtTF1(}(P&B8 <\[} ͦO}pP- $-^1^J:ʲ.ȃ 4^ТNQTɹ~꠶BKݞ#V(\2Ga=+O^CٜZG.Ug&?\ 9r,7r״ זr VOEVLUP3-ّut#A9@SŪe"J*6~ӜpDfQ}p:4ZtVmXMyn#;CkOHt!$G鱚Pu^ - 6u"o4y)%\4 #`2n|Ζn_LG[tё iOR#B҈HJϤh({xvDGY$ɋx]m _6@LjdA&]&&&PnKz=?#܅$y<1SjWZ'C,KӋV'[o0ޅ~_RZ :wVIW/|v-/rzf'Es(3L|խ{/֗d:{FNd1k{H,o(("{1{P \~j/sQ_ E:ݙV]wE)8 paN\6;{{LsM{3ӗ1Lj8Mgo}nk^o-nbnh\=Y @2۠2rfChJ.)8WBѓ1݁/vU.FQ@ֿEdC,Op9Oa+gSZ4]Br[EkZfoAlKX& u8t^EGֹ{%WQlȀUkZfųP !Y]o?2™.ZEUe> e(j/Ξ MYdJN'WBuam7Lo#8"<`a+ Q$$t>ۉ%JSMim5!+ьXcfkpCכA:hOiMjzΆ!օi.dp*J)vRJ3C#䖋DQ'%f"k|?"=0+1iBZٝh_˚kIr34w:Q)Jrxl(n: xͲiK S3')ʠ:W-qđƵw|}Fѣ3>} 2 Qɚ?:xɣ3lJ*at# B?AuٹHBm{(^UD fT Vr[ߨbvK䮩 b;X uicƟ'p1,ȘO Z?yjūA6ff\`!-> u7𬄇ee!lc\@qgCb)3eu3i&w;A0ś OwswTx` +rqnTc؈|A Jtw޹$<\1z. =REE|y!FZ{h h =>_h=" 6/nh7Ɓ0￿j+;<ʽVGiL2D A2H؁MoOJWمX1)ﳫcùIDSL5H\wX'*7!!`WfHELC*B=`;l?x6K 15~ۄQdmIXʗaOa9PIlI)E8U PJ'vGcyk0+,D2d~ѿ0T ?@gqA-5yDշ(Ā_ Ulh//RgȊ#vy XF#cgzRe:Q/x;!l 'N#En?(ңM`=${\NXjpX$*V̻(qVEP.ej-vT LVtL0)&{%  X|1)2Mwɦ4=|>9fY9z99,yIGz^6u++ 0U[ .R2ND{ _7sQ 4Nr2mi~BtEf 1G$O 6>y]28"Dނ=i9&h+3 rbJ cGA1HѬat>0LLFl}W6|*).7֪A4A9Q@U($A2AJ,?,knJLByj]K-r]KS~qb 6X[f|50 ybE:roVNr3 uiTAӡ{ʂ'5^5G"(A$T"uqWHԥ*l%?rۜhtȧN&ѸA7Fކf]![ βie [ѱ'^ɳ71.C z+8g"*XsӾP2oM\f&H W2=.iR[<ϔ ⼬fRhS:9/qdz${\{|@|.|*GiW#.c\D+Fe,LToZ &;{7Zfeq)zS!FknmW T:fg6:%& 檕'\> f *==xqddD[21AP4䉄e^وCFqwٰ)yRfoцiPW n=i2D𽃋(Jq8|t PF{"Zz0ƽ}-ki KK0}I)g߳%RgE)?6i)$9)mǎQ>V /…=ЇIPBt'LjrQ8s:YC1qE\Ӭ,=1ЃHCtwa$ޠELq L |ZZtCDEDҁ9 Q:P3]YWcnRgtE|gA:*_XN${=Vir"d0U=+NC  JK0v^#5eK\wz> !z덭.쫰O:)?,-] xɐ=RjB:a DO&@Mu\RV[L''K+_ \@6rB昌Ӵ.(IlA:!dPaE^,pc)y婮s(A3)^ S Xt5oV`ȑf^K5&lqnvG,C'r.X4"05p'`XꞴjq^jW`9qQc>OMDWijkʄJj5 * 3xߠɾ, CG29>MQQozJFXN3hUvGGDKe<婍Еa!a9F3j|my`Q]~r0&p/gsB<-^&"ȵ @lq!ckv`NN" uo_w$ν8²ȣEi4w a HFt*z&XBt_*fMHԊꉛKc\dz6]3&nFgቄ35dThSy-Mۘ@%APocxe*R7+Cl\6^eq[~0F8iG|lG&8j"']̊.$<(˩6 v=Ncm+\teg!bm*Z Aہ18 ԘQ}'֯ =T{TpŌzQ^oE.-)h/!5Q&nPf $Iu+@uI S4{'8}c{&c:'a@XA y8ܕ.V#Ӈdy\(Vya$47l tJ?iO<9`ŃLTl3e_IW£ďLB&Id#HGN;I$ .$H ,G#,=&B PVVq @@ Mվdv&1#;)AFNsd?hؾ:-B(攢trhH祣Y&in [P('f[&W b>Gp')r#cwPMBдftngWsSev[>dф4> *s?İy@hh$yȾR".6($u}ax)C|kyI칽^?]J(&?HLsfmS퇑'[3XL2e6twƿ5M9IqdW9*A0_痧ishhI>HéڮJ aR%$oŗ0G(aiP}޽a]sk_ ޷)irpE5ܶ,ݹϯ&l"]p D pOV[b xͲCO>Qq)> EF׶Y^2y#Ovli|@-,{0+׹ GUJvv:a(-f!Q 3Q3 wLvF4[mup6OL@+>`uzO)3YP-Osκg_*_Mi@sĮWIl'drmaW6#O%EH_׵ۘǷ>t"GQYZ|d">̵2e|`O .P$וқoί_oSΌ-vңLC]ZPKa9O;.,.NxAp(_5`#agCPz@] yGq][LJ-,,~cDH.o~$EU a-|v{]+9 z`9j\ ypI[ᾘU02\{&y'ݑh hzրHfZVAT6WLhyX#SDG+4{o~oB΋O9j |v֭93w$^Ffm  Ⱥ:ƍzQlI)VG'nd):|I5&ܘ8)ƔOZ20l0*EЏ3l=-zڒ T-ΞLv^2+yƉG9lS-!^珧#[|5Hfa9S%3 DeBpg\ # Copyright (c) 2018-2020 Trim21 # Licensed under the MIT license. from __future__ import annotations import datetime from typing import Any from unittest import mock import pytest from transmission_rpc import from_url, utils from transmission_rpc.constants import DEFAULT_TIMEOUT, LOGGER def assert_almost_eq(value: float, expected: float): assert abs(value - expected) < 1 @pytest.mark.parametrize( ("size", "expected"), { 512: (512, "B"), 1024: (1.0, "KiB"), 1048575: (1023.999, "KiB"), 1048576: (1.0, "MiB"), 1073741824: (1.0, "GiB"), 1099511627776: (1.0, "TiB"), 1125899906842624: (1.0, "PiB"), 1152921504606846976: (1.0, "EiB"), }.items(), ) def test_format_size(size, expected: tuple[float, str]): result = utils.format_size(size) assert_almost_eq(result[0], expected[0]) assert result[1] == expected[1] @pytest.mark.parametrize( ("size", "expected"), [ (512, (512, "B/s")), (1024, (1.0, "KiB/s")), (1048575, (1023.999, "KiB/s")), (1048576, (1.0, "MiB/s")), (1073741824, (1.0, "GiB/s")), (1099511627776, (1.0, "TiB/s")), (1125899906842624, (1.0, "PiB/s")), (1152921504606846976, (1.0, "EiB/s")), ], ) def test_format_speed(size, expected): result = utils.format_speed(size) assert_almost_eq(result[0], expected[0]) assert result[1] == expected[1] @pytest.mark.parametrize( ("delta", "expected"), { datetime.timedelta(0, 0): "0 00:00:00", datetime.timedelta(0, 10): "0 00:00:10", datetime.timedelta(0, 60): "0 00:01:00", datetime.timedelta(0, 61): "0 00:01:01", datetime.timedelta(0, 3661): "0 01:01:01", datetime.timedelta(1, 3661): "1 01:01:01", datetime.timedelta(13, 65660): "13 18:14:20", }.items(), ) def test_format_timedelta(delta, expected): assert utils.format_timedelta(delta), expected @pytest.mark.parametrize( ("url", "kwargs"), { "http://a:b@127.0.0.1:9092/transmission/rpc": { "protocol": "http", "username": "a", "password": "b", "host": "127.0.0.1", "port": 9092, "path": "/transmission/rpc", }, "http://127.0.0.1/transmission/rpc": { "protocol": "http", "username": None, "password": None, "host": "127.0.0.1", "port": 80, "path": "/transmission/rpc", }, "https://127.0.0.1/tr/transmission/rpc": { "protocol": "https", "username": None, "password": None, "host": "127.0.0.1", "port": 443, "path": "/tr/transmission/rpc", }, "https://127.0.0.1/": { "protocol": "https", "username": None, "password": None, "host": "127.0.0.1", "port": 443, "path": "/", }, }.items(), ) def test_from_url(url: str, kwargs: dict[str, Any]): with mock.patch("transmission_rpc.Client") as m: from_url(url) m.assert_called_once_with( **kwargs, timeout=DEFAULT_TIMEOUT, logger=LOGGER, ) transmission-rpc-7.0.11/tests/util.py000066400000000000000000000012101466121555100176040ustar00rootroot00000000000000from functools import wraps import pytest class ServerTooLowError(Exception): pass def skip_on(exception, reason="Default reason"): # Func below is the real decorator and will receive the test function as param def decorator_func(f): @wraps(f) def wrapper(*args, **kwargs): try: # Try to run the test return f(*args, **kwargs) except exception: # If exception of given type happens # just swallow it and raise pytest.Skip with given reason pytest.skip(reason) return wrapper return decorator_func transmission-rpc-7.0.11/transmission_rpc/000077500000000000000000000000001466121555100205165ustar00rootroot00000000000000transmission-rpc-7.0.11/transmission_rpc/__init__.py000066400000000000000000000042661466121555100226370ustar00rootroot00000000000000import logging import urllib.parse from transmission_rpc.client import Client from transmission_rpc.constants import DEFAULT_TIMEOUT, LOGGER, IdleMode, Priority, RatioLimitMode from transmission_rpc.error import ( TransmissionAuthError, TransmissionConnectError, TransmissionError, TransmissionTimeoutError, ) from transmission_rpc.session import Session, SessionStats, Stats from transmission_rpc.torrent import FileStat, Status, Torrent, Tracker, TrackerStats from transmission_rpc.types import File, Group __all__ = [ "Client", "Group", "Status", "DEFAULT_TIMEOUT", "LOGGER", "TransmissionError", "TransmissionTimeoutError", "TransmissionAuthError", "TransmissionConnectError", "Session", "Stats", "SessionStats", "Torrent", "File", "FileStat", "Tracker", "TrackerStats", "from_url", "Priority", "RatioLimitMode", "IdleMode", ] def from_url( url: str, timeout: float = DEFAULT_TIMEOUT, logger: logging.Logger = LOGGER, ) -> Client: """ .. code-block:: python from_url("http://127.0.0.1/transmission/rpc") # http://127.0.0.1:80/transmission/rpc from_url("https://127.0.0.1/transmission/rpc") # https://127.0.0.1:443/transmission/rpc from_url("http://127.0.0.1") # http://127.0.0.1:80/transmission/rpc from_url("http://127.0.0.1/") # http://127.0.0.1:80/ Warnings: you can't ignore scheme, ``127.0.0.1:9091`` is not valid url, please use ``http://127.0.0.1:9091`` And ``from_url("http://127.0.0.1")`` is not same as ``from_url("http://127.0.0.1/")``, ``path`` of ``http://127.0.0.1/`` is ``/`` """ u = urllib.parse.urlparse(url) protocol = u.scheme if protocol == "http": default_port = 80 elif protocol == "https": default_port = 443 else: raise ValueError(f"unknown url scheme {u.scheme}") return Client( protocol=protocol, # type: ignore username=u.username, password=u.password, host=u.hostname or "127.0.0.1", port=u.port or default_port, path=u.path or "/transmission/rpc", timeout=timeout, logger=logger, ) transmission-rpc-7.0.11/transmission_rpc/client.py000066400000000000000000001312561466121555100223560ustar00rootroot00000000000000from __future__ import annotations import json import logging import pathlib import string import time import types from typing import Any, BinaryIO, Iterable, List, TypeVar, Union from urllib.parse import quote import requests import requests.auth import requests.exceptions from typing_extensions import Literal, Self, TypedDict, deprecated from transmission_rpc.constants import DEFAULT_TIMEOUT, LOGGER, RpcMethod from transmission_rpc.error import ( TransmissionAuthError, TransmissionConnectError, TransmissionError, TransmissionTimeoutError, ) from transmission_rpc.session import Session, SessionStats from transmission_rpc.torrent import Torrent from transmission_rpc.types import Group, _Timeout from transmission_rpc.utils import _try_read_torrent, get_torrent_arguments _hex_chars = frozenset(string.hexdigits.lower()) _TorrentID = Union[int, str] _TorrentIDs = Union[_TorrentID, List[_TorrentID], None] _header_session_id = "x-transmission-session-id" class ResponseData(TypedDict): arguments: Any tag: int result: str def ensure_location_str(s: str | pathlib.Path) -> str: if isinstance(s, pathlib.Path): if s.is_absolute(): return str(s) raise ValueError( "using relative `pathlib.Path` as remote path is not supported in v4.", ) return str(s) def _parse_torrent_id(raw_torrent_id: int | str) -> int | str: if isinstance(raw_torrent_id, int): if raw_torrent_id >= 0: return raw_torrent_id elif isinstance(raw_torrent_id, str): if len(raw_torrent_id) != 40 or (set(raw_torrent_id) - _hex_chars): raise ValueError(f"torrent ids {raw_torrent_id} is not valid torrent id, should be a hex str for sha1 hash") return raw_torrent_id raise ValueError(f"{raw_torrent_id} is not valid torrent id") def _parse_torrent_ids(args: Any) -> str | list[str | int]: if args is None: return [] if isinstance(args, int): return [_parse_torrent_id(args)] if isinstance(args, str): if args == "recently-active": return args return [_parse_torrent_id(args)] if isinstance(args, (list, tuple)): return [_parse_torrent_id(item) for item in args] raise ValueError(f"Invalid torrent id {args}") class Client: def __init__( self, *, protocol: Literal["http", "https"] = "http", username: str | None = None, password: str | None = None, host: str = "127.0.0.1", port: int = 9091, path: str = "/transmission/rpc", timeout: float = DEFAULT_TIMEOUT, logger: logging.Logger = LOGGER, ): """ Parameters: protocol: username: password: host: port: path: rpc request target path, default ``/transmission/rpc`` timeout: logger: """ if isinstance(logger, logging.Logger): self.logger = logger else: raise TypeError( "logger must be instance of `logging.Logger`, default: logging.getLogger('transmission-rpc')" ) self._query_timeout: _Timeout = timeout username = quote(username or "", safe="$-_.+!*'(),;&=", encoding="utf8") if username else "" password = ":" + quote(password or "", safe="$-_.+!*'(),;&=", encoding="utf8") if password else "" auth = f"{username}{password}@" if (username or password) else "" if path == "/transmission/": path = "/transmission/rpc" url = f"{protocol}://{auth}{host}:{port}{path}" self._url = str(url) self.__raw_session: dict[str, Any] = {} self.__session_id = "0" self.__server_version: str = "(unknown)" self.__protocol_version: int = 17 # default 17 self._http_session = requests.Session() self._http_session.trust_env = False self.__semver_version = None self.get_session() self.__torrent_get_arguments = get_torrent_arguments(self.__protocol_version) @property @deprecated("do not use internal property") def url(self) -> str: return self._url @property @deprecated("do not use internal property, use `get_torrent_arguments(rpc_version)` if you need") def torrent_get_arguments(self) -> list[str]: return self.__torrent_get_arguments @property @deprecated("do not use internal property, use `.get_session()` instead") def raw_session(self) -> dict[str, Any]: return self.__raw_session @property @deprecated("do not use internal property") def session_id(self) -> str: return self.__session_id @property @deprecated("do not use internal property, use `.get_session().version` instead") def server_version(self) -> str: return self.__server_version @property def timeout(self) -> _Timeout: """ Get current timeout for HTTP queries. """ return self._query_timeout @timeout.setter def timeout(self, value: _Timeout) -> None: """ Set timeout for HTTP queries. """ if isinstance(value, (tuple, list)): if len(value) != 2: raise ValueError("timeout tuple can only include 2 numbers elements") for v in value: if not isinstance(v, (float, int)): raise TypeError("element of timeout tuple can only be int or float") self._query_timeout = (value[0], value[1]) # for type checker elif value is None: self._query_timeout = DEFAULT_TIMEOUT else: self._query_timeout = float(value) @timeout.deleter def timeout(self) -> None: """ Reset the HTTP query timeout to the default. """ self._query_timeout = DEFAULT_TIMEOUT @property def _http_header(self) -> dict[str, str]: return {_header_session_id: self.__session_id} def _http_query(self, query: dict[str, Any], timeout: _Timeout | None = None) -> str: """ Query Transmission through HTTP. """ request_count = 0 if timeout is None: timeout = self.timeout while True: if request_count >= 3: raise TransmissionError("too much request, try enable logger to see what happened") self.logger.debug( { "url": self._url, "headers": self._http_header, "data": query, "timeout": timeout, } ) request_count += 1 try: r = self._http_session.post( self._url, headers=self._http_header, json=query, timeout=timeout, ) except requests.exceptions.Timeout as e: raise TransmissionTimeoutError("timeout when connection to transmission daemon") from e except requests.exceptions.ConnectionError as e: raise TransmissionConnectError(f"can't connect to transmission daemon: {e!s}") from e self.logger.debug(r.text) if r.status_code in {401, 403}: self.logger.debug(r.request.headers) raise TransmissionAuthError("transmission daemon require auth", original=r) if _header_session_id in r.headers: self.__session_id = r.headers["x-transmission-session-id"] if r.status_code != 409: return r.text def _request( self, method: RpcMethod, arguments: dict[str, Any] | None = None, ids: _TorrentIDs | None = None, require_ids: bool = False, timeout: _Timeout | None = None, ) -> dict[str, Any]: """ Send json-rpc request to Transmission using http POST """ if not isinstance(method, str): raise TypeError("request takes method as string") if arguments is None: arguments = {} if not isinstance(arguments, dict): raise TypeError("request takes arguments should be dict") ids = _parse_torrent_ids(ids) if len(ids) > 0: arguments["ids"] = ids elif require_ids: raise ValueError("request require ids") query = {"method": method, "arguments": arguments} start = time.monotonic() try: http_data = self._http_query(query, timeout) finally: elapsed = time.monotonic() - start self.logger.debug("http request took %.3f s", elapsed) try: data: ResponseData = json.loads(http_data) except json.JSONDecodeError as error: self.logger.exception("Error:") self.logger.exception('Request: "%s"', query) self.logger.exception('HTTP data: "%s"', http_data) raise TransmissionError( "failed to parse response as json", method=method, argument=arguments, rawResponse=http_data ) from error if self.logger.isEnabledFor(logging.DEBUG): self.logger.debug(json.dumps(data, indent=2)) if "result" not in data: raise TransmissionError( "Query failed, response data missing without result.", method=method, argument=arguments, response=data, rawResponse=http_data, ) if data["result"] != "success": raise TransmissionError( f'Query failed with result "{data["result"]}".', method=method, argument=arguments, response=data, rawResponse=http_data, ) res = data["arguments"] results = {} if method == RpcMethod.TorrentGet: return res if method == RpcMethod.TorrentAdd: item = None if "torrent-added" in res: item = res["torrent-added"] elif "torrent-duplicate" in res: item = res["torrent-duplicate"] if item: results[item["id"]] = Torrent(fields=item) else: raise TransmissionError( "Invalid torrent-add response.", method=method, argument=arguments, response=data, rawResponse=http_data, ) elif method == RpcMethod.SessionGet: self.__raw_session.update(res) elif method == RpcMethod.SessionStats: # older versions of T has the return data in "session-stats" if "session-stats" in res: return res["session-stats"] return res elif method in ( RpcMethod.PortTest, RpcMethod.BlocklistUpdate, RpcMethod.FreeSpace, RpcMethod.TorrentRenamePath, ): return res else: return res return results def _update_server_version(self) -> None: """Decode the Transmission version string, if available.""" self.__semver_version = self.__raw_session.get("rpc-version-semver") self.__server_version = self.__raw_session["version"] self.__protocol_version = self.__raw_session["rpc-version"] @property @deprecated("use .get_session().rpc_version_semver instead") def semver_version(self) -> str | None: """Get the Transmission daemon RPC version. .. deprecated:: 7.0.5 Use ``.get_session().rpc_version_semver`` instead """ return self.__semver_version @property @deprecated("use .get_session().rpc_version instead") def rpc_version(self) -> int: """Get the Transmission daemon RPC version. .. deprecated:: 7.0.5 Use ``.get_session().rpc_version`` instead """ return self.__protocol_version def _rpc_version_warning(self, required_version: int) -> None: """ Add a warning to the log if the Transmission RPC version is lower then the provided version. """ if self.__protocol_version < required_version: self.logger.warning( "Using feature not supported by server. RPC version for server %d, feature introduced in %d.", self.__protocol_version, required_version, ) def add_torrent( self, torrent: BinaryIO | str | bytes | pathlib.Path, timeout: _Timeout | None = None, *, download_dir: str | None = None, files_unwanted: list[int] | None = None, files_wanted: list[int] | None = None, paused: bool | None = None, peer_limit: int | None = None, priority_high: list[int] | None = None, priority_low: list[int] | None = None, priority_normal: list[int] | None = None, cookies: str | None = None, labels: Iterable[str] | None = None, bandwidthPriority: int | None = None, ) -> Torrent: """ Add torrent to transfers list. ``torrent`` can be: - ``http://``, ``https://`` or ``magnet:`` URL - torrent file-like object in **binary mode** - bytes of torrent content - ``pathlib.Path`` for local torrent file, will be read and encoded as base64. Warnings: base64 string or ``file://`` protocol URL are not supported in v4. Parameters: torrent: torrent to add timeout: request timeout bandwidthPriority: Priority for this transfer. cookies: One or more HTTP cookie(s). download_dir: The directory where the downloaded contents will be saved in. files_unwanted: A list of file id's that shouldn't be downloaded. files_wanted: A list of file id's that should be downloaded. paused: If ``True``, does not start the transfer when added. Magnet url will always start to downloading torrents. peer_limit: Maximum number of peers allowed. priority_high: A list of file id's that should have high priority. priority_low: A list of file id's that should have low priority. priority_normal: A list of file id's that should have normal priority. labels: Array of string labels. Add in rpc 17. """ if torrent is None: raise ValueError("add_torrent requires data or a URI.") if labels is not None: self._rpc_version_warning(17) kwargs: dict[str, Any] = remove_unset_value( { "download-dir": download_dir, "files-unwanted": files_unwanted, "files-wanted": files_wanted, "paused": paused, "peer-limit": peer_limit, "priority-high": priority_high, "priority-low": priority_low, "priority-normal": priority_normal, "bandwidthPriority": bandwidthPriority, "cookies": cookies, "labels": list_or_none(_single_str_as_list(labels)), } ) torrent_data = _try_read_torrent(torrent) if torrent_data is None: kwargs["filename"] = torrent else: if not torrent_data: raise ValueError("Torrent metadata is empty") kwargs["metainfo"] = torrent_data return next(iter(self._request(RpcMethod.TorrentAdd, kwargs, timeout=timeout).values())) def remove_torrent(self, ids: _TorrentIDs, delete_data: bool = False, timeout: _Timeout | None = None) -> None: """ remove torrent(s) with provided id(s). Local data will be removed by transmission daemon if ``delete_data`` is set to ``True``. """ self._request( RpcMethod.TorrentRemove, {"delete-local-data": delete_data}, ids, True, timeout=timeout, ) def start_torrent(self, ids: _TorrentIDs, bypass_queue: bool = False, timeout: _Timeout | None = None) -> None: """Start torrent(s) with provided id(s)""" method = RpcMethod.TorrentStart if bypass_queue: method = RpcMethod.TorrentStartNow self._request(method, {}, ids, True, timeout=timeout) def start_all(self, bypass_queue: bool = False, timeout: _Timeout | None = None) -> None: """Start all torrents respecting the queue order""" method = RpcMethod.TorrentStart if bypass_queue: method = RpcMethod.TorrentStartNow torrent_list = sorted(self.get_torrents(), key=lambda t: t.queue_position) self._request( method, {}, ids=[x.id for x in torrent_list], require_ids=True, timeout=timeout, ) def stop_torrent(self, ids: _TorrentIDs, timeout: _Timeout | None = None) -> None: """stop torrent(s) with provided id(s)""" self._request(RpcMethod.TorrentStop, {}, ids, True, timeout=timeout) def verify_torrent(self, ids: _TorrentIDs, timeout: _Timeout | None = None) -> None: """verify torrent(s) with provided id(s)""" self._request(RpcMethod.TorrentVerify, {}, ids, True, timeout=timeout) def reannounce_torrent(self, ids: _TorrentIDs, timeout: _Timeout | None = None) -> None: """Reannounce torrent(s) with provided id(s)""" self._request(RpcMethod.TorrentReannounce, {}, ids, True, timeout=timeout) def get_torrent( self, torrent_id: _TorrentID, arguments: Iterable[str] | None = None, timeout: _Timeout | None = None, ) -> Torrent: """ Get information for torrent with provided id. ``arguments`` contains a list of field names to be returned, when ``arguments=None`` (default), all fields are requested. See the Torrent class for more information. new argument ``format`` in rpc_version 16 is unnecessarily and this lib can't handle table response, So it's unsupported. Returns a Torrent object with the requested fields. Note: It's recommended that you only fetch arguments you need, this could improve response speed. For example, fetch all fields from transmission daemon with 1500 torrents would take ~5s, but is only ~0.2s if to fetch 6 fields. Parameters: torrent_id: torrent id can be an int or a torrent ``info_hash`` (``hashString`` property of the ``Torrent`` object). arguments: fetched torrent arguments, in most cases you don't need to set this, transmission-rpc will fetch all torrent fields it supported. timeout: requests timeout Raises: KeyError: torrent with given ``torrent_id`` not found """ if arguments: arguments = list(set(arguments) | {"id", "hashString"}) else: arguments = self.__torrent_get_arguments torrent_id = _parse_torrent_id(torrent_id) if torrent_id is None: raise ValueError("Invalid id") result = self._request( RpcMethod.TorrentGet, {"fields": arguments}, torrent_id, require_ids=True, timeout=timeout, ) for torrent in result["torrents"]: if torrent.get("hashString") == torrent_id or torrent.get("id") == torrent_id: return Torrent(fields=torrent) raise KeyError("Torrent not found in result") def get_torrents( self, ids: _TorrentIDs | None = None, arguments: Iterable[str] | None = None, timeout: _Timeout | None = None, ) -> list[Torrent]: """ Get information for torrents with provided ids. For more information see :py:meth:`Client.get_torrent`. Returns a list of Torrent object. """ if arguments: arguments = list(set(arguments) | {"id", "hashString"}) else: arguments = self.__torrent_get_arguments return [ Torrent(fields=x) for x in self._request(RpcMethod.TorrentGet, {"fields": arguments}, ids, timeout=timeout)["torrents"] ] def get_recently_active_torrents( self, arguments: Iterable[str] | None = None, timeout: _Timeout | None = None ) -> tuple[list[Torrent], list[int]]: """ Get information for torrents for recently active torrent. If you want to get recently-removed torrents. you should use this method. Returns: active_torrents, removed_torrents list of recently active torrents and list of torrent-id of recently-removed torrents. """ if arguments: arguments = list(set(arguments) | {"id", "hashString"}) else: arguments = self.__torrent_get_arguments result = self._request(RpcMethod.TorrentGet, {"fields": arguments}, "recently-active", timeout=timeout) return [Torrent(fields=x) for x in result["torrents"]], result["removed"] def change_torrent( self, ids: _TorrentIDs, timeout: _Timeout | None = None, *, bandwidth_priority: int | None = None, download_limit: int | None = None, download_limited: bool | None = None, upload_limit: int | None = None, upload_limited: bool | None = None, files_unwanted: Iterable[int] | None = None, files_wanted: Iterable[int] | None = None, honors_session_limits: bool | None = None, location: str | None = None, peer_limit: int | None = None, priority_high: Iterable[int] | None = None, priority_low: Iterable[int] | None = None, priority_normal: Iterable[int] | None = None, queue_position: int | None = None, seed_idle_limit: int | None = None, seed_idle_mode: int | None = None, seed_ratio_limit: float | None = None, seed_ratio_mode: int | None = None, tracker_add: Iterable[str] | None = None, labels: Iterable[str] | None = None, group: str | None = None, tracker_list: Iterable[Iterable[str]] | None = None, tracker_replace: Iterable[tuple[int, str]] | None = None, tracker_remove: Iterable[int] | None = None, **kwargs: Any, ) -> None: """Change torrent parameters for the torrent(s) with the supplied id's. Parameters: ids: torrent(s) to change. timeout: requesst timeout. honors_session_limits: true if session upload limits are honored. location: new location of the torrent's content peer_limit: maximum number of peers queue_position: position of this torrent in its queue [0...n) files_wanted: Array of file id to download. files_unwanted: Array of file id to not download. download_limit: maximum download speed (KBps) download_limited: true if ``download_limit`` is honored upload_limit: maximum upload speed (KBps) upload_limited: true if ``upload_limit`` is honored bandwidth_priority: Priority for this transfer. priority_high: list of file id to set high download priority priority_low: list of file id to set low download priority priority_normal: list of file id to set normal download priority seed_ratio_limit: Seed inactivity limit in minutes. seed_ratio_mode: Torrent seed ratio mode Valid options are :py:class:`transmission_rpc.RatioLimitMode` seed_idle_limit: torrent-level seeding ratio seed_idle_mode: Seed inactivity mode. Valid options are :py:class:`transmission_rpc.IdleMode` labels: Array of string labels. Add in rpc 16. group: The name of this torrent's bandwidth group. Add in rpc 17. tracker_list: A ``Iterable[Iterable[str]]``, each ``Iterable[str]`` for a tracker tier. Add in rpc 17. Example: ``[['https://tracker1/announce', 'https://tracker2/announce'], ['https://backup1.example.com/announce'], ['https://backup2.example.com/announce']]``. tracker_add: Array of string with announce URLs to add. Warnings: since transmission daemon 4.0.0, this argument is deprecated, use ``tracker_list`` instead. tracker_remove: Array of ids of trackers to remove. Warnings: since transmission daemon 4.0.0, this argument is deprecated, use ``tracker_list`` instead. tracker_replace: Array of (id, url) tuples where the announcement URL should be replaced. Warning: since transmission daemon 4.0.0, this argument is deprecated, use ``tracker_list`` instead. Warnings: ``kwargs`` is for the future features not supported yet, it's not compatibility promising. It will be bypassed to request arguments **as-is**, the underline in the key will not be replaced, so you should use kwargs like ``{'a-argument': 'value'}`` """ if labels is not None: self._rpc_version_warning(16) if tracker_list is not None: self._rpc_version_warning(17) if group is not None: self._rpc_version_warning(17) args: dict[str, Any] = remove_unset_value( { "bandwidthPriority": bandwidth_priority, "downloadLimit": download_limit, "downloadLimited": download_limited, "uploadLimit": upload_limit, "uploadLimited": upload_limited, "files-unwanted": list_or_none(files_unwanted), "files-wanted": list_or_none(files_wanted), "honorsSessionLimits": honors_session_limits, "location": location, "peer-limit": peer_limit, "priority-high": list_or_none(priority_high), "priority-low": list_or_none(priority_low), "priority-normal": list_or_none(priority_normal), "queuePosition": queue_position, "seedIdleLimit": seed_idle_limit, "seedIdleMode": seed_idle_mode, "seedRatioLimit": seed_ratio_limit, "seedRatioMode": seed_ratio_mode, "trackerAdd": tracker_add, "trackerRemove": tracker_remove, "trackerReplace": tracker_replace, "labels": list_or_none(_single_str_as_list(labels)), "trackerList": None if tracker_list is None else "\n\n".join("\n".join(tier) for tier in tracker_list), "group": group, } ) args.update(kwargs) if args: self._request(RpcMethod.TorrentSet, args, ids, True, timeout=timeout) else: raise ValueError("No arguments to set") def move_torrent_data( self, ids: _TorrentIDs, location: str | pathlib.Path, timeout: _Timeout | None = None, *, move: bool = True, ) -> None: """ Move torrent data to the new location. See Also: `RPC Spec: moving-a-torrent `_ """ args = {"location": ensure_location_str(location), "move": bool(move)} self._request(RpcMethod.TorrentSetLocation, args, ids, True, timeout=timeout) def rename_torrent_path( self, torrent_id: _TorrentID, location: str, name: str, timeout: _Timeout | None = None, ) -> tuple[str, str]: """ Warnings: This method can only be called on single torrent. Warnings: This is not the method to move torrent data directory, See Also: `RPC Spec: renaming-a-torrents-path `_ """ self._rpc_version_warning(15) torrent_id = _parse_torrent_id(torrent_id) name = name.strip() # https://github.com/trim21/transmission-rpc/issues/185 result = self._request( RpcMethod.TorrentRenamePath, {"path": ensure_location_str(location), "name": name}, torrent_id, True, timeout=timeout, ) return result["path"], result["name"] def queue_top(self, ids: _TorrentIDs, timeout: _Timeout | None = None) -> None: """ Move transfer to the top of the queue. https://github.com/transmission/transmission/blob/main/docs/rpc-spec.md#46-queue-movement-requests """ self._request(RpcMethod.QueueMoveTop, ids=ids, require_ids=True, timeout=timeout) def queue_bottom(self, ids: _TorrentIDs, timeout: _Timeout | None = None) -> None: """ Move transfer to the bottom of the queue. https://github.com/transmission/transmission/blob/main/docs/rpc-spec.md#46-queue-movement-requests """ self._request(RpcMethod.QueueMoveBottom, ids=ids, require_ids=True, timeout=timeout) def queue_up(self, ids: _TorrentIDs, timeout: _Timeout | None = None) -> None: """Move transfer up in the queue.""" self._request(RpcMethod.QueueMoveUp, ids=ids, require_ids=True, timeout=timeout) def queue_down(self, ids: _TorrentIDs, timeout: _Timeout | None = None) -> None: """Move transfer down in the queue.""" self._request(RpcMethod.QueueMoveDown, ids=ids, require_ids=True, timeout=timeout) def get_session(self, timeout: _Timeout | None = None) -> Session: """ Get session parameters. See the Session class for more information. """ self._request(RpcMethod.SessionGet, timeout=timeout) self._update_server_version() return Session(fields=self.__raw_session) def set_session( self, timeout: _Timeout | None = None, *, alt_speed_down: int | None = None, alt_speed_enabled: bool | None = None, alt_speed_time_begin: int | None = None, alt_speed_time_day: int | None = None, alt_speed_time_enabled: bool | None = None, alt_speed_time_end: int | None = None, alt_speed_up: int | None = None, blocklist_enabled: bool | None = None, blocklist_url: str | None = None, cache_size_mb: int | None = None, dht_enabled: bool | None = None, default_trackers: Iterable[str] | None = None, download_dir: str | None = None, download_queue_enabled: bool | None = None, download_queue_size: int | None = None, encryption: Literal["required", "preferred", "tolerated"] | None = None, idle_seeding_limit: int | None = None, idle_seeding_limit_enabled: bool | None = None, incomplete_dir: str | None = None, incomplete_dir_enabled: bool | None = None, lpd_enabled: bool | None = None, peer_limit_global: int | None = None, peer_limit_per_torrent: int | None = None, peer_port: int | None = None, peer_port_random_on_start: bool | None = None, pex_enabled: bool | None = None, port_forwarding_enabled: bool | None = None, queue_stalled_enabled: bool | None = None, queue_stalled_minutes: int | None = None, rename_partial_files: bool | None = None, script_torrent_done_enabled: bool | None = None, script_torrent_done_filename: str | None = None, seed_queue_enabled: bool | None = None, seed_queue_size: int | None = None, seed_ratio_limit: float | None = None, seed_ratio_limited: bool | None = None, speed_limit_down: int | None = None, speed_limit_down_enabled: bool | None = None, speed_limit_up: int | None = None, speed_limit_up_enabled: bool | None = None, start_added_torrents: bool | None = None, trash_original_torrent_files: bool | None = None, utp_enabled: bool | None = None, script_torrent_done_seeding_filename: str | None = None, script_torrent_done_seeding_enabled: bool | None = None, script_torrent_added_enabled: bool | None = None, script_torrent_added_filename: str | None = None, **kwargs: Any, ) -> None: """ Set session parameters. Parameters: timeout request timeout alt_speed_down: max global download speed (KBps) alt_speed_enabled: true means use the alt speeds alt_speed_time_begin: Time when alternate speeds should be enabled. Minutes after midnight. alt_speed_time_day: Enables alternate speeds scheduling these days. alt_speed_time_enabled: Enables alternate speeds scheduling. alt_speed_time_end: Time when alternate speeds should be disabled. Minutes after midnight. alt_speed_up: Alternate session upload speed limit (in Kib/s). blocklist_enabled: Enables the block list blocklist_url: Location of the block list. Updated with blocklist-update. cache_size_mb: The maximum size of the disk cache in MB default_trackers: list of default trackers to use on public torrents. dht_enabled: Enables DHT. download_dir: Set the session download directory. download_queue_enabled: Enables download queue. download_queue_size: Number of slots in the download queue. encryption: Set the session encryption mode, one of ``required``, ``preferred`` or ``tolerated``. idle_seeding_limit: The default seed inactivity limit in minutes. idle_seeding_limit_enabled: Enables the default seed inactivity limit incomplete_dir: The path to the directory of incomplete transfer data. incomplete_dir_enabled: Enables the incomplete transfer data directory, Otherwise data for incomplete transfers are stored in the download target. lpd_enabled: Enables local peer discovery for public torrents. peer_limit_global: Maximum number of peers. peer_limit_per_torrent: Maximum number of peers per transfer. peer_port: Peer port. peer_port_random_on_start: Enables randomized peer port on start of Transmission. pex_enabled: Allowing PEX in public torrents. port_forwarding_enabled: Enables port forwarding. queue_stalled_enabled: Enable tracking of stalled transfers. queue_stalled_minutes: Number of minutes of idle that marks a transfer as stalled. rename_partial_files: Appends ".part" to incomplete files seed_queue_enabled: Enables upload queue. seed_queue_size: Number of slots in the upload queue. seed_ratio_limit: Seed ratio limit. 1.0 means 1:1 download and upload ratio. seed_ratio_limited: Enables seed ration limit. speed_limit_down: Download speed limit (in Kib/s). speed_limit_down_enabled: Enables download speed limiting. speed_limit_up: Upload speed limit (in Kib/s). speed_limit_up_enabled: Enables upload speed limiting. start_added_torrents: Added torrents will be started right away. trash_original_torrent_files: The .torrent file of added torrents will be deleted. utp_enabled: Enables Micro Transport Protocol (UTP). script_torrent_done_enabled: Whether to call the "done" script. script_torrent_done_filename: Filename of the script to run when the transfer is done. script_torrent_added_filename: filename of the script to run script_torrent_added_enabled: whether or not to call the ``added`` script script_torrent_done_seeding_enabled: whether or not to call the ``seeding-done`` script script_torrent_done_seeding_filename: filename of the script to run Warnings: ``kwargs`` is pass the arguments not supported yet future, it's not compatibility promising. transmission-rpc will merge ``kwargs`` in rpc arguments **as-is** """ if encryption is not None and encryption not in ["required", "preferred", "tolerated"]: raise ValueError("Invalid encryption value") if default_trackers is not None: self._rpc_version_warning(17) if script_torrent_done_seeding_filename is not None: self._rpc_version_warning(17) if script_torrent_done_seeding_enabled is not None: self._rpc_version_warning(17) if script_torrent_added_enabled is not None: self._rpc_version_warning(17) if script_torrent_added_filename is not None: self._rpc_version_warning(17) args: dict[str, Any] = remove_unset_value( { "alt-speed-down": alt_speed_down, "alt-speed-enabled": alt_speed_enabled, "alt-speed-time-begin": alt_speed_time_begin, "alt-speed-time-day": alt_speed_time_day, "alt-speed-time-enabled": alt_speed_time_enabled, "alt-speed-time-end": alt_speed_time_end, "alt-speed-up": alt_speed_up, "blocklist-enabled": blocklist_enabled, "blocklist-url": blocklist_url, "cache-size-mb": cache_size_mb, "dht-enabled": dht_enabled, "download-dir": download_dir, "download-queue-enabled": download_queue_enabled, "download-queue-size": download_queue_size, "idle-seeding-limit-enabled": idle_seeding_limit_enabled, "idle-seeding-limit": idle_seeding_limit, "incomplete-dir": incomplete_dir, "incomplete-dir-enabled": incomplete_dir_enabled, "lpd-enabled": lpd_enabled, "peer-limit-global": peer_limit_global, "peer-limit-per-torrent": peer_limit_per_torrent, "peer-port-random-on-start": peer_port_random_on_start, "peer-port": peer_port, "pex-enabled": pex_enabled, "port-forwarding-enabled": port_forwarding_enabled, "queue-stalled-enabled": queue_stalled_enabled, "queue-stalled-minutes": queue_stalled_minutes, "rename-partial-files": rename_partial_files, "script-torrent-done-enabled": script_torrent_done_enabled, "script-torrent-done-filename": script_torrent_done_filename, "seed-queue-enabled": seed_queue_enabled, "seed-queue-size": seed_queue_size, "seedRatioLimit": seed_ratio_limit, "seedRatioLimited": seed_ratio_limited, "speed-limit-down": speed_limit_down, "speed-limit-down-enabled": speed_limit_down_enabled, "speed-limit-up": speed_limit_up, "speed-limit-up-enabled": speed_limit_up_enabled, "start-added-torrents": start_added_torrents, "trash-original-torrent-files": trash_original_torrent_files, "utp-enabled": utp_enabled, "encryption": encryption, "script-torrent-added-filename": script_torrent_added_filename, "script-torrent-done-seeding-filename": script_torrent_done_seeding_filename, "script-torrent-done-seeding-enabled": script_torrent_done_seeding_enabled, "script-torrent-added-enabled": script_torrent_added_enabled, "default-trackers": "\n".join(default_trackers) if default_trackers is not None else None, } ) args.update(kwargs) if args: self._request(RpcMethod.SessionSet, args, timeout=timeout) def blocklist_update(self, timeout: _Timeout | None = None) -> int | None: """Update block list. Returns the size of the block list.""" result = self._request(RpcMethod.BlocklistUpdate, timeout=timeout) return result.get("blocklist-size") def port_test(self, timeout: _Timeout | None = None) -> bool | None: """ Tests to see if your incoming peer port is accessible from the outside world. """ result = self._request(RpcMethod.PortTest, timeout=timeout) return result.get("port-is-open") def free_space(self, path: str | pathlib.Path, timeout: _Timeout | None = None) -> int | None: """ Get the amount of free space (in bytes) at the provided location. """ self._rpc_version_warning(15) path = ensure_location_str(path) result: dict[str, Any] = self._request(RpcMethod.FreeSpace, {"path": path}, timeout=timeout) if result["path"] == path: return result["size-bytes"] return None def session_stats(self, timeout: _Timeout | None = None) -> SessionStats: """Get session statistics""" result = self._request(RpcMethod.SessionStats, timeout=timeout) return SessionStats(fields=result) def set_group( self, name: str, *, timeout: _Timeout | None = None, honors_session_limits: bool | None = None, speed_limit_down_enabled: bool | None = None, speed_limit_down: int | None = None, speed_limit_up_enabled: bool | None = None, speed_limit_up: int | None = None, ) -> None: """create or update a Bandwidth group. :param name: Bandwidth group name :param honors_session_limits: true if session upload limits are honored :param speed_limit_down_enabled: true means enabled :param speed_limit_down: max global download speed (KBps) :param speed_limit_up_enabled: true means enabled :param speed_limit_up: max global upload speed (KBps) :param timeout: request timeout """ self._rpc_version_warning(17) arguments: dict[str, Any] = remove_unset_value( { "name": name, "honorsSessionLimits": honors_session_limits, "speed-limit-down": speed_limit_down, "speed-limit-up-enabled": speed_limit_up_enabled, "speed-limit-up": speed_limit_up, "speed-limit-down-enabled": speed_limit_down_enabled, } ) self._request(RpcMethod.GroupSet, arguments, timeout=timeout) def get_group(self, name: str, *, timeout: _Timeout | None = None) -> Group | None: self._rpc_version_warning(17) result: dict[str, Any] = self._request(RpcMethod.GroupGet, {"group": name}, timeout=timeout) if result["group"]: return Group(fields=result["group"][0]) return None def get_groups(self, name: list[str] | None = None, *, timeout: _Timeout | None = None) -> dict[str, Group]: payload = {} if name is not None: payload = {"group": name} result: dict[str, Any] = self._request(RpcMethod.GroupGet, payload, timeout=timeout) return {x["name"]: Group(fields=x) for x in result["group"]} def __enter__(self) -> Self: return self def __exit__( self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: types.TracebackType | None, ) -> None: self._http_session.close() T = TypeVar("T") def _single_str_as_list(v: Iterable[str] | None) -> list[str] | None: if v is None: return v if isinstance(v, str): return [v] return list(v) def list_or_none(v: Iterable[T] | None) -> list[T] | None: if v is None: return None return list(v) def remove_unset_value(data: dict[str, Any]) -> dict[str, Any]: return {key: value for key, value in data.items() if value is not None} transmission-rpc-7.0.11/transmission_rpc/constants.py000066400000000000000000000235401466121555100231100ustar00rootroot00000000000000# Copyright (c) 2018-2022 Trim21 # Copyright (c) 2008-2014 Erik Svensson # Licensed under the MIT license. from __future__ import annotations import enum import logging from typing import NamedTuple LOGGER = logging.getLogger("transmission-rpc") LOGGER.setLevel(logging.ERROR) DEFAULT_TIMEOUT = 30.0 class Priority(enum.IntEnum): Low = -1 Normal = 0 High = 1 class RatioLimitMode(enum.IntEnum): """torrent radio limit mode""" #: follow the global settings Global = 0 #: override the global settings, seeding until a certain ratio Single = 1 #: override the global settings, seeding regardless of ratio Unlimited = 2 class IdleMode(enum.IntEnum): """torrent idle mode""" #: follow the global settings Global = 0 #: override the global settings, seeding until a certain idle time Single = 1 #: override the global settings, seeding regardless of activity Unlimited = 2 class Args(NamedTuple): type: str added_version: int removed_version: int | None = None previous_argument_name: str | None = None next_argument_name: str | None = None description: str = "" def __repr__(self) -> str: return ( f"Args({self.type!r}," f" {self.added_version!r}," f" {self.removed_version!r}," f" {self.previous_argument_name!r}," f" {self.next_argument_name!r}," f" {self.description!r})" ) def __str__(self) -> str: return f"Args str: if self.original: original_name = type(self.original).__name__ return f'{self.message} Original exception: {original_name}, "{self.original}"' return self.message class TransmissionAuthError(TransmissionError): """Raised when username or password is incorrect""" class TransmissionConnectError(TransmissionError): """raised when client can't connect to transmission daemon""" class TransmissionTimeoutError(TransmissionConnectError): """Timeout""" transmission-rpc-7.0.11/transmission_rpc/py.typed000066400000000000000000000000001466121555100222030ustar00rootroot00000000000000transmission-rpc-7.0.11/transmission_rpc/session.py000066400000000000000000000303171466121555100225570ustar00rootroot00000000000000from __future__ import annotations from typing_extensions import Literal from transmission_rpc.types import Container class Stats(Container): @property def uploaded_bytes(self) -> int: return self.fields["uploadedBytes"] @property def downloaded_bytes(self) -> int: return self.fields["downloadedBytes"] @property def files_added(self) -> int: return self.fields["filesAdded"] @property def session_count(self) -> int: return self.fields["sessionCount"] @property def seconds_active(self) -> int: return self.fields["secondsActive"] class SessionStats(Container): # https://github.com/transmission/transmission/blob/main/docs/rpc-spec.md # 42-session-statistics @property def active_torrent_count(self) -> int: return self.fields["activeTorrentCount"] @property def download_speed(self) -> int: return self.fields["downloadSpeed"] @property def paused_torrent_count(self) -> int: return self.fields["pausedTorrentCount"] @property def torrent_count(self) -> int: return self.fields["torrentCount"] @property def upload_speed(self) -> int: return self.fields["uploadSpeed"] @property def cumulative_stats(self) -> Stats: return Stats(fields=self.fields["cumulative-stats"]) @property def current_stats(self) -> Stats: return Stats(fields=self.fields["current-stats"]) class Units(Container): # 4 strings: KB/s, MB/s, GB/s, TB/s @property def speed_units(self) -> list[str]: return self.fields["speed-units"] # number of bytes in a KB (1000 for kB; 1024 for KiB) @property def speed_bytes(self) -> int: return self.fields["speed-bytes"] # 4 strings: KB/s, MB/s, GB/s, TB/s @property def size_units(self) -> list[str]: return self.fields["size-units"] # number of bytes in a KB (1000 for kB; 1024 for KiB) @property def size_bytes(self) -> int: return self.fields["size-bytes"] # 4 strings: KB/s, MB/s, GB/s, TB/s @property def memory_units(self) -> list[str]: return self.fields["memory-units"] # number of bytes in a KB (1000 for kB; 1024 for KiB) @property def memory_bytes(self) -> int: return self.fields["memory-bytes"] class Session(Container): """ Session is a class holding the session data for a Transmission daemon. Access the session field can be done through attributes. The attributes available are the same as the session arguments in the Transmission RPC specification, but with underscore instead of hyphen. You should use ``session.download_dir`` to get ``'download-dir'``. .. code-block:: python session = Client().get_session() current = session.download_dir https://github.com/transmission/transmission/blob/main/docs/rpc-spec.md#41-session-arguments Warnings: setter on session's properties has been removed, please use :py:meth`Client.set_session` instead """ @property def alt_speed_down(self) -> int: """max global download speed (KBps)""" return self.fields["alt-speed-down"] @property def alt_speed_enabled(self) -> bool: # true means use the alt speeds return self.fields["alt-speed-enabled"] @property def alt_speed_time_begin(self) -> int: """when to turn on alt speeds (units: minutes after midnight)""" return self.fields["alt-speed-time-begin"] @property def alt_speed_time_day(self) -> int: """what day(s) to turn on alt speeds (look at tr_sched_day)""" return self.fields["alt-speed-time-day"] @property def alt_speed_time_enabled(self) -> bool: """true means the scheduled on/off times are used""" return self.fields["alt-speed-time-enabled"] @property def alt_speed_time_end(self) -> int: """when to turn off alt speeds (units: same)""" return self.fields["alt-speed-time-end"] @property def alt_speed_up(self) -> int: """max global upload speed (KBps)""" return self.fields["alt-speed-up"] @property def blocklist_enabled(self) -> bool: """true means enabled""" return self.fields["blocklist-enabled"] @property def blocklist_size(self) -> int: """int of rules in the blocklist""" return self.fields["blocklist-size"] @property def blocklist_url(self) -> str: """location of the blocklist to use for `blocklist-update`""" return self.fields["blocklist-url"] @property def cache_size_mb(self) -> int: """maximum size of the disk cache (MB)""" return self.fields["cache-size-mb"] @property def config_dir(self) -> str: """location of transmission's configuration directory""" return self.fields["config-dir"] @property def dht_enabled(self) -> bool: """true means allow dht in public torrents""" return self.fields["dht-enabled"] @property def download_dir(self) -> str: """default path to download torrents""" return self.fields["download-dir"] @property def download_dir_free_space(self) -> int: """**DEPRECATED** Use the `free-space` method instead.""" return self.fields["download-dir-free-space"] @property def download_queue_enabled(self) -> bool: """if true, limit how many torrents can be downloaded at once""" return self.fields["download-queue-enabled"] @property def download_queue_size(self) -> int: """max int of torrents to download at once (see download-queue-enabled)""" return self.fields["download-queue-size"] @property def encryption(self) -> Literal["required", "preferred", "tolerated"]: return self.fields["encryption"] @property def idle_seeding_limit_enabled(self) -> bool: """true if the seeding inactivity limit is honored by default""" return self.fields["idle-seeding-limit-enabled"] @property def idle_seeding_limit(self) -> int: """torrents we're seeding will be stopped if they're idle for this long""" return self.fields["idle-seeding-limit"] @property def incomplete_dir_enabled(self) -> bool: """true means keep torrents in incomplete-dir until done""" return self.fields["incomplete-dir-enabled"] @property def incomplete_dir(self) -> str: """path for incomplete torrents, when enabled""" return self.fields["incomplete-dir"] @property def lpd_enabled(self) -> bool: """true means allow Local Peer Discovery in public torrents""" return self.fields["lpd-enabled"] @property def peer_limit_global(self) -> int: """maximum global int of peers""" return self.fields["peer-limit-global"] @property def peer_limit_per_torrent(self) -> int: """maximum global int of peers""" return self.fields["peer-limit-per-torrent"] @property def peer_port_random_on_start(self) -> bool: """true means pick a random peer port on launch""" return self.fields["peer-port-random-on-start"] @property def peer_port(self) -> int: """port int""" return self.fields["peer-port"] @property def pex_enabled(self) -> bool: """true means allow pex in public torrents""" return self.fields["pex-enabled"] @property def port_forwarding_enabled(self) -> bool: """true means ask upstream router to forward the configured peer port to transmission using UPnP or NAT-PMP""" return self.fields["port-forwarding-enabled"] @property def queue_stalled_enabled(self) -> bool: """whether or not to consider idle torrents as stalled""" return self.fields["queue-stalled-enabled"] @property def queue_stalled_minutes(self) -> int: """torrents that are idle for N minutes aren't counted toward seed-queue-size or download-queue-size""" return self.fields["queue-stalled-minutes"] @property def rename_partial_files(self) -> bool: """true means append `.part` to incomplete files""" return self.fields["rename-partial-files"] @property def rpc_version_minimum(self) -> int: """the minimum RPC API version supported""" return self.fields["rpc-version-minimum"] @property def rpc_version(self) -> int: """the current RPC API version""" return self.fields["rpc-version"] @property def script_torrent_done_enabled(self) -> bool: """whether or not to call the `done` script""" return self.fields["script-torrent-done-enabled"] @property def script_torrent_done_filename(self) -> str: """filename of the script to run""" return self.fields["script-torrent-done-filename"] @property def seed_queue_enabled(self) -> bool: """if true, limit how many torrents can be uploaded at once""" return self.fields["seed-queue-enabled"] @property def seed_queue_size(self) -> int: """max int of torrents to uploaded at once (see seed-queue-enabled)""" return self.fields["seed-queue-size"] @property def seed_ratio_limit(self) -> float: """the default seed ratio for torrents to use""" return float(self.fields["seedRatioLimit"]) @property def seed_ratio_limited(self) -> bool: """true if seedRatioLimit is honored by default""" return self.fields["seedRatioLimited"] @property def speed_limit_down_enabled(self) -> bool: """true means enabled""" return self.fields["speed-limit-down-enabled"] @property def speed_limit_down(self) -> int: """max global download speed (KBps)""" return self.fields["speed-limit-down"] @property def speed_limit_up_enabled(self) -> bool: """true means enabled""" return self.fields["speed-limit-up-enabled"] @property def speed_limit_up(self) -> int: """max global upload speed (KBps)""" return self.fields["speed-limit-up"] @property def start_added_torrents(self) -> bool: """true means added torrents will be started right away""" return self.fields["start-added-torrents"] @property def trash_original_torrent_files(self) -> bool: """true means the .torrent file of added torrents will be deleted""" return self.fields["trash-original-torrent-files"] # see below @property def units(self) -> Units: return Units(fields=self.fields["units"]) @property def utp_enabled(self) -> bool: """true means allow utp""" return self.fields["utp-enabled"] @property def version(self) -> str: """long version str `$version ($revision)`""" return self.fields["version"] @property def default_trackers(self) -> list[str] | None: """ list of default trackers to use on public torrents new at rpc-version 17 """ trackers = self.get("default-trackers") if trackers: return trackers.split("\n") return None @property def rpc_version_semver(self) -> str | None: """ the current RPC API version in a semver-compatible str new at rpc-version 17 """ return self.get("rpc-version-semver") @property def script_torrent_added_enabled(self) -> bool | None: """ whether to call the `added` script new at rpc-version 17 """ return self.get("script-torrent-added-enabled") @property def script_torrent_added_filename(self) -> str | None: """ filename of the script to run new at rpc-version 17 """ return self.get("script-torrent-added-filename") @property def script_torrent_done_seeding_enabled(self) -> bool | None: """ whether to call the `seeding-done` script new at rpc-version 17 """ return self.get("script-torrent-done-seeding-enabled") @property def script_torrent_done_seeding_filename(self) -> str | None: """ filename of the script to run new at rpc-version 17 """ return self.get("script-torrent-done-seeding-filename") transmission-rpc-7.0.11/transmission_rpc/torrent.py000066400000000000000000000576071466121555100226040ustar00rootroot00000000000000from __future__ import annotations import enum from datetime import datetime, timedelta, timezone from typing import Any from typing_extensions import deprecated from transmission_rpc.constants import IdleMode, Priority, RatioLimitMode from transmission_rpc.types import Container, File from transmission_rpc.utils import format_timedelta _STATUS_NEW_MAPPING = { 0: "stopped", 1: "check pending", 2: "checking", 3: "download pending", 4: "downloading", 5: "seed pending", 6: "seeding", } def get_status(code: int) -> str: """Get the torrent status using new status codes""" return _STATUS_NEW_MAPPING.get(code) or f"unknown status {code}" class Status(str, enum.Enum): """enum for torrent status""" STOPPED = "stopped" """""" CHECK_PENDING = "check pending" """""" CHECKING = "checking" """""" DOWNLOAD_PENDING = "download pending" """""" DOWNLOADING = "downloading" """""" SEED_PENDING = "seed pending" """""" SEEDING = "seeding" """""" @property def stopped(self) -> bool: """if torrent stopped""" return self == "stopped" @property def check_pending(self) -> bool: """if torrent check pending""" return self == "check pending" @property def checking(self) -> bool: """if torrent checking""" return self == "checking" @property def download_pending(self) -> bool: """if download pending""" return self == "download pending" @property def downloading(self) -> bool: """if downloading""" return self == "downloading" @property def seed_pending(self) -> bool: """if seed pending""" return self == "seed pending" @property def seeding(self) -> bool: """if seeding""" return self == "seeding" def __str__(self) -> str: return self.value class FileStat(Container): """ type for :py:meth:`Torrent.file_stats` """ @property def bytesCompleted(self) -> int: return self.fields["bytesCompleted"] @property def wanted(self) -> int: return self.fields["wanted"] @property def priority(self) -> int: return self.fields["priority"] class Tracker(Container): """ type for :py:attr:`Torrent.trackers` """ @property def id(self) -> int: return self.fields["id"] @property def announce(self) -> str: return self.fields["announce"] @property def scrape(self) -> str: return self.fields["scrape"] @property def tier(self) -> int: return self.fields["tier"] class TrackerStats(Container): """ type for :py:attr:`Torrent.tracker_stats` """ @property def id(self) -> int: return self.fields["id"] @property def announce_state(self) -> int: return self.fields["announceState"] @property def announce(self) -> str: return self.fields["announce"] @property def download_count(self) -> int: return self.fields["downloadCount"] @property def has_announced(self) -> bool: return self.fields["hasAnnounced"] @property def has_scraped(self) -> bool: return self.fields["hasScraped"] @property def host(self) -> str: return self.fields["host"] @property def is_backup(self) -> bool: return self.fields["isBackup"] @property def last_announce_peer_count(self) -> int: return self.fields["lastAnnouncePeerCount"] @property def last_announce_result(self) -> str: return self.fields["lastAnnounceResult"] @property def last_announce_start_time(self) -> int: return self.fields["lastAnnounceStartTime"] @property def last_announce_succeeded(self) -> bool: return self.fields["lastAnnounceSucceeded"] @property def last_announce_time(self) -> int: return self.fields["lastAnnounceTime"] @property def last_announce_timed_out(self) -> bool: return self.fields["lastAnnounceTimedOut"] @property def last_scrape_result(self) -> str: return self.fields["lastScrapeResult"] @property def last_scrape_start_time(self) -> int: return self.fields["lastScrapeStartTime"] @property def last_scrape_succeeded(self) -> bool: return self.fields["lastScrapeSucceeded"] @property def last_scrape_time(self) -> int: return self.fields["lastScrapeTime"] @property def last_scrape_timed_out(self) -> bool: return self.fields["lastScrapeTimedOut"] @property def leecher_count(self) -> int: return self.fields["leecherCount"] @property def next_announce_time(self) -> int: return self.fields["nextAnnounceTime"] @property def next_scrape_time(self) -> int: return self.fields["nextScrapeTime"] @property def scrape_state(self) -> int: return self.fields["scrapeState"] @property def scrape(self) -> str: return self.fields["scrape"] @property def seeder_count(self) -> int: return self.fields["seederCount"] @property def site_name(self) -> str: return self.fields["sitename"] @property def tier(self) -> int: return self.fields["tier"] class Torrent(Container): """ Torrent is a class holding the data received from Transmission regarding a bittorrent transfer. Warnings: setter on Torrent's properties has been removed, please use :py:meth:`Client.change_torrent` instead """ def __init__(self, *, fields: dict[str, Any]): if "id" not in fields: raise ValueError( "Torrent object requires field 'id', " "you need to add 'id' in your 'arguments' when calling 'get_torrent'" ) super().__init__(fields=fields) @property def id(self) -> int: return self.fields["id"] @property def name(self) -> str: return self.fields["name"] @property def hashString(self) -> str: """Torrent info hash string, can also be used as Torrent ID""" return self.fields["hashString"] @property def hash_string(self) -> str: """Torrent info hash string, can also be used as Torrent ID""" return self.fields["hashString"] @property def info_hash(self) -> str: """alias of ``hashString``""" return self.hashString @property @deprecated("this is a typo, do not use this. use `.info_hash` instead") def into_hash(self) -> str: """alias of ``hashString``""" return self.hashString @property def available(self) -> float: """Availability in percent""" bytes_all = self.total_size bytes_done = sum(x["bytesCompleted"] for x in self.fields["fileStats"]) bytes_avail = self.desired_available + bytes_done return float((bytes_avail / bytes_all) * 100 if bytes_all else 0) # @property # def availability(self) -> list: # """TODO""" # return self.fields["availability"] @property def bandwidth_priority(self) -> Priority: """this torrent's bandwidth priority""" return Priority(self.fields["bandwidthPriority"]) @property def comment(self) -> str: return self.fields["comment"] @property def corrupt_ever(self) -> int: """ Byte count of all the corrupt data you've ever downloaded for this torrent. If you're on a poisoned torrent, this number can grow very large. """ return self.fields["corruptEver"] @property def creator(self) -> str: return self.fields["creator"] # TODO # @property # def date_created(self): # return self.fields["dateCreated"] @property def desired_available(self) -> int: """ Byte count of all the piece data we want and don't have yet, but that a connected peer does have. [0...leftUntilDone] """ return self.fields["desiredAvailable"] @property def download_dir(self) -> str: """The download directory. :available: transmission version 1.5. :available: RPC version 4. """ return self.fields["downloadDir"] @property def downloaded_ever(self) -> int: """ Byte count of all the non-corrupt data you've ever downloaded for this torrent. If you deleted the files and downloaded a second time, this will be 2*totalSize. """ return self.fields["downloadedEver"] @property def download_limit(self) -> int: return self.fields["downloadLimit"] @property def download_limited(self) -> bool: return self.fields["downloadLimited"] @property def edit_date(self) -> datetime: """ The last time during this session that a rarely-changing field changed -- e.g. any tr_torrent_metainfo field (trackers, filenames, name) or download directory. RPC clients can monitor this to know when to reload fields that rarely change. """ return datetime.fromtimestamp(self.fields["editDate"], timezone.utc) @property def error(self) -> int: """``0`` for fine task, non-zero for error torrent""" return self.fields["error"] @property def error_string(self) -> str: """empty string for fine task""" return self.fields["errorString"] @property def eta(self) -> timedelta | None: """ the "eta" as datetime.timedelta. If downloading, estimated the ``timedelta`` left until the torrent is done. If seeding, estimated the ``timedelta`` left until seed ratio is reached. raw `eta` maybe negative: - `-1` for ETA Not Available. - `-2` for ETA Unknown. https://github.com/transmission/transmission/blob/3.00/libtransmission/transmission.h#L1748-L1749 """ eta = self.fields["eta"] if eta >= 0: return timedelta(seconds=eta) return None @property def eta_idle(self) -> timedelta | None: v = self.fields["etaIdle"] if v >= 0: return timedelta(seconds=v) return None @property def file_count(self) -> int | None: return self.fields["file-count"] def get_files(self) -> list[File]: """ Get list of files for this torrent. Note: The order of the files is guaranteed. The index of file object is the id of the file when calling :py:meth:`transmission_rpc.Client.change_torrent` .. code-block:: python from transmission_rpc import Client torrent = Client().get_torrent(0) for file in torrent.get_files(): print(file.id) """ result: list[File] = [] if "files" in self.fields: files = self.fields["files"] indices = range(len(files)) priorities = self.fields["priorities"] wanted = self.fields["wanted"] result.extend( File( selected=bool(raw_selected), priority=Priority(raw_priority), size=file["length"], name=file["name"], completed=file["bytesCompleted"], id=id, ) for id, file, raw_priority, raw_selected in zip(indices, files, priorities, wanted) ) return result @property def file_stats(self) -> list[FileStat]: """file stats""" return [FileStat(fields=x) for x in self.fields["fileStats"]] @property def group(self) -> str: return self.get("group", "") @property def have_unchecked(self) -> int: """ Byte count of all the partial piece data we have for this torrent. As pieces become complete, this value may decrease as portions of it are moved to "corrupt" or "haveValid". """ return self.fields["haveUnchecked"] @property def have_valid(self) -> int: """Byte count of all the checksum-verified data we have for this torrent.""" return self.fields["haveValid"] @property def honors_session_limits(self) -> bool: """true if session upload limits are honored""" return self.fields["honorsSessionLimits"] @property def is_finished(self) -> bool: return self.fields["isFinished"] @property def is_private(self) -> bool: return self.fields["isPrivate"] @property def is_stalled(self) -> bool: return self.fields["isStalled"] @property def labels(self) -> list[str]: return self.fields["labels"] @property def left_until_done(self) -> int: """ Byte count of how much data is left to be downloaded until we've got all the pieces that we want. [0...tr_stat.sizeWhenDone] """ return self.fields["leftUntilDone"] @property def magnet_link(self) -> str: return self.fields["magnetLink"] @property def manual_announce_time(self) -> datetime: return datetime.fromtimestamp(self.fields["manualAnnounceTime"], timezone.utc) @property def max_connected_peers(self) -> int: return self.fields["maxConnectedPeers"] @property def metadata_percent_complete(self) -> float: """ How much of the metadata the torrent has. For torrents added from a torrent this will always be 1. For magnet links, this number will from from 0 to 1 as the metadata is downloaded. Range is [0..1] """ return float(self.fields["metadataPercentComplete"]) @property def peer_limit(self) -> int: """maximum number of peers""" return self.fields["peer-limit"] @property def peers(self) -> int: return self.fields["peers"] @property def peers_connected(self) -> int: """Number of peers that we're connected to""" return self.fields["peersConnected"] @property def peers_from(self) -> int: """How many peers we found out about from the tracker, or from pex, or from incoming connections, or from our resume file.""" return self.fields["peersFrom"] @property def peers_getting_from_us(self) -> int: """Number of peers that we're sending data to""" return self.fields["peersGettingFromUs"] @property def peers_sending_to_us(self) -> int: """Number of peers that are sending data to us.""" return self.fields["peersSendingToUs"] @property def percent_complete(self) -> float: """How much has been downloaded of the entire torrent. Range is [0..1]""" return float(self.fields["percentComplete"]) @property def percent_done(self) -> float: """ How much has been downloaded of the files the user wants. This differs from percentComplete if the user wants only some of the torrent's files. Range is [0..1] """ return float(self.fields["percentDone"]) @property def pieces(self) -> str: """ A bitfield holding pieceCount flags which are set to 'true' if we have the piece matching that position. JSON doesn't allow raw binary data, so this is a base64-encoded string. (Source: tr_torrent) """ return self.fields["pieces"] @property def piece_count(self) -> int: return self.fields["pieceCount"] @property def piece_size(self) -> int: return self.fields["pieceSize"] # TODO # @property # def priorities(self): # return self.fields["priorities"] @property def primary_mime_type(self) -> str: return self.fields["primary-mime-type"] @property def queue_position(self) -> int: """position of this torrent in its queue [0...n)""" return self.fields["queuePosition"] @property def rate_download(self) -> int: """download rate (B/s)""" return self.fields["rateDownload"] @property def rate_upload(self) -> int: """upload rate (B/s)""" return self.fields["rateUpload"] @property def recheck_progress(self) -> float: return float(self.fields["recheckProgress"]) @property def seconds_downloading(self) -> int: return self.fields["secondsDownloading"] @property def seconds_seeding(self) -> int: return self.fields["secondsSeeding"] @property def seed_idle_limit(self) -> int: return self.fields["seedIdleLimit"] # @property # def seed_idle_mode(self) -> int: # """ which seeding inactivity to use. See tr_idlelimit""" # return self.fields["seedIdleMode"] @property def size_when_done(self) -> int: return self.fields["sizeWhenDone"] @property def trackers(self) -> list[Tracker]: """trackers of torrent""" return [Tracker(fields=x) for x in self.fields["trackers"]] @property def tracker_list(self) -> list[str]: """list of str of announce URLs""" return [x for x in self.fields["trackerList"].splitlines() if x] @property def tracker_stats(self) -> list[TrackerStats]: """tracker status, for example, announce success/failure status""" return [TrackerStats(fields=x) for x in self.fields["trackerStats"]] @property def total_size(self) -> int: return self.fields["totalSize"] @property def torrent_file(self) -> str: """ torrent file location on transmission server Examples -------- /var/lib/transmission-daemon/.config/transmission-daemon/torrents/00000000000000000000000000.torrent """ return self.fields["torrentFile"] @property def uploaded_ever(self) -> int: return self.fields["uploadedEver"] @property def upload_limit(self) -> int: return self.fields["uploadLimit"] @property def upload_limited(self) -> bool: return self.fields["uploadLimited"] @property def upload_ratio(self) -> float: return float(self.fields["uploadRatio"]) @property def wanted(self) -> list[int]: """if files are wanted, sorted by file index. 1 for wanted 0 for unwanted""" return self.fields["wanted"] @property def webseeds(self) -> list[str]: return self.fields["webseeds"] @property def webseeds_sending_to_us(self) -> int: """Number of webseeds that are sending data to us.""" return self.fields["webseedsSendingToUs"] @property def _status(self) -> int: """Get the torrent status""" return self.fields["status"] @property def _status_str(self) -> str: """Get the torrent status""" return get_status(self.fields["status"]) @property def status(self) -> Status: """ Returns the torrent status. Is either one of 'check pending', 'checking', 'downloading', 'download pending', 'seeding', 'seed pending' or 'stopped'. The first two is related to verification. Examples: .. code-block:: python torrent = Torrent() torrent.status.downloading torrent.status == 'downloading' """ return Status(self._status_str) @property def stopped(self) -> bool: return self._status == 0 @property def check_pending(self) -> bool: return self._status == 1 @property def checking(self) -> bool: return self._status == 2 @property def download_pending(self) -> bool: return self._status == 3 @property def downloading(self) -> bool: return self._status == 4 @property def seed_pending(self) -> bool: return self._status == 5 @property def seeding(self) -> bool: return self._status == 6 @property def progress(self) -> float: """ download progress in percent. """ try: # https://gist.github.com/jackiekazil/6201722#gistcomment-2788556 return round((100.0 * self.fields["percentDone"]), 2) except KeyError: try: size = self.fields["sizeWhenDone"] left = self.fields["leftUntilDone"] return round((100.0 * (size - left) / float(size)), 2) except ZeroDivisionError: return 0.0 @property def ratio(self) -> float: """ upload/download ratio. """ return float(self.fields["uploadRatio"]) @property def activity_date(self) -> datetime: """ The last time we uploaded or downloaded piece data on this torrent. the attribute ``activityDate`` as ``datetime.datetime`` in **UTC timezone**. .. note:: raw ``activityDate`` value could be ``0`` for never activated torrent, therefore it can't always be converted to local timezone. """ return datetime.fromtimestamp(self.fields["activityDate"], timezone.utc) @property def added_date(self) -> datetime: """When the torrent was first added.""" return datetime.fromtimestamp(self.fields["addedDate"], timezone.utc) @property def start_date(self) -> datetime: """raw field ``startDate`` as ``datetime.datetime`` in **utc timezone**.""" return datetime.fromtimestamp(self.fields["startDate"], timezone.utc) @property def done_date(self) -> datetime | None: """the attribute "doneDate" as datetime.datetime. returns None if "doneDate" is invalid.""" done_date = self.fields["doneDate"] # Transmission might forget to set doneDate which is initialized to zero, # so if doneDate is zero return None if done_date == 0: return None return datetime.fromtimestamp(done_date).astimezone() def format_eta(self) -> str: """ Returns the attribute *eta* formatted as a string. * If eta is -1 the result is 'not available' * If eta is -2 the result is 'unknown' * Otherwise eta is formatted as ::. """ eta = self.fields["eta"] if eta == -1: return "not available" if eta == -2: return "unknown" return format_timedelta(timedelta(seconds=eta)) # @property # def download_limit(self) -> Optional[int]: # """The download limit. # # Can be a number or None. # """ # if self.fields["downloadLimited"]: # return self.fields["downloadLimit"] # return None @property def priority(self) -> Priority: """ Bandwidth priority as string. Can be one of 'low', 'normal', 'high'. This is a mutator. """ return Priority(self.fields["bandwidthPriority"]) @property def seed_idle_mode(self) -> IdleMode: """ Seed idle mode as string. Can be one of 'global', 'single' or 'unlimited'. * global, use session seed idle limit. * single, use torrent seed idle limit. See seed_idle_limit. * unlimited, no seed idle limit. """ return IdleMode(self.fields["seedIdleMode"]) @property def seed_ratio_limit(self) -> float: """ Torrent seed ratio limit as float. Also see seed_ratio_mode. This is a mutator. """ return float(self.fields["seedRatioLimit"]) @property def seed_ratio_mode(self) -> RatioLimitMode: """ Seed ratio mode as string. Can be one of 'global', 'single' or 'unlimited'. * global, use session seed ratio limit. * single, use torrent seed ratio limit. See seed_ratio_limit. * unlimited, no seed ratio limit. """ return RatioLimitMode(self.fields["seedRatioMode"]) def __repr__(self) -> str: return f"" def __str__(self) -> str: return f"" transmission-rpc-7.0.11/transmission_rpc/types.py000066400000000000000000000036561466121555100222460ustar00rootroot00000000000000from __future__ import annotations from typing import Any, NamedTuple, Optional, Tuple, TypeVar, Union from transmission_rpc.constants import Priority _Number = Union[int, float] _Timeout = Optional[Union[_Number, Tuple[_Number, _Number]]] T = TypeVar("T") class Container: fields: dict[str, Any] #: raw response data def __init__(self, *, fields: dict[str, Any]): self.fields = fields def get(self, key: str, default: T | None = None) -> Any: """get the raw value by the **raw rpc response key**""" return self.fields.get(key, default) class File(NamedTuple): name: str """file name""" size: int """file size in bytes""" completed: int """bytes completed""" priority: Priority """download priority""" selected: bool """if selected for download""" id: int """id of the file of this torrent, not should not be used outside the torrent scope""" class Group(Container): """ https://github.com/transmission/transmission/blob/4.0.5/docs/rpc-spec.md#482-bandwidth-group-accessor-group-get """ @property def name(self) -> str: """Bandwidth group name""" return self.fields["name"] @property def honors_session_limits(self) -> bool: """true if session upload limits are honored""" return self.fields["honorsSessionLimits"] @property def speed_limit_down_enabled(self) -> bool: """true means enabled""" return self.fields["speed-limit-down-enabled"] @property def speed_limit_down(self) -> int: """max global download speed (KBps)""" return self.fields["speed-limit-down"] @property def speed_limit_up_enabled(self) -> bool: """true means enabled""" return self.fields["speed-limit-up-enabled"] @property def speed_limit_up(self) -> int: """max global upload speed (KBps)""" return self.fields["speed-limit-up"] transmission-rpc-7.0.11/transmission_rpc/utils.py000066400000000000000000000051231466121555100222310ustar00rootroot00000000000000# Copyright (c) 2018-2021 Trim21 # Copyright (c) 2008-2014 Erik Svensson # Licensed under the MIT license. from __future__ import annotations import base64 import datetime import pathlib from typing import BinaryIO from urllib.parse import urlparse from transmission_rpc import constants UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"] def format_size(size: int) -> tuple[float, str]: """ Format byte size into IEC prefixes, B, KiB, MiB ... """ s = float(size) i = 0 while s >= 1024.0 and i < len(UNITS): i += 1 s /= 1024.0 return s, UNITS[i] def format_speed(size: int) -> tuple[float, str]: """ Format bytes per second speed into IEC prefixes, B/s, KiB/s, MiB/s ... """ (s, unit) = format_size(size) return s, f"{unit}/s" def format_timedelta(delta: datetime.timedelta) -> str: """ Format datetime.timedelta into ::. """ minutes, seconds = divmod(delta.seconds, 60) hours, minutes = divmod(minutes, 60) return f"{delta.days:d} {hours:02d}:{minutes:02d}:{seconds:02d}" def get_torrent_arguments(rpc_version: int) -> list[str]: """ Get torrent arguments for method in specified Transmission RPC version. """ accessible = [] for argument, info in constants.TORRENT_GET_ARGS.items(): valid_version = True if rpc_version < info.added_version: valid_version = False if info.removed_version is not None and info.removed_version <= rpc_version: valid_version = False if valid_version: accessible.append(argument) return accessible def _try_read_torrent(torrent: BinaryIO | str | bytes | pathlib.Path) -> str | None: """ if torrent should be encoded with base64, return a non-None value. """ # torrent is a str, may be a url if isinstance(torrent, str): parsed_uri = urlparse(torrent) # torrent starts with file, read from local disk and encode it to base64 url. if parsed_uri.scheme in ["https", "http", "magnet"]: return None if parsed_uri.scheme in ["file"]: raise ValueError("support for `file://` URL has been removed.") elif isinstance(torrent, pathlib.Path): return base64.b64encode(torrent.read_bytes()).decode("utf-8") elif isinstance(torrent, bytes): return base64.b64encode(torrent).decode("utf-8") # maybe a file, try read content and encode it. elif hasattr(torrent, "read"): return base64.b64encode(torrent.read()).decode("utf-8") return None