Script 'mail_helper' called by obssrc
Hello community,
here is the log from the commit of package python-osc-tiny for openSUSE:Factory
checked in at 2022-06-10 15:57:37
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-osc-tiny (Old)
and /work/SRC/openSUSE:Factory/.python-osc-tiny.new.1548 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-osc-tiny"
Fri Jun 10 15:57:37 2022 rev:13 rq:981539 version:0.6.0
Changes:
--------
--- /work/SRC/openSUSE:Factory/python-osc-tiny/python-osc-tiny.changes
2022-05-18 13:13:29.506678560 +0200
+++
/work/SRC/openSUSE:Factory/.python-osc-tiny.new.1548/python-osc-tiny.changes
2022-06-10 15:57:58.868853082 +0200
@@ -1,0 +2,7 @@
+Thu Jun 9 11:53:07 UTC 2022 - Andreas Hasenkopf <[email protected]>
+
+- Release 0.6.0
+ * Support for the "Signature authentication scheme"
+ * Revised method to retrieve credentials from `osc`
+
+-------------------------------------------------------------------
Old:
----
osc-tiny-0.5.0.tar.gz
New:
----
osc-tiny-0.6.0.tar.gz
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Other differences:
------------------
++++++ python-osc-tiny.spec ++++++
--- /var/tmp/diff_new_pack.lGkaGZ/_old 2022-06-10 15:58:00.392854930 +0200
+++ /var/tmp/diff_new_pack.lGkaGZ/_new 2022-06-10 15:58:00.396854935 +0200
@@ -19,7 +19,7 @@
%{?!python_module:%define python_module() python-%{**} python3-%{**}}
%define skip_python2 1
Name: python-osc-tiny
-Version: 0.5.0
+Version: 0.6.0
Release: 0
Summary: Client API for openSUSE BuildService
License: MIT
@@ -44,6 +44,7 @@
Requires: python-python-dateutil
Requires: python-pytz
Requires: python-requests
+Suggests: openssh
BuildArch: noarch
%python_subpackages
++++++ osc-tiny-0.5.0.tar.gz -> osc-tiny-0.6.0.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/osc-tiny-0.5.0/PKG-INFO new/osc-tiny-0.6.0/PKG-INFO
--- old/osc-tiny-0.5.0/PKG-INFO 2022-05-17 14:54:34.581646200 +0200
+++ new/osc-tiny-0.6.0/PKG-INFO 2022-06-09 13:50:02.441182900 +0200
@@ -1,6 +1,6 @@
Metadata-Version: 2.1
Name: osc-tiny
-Version: 0.5.0
+Version: 0.6.0
Summary: Client API for openSUSE BuildService
Home-page: http://github.com/crazyscientist/osc-tiny
Download-URL: http://github.com/crazyscientist/osc-tiny/tarball/master
@@ -16,6 +16,7 @@
Classifier: Programming Language :: Python :: 3.7
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
+Classifier: Programming Language :: Python :: 3.10
Description-Content-Type: text/markdown
License-File: LICENSE
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/osc-tiny-0.5.0/osc_tiny.egg-info/PKG-INFO
new/osc-tiny-0.6.0/osc_tiny.egg-info/PKG-INFO
--- old/osc-tiny-0.5.0/osc_tiny.egg-info/PKG-INFO 2022-05-17
14:54:33.000000000 +0200
+++ new/osc-tiny-0.6.0/osc_tiny.egg-info/PKG-INFO 2022-06-09
13:50:01.000000000 +0200
@@ -1,6 +1,6 @@
Metadata-Version: 2.1
Name: osc-tiny
-Version: 0.5.0
+Version: 0.6.0
Summary: Client API for openSUSE BuildService
Home-page: http://github.com/crazyscientist/osc-tiny
Download-URL: http://github.com/crazyscientist/osc-tiny/tarball/master
@@ -16,6 +16,7 @@
Classifier: Programming Language :: Python :: 3.7
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
+Classifier: Programming Language :: Python :: 3.10
Description-Content-Type: text/markdown
License-File: LICENSE
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/osc-tiny-0.5.0/osc_tiny.egg-info/SOURCES.txt
new/osc-tiny-0.6.0/osc_tiny.egg-info/SOURCES.txt
--- old/osc-tiny-0.5.0/osc_tiny.egg-info/SOURCES.txt 2022-05-17
14:54:34.000000000 +0200
+++ new/osc-tiny-0.6.0/osc_tiny.egg-info/SOURCES.txt 2022-06-09
13:50:02.000000000 +0200
@@ -40,6 +40,7 @@
osctiny/tests/osc/__init__.py
osctiny/tests/osc/conf.py
osctiny/utils/__init__.py
+osctiny/utils/auth.py
osctiny/utils/backports.py
osctiny/utils/base.py
osctiny/utils/changelog.py
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/osc-tiny-0.5.0/osctiny/osc.py
new/osc-tiny-0.6.0/osctiny/osc.py
--- old/osc-tiny-0.5.0/osctiny/osc.py 2022-05-17 14:54:21.000000000 +0200
+++ new/osc-tiny-0.6.0/osctiny/osc.py 2022-06-09 13:49:46.000000000 +0200
@@ -3,9 +3,12 @@
---------------
"""
from __future__ import unicode_literals
+
+import typing
from io import BufferedReader, BytesIO, StringIO
import gc
import logging
+from pathlib import Path
import re
from ssl import get_default_verify_paths
import time
@@ -16,6 +19,7 @@
from lxml.objectify import fromstring, makeparser
from requests import Session, Request
from requests.auth import HTTPBasicAuth
+from requests.cookies import RequestsCookieJar, cookiejar_from_dict
from requests.exceptions import ConnectionError as _ConnectionError
from .extensions.buildresults import Build
@@ -28,6 +32,7 @@
from .extensions.bs_requests import Request as BsRequest
from .extensions.search import Search
from .extensions.users import Group, Person
+from .utils.auth import HttpSignatureAuth
from .utils.conf import get_credentials
from .utils.errors import OscError
@@ -78,10 +83,12 @@
- :py:attr:`origins`
:param url: API URL of a BuildService instance
- :param username: Credential for login
- :param password: Password for login
+ :param username: Username
+ :param password: Password; this is either the user password or the SSH
passphrase, if
+ ``ssh_key_file`` is defined
:param verify: See `SSL Cert Verification`_ for more details
:param cache: Store API responses in a cache
+ :param ssh_key_file: Path to SSH private key file
:raises osctiny.errors.OscError: if no credentials are provided
.. versionadded:: 0.1.1
@@ -102,6 +109,10 @@
.. versionchanged:: 0.4.0
Raises an exception when no credentials are provided
+ .. versionchanged:: 0.6.0
+ Support for 2FA authentication (i.e. added the ``ssh_key_file``
parameter and changed the
+ meaning of the ``password`` parameter
+
.. _SSL Cert Verification:
http://docs.python-requests.org/en/master/user/advanced/
#ssl-cert-verification
@@ -115,22 +126,26 @@
default_connection_retries = 5
default_retry_timeout = 5
- def __init__(self, url=None, username=None, password=None, verify=None,
- cache=False):
+ def __init__(self, url: str = None, username: typing.Optional[str] = None,
+ password: typing.Optional[str] = None, verify:
typing.Optional[str] = None,
+ cache: bool = False,
+ ssh_key_file: typing.Optional[typing.Union[Path, str]] =
None):
# Basic URL and authentication settings
self.url = url or self.url
self.username = username or self.username
self.password = password or self.password
+ self.verify = verify
+ self.cache = cache
+ self.ssh_key = ssh_key_file
+ if self.ssh_key is not None and not isinstance(self.ssh_key, Path):
+ self.ssh_key = Path(self.ssh_key)
- if not self.username and not self.password:
+ if not self.username and not self.password and not self.ssh_key:
try:
- self.username, self.password = get_credentials(self.url)
+ self.username, self.password, self.ssh_key =
get_credentials(self.url)
except (ValueError, NotImplementedError, FileNotFoundError) as
error:
raise OscError from error
- self._session = Session()
- self._session.verify = verify or get_default_verify_paths().capath
- self.auth = HTTPBasicAuth(self.username, self.password)
self.parser = makeparser(huge_tree=True)
# API endpoints
@@ -146,8 +161,50 @@
self.search = Search(osc_obj=self)
self.users = Person(osc_obj=self)
+ self._session, self.session = None, None
+
+ def __del__(self):
+ # Just in case ;-)
+ gc.collect()
+
+ @property
+ def cookies(self) -> RequestsCookieJar:
+ """
+ Access session cookies
+ """
+ if self._session is None:
+ self._init_session()
+
+ return self.session.cookies
+
+ @cookies.setter
+ def cookies(self, value: RequestsCookieJar):
+ if not isinstance(value, (RequestsCookieJar, dict)):
+ raise TypeError(f"Expected a cookie jar or dict. Got instead:
{type(value)}")
+
+ if self._session is None:
+ self._init_session()
+
+ if isinstance(value, RequestsCookieJar):
+ self._session.cookies = value
+ else:
+ self._session.cookies = cookiejar_from_dict(value)
+
+ def _init_session(self):
+ """
+ Lazy session initialization
+ """
+ self._session = Session()
+ self._session.verify = self.verify or get_default_verify_paths().capath
+
+ if self.ssh_key is not None:
+ self._session.auth = HttpSignatureAuth(username=self.username,
password=self.password,
+ ssh_key_file=self.ssh_key)
+ else:
+ self._session.auth = HTTPBasicAuth(self.username, self.password)
+
# Cache
- if cache:
+ if self.cache:
# pylint: disable=broad-except
try:
self.session = CacheControl(self._session)
@@ -158,10 +215,6 @@
else:
self.session = self._session
- def __del__(self):
- # Just in case ;-)
- gc.collect()
-
def request(self, url, method="GET", stream=False, data=None, params=None,
raise_for_status=True, timeout=None):
"""
@@ -191,7 +244,7 @@
.. versionadded:: 0.1.7
Added parameter `params`
- .. versionchanged:: {{ NEXT_RELEASE }}
+ .. versionchanged:: 0.5.0
Added logging of request/response
:param url: Full URL
@@ -215,6 +268,9 @@
https://2.python-requests.org/en/master/user/advanced/#timeouts
"""
timeout = timeout or self.default_timeout
+ if self._session is None:
+ self._init_session()
+
if stream:
session = self._session
else:
@@ -223,7 +279,6 @@
req = Request(
method,
url.replace("#", quote("#")).replace("?", quote("?")),
- auth=self.auth,
data=self.handle_params(data),
params=self.handle_params(params)
)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/osc-tiny-0.5.0/osctiny/tests/test_basic.py
new/osc-tiny-0.6.0/osctiny/tests/test_basic.py
--- old/osc-tiny-0.5.0/osctiny/tests/test_basic.py 2022-05-17
14:54:21.000000000 +0200
+++ new/osc-tiny-0.6.0/osctiny/tests/test_basic.py 2022-06-09
13:49:46.000000000 +0200
@@ -75,7 +75,6 @@
def callback(headers, params, request):
match = pattern.match(request.url)
self.assertIsNotNone(match)
- print(match.groups())
self.assertEqual(unquote_plus(match.group("filename")), filename)
for special_c in special_chars:
self.assertNotIn(special_c, match.group("filename"))
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/osc-tiny-0.5.0/osctiny/tests/test_utils.py
new/osc-tiny-0.6.0/osctiny/tests/test_utils.py
--- old/osc-tiny-0.5.0/osctiny/tests/test_utils.py 2022-05-17
14:54:21.000000000 +0200
+++ new/osc-tiny-0.6.0/osctiny/tests/test_utils.py 2022-06-09
13:49:46.000000000 +0200
@@ -15,6 +15,7 @@
from ..utils.changelog import ChangeLog, Entry
from ..utils.conf import get_config_path, get_credentials
+from ..utils.mapping import Mappable
sys.path.append(os.path.dirname(__file__))
@@ -77,6 +78,19 @@
"""
+class TestMappable(TestCase):
+ def test(self):
+ m = Mappable(a="a", b="b")
+ m["c"] = "c"
+
+ for key in ('a', 'b', 'c'):
+ with self.subTest(f"get {key}"):
+ self.assertEqual(key, m.get(key))
+
+ with self.subTest("Default"):
+ self.assertEqual("f????", m.get("d", "f????"))
+
+
class TestEntry(TestCase):
def test_timestamp(self):
cet = timezone("Europe/Berlin")
@@ -317,7 +331,7 @@
self.assertIn("Cannot parse changelog entry", wmock.call_args[0][0])
[email protected]("osc.conf", side_effect=ImportError, create=True)
[email protected]("osctiny.utils.conf._conf", new_callable=lambda: None, create=True)
@mock.patch("pathlib.Path.is_file", return_value=True)
class TestConfig(TestCase):
def test_get_config_path(self, *_):
@@ -334,8 +348,8 @@
_, path1 = mkstemp()
_, path2 = mkstemp()
- expected_insecure_credentials = ("my-dummy-user",
"my-insecure-dummy-password")
- expected_secure_credentials = ('my-dummy-user',
'my-secure-dummy-password')
+ expected_insecure_credentials = ("my-dummy-user",
"my-insecure-dummy-password", None)
+ expected_secure_credentials = ('my-dummy-user',
'my-secure-dummy-password', None)
with open(path1, "w") as handle:
handle.write("[http://api.dummy-bs.org]\n")
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/osc-tiny-0.5.0/osctiny/utils/auth.py
new/osc-tiny-0.6.0/osctiny/utils/auth.py
--- old/osc-tiny-0.5.0/osctiny/utils/auth.py 1970-01-01 01:00:00.000000000
+0100
+++ new/osc-tiny-0.6.0/osctiny/utils/auth.py 2022-06-09 13:49:46.000000000
+0200
@@ -0,0 +1,135 @@
+"""
+Authentication handlers for 2FA
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+.. versionadded:: 0.6.0
+"""
+import typing
+from base64 import b64decode, b64encode
+from pathlib import Path
+from subprocess import Popen, PIPE
+import re
+import sys
+from time import time
+
+from requests.auth import HTTPDigestAuth
+from requests.cookies import extract_cookies_to_jar
+from requests.utils import parse_dict_header
+from requests import Response
+
+from .errors import OscError
+
+
+class HttpSignatureAuth(HTTPDigestAuth):
+ """
+ Implementation of the "Signature authentication scheme"
+
+ .. note::
+
+ This seems to be a variation of the `HTTP Message Signatures`_
specification.
+
+ See also the `reference implementation for osc`_
+
+ .. _HTTP Message Signatures:
+
https://datatracker.ietf.org/doc/draft-ietf-httpbis-message-signatures/
+
+ .. _reference implementation for osc:
https://github.com/openSUSE/osc/pull/1032
+
+ :param username: The username
+ :param password: Passphrase for SSH key. This can be omitted, if
``ssh-agent`` is also installed
+ :param ssh_key_file: Path of SSK key
+ """
+ def __init__(self, username: str, password: typing.Optional[str],
ssh_key_file: Path):
+ super().__init__(username=username, password=password)
+ if not ssh_key_file.is_file():
+ raise FileNotFoundError(f"SSH key at location does not exist:
{ssh_key_file}")
+ self.ssh_key_file = ssh_key_file
+ self.pattern = re.compile(r"(?<=\)) (?=\()")
+
+ def __eq__(self, other: 'HttpSignatureAuth') -> bool:
+ return self.ssh_key_file == getattr(other, 'ssh_key_file', None) and
super().__eq__(other)
+
+ def split_headers(self, headers: str) -> typing.List[str]:
+ """
+ Split ``headers`` parameter from ``WWW-Authenticate Signature`` header
+
+ :param headers: Value of the ``headers`` parameter
+ """
+ parts = self.pattern.split(headers)
+ return [part.strip("()") for part in parts]
+
+ def ssh_sign(self) -> str:
+ """
+ Solve the challenge via SSH signing
+ """
+ data = "\n".join(f"({header}): {self._thread_local.chal[header]}"
+ for header in self._thread_local.chal["headers"])
+ cmd = ['ssh-keygen', '-Y', 'sign', '-f', self.ssh_key_file.as_posix(),
'-q',
+ '-n', self._thread_local.chal.get('realm', '')]
+ if self.password:
+ cmd += ['-P', self.password]
+
+ encoding = sys.getdefaultencoding()
+ with Popen(cmd, stdout=PIPE, stderr=PIPE, stdin=PIPE) as proc:
+ signature, error = proc.communicate(data.encode(encoding))
+ if proc.returncode:
+ raise OscError(f"ssh-keygen returned {proc.returncode}:
{error}")
+
+ match = re.match(br"\A-----BEGIN SSH SIGNATURE-----\n(.*)\n-----END
SSH SIGNATURE-----",
+ signature, re.S)
+ if not match:
+ raise OscError("Could not generate challenge response")
+ return b64encode(b64decode(match.group(1))).decode(encoding)
+
+ def build_digest_header(self, method: str, url: str) -> str:
+ """
+ Generate Authentication header
+ """
+ headers = " ".join(f"({header})" for header in
self._thread_local.chal["headers"])
+ return f'Signature
keyId="{self.username}",algorithm="ssh",signature={self.ssh_sign()},' \
+
f'headers="{headers}",created={self._thread_local.chal["created"]}'
+
+ def handle_401(self, r: Response, **kwargs) -> Response:
+ """
+ Handle authentication in case of 401
+
+ Contents of method copied from
:py:meth:`requests.auth.HTTPDigestAuth.handle_401` and edited
+ """
+ if not 400 <= r.status_code < 500:
+ self._thread_local.num_401_calls = 1
+ return r
+
+ if self._thread_local.pos is not None:
+ # Rewind the file position indicator of the body to where
+ # it was to resend the request.
+ r.request.body.seek(self._thread_local.pos)
+ s_auth = r.headers.get('www-authenticate', '')
+
+ if s_auth.lower().startswith("signature") and
self._thread_local.num_401_calls < 2:
+ self._thread_local.num_401_calls += 1
+
+ _, challenge = s_auth.split(" ", maxsplit=1)
+ challenge = parse_dict_header(challenge)
+ challenge.setdefault("headers", ["created"])
+ challenge["created"] = int(time())
+ challenge["headers"] = self.split_headers(challenge["headers"])
+ self._thread_local.chal.update(challenge)
+
+ # The following is unchanged from
:py:meth:`requests.auth.HTTPDigestAuth.handle_401`,
+ # so we ignore linter issues about it.
+ # pylint: disable=pointless-statement,protected-access
+ r.content
+ r.close()
+ prep = r.request.copy()
+ extract_cookies_to_jar(prep._cookies, r.request, r.raw)
+ prep.prepare_cookies(prep._cookies)
+ prep.headers['Authorization'] = self.build_digest_header(
+ prep.method, prep.url)
+ _r = r.connection.send(prep, **kwargs)
+ _r.history.append(r)
+ _r.request = prep
+
+ return _r
+
+ self._thread_local.num_401_calls = 1
+ return r
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/osc-tiny-0.5.0/osctiny/utils/conf.py
new/osc-tiny-0.6.0/osctiny/utils/conf.py
--- old/osc-tiny-0.5.0/osctiny/utils/conf.py 2022-05-17 14:54:21.000000000
+0200
+++ new/osc-tiny-0.6.0/osctiny/utils/conf.py 2022-06-09 13:49:46.000000000
+0200
@@ -8,6 +8,7 @@
.. versionadded:: 0.4.0
"""
+import typing
from base64 import b64decode
from bz2 import decompress
from configparser import ConfigParser, NoSectionError
@@ -17,11 +18,12 @@
try:
from osc import conf as _conf
+ from osc.oscerr import ConfigError, ConfigMissingApiurl
except ImportError:
_conf = None
-def get_config_path():
+def get_config_path() -> Path:
"""
Return path of ``osc`` configuration file
@@ -43,7 +45,9 @@
raise FileNotFoundError("No `osc` configuration file found")
-def get_credentials(url=None):
+# pylint: disable=too-many-branches
+def get_credentials(url: typing.Optional[str] = None) \
+ -> typing.Tuple[str, str, typing.Optional[Path]]:
"""
Get credentials for Build Service instance identified by ``url``
@@ -56,26 +60,31 @@
:param str url: URL of Build Service instance (including schema). If not
specified, the value
from the ``apiurl`` parameter in the config file will be
used.
- :return: (username, password)
+ :return: (username, password, SSH private key path)
:raises ValueError: if config provides no credentials
"""
if _conf is not None:
- # pylint: disable=protected-access
- parser = _conf.get_configParser()
try:
+ _conf.get_config()
if url is None:
- url = parser["general"].get("apiurl", url)
- cred_mgr = _conf._get_credentials_manager(url, parser)
- username = _conf._extract_user_compat(parser, url, cred_mgr)
- except (KeyError, NoSectionError) as error:
- raise ValueError("`osc` config does not provide the default API
URL") from error
+ # get the default api url from osc's config
+ url = _conf.config["apiurl"]
+ # and now fetch the options for that particular url
+ api_config = _conf.get_apiurl_api_host_options(url)
+ username = api_config["user"]
+ password = api_config["pass"]
+ sshkey = Path(api_config["sshkey"]) if api_config["sshkey"] else
None
+ except (ConfigError, ConfigMissingApiurl) as error:
+ if isinstance(error, ConfigError):
+ raise ValueError("`osc` config was not found.") from error
+ # this is the case of ConfigMissingApiurl
+ raise ValueError("`osc` config has no options for URL
{}".format(url)) from error
if not username:
raise ValueError("`osc` config provides no username for URL
{}".format(url))
- password = cred_mgr.get_password(url, username, defer=False)
if not password:
raise ValueError("`osc` config provides no password for URL
{}".format(url))
- return username, password
+ return username, password, sshkey
warnings.warn("`osc` is not installed. Not all configuration backends of
`osc` will be "
"available.")
@@ -104,4 +113,8 @@
if not password:
raise ValueError("`osc` config provides no password for URL
{}".format(url))
- return username, password
+ sshkey = parser[url].get("sshkey", None)
+ if sshkey:
+ sshkey = Path(sshkey)
+
+ return username, password, sshkey
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/osc-tiny-0.5.0/osctiny/utils/mapping.py
new/osc-tiny-0.6.0/osctiny/utils/mapping.py
--- old/osc-tiny-0.5.0/osctiny/utils/mapping.py 2022-05-17 14:54:21.000000000
+0200
+++ new/osc-tiny-0.6.0/osctiny/utils/mapping.py 2022-06-09 13:49:46.000000000
+0200
@@ -48,7 +48,7 @@
def get(self, key, default=None):
try:
- return self.__getitem__(key)
+ return self[key]
except KeyError:
return default
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/osc-tiny-0.5.0/setup.py new/osc-tiny-0.6.0/setup.py
--- old/osc-tiny-0.5.0/setup.py 2022-05-17 14:54:21.000000000 +0200
+++ new/osc-tiny-0.6.0/setup.py 2022-06-09 13:49:46.000000000 +0200
@@ -19,7 +19,7 @@
setup(
name='osc-tiny',
- version='0.5.0',
+ version='0.6.0',
description='Client API for openSUSE BuildService',
long_description=long_description,
long_description_content_type="text/markdown",
@@ -40,5 +40,6 @@
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
+ "Programming Language :: Python :: 3.10",
]
)