Script 'mail_helper' called by obssrc
Hello community,
here is the log from the commit of package python-proxmoxer for
openSUSE:Factory checked in at 2026-03-07 20:09:51
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-proxmoxer (Old)
and /work/SRC/openSUSE:Factory/.python-proxmoxer.new.8177 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-proxmoxer"
Sat Mar 7 20:09:51 2026 rev:6 rq:1337381 version:2.3.0
Changes:
--------
--- /work/SRC/openSUSE:Factory/python-proxmoxer/python-proxmoxer.changes
2025-06-11 16:27:30.846946639 +0200
+++
/work/SRC/openSUSE:Factory/.python-proxmoxer.new.8177/python-proxmoxer.changes
2026-03-07 20:14:42.267717012 +0100
@@ -1,0 +2,14 @@
+Thu Mar 05 07:56:20 UTC 2026 - Johannes Kastl
<[email protected]>
+
+- Update to version 2.3.0:
+ * Mark supported Python as 3.10-3.14
+ * Only decode response as JSON if call was successful (#204)
+ * Add ability to pass proxy configuration to https backend (#206)
+ * Update 2FA Mechanism (#158)
+ * Update testing to python 3.10 - 3.13 (#214)
+ * Add test for `pvesh` JSON preceded by non-JSON.
+ * Add workaround for broken `pvesh` output.
+ * Adjust tests for responses==0.25.5 changes
+ * Add exit_code to Response from command_base and ResourceException
+
+-------------------------------------------------------------------
Old:
----
proxmoxer-2.2.0.obscpio
New:
----
proxmoxer-2.3.0.obscpio
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Other differences:
------------------
++++++ python-proxmoxer.spec ++++++
--- /var/tmp/diff_new_pack.Y0Aq2Y/_old 2026-03-07 20:14:42.935744646 +0100
+++ /var/tmp/diff_new_pack.Y0Aq2Y/_new 2026-03-07 20:14:42.935744646 +0100
@@ -1,7 +1,7 @@
#
# spec file for package python-proxmoxer
#
-# Copyright (c) 2025 SUSE LLC
+# Copyright (c) 2026 SUSE LLC and contributors
#
# All modifications and additions to the file contributed by third parties
# remain the property of their copyright owners, unless otherwise agreed
@@ -18,14 +18,14 @@
%{?sle15_python_module_pythons}
Name: python-proxmoxer
-Version: 2.2.0
+Version: 2.3.0
Release: 0
Summary: Python Wrapper for the Proxmox 2x API (HTTP and SSH)
License: MIT
URL: https://github.com/proxmoxer/proxmoxer/
# the Pypi tarball does not contain the tests directory
Source: proxmoxer-%{version}.tar.gz
-BuildRequires: %{python_module base >= 3.8}
+BuildRequires: %{python_module base >= 3.10}
BuildRequires: %{python_module pip}
BuildRequires: %{python_module setuptools}
BuildRequires: %{python_module wheel}
++++++ _service ++++++
--- /var/tmp/diff_new_pack.Y0Aq2Y/_old 2026-03-07 20:14:43.003747459 +0100
+++ /var/tmp/diff_new_pack.Y0Aq2Y/_new 2026-03-07 20:14:43.007747625 +0100
@@ -3,7 +3,7 @@
<param name="url">https://github.com/proxmoxer/proxmoxer</param>
<param name="scm">git</param>
<param name="exclude">.git</param>
- <param name="revision">2.2.0</param>
+ <param name="revision">2.3.0</param>
<param name="versionformat">@PARENT_TAG@</param>
<param name="changesgenerate">enable</param>
</service>
++++++ _servicedata ++++++
--- /var/tmp/diff_new_pack.Y0Aq2Y/_old 2026-03-07 20:14:43.043749114 +0100
+++ /var/tmp/diff_new_pack.Y0Aq2Y/_new 2026-03-07 20:14:43.051749445 +0100
@@ -1,6 +1,6 @@
<servicedata>
<service name="tar_scm">
<param
name="url">https://github.com/proxmoxer/proxmoxer</param>
- <param
name="changesrevision">336a0317123bdd8e1e4a43f789ed289487097f08</param></service></servicedata>
+ <param
name="changesrevision">99fe9814d6212c614a944ce7b3d907e05042c4fa</param></service></servicedata>
(No newline at EOF)
++++++ proxmoxer-2.2.0.obscpio -> proxmoxer-2.3.0.obscpio ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/proxmoxer-2.2.0/.devcontainer/devcontainer.json
new/proxmoxer-2.3.0/.devcontainer/devcontainer.json
--- old/proxmoxer-2.2.0/.devcontainer/devcontainer.json 2024-12-15
03:12:42.000000000 +0100
+++ new/proxmoxer-2.3.0/.devcontainer/devcontainer.json 2026-03-04
03:11:21.000000000 +0100
@@ -7,7 +7,7 @@
"context": "..",
"args": {
// Update 'VARIANT' to pick a Python version: 3, 3.6, 3.7, 3.8, 3.9,
3.10, 3.11
- "VARIANT": "3.8"
+ "VARIANT": "3.10"
}
},
// Set *default* container specific settings.json values on container create.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/proxmoxer-2.2.0/.git-blame-ignore-revs
new/proxmoxer-2.3.0/.git-blame-ignore-revs
--- old/proxmoxer-2.2.0/.git-blame-ignore-revs 2024-12-15 03:12:42.000000000
+0100
+++ new/proxmoxer-2.3.0/.git-blame-ignore-revs 1970-01-01 01:00:00.000000000
+0100
@@ -1,4 +0,0 @@
-# use with `git config blame.ignorerevsfile .git-blame-ignore-revs`
-
-# Format code base with Black
-7a976de985fc7b71fdf31d3161f223eeaada38da
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/proxmoxer-2.2.0/.github/workflows/ci.yaml
new/proxmoxer-2.3.0/.github/workflows/ci.yaml
--- old/proxmoxer-2.2.0/.github/workflows/ci.yaml 2024-12-15
03:12:42.000000000 +0100
+++ new/proxmoxer-2.3.0/.github/workflows/ci.yaml 1970-01-01
01:00:00.000000000 +0100
@@ -1,74 +0,0 @@
-name: CI
-
-on:
- push:
-
- pull_request:
-
- # Allows you to run this workflow manually from the Actions tab
- workflow_dispatch:
-
-jobs:
- unit-test:
- continue-on-error: ${{ github.repository == 'proxmoxer/proxmoxer' }}
- runs-on: ubuntu-latest
- strategy:
- fail-fast: false
- matrix:
- python-version:
- - "3.8"
- - "3.9"
- - "3.10"
- - "3.11"
- - "3.12"
-
- steps:
- - name: Checkout
- uses: actions/checkout@v3
-
- - name: Set up Python
- uses: actions/setup-python@v4
- with:
- python-version: ${{ matrix.python-version }}
-
- - name: Cache PIP packages
- uses: actions/cache@v3
- with:
- path: ~/.cache/pip
- key: ${{ runner.os }}-pip-python${{ matrix.python-version }}-${{
hashFiles('*requirements.txt') }}
- restore-keys: |
- ${{ runner.os }}-pip-python${{ matrix.python-version }}-
- ${{ runner.os }}-pip-
-
- - name: Install pip Packages
- run: pip install -r test_requirements.txt
-
- - name: Install Self as Package
- run: pip install .
-
- - name: Run Tests
- run: pytest -v --cov tests/
-
- - name: Run pre-commit lint/format checks
- uses: pre-commit/[email protected]
-
- - name: Upload coverage data to coveralls.io
- if: github.repository == 'proxmoxer/proxmoxer'
- run: coveralls --service=github
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- COVERALLS_FLAG_NAME: Unit Test (${{ matrix.python-version }})
- COVERALLS_PARALLEL: true
-
-
- complete:
- name: Finalize Coveralls Report
- if: github.repository == 'proxmoxer/proxmoxer'
- needs: unit-test
- runs-on: ubuntu-latest
- steps:
- - name: Coveralls Finished
- uses: coverallsapp/[email protected]
- with:
- parallel-finished: true
- github-token: ${{ secrets.GITHUB_TOKEN }}
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/proxmoxer-2.2.0/.gitignore
new/proxmoxer-2.3.0/.gitignore
--- old/proxmoxer-2.2.0/.gitignore 2024-12-15 03:12:42.000000000 +0100
+++ new/proxmoxer-2.3.0/.gitignore 1970-01-01 01:00:00.000000000 +0100
@@ -1,147 +0,0 @@
-# IDE files
-.idea
-*.code-workspace
-
-coverage.*
-
-# generated files
-README.txt
-
-# Byte-compiled / optimized / DLL files
-__pycache__/
-*.py[cod]
-*$py.class
-
-# C extensions
-*.so
-
-# Distribution / packaging
-.Python
-build/
-develop-eggs/
-dist/
-downloads/
-eggs/
-.eggs/
-lib/
-lib64/
-parts/
-sdist/
-var/
-wheels/
-share/python-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
-
-# Unit test / coverage reports
-htmlcov/
-.tox/
-.nox/
-.coverage
-.coverage.*
-.cache
-nosetests.xml
-coverage.xml
-*.cover
-*.py,cover
-.hypothesis/
-.pytest_cache/
-cover/
-
-# Translations
-*.mo
-*.pot
-
-# Django stuff:
-*.log
-local_settings.py
-db.sqlite3
-db.sqlite3-journal
-
-# Flask stuff:
-instance/
-.webassets-cache
-
-# Scrapy stuff:
-.scrapy
-
-# Sphinx documentation
-docs/_build/
-
-# PyBuilder
-.pybuilder/
-target/
-
-# Jupyter Notebook
-.ipynb_checkpoints
-
-# IPython
-profile_default/
-ipython_config.py
-
-# pyenv
-# For a library or package, you might want to ignore these files since the
code is
-# intended to run in multiple environments; otherwise, check them in:
-# .python-version
-
-# pipenv
-# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in
version control.
-# However, in case of collaboration, if having platform-specific
dependencies or dependencies
-# having no cross-platform support, pipenv may install dependencies that
don't work, or not
-# install all needed dependencies.
-#Pipfile.lock
-
-# PEP 582; used by e.g. github.com/David-OConnor/pyflow
-__pypackages__/
-
-# Celery stuff
-celerybeat-schedule
-celerybeat.pid
-
-# 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/
-.dmypy.json
-dmypy.json
-
-# Pyre type checker
-.pyre/
-
-# pytype static type analyzer
-.pytype/
-
-# Cython debug symbols
-cython_debug/
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/proxmoxer-2.2.0/.pre-commit-config.yaml
new/proxmoxer-2.3.0/.pre-commit-config.yaml
--- old/proxmoxer-2.2.0/.pre-commit-config.yaml 2024-12-15 03:12:42.000000000
+0100
+++ new/proxmoxer-2.3.0/.pre-commit-config.yaml 2026-03-04 03:11:21.000000000
+0100
@@ -1,13 +1,13 @@
repos:
###### FORMATTING ######
- repo: https://github.com/psf/black-pre-commit-mirror
- rev: 23.11.0
+ rev: 23.12.1
hooks:
- id: black
language_version: python3 # Should be a command that runs python3.6+
- repo: https://github.com/PyCQA/isort
- rev: 5.12.0
+ rev: 5.13.2
hooks:
- id: isort
name: isort (python)
@@ -16,14 +16,14 @@
types: [pyi]
###### LINTING ######
- - repo: https://github.com/PyCQA/bandit
- rev: 1.7.5
- hooks:
- - id: bandit
- args: ["--configfile", ".bandit", "--baseline",
"tests/known_issues.json"]
+ # - repo: https://github.com/PyCQA/bandit
+ # rev: 1.7.6
+ # hooks:
+ # - id: bandit
+ # args: ["--configfile", ".bandit", "--baseline",
"tests/known_issues.json"]
- repo: https://github.com/PyCQA/flake8
- rev: 6.1.0
+ rev: 7.1.1
hooks:
- id: flake8
# any flake8 plugins must be included in the hook venv
@@ -35,7 +35,7 @@
# - id: pylint
- repo: https://github.com/pre-commit/pre-commit-hooks
- rev: v4.5.0
+ rev: v5.0.0
hooks:
- id: check-case-conflict
- id: check-symlinks
@@ -46,10 +46,10 @@
args: [--fix=no]
- repo: https://github.com/asottile/blacken-docs
- rev: 1.16.0
+ rev: 1.18.0
hooks:
- id: blacken-docs
- additional_dependencies: [black==23.11.0]
+ additional_dependencies: [black==23.12.1]
- repo: https://github.com/pre-commit/pygrep-hooks
rev: v1.10.0
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/proxmoxer-2.2.0/CHANGELOG.md
new/proxmoxer-2.3.0/CHANGELOG.md
--- old/proxmoxer-2.2.0/CHANGELOG.md 2024-12-15 03:12:42.000000000 +0100
+++ new/proxmoxer-2.3.0/CHANGELOG.md 2026-03-04 03:11:21.000000000 +0100
@@ -1,3 +1,11 @@
+## 2.3.0 (2026-02-07)
+
+* Improvement (all): Add exit_code to Response from command_base and
ResourceException ([John Hollowell](https://github.com/jhollowe))
+* Improvement (local,openssh,paramiko): Add workaround for broken pvesh output
([Markus Reiter](https://github.com/reitermarkus))
+* Bugfix (https): Update 2FA to support modern 2-step flow
([jpattWPC](https://github.com/jpattWPC))
+* Improvement (https): Support direct proxy configuration ([Eric
Baudach](https://github.com/sniffer32))
+* Bugfix (all): Only decode response as JSON if call was successful ([Michael
Ablassmeier](https://github.com/abbbi))
+
## 2.2.0 (2024-12-13)
* Bugfix (local,openssh,paramiko): Remove IP/hostname from command path
([Andrea Dainese](https://github.com/dainok), [John
Hollowell](https://github.com/jhollowe))
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/proxmoxer-2.2.0/proxmoxer/__init__.py
new/proxmoxer-2.3.0/proxmoxer/__init__.py
--- old/proxmoxer-2.2.0/proxmoxer/__init__.py 2024-12-15 03:12:42.000000000
+0100
+++ new/proxmoxer-2.3.0/proxmoxer/__init__.py 2026-03-04 03:11:21.000000000
+0100
@@ -1,6 +1,6 @@
__author__ = "Oleg Butovich"
__copyright__ = "(c) Oleg Butovich 2013-2024"
-__version__ = "2.2.0"
+__version__ = "2.3.0"
__license__ = "MIT"
from .core import * # noqa
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/proxmoxer-2.2.0/proxmoxer/backends/command_base.py
new/proxmoxer-2.3.0/proxmoxer/backends/command_base.py
--- old/proxmoxer-2.2.0/proxmoxer/backends/command_base.py 2024-12-15
03:12:42.000000000 +0100
+++ new/proxmoxer-2.3.0/proxmoxer/backends/command_base.py 2026-03-04
03:11:21.000000000 +0100
@@ -30,8 +30,9 @@
class Response:
- def __init__(self, content, status_code):
+ def __init__(self, content, status_code, exit_code):
self.status_code = status_code
+ self.exit_code = exit_code
self.content = content
self.text = str(content)
self.headers = {"content-type": "application/json"}
@@ -110,7 +111,7 @@
if self.sudo:
full_cmd = ["sudo"] + full_cmd
- stdout, stderr = self._exec(full_cmd)
+ stdout, stderr, exit_code = self._exec(full_cmd)
def is_http_status_string(s):
return re.match(r"\d\d\d [a-zA-Z]", str(s))
@@ -135,8 +136,8 @@
else:
status_code = 200
if stdout:
- return Response(stdout, status_code)
- return Response(stderr, status_code)
+ return Response(stdout, status_code, exit_code)
+ return Response(stderr, status_code, exit_code)
def upload_file_obj(self, file_obj, remote_path):
raise NotImplementedError()
@@ -144,10 +145,24 @@
class JsonSimpleSerializer:
def loads(self, response):
+ # FIXME: Workaround for
https://bugzilla.proxmox.com/show_bug.cgi?id=4333.
+ #
+ # With each iteration, try parsing one fewer line, until
+ # we reach the beginning of the actual JSON message.
try:
- return json.loads(response.content)
- except (UnicodeDecodeError, ValueError):
- return {"errors": response.content}
+ content = response.content
+ if isinstance(content, bytes):
+ content = content.decode("utf-8")
+ content_lines = content.splitlines()
+ while content_lines:
+ try:
+ return json.loads("\n".join(content_lines))
+ except ValueError:
+ content_lines = content_lines[1:]
+ except UnicodeDecodeError:
+ pass
+
+ return {"errors": response.content}
def loads_errors(self, response):
try:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/proxmoxer-2.2.0/proxmoxer/backends/https.py
new/proxmoxer-2.3.0/proxmoxer/backends/https.py
--- old/proxmoxer-2.2.0/proxmoxer/backends/https.py 2024-12-15
03:12:42.000000000 +0100
+++ new/proxmoxer-2.3.0/proxmoxer/backends/https.py 2026-03-04
03:11:21.000000000 +0100
@@ -42,11 +42,12 @@
def get_tokens(self):
return None, None
- def __init__(self, timeout=5, service="PVE", verify_ssl=False, cert=None):
+ def __init__(self, timeout=5, service="PVE", verify_ssl=False, cert=None,
proxies=None):
self.timeout = timeout
self.service = service
self.verify_ssl = verify_ssl
self.cert = cert
+ self.proxies = proxies
class ProxmoxHTTPAuth(ProxmoxHTTPAuthBase):
@@ -54,45 +55,63 @@
# if calls are made less frequently than 2 hrs, using the API token auth
is recommended
renew_age = 3600
- def __init__(self, username, password, otp=None, base_url="", **kwargs):
+ def __init__(self, username, password, otp=None, base_url="",
otptype="totp", **kwargs):
super().__init__(**kwargs)
self.base_url = base_url
self.username = username
self.pve_auth_ticket = ""
- self._get_new_tokens(password=password, otp=otp)
+ self._get_new_tokens(password=password, otp=otp, otptype=otptype)
- def _get_new_tokens(self, password=None, otp=None):
+ def _get_new_tokens(self, password=None, otp=None, otptype=None):
if password is None:
# refresh from existing (unexpired) ticket
password = self.pve_auth_ticket
data = {"username": self.username, "password": password}
- if otp:
- data["otp"] = otp
- response_data = requests.post(
+ response = requests.post(
self.base_url + "/access/ticket",
verify=self.verify_ssl,
timeout=self.timeout,
data=data,
cert=self.cert,
- ).json()["data"]
- if response_data is None:
+ proxies=self.proxies,
+ )
+ if response.status_code != 200:
raise AuthenticationError(
- "Couldn't authenticate user: {0} to {1}".format(
- self.username, self.base_url + "/access/ticket"
+ "Couldn't authenticate user: {0} to {1} code: {2}".format(
+ self.username,
+ self.base_url + "/access/ticket",
+ response.status_code,
)
)
- if response_data.get("NeedTFA") is not None:
- raise AuthenticationError(
- "Couldn't authenticate user: missing Two Factor Authentication
(TFA)"
- )
+ response_data = response.json()["data"]
self.birth_time = time.monotonic()
self.pve_auth_ticket = response_data["ticket"]
self.csrf_prevention_token = response_data["CSRFPreventionToken"]
+ if response_data.get("NeedTFA") is not None:
+ otpdata = {
+ "username": self.username,
+ "tfa-challenge": self.pve_auth_ticket,
+ "password": f"{otptype}:{otp}",
+ }
+ otpresp = response_data = requests.post(
+ self.base_url + "/access/ticket",
+ verify=self.verify_ssl,
+ timeout=self.timeout,
+ data=otpdata,
+ ).json()["data"]
+ if not otpresp:
+ raise AuthenticationError(
+ "Couldn't authenticate user: missing Two Factor
Authentication (TFA)"
+ )
+ self.birth_time = time.monotonic()
+ self.pve_auth_ticket = otpresp["ticket"]
+ self.csrf_prevention_token = otpresp["CSRFPreventionToken"]
+
def get_cookies(self):
return cookiejar_from_dict({self.service + "AuthCookie":
self.pve_auth_ticket})
@@ -207,7 +226,11 @@
# add in filename from file pointer (patch for
https://github.com/requests/toolbelt/pull/316)
# add Content-Type since Proxmox requires it
(https://bugzilla.proxmox.com/show_bug.cgi?id=4344)
- files[k] = (requests.utils.guess_filename(v), v,
"application/octet-stream")
+ files[k] = (
+ requests.utils.guess_filename(v),
+ v,
+ "application/octet-stream",
+ )
del data[k]
# if there are any large files, send all data and files using
streaming multipart encoding
@@ -267,7 +290,9 @@
path_prefix=None,
service="PVE",
cert=None,
+ proxies=None,
):
+ self.proxies = proxies
self.cert = cert
host_port = ""
if len(host.split(":")) > 2: # IPv6
@@ -302,6 +327,7 @@
timeout=timeout,
service=service,
cert=self.cert,
+ proxies=proxies,
)
elif password is not None:
if "password" not in SERVICES[service]["supported_https_auths"]:
@@ -316,6 +342,7 @@
timeout=timeout,
service=service,
cert=self.cert,
+ proxies=proxies,
)
else:
config_failure("No valid authentication credentials were supplied")
@@ -327,6 +354,8 @@
# cookies are taken from the auth
session.headers["Connection"] = "keep-alive"
session.headers["accept"] = self.get_serializer().get_accept_types()
+ if self.proxies:
+ session.proxies.update(self.proxies)
return session
def get_base_url(self):
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/proxmoxer-2.2.0/proxmoxer/backends/local.py
new/proxmoxer-2.3.0/proxmoxer/backends/local.py
--- old/proxmoxer-2.2.0/proxmoxer/backends/local.py 2024-12-15
03:12:42.000000000 +0100
+++ new/proxmoxer-2.3.0/proxmoxer/backends/local.py 2026-03-04
03:11:21.000000000 +0100
@@ -12,7 +12,7 @@
def _exec(self, cmd):
proc = Popen(cmd, stdout=PIPE, stderr=PIPE)
stdout, stderr = proc.communicate(timeout=self.timeout)
- return stdout.decode(), stderr.decode()
+ return stdout.decode(), stderr.decode(), proc.returncode
def upload_file_obj(self, file_obj, remote_path):
with open(remote_path, "wb") as dest_fp:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/proxmoxer-2.2.0/proxmoxer/backends/openssh.py
new/proxmoxer-2.3.0/proxmoxer/backends/openssh.py
--- old/proxmoxer-2.2.0/proxmoxer/backends/openssh.py 2024-12-15
03:12:42.000000000 +0100
+++ new/proxmoxer-2.3.0/proxmoxer/backends/openssh.py 2026-03-04
03:11:21.000000000 +0100
@@ -55,7 +55,7 @@
def _exec(self, cmd):
ret = self.ssh_client.run(shell_join(cmd),
forward_ssh_agent=self.forward_ssh_agent)
- return ret.stdout, ret.stderr
+ return ret.stdout, ret.stderr, ret.returncode
def upload_file_obj(self, file_obj, remote_path):
self.ssh_client.scp((file_obj,), target=remote_path)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/proxmoxer-2.2.0/proxmoxer/backends/ssh_paramiko.py
new/proxmoxer-2.3.0/proxmoxer/backends/ssh_paramiko.py
--- old/proxmoxer-2.2.0/proxmoxer/backends/ssh_paramiko.py 2024-12-15
03:12:42.000000000 +0100
+++ new/proxmoxer-2.3.0/proxmoxer/backends/ssh_paramiko.py 2026-03-04
03:11:21.000000000 +0100
@@ -63,7 +63,7 @@
session.exec_command(shell_join(cmd))
stdout = session.makefile("rb", -1).read().decode()
stderr = session.makefile_stderr("rb", -1).read().decode()
- return stdout, stderr
+ return stdout, stderr, session.recv_exit_status()
def upload_file_obj(self, file_obj, remote_path):
sftp = self.ssh_client.open_sftp()
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/proxmoxer-2.2.0/proxmoxer/core.py
new/proxmoxer-2.3.0/proxmoxer/core.py
--- old/proxmoxer-2.2.0/proxmoxer/core.py 2024-12-15 03:12:42.000000000
+0100
+++ new/proxmoxer-2.3.0/proxmoxer/core.py 2026-03-04 03:11:21.000000000
+0100
@@ -54,7 +54,7 @@
An Exception thrown when an Proxmox API call failed
"""
- def __init__(self, status_code, status_message, content, errors=None):
+ def __init__(self, status_code, status_message, content, errors=None,
exit_code=None):
"""
Create a new ResourceException
@@ -71,6 +71,7 @@
self.status_message = status_message
self.content = content
self.errors = errors
+ self.exit_code = exit_code
if errors is not None:
content += f" - {errors}"
message = f"{status_code} {status_message}: {content}".strip()
@@ -151,6 +152,7 @@
),
resp.reason,
errors=(self._store["serializer"].loads_errors(resp)),
+ exit_code=resp.exit_code if hasattr(resp, "exit_code")
else None,
)
else:
raise ResourceException(
@@ -159,6 +161,7 @@
resp.status_code,
ANYEVENT_HTTP_STATUS_CODES.get(resp.status_code)
),
resp.text,
+ exit_code=resp.exit_code if hasattr(resp, "exit_code")
else None,
)
elif 200 <= resp.status_code <= 299:
return self._store["serializer"].loads(resp)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/proxmoxer-2.2.0/setup.py new/proxmoxer-2.3.0/setup.py
--- old/proxmoxer-2.2.0/setup.py 2024-12-15 03:12:42.000000000 +0100
+++ new/proxmoxer-2.3.0/setup.py 2026-03-04 03:11:21.000000000 +0100
@@ -41,11 +41,11 @@
"Intended Audience :: System Administrators",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
- "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 :: 3.13",
+ "Programming Language :: Python :: 3.14",
"Programming Language :: Python :: 3",
"Programming Language :: Python",
"Topic :: Software Development :: Libraries :: Python Modules",
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/proxmoxer-2.2.0/test_requirements.txt
new/proxmoxer-2.3.0/test_requirements.txt
--- old/proxmoxer-2.2.0/test_requirements.txt 2024-12-15 03:12:42.000000000
+0100
+++ new/proxmoxer-2.3.0/test_requirements.txt 2026-03-04 03:11:21.000000000
+0100
@@ -1,5 +1,6 @@
# required libraries for full functionality
-openssh_wrapper
+# openssh_wrapper # library has not been updated in over a decade, so using a
fork with needed patches
+git+https://github.com/proxmoxer/openssh-wrapper.git@master
paramiko
requests
requests_toolbelt
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/proxmoxer-2.2.0/tests/api_mock.py
new/proxmoxer-2.3.0/tests/api_mock.py
--- old/proxmoxer-2.2.0/tests/api_mock.py 2024-12-15 03:12:42.000000000
+0100
+++ new/proxmoxer-2.3.0/tests/api_mock.py 2026-03-04 03:11:21.000000000
+0100
@@ -8,7 +8,6 @@
import pytest
import responses
-from requests_toolbelt import MultipartEncoder
@pytest.fixture()
@@ -141,8 +140,6 @@
def _cb_echo(self, request):
body = request.body
if body is not None:
- if isinstance(body, MultipartEncoder):
- body = body.to_string() # really, to byte string
body = body if isinstance(body, str) else str(body, "utf-8")
resp = {
@@ -166,7 +163,7 @@
json.dumps({"data": None}),
)
# if this user requires OTP and it is not included
- if form_data_dict.get("username") == "otp" and
form_data_dict.get("otp") is None:
+ if form_data_dict.get("username") == "otp" and "tfa-challenge" not in
form_data_dict:
return (
200,
self.common_headers,
@@ -180,7 +177,30 @@
}
),
)
-
+ # if OTP key is not valid
+ elif (
+ form_data_dict.get("username") == "otp"
+ and form_data_dict.get("tfa-challenge") == "otp_ticket"
+ ):
+ if form_data_dict.get("password") == "totp:123456":
+ return (
+ 200,
+ self.common_headers,
+ json.dumps(
+ {
+ "data": {
+ "ticket": "new_ticket",
+ "CSRFPreventionToken": "CSRFPreventionToken_2",
+ }
+ }
+ ),
+ )
+ else:
+ return (
+ 401,
+ self.common_headers,
+ json.dumps({"data": None}),
+ )
# if this is the first ticket
if form_data_dict.get("password") != "ticket":
return (
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/proxmoxer-2.2.0/tests/test_command_base.py
new/proxmoxer-2.3.0/tests/test_command_base.py
--- old/proxmoxer-2.2.0/tests/test_command_base.py 2024-12-15
03:12:42.000000000 +0100
+++ new/proxmoxer-2.3.0/tests/test_command_base.py 2026-03-04
03:11:21.000000000 +0100
@@ -16,11 +16,12 @@
class TestResponse:
def test_init_all_args(self):
- resp = command_base.Response(b"content", 200)
+ resp = command_base.Response(b"content", 200, 201)
assert resp.content == b"content"
assert resp.text == "b'content'"
assert resp.status_code == 200
+ assert resp.exit_code == 201
assert resp.headers == {"content-type": "application/json"}
assert str(resp) == "Response (200) b'content'"
@@ -48,6 +49,7 @@
resp = self._session.request("GET", self.base_url + "/fake/echo")
assert resp.status_code == 200
+ assert resp.exit_code == 201
assert resp.content == [
"pvesh",
"get",
@@ -60,6 +62,7 @@
resp = self._session.request("GET", self.base_url + "/stdout")
assert resp.status_code == 200
+ assert resp.exit_code == 201
assert (
resp.content ==
"UPID:node:003094EA:095F1EFE:63E88772:download:file.iso:root@pam:done"
)
@@ -67,6 +70,7 @@
resp_stderr = self._session.request("GET", self.base_url + "/stderr")
assert resp_stderr.status_code == 200
+ assert resp.exit_code == 201
assert (
resp_stderr.content
==
"UPID:node:003094EA:095F1EFE:63E88772:download:file.iso:root@pam:done"
@@ -79,6 +83,7 @@
)
assert resp.status_code == 403
+ assert resp.exit_code == 501
assert (
resp.content
==
"pvesh\nget\nhttps://1.2.3.4:1234/api2/json/fake/echo\n-thing\n403
Unauthorized\n--output-format\njson"
@@ -88,6 +93,7 @@
resp = self._session.request("GET", self.base_url + "/fake/echo",
data={"thing": "failure"})
assert resp.status_code == 500
+ assert resp.exit_code == 501
assert (
resp.content
==
"pvesh\nget\nhttps://1.2.3.4:1234/api2/json/fake/echo\n-thing\nfailure\n--output-format\njson"
@@ -99,6 +105,7 @@
)
assert resp.status_code == 200
+ assert resp.exit_code == 201
assert resp.content == [
"sudo",
"pvesh",
@@ -112,6 +119,7 @@
resp = self._session.request("GET", self.base_url + "/fake/echo",
data={"key": "value"})
assert resp.status_code == 200
+ assert resp.exit_code == 201
assert resp.content == [
"pvesh",
"get",
@@ -128,6 +136,7 @@
)
assert resp.status_code == 200
+ assert resp.exit_code == 201
assert resp.content == [
"pvesh",
"get",
@@ -146,6 +155,7 @@
)
assert resp.status_code == 200
+ assert resp.exit_code == 201
assert resp.content == [
"pvesh",
"create",
@@ -166,6 +176,7 @@
)
assert resp.status_code == 200
+ assert resp.exit_code == 201
assert resp.content == [
"pvesh",
"create",
@@ -187,6 +198,7 @@
)
assert resp.status_code == 200
+ assert resp.exit_code == 201
assert resp.content == [
"pvesh",
"create",
@@ -209,7 +221,7 @@
input_str = '{"key1": "value1", "key2": "value2"}'
exp_output = {"key1": "value1", "key2": "value2"}
- response = command_base.Response(input_str.encode("utf-8"), 200)
+ response = command_base.Response(input_str.encode("utf-8"), 200, 201)
act_output = self._serializer.loads(response)
@@ -219,7 +231,7 @@
input_str = "There was an error with the request"
exp_output = {"errors": b"There was an error with the request"}
- response = command_base.Response(input_str.encode("utf-8"), 200)
+ response = command_base.Response(input_str.encode("utf-8"), 200, 201)
act_output = self._serializer.loads(response)
@@ -229,7 +241,20 @@
input_str = '{"data": {"key1": "value1", "key2": "value2"}, "errors":
{}}\x80'
exp_output = {"errors": input_str.encode("utf-8")}
- response = command_base.Response(input_str.encode("utf-8"), 200)
+ response = command_base.Response(input_str.encode("utf-8"), 200, 201)
+
+ act_output = self._serializer.loads(response)
+
+ assert act_output == exp_output
+
+ def test_loads_json_preceded_by_non_json(self):
+ input_str = """
+ virtio0: successfully created disk
'local-zfs:vm-7777-disk-0,discard=on,iothread=1,size=4G'
+ "UPID:net2-pve:002605B4:00FB48C2:62B9E7EB:qmcreate:7777:root@pam:"
+ """
+ exp_output =
"UPID:net2-pve:002605B4:00FB48C2:62B9E7EB:qmcreate:7777:root@pam:"
+
+ response = command_base.Response(input_str.encode("utf-8"), 200, 201)
act_output = self._serializer.loads(response)
@@ -267,21 +292,21 @@
"import tempfile; import sys; tf = tempfile.NamedTemporaryFile();
sys.stdout.write(tf.name)",
]:
return b"/tmp/tmpasdfasdf", None
- return cmd, None
+ return cmd, None, 201
@classmethod
def _exec_err(_, cmd):
- return None, "\n".join(cmd)
+ return None, "\n".join(cmd), 501
@classmethod
def _exec_task(_, cmd):
upid =
"UPID:node:003094EA:095F1EFE:63E88772:download:file.iso:root@pam:done"
if "stderr" in cmd[2]:
- return None, upid
+ return None, upid, 501
else:
- return upid, None
+ return upid, None, 201
@classmethod
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/proxmoxer-2.2.0/tests/test_core.py
new/proxmoxer-2.3.0/tests/test_core.py
--- old/proxmoxer-2.2.0/tests/test_core.py 2024-12-15 03:12:42.000000000
+0100
+++ new/proxmoxer-2.3.0/tests/test_core.py 2026-03-04 03:11:21.000000000
+0100
@@ -58,6 +58,16 @@
== "ResourceException('500 Internal Error: Unable to do the thing
- functionality not found')"
)
+ def test_init_exit_code(self):
+ e = core.ResourceException(500, "Internal Error", "Unable to do the
thing", exit_code=255)
+
+ assert e.status_code == 500
+ assert e.status_message == "Internal Error"
+ assert e.content == "Unable to do the thing"
+ assert e.exit_code == 255
+ assert str(e) == "500 Internal Error: Unable to do the thing"
+ assert repr(e) == "ResourceException('500 Internal Error: Unable to do
the thing')"
+
class TestProxmoxResource:
obj = core.ProxmoxResource()
@@ -183,6 +193,7 @@
),
]
assert exc_info.value.status_code == 500
+ assert exc_info.value.exit_code == 501
assert exc_info.value.status_message == "Internal Server Error"
assert exc_info.value.content == str(b"this is the error")
assert exc_info.value.errors is None
@@ -206,6 +217,7 @@
),
]
assert exc_info.value.status_code == 500
+ assert exc_info.value.exit_code == 501
assert exc_info.value.status_message == "Internal Server Error"
assert exc_info.value.content == "this is the reason"
assert exc_info.value.errors == {"errors": b"this is the error"}
@@ -380,12 +392,12 @@
self.url = url
if "fail" in url:
- r = Response(b"this is the error", 500)
+ r = Response(b"this is the error", 500, 501)
if "reason" in url:
r.reason = "this is the reason"
return r
else:
- return Response(b'{"data": {"key": "value"}}', 200)
+ return Response(b'{"data": {"key": "value"}}', 200, 201)
@pytest.fixture
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/proxmoxer-2.2.0/tests/test_https.py
new/proxmoxer-2.3.0/tests/test_https.py
--- old/proxmoxer-2.2.0/tests/test_https.py 2024-12-15 03:12:42.000000000
+0100
+++ new/proxmoxer-2.3.0/tests/test_https.py 2026-03-04 03:11:21.000000000
+0100
@@ -38,6 +38,14 @@
assert str(exc_info.value) == "No valid authentication credentials
were supplied"
+ def test_init_with_proxy(self):
+ proxy_url = "http://proxy.example.com:8080"
+ proxies = {"http": proxy_url, "https": proxy_url}
+ backend = https.Backend("1.2.3.4:1234", token_name="", proxies=proxies)
+
+ session = backend.get_session()
+ assert session.proxies == proxies
+
def test_init_ip4_separate_port(self):
backend = https.Backend("1.2.3.4", port=1234, token_name="")
exp_base_url = "https://1.2.3.4:1234/api2/json"
@@ -198,7 +206,7 @@
auth = https.ProxmoxHTTPAuth(
"otp",
"password",
- otp="otp",
+ otp="123456",
base_url=self.base_url,
service="PMG",
timeout=1234,
@@ -206,8 +214,8 @@
)
assert auth.username == "otp"
- assert auth.pve_auth_ticket == "ticket"
- assert auth.csrf_prevention_token == "CSRFPreventionToken"
+ assert auth.pve_auth_ticket == "new_ticket"
+ assert auth.csrf_prevention_token == "CSRFPreventionToken_2"
assert auth.service == "PMG"
assert auth.timeout == 1234
assert auth.verify_ssl is True
@@ -239,11 +247,7 @@
assert (
str(exc_info.value)
- == f"Couldn't authenticate user: bad_auth to
{self.base_url}/access/ticket"
- )
- assert (
- repr(exc_info.value)
- == f'AuthenticationError("Couldn\'t authenticate user: bad_auth to
{self.base_url}/access/ticket")'
+ == f"Couldn't authenticate user: bad_auth to
{self.base_url}/access/ticket code: 401"
)
def test_auth_otp(self, mock_pve):
@@ -358,7 +362,7 @@
assert m is not None # content matches multipart for the created file
assert content["headers"]["Content-Type"] == "multipart/form-data;
boundary=" + m[1]
- def test_request_streaming(self, toolbelt_on_off, caplog, mock_pve):
+ def test_request_streaming(self, shrink_thresholds, toolbelt_on_off,
caplog, mock_pve):
caplog.set_level(logging.INFO, logger=MODULE_LOGGER_NAME)
size = https.STREAMING_SIZE_THRESHOLD + 1
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/proxmoxer-2.2.0/tests/test_local.py
new/proxmoxer-2.3.0/tests/test_local.py
--- old/proxmoxer-2.2.0/tests/test_local.py 2024-12-15 03:12:42.000000000
+0100
+++ new/proxmoxer-2.3.0/tests/test_local.py 2026-03-04 03:11:21.000000000
+0100
@@ -54,7 +54,8 @@
'import sys; sys.stdout.write("stdout content");
sys.stderr.write("stderr content")',
]
- stdout, stderr = self._session._exec(cmd)
+ stdout, stderr, exit_code = self._session._exec(cmd)
assert stdout == "stdout content"
assert stderr == "stderr content"
+ assert exit_code == 0
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/proxmoxer-2.2.0/tests/test_openssh.py
new/proxmoxer-2.3.0/tests/test_openssh.py
--- old/proxmoxer-2.2.0/tests/test_openssh.py 2024-12-15 03:12:42.000000000
+0100
+++ new/proxmoxer-2.3.0/tests/test_openssh.py 2026-03-04 03:11:21.000000000
+0100
@@ -53,10 +53,11 @@
"world",
]
- stdout, stderr = mock_session._exec(cmd)
+ stdout, stderr, exit_code = mock_session._exec(cmd)
assert stdout == "stdout content"
assert stderr == "stderr content"
+ assert exit_code == 0
mock_session.ssh_client.run.assert_called_once_with(
"echo hello world",
forward_ssh_agent=True,
@@ -83,7 +84,7 @@
ssh_conn.run = mock.Mock(
# spec=openssh_wrapper.SSHConnection.run,
- return_value=mock.Mock(stdout="stdout content", stderr="stderr
content"),
+ return_value=mock.Mock(stdout="stdout content", stderr="stderr
content", returncode=0),
)
ssh_conn.scp = mock.Mock()
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/proxmoxer-2.2.0/tests/test_paramiko.py
new/proxmoxer-2.3.0/tests/test_paramiko.py
--- old/proxmoxer-2.2.0/tests/test_paramiko.py 2024-12-15 03:12:42.000000000
+0100
+++ new/proxmoxer-2.3.0/tests/test_paramiko.py 2026-03-04 03:11:21.000000000
+0100
@@ -100,10 +100,11 @@
sess = ssh_paramiko.SshParamikoSession("host", "user")
sess.ssh_client = mock_client
- stdout, stderr = sess._exec(["echo", "hello", "world"])
+ stdout, stderr, exit_code = sess._exec(["echo", "hello", "world"])
assert stdout == "stdout contents"
assert stderr == "stderr contents"
+ assert exit_code == 0
mock_session.exec_command.assert_called_once_with("echo hello world")
def test_upload_file_obj(self, mock_ssh_client):
@@ -147,6 +148,7 @@
mock_stderr.read.return_value = b"stderr contents"
mock_channel.makefile.return_value = mock_stdout
mock_channel.makefile_stderr.return_value = mock_stderr
+ mock_channel.recv_exit_status.return_value = 0
mock_transport.open_session.return_value = mock_channel
mock_client.get_transport.return_value = mock_transport
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/proxmoxer-2.2.0/tests/tools/test_tasks.py
new/proxmoxer-2.3.0/tests/tools/test_tasks.py
--- old/proxmoxer-2.2.0/tests/tools/test_tasks.py 2024-12-15
03:12:42.000000000 +0100
+++ new/proxmoxer-2.3.0/tests/tools/test_tasks.py 2026-03-04
03:11:21.000000000 +0100
@@ -17,7 +17,8 @@
caplog.set_level(logging.DEBUG, logger="proxmoxer.core")
status = Tasks.blocking_status(
- mocked_prox,
"UPID:node1:000FF1FD:10F9374C:630D702C:vzdump:110:root@pam:done"
+ mocked_prox,
+ "UPID:node1:000FF1FD:10F9374C:630D702C:vzdump:110:root@pam:done",
)
assert status == {
@@ -81,7 +82,8 @@
caplog.set_level(logging.DEBUG, logger="proxmoxer.core")
status = Tasks.blocking_status(
- mocked_prox,
"UPID:node1:000FF1FD:10F9374C:630D702C:vzdump:110:root@pam:stopped"
+ mocked_prox,
+
"UPID:node1:000FF1FD:10F9374C:630D702C:vzdump:110:root@pam:stopped",
)
assert status == {
@@ -115,11 +117,12 @@
status = Tasks.blocking_status(
mocked_prox,
"UPID:node1:000FF1FD:10F9374C:630D702C:vzdump:110:root@pam:keep-running",
- timeout=0.021,
+ timeout=0.023,
polling_interval=0.01,
)
assert status is None
+ # assert it polls 3 times (1 initial + floor(timeout/polling_interval)
attempts)
assert caplog.record_tuples == [
(
"proxmoxer.core",
@@ -200,7 +203,10 @@
class TestDecodeLog:
def test_basic(self):
- log_list = [{"n": 1, "t": "client connection: 127.0.0.1:49608"}, {"t":
"TASK OK", "n": 2}]
+ log_list = [
+ {"n": 1, "t": "client connection: 127.0.0.1:49608"},
+ {"t": "TASK OK", "n": 2},
+ ]
log_str = Tasks.decode_log(log_list)
assert log_str == "client connection: 127.0.0.1:49608\nTASK OK"
@@ -212,7 +218,11 @@
assert log_str == ""
def test_unordered(self):
- log_list = [{"n": 3, "t": "third"}, {"t": "first", "n": 1}, {"t":
"second", "n": 2}]
+ log_list = [
+ {"n": 3, "t": "third"},
+ {"t": "first", "n": 1},
+ {"t": "second", "n": 2},
+ ]
log_str = Tasks.decode_log(log_list)
assert log_str == "first\nsecond\nthird"
++++++ proxmoxer.obsinfo ++++++
--- /var/tmp/diff_new_pack.Y0Aq2Y/_old 2026-03-07 20:14:43.251757718 +0100
+++ /var/tmp/diff_new_pack.Y0Aq2Y/_new 2026-03-07 20:14:43.251757718 +0100
@@ -1,5 +1,5 @@
name: proxmoxer
-version: 2.2.0
-mtime: 1734228762
-commit: 336a0317123bdd8e1e4a43f789ed289487097f08
+version: 2.3.0
+mtime: 1772590281
+commit: 99fe9814d6212c614a944ce7b3d907e05042c4fa