Skip to content

Commit 376e2c9

Browse files
auburusallburov
andauthored
Python 3.12 compat (#463)
* refactor: change ArtifactoryFlavour to behave like UnixFlavour When checking the root in a UnixFlavour system, it is / for an absolute path, and '' for a relative one, and unix systems have no drive. For artifactory, if we consider the drive to be "http://b/artifactory" and root to be "/", it behaves exactly like Windows and Unix Flavours, and no if's and exceptions to handle the root are needed in the code. This refactor makes the migration to python 3.12 much easier, since we can subclass pathlib.Path directly and we don't need to override as many methods. Notice: This is a breaking change if you are accessing the `root` property directly, but I'm assuming that mostly everyone should be using the `repo` accessor for that. There is also some minor changes in leading/trailing slashes * feat: Code works under python3.12, but not under older versions of python * fix: Make project work in 3.11 and 3.12 Also, if tox is installed with python3.12, some pre-commit plugins didn't work, so I have updated those. * Reorganize code based on python 3.11 and 3.12 This commit addresses the concerns raised in the Pull request. It helps minimize the amount of changes (way less indentation) compared to master. Also, used a single constant for marking code that will be removed once 3.11 is deprecated. This commit likely should be "merged" into the previous two once the PR review process is over. And if not, well, you have this nice comment here forever :) --------- Co-authored-by: allburov <[email protected]>
1 parent e386952 commit 376e2c9

File tree

6 files changed

+266
-112
lines changed

6 files changed

+266
-112
lines changed

.github/workflows/ci.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ jobs:
1414
strategy:
1515
fail-fast: false
1616
matrix:
17-
python-version: ['3.8', '3.9', '3.10', '3.11']
17+
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12']
1818

1919
steps:
2020
- uses: actions/checkout@v3

.pre-commit-config.yaml

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
repos:
2-
- repo: https://github.com/humitos/mirrors-autoflake
3-
rev: v1.1
2+
- repo: https://github.com/PyCQA/autoflake
3+
rev: v2.3.1
44
hooks:
55
- id: autoflake
66
args: ['-i', '--remove-all-unused-imports']
@@ -19,7 +19,7 @@ repos:
1919
hooks:
2020
- id: reorder-python-imports
2121
- repo: https://github.com/pycqa/flake8
22-
rev: 6.0.0
22+
rev: 6.1.0
2323
hooks:
2424
- id: flake8
2525
- repo: https://github.com/mgedmin/check-python-versions

artifactory.py

+123-19
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,11 @@
7171
default_config_path = "~/.artifactory_python.cfg"
7272
global_config = None
7373

74+
# Pathlib.Path changed significantly in 3.12, so we will not need several
75+
# parts of the code once python3.11 is no longer supported. This constant helps
76+
# identifying those.
77+
_IS_PYTHON_3_12_OR_NEWER = sys.version_info >= (3, 12)
78+
7479

7580
def read_config(config_path=default_config_path):
7681
"""
@@ -422,7 +427,7 @@ def quote_url(url):
422427
return quoted_url
423428

424429

425-
class _ArtifactoryFlavour(pathlib._Flavour):
430+
class _ArtifactoryFlavour(object if _IS_PYTHON_3_12_OR_NEWER else pathlib._Flavour):
426431
"""
427432
Implements Artifactory-specific pure path manipulations.
428433
I.e. what is 'drive', 'root' and 'path' and how to split full path into
@@ -432,7 +437,7 @@ class _ArtifactoryFlavour(pathlib._Flavour):
432437
drive: in context of artifactory, it's the base URI like
433438
http://mysite/artifactory
434439
435-
root: repository, e.g. 'libs-snapshot-local' or 'ext-release-local'
440+
root: like in unix, / when absolute, empty when relative
436441
437442
path: relative artifact path within the repository
438443
"""
@@ -458,13 +463,6 @@ def join_parsed_parts(self, drv, root, parts, drv2, root2, parts2):
458463
drv, root, parts, drv2, root2, parts2
459464
)
460465

461-
if not root2 and len(parts2) > 1:
462-
root2 = self.sep + parts2.pop(1) + self.sep
463-
464-
# quick hack for https://github.com/devopshq/artifactory/issues/29
465-
# drive or repository must start with / , if not - add it
466-
if not drv2.endswith("/") and not root2.startswith("/"):
467-
drv2 = drv2 + self.sep
468466
return drv2, root2, parts2
469467

470468
def splitroot(self, part, sep=sep):
@@ -501,7 +499,7 @@ def splitroot(self, part, sep=sep):
501499

502500
if url.path is None or url.path == sep:
503501
if url.scheme:
504-
return part.rstrip(sep), "", ""
502+
return part.rstrip(sep), "/", ""
505503
return "", "", part
506504
elif url.path.lstrip("/").startswith("artifactory"):
507505
mark = sep + "artifactory" + sep
@@ -510,8 +508,8 @@ def splitroot(self, part, sep=sep):
510508
path = self._get_path(part)
511509
drv = part.rpartition(path)[0]
512510
path_parts = path.strip(sep).split(sep)
513-
root = sep + path_parts[0] + sep
514-
rest = sep.join(path_parts[1:])
511+
root = sep
512+
rest = sep.join(path_parts[0:])
515513
return drv, root, rest
516514

517515
if len(parts) >= 2:
@@ -524,14 +522,14 @@ def splitroot(self, part, sep=sep):
524522
rest = part
525523

526524
if not rest:
527-
return drv, "", ""
525+
return drv, "/", ""
528526

529527
if rest == sep:
530-
return drv, "", ""
528+
return drv, "/", ""
531529

532530
if rest.startswith(sep):
533-
root, _, part = rest[1:].partition(sep)
534-
root = sep + root + sep
531+
root = sep
532+
part = rest.lstrip("/")
535533

536534
return drv, root, part
537535

@@ -587,6 +585,29 @@ def make_uri(self, path):
587585
"""
588586
return path
589587

588+
def normcase(self, path):
589+
return path
590+
591+
def splitdrive(self, path):
592+
drv, root, part = self.splitroot(path)
593+
return (drv + root, self.sep.join(part))
594+
595+
# This function is consumed by PurePath._load_parts() after python 3.12
596+
def join(self, path, *paths):
597+
drv, root, part = self.splitroot(path)
598+
599+
for next_path in paths:
600+
drv2, root2, part2 = self.splitroot(next_path)
601+
if drv2 != "":
602+
drv, root, part = drv2, root2, part2
603+
continue
604+
if root2 != "":
605+
root, part = root2, part2
606+
continue
607+
part = part + self.sep + part2
608+
609+
return drv + root + part
610+
590611

591612
class _ArtifactorySaaSFlavour(_ArtifactoryFlavour):
592613
def _get_base_url(self, url):
@@ -877,7 +898,11 @@ def get_stat_json(self, pathobj, key=None):
877898
)
878899
code = response.status_code
879900
text = response.text
880-
if code == 404 and ("Unable to find item" in text or "Not Found" in text or "File not found" in text):
901+
if code == 404 and (
902+
"Unable to find item" in text
903+
or "Not Found" in text
904+
or "File not found" in text
905+
):
881906
raise OSError(2, f"No such file or directory: {url}")
882907

883908
raise_for_status(response)
@@ -1479,6 +1504,21 @@ class PureArtifactoryPath(pathlib.PurePath):
14791504
_flavour = _artifactory_flavour
14801505
__slots__ = ()
14811506

1507+
def _init(self, *args):
1508+
super()._init(*args)
1509+
1510+
@classmethod
1511+
def _split_root(cls, part):
1512+
cls._flavour.splitroot(part)
1513+
1514+
@classmethod
1515+
def _parse_parts(cls, parts):
1516+
return super()._parse_parts(parts)
1517+
1518+
@classmethod
1519+
def _format_parsed_parts(cls, drv, root, tail):
1520+
return super()._format_parsed_parts(drv, root, tail)
1521+
14821522

14831523
class _FakePathTemplate(object):
14841524
def __init__(self, accessor):
@@ -1513,7 +1553,11 @@ def __new__(cls, *args, **kwargs):
15131553
So we have to first construct ArtifactoryPath by Pathlib and
15141554
only then add auth information.
15151555
"""
1556+
15161557
obj = pathlib.Path.__new__(cls, *args, **kwargs)
1558+
if _IS_PYTHON_3_12_OR_NEWER:
1559+
# After python 3.12, all this logic can be moved to __init__
1560+
return obj
15171561

15181562
cfg_entry = get_global_config_entry(obj.drive)
15191563

@@ -1565,6 +1609,56 @@ def _init(self, *args, **kwargs):
15651609

15661610
super(ArtifactoryPath, self)._init(*args, **kwargs)
15671611

1612+
def __init__(self, *args, **kwargs):
1613+
# Up until python3.12, pathlib.Path was not designed to be initialized
1614+
# through __init__, so all that logic is in the __new__ method.
1615+
if not _IS_PYTHON_3_12_OR_NEWER:
1616+
return
1617+
1618+
super().__init__(*args, **kwargs)
1619+
1620+
cfg_entry = get_global_config_entry(self.drive)
1621+
1622+
# Auth section
1623+
apikey = kwargs.get("apikey")
1624+
token = kwargs.get("token")
1625+
auth_type = kwargs.get("auth_type")
1626+
1627+
if apikey:
1628+
logger.debug("Use XJFrogApiAuth apikey")
1629+
self.auth = XJFrogArtApiAuth(apikey=apikey)
1630+
elif token:
1631+
logger.debug("Use XJFrogArtBearerAuth token")
1632+
self.auth = XJFrogArtBearerAuth(token=token)
1633+
else:
1634+
auth = kwargs.get("auth")
1635+
self.auth = auth if auth_type is None else auth_type(*auth)
1636+
1637+
if self.auth is None and cfg_entry:
1638+
auth = (cfg_entry["username"], cfg_entry["password"])
1639+
self.auth = auth if auth_type is None else auth_type(*auth)
1640+
1641+
self.cert = kwargs.get("cert")
1642+
self.session = kwargs.get("session")
1643+
self.timeout = kwargs.get("timeout")
1644+
1645+
if self.cert is None and cfg_entry:
1646+
self.cert = cfg_entry["cert"]
1647+
1648+
if "verify" in kwargs:
1649+
self.verify = kwargs.get("verify")
1650+
elif cfg_entry:
1651+
self.verify = cfg_entry["verify"]
1652+
else:
1653+
self.verify = True
1654+
1655+
if self.session is None:
1656+
self.session = requests.Session()
1657+
self.session.auth = self.auth
1658+
self.session.cert = self.cert
1659+
self.session.verify = self.verify
1660+
self.session.timeout = self.timeout
1661+
15681662
def __reduce__(self):
15691663
# pathlib.PurePath.__reduce__ doesn't include instance state, but we
15701664
# have state that needs to be included when pickling
@@ -1635,6 +1729,16 @@ def stat(self, pathobj=None):
16351729
pathobj = pathobj or self
16361730
return self._accessor.stat(pathobj=pathobj)
16371731

1732+
def exists(self):
1733+
try:
1734+
self.stat()
1735+
except OSError:
1736+
return False
1737+
except ValueError:
1738+
# Non-encodable path
1739+
return False
1740+
return True
1741+
16381742
def mkdir(self, mode=0o777, parents=False, exist_ok=False):
16391743
"""
16401744
Create a new directory at this given path.
@@ -2367,12 +2471,12 @@ def promote_docker_image(
23672471

23682472
@property
23692473
def repo(self):
2370-
return self._root.replace("/", "")
2474+
return self.parts[1]
23712475

23722476
@property
23732477
def path_in_repo(self):
23742478
parts = self.parts
2375-
path_in_repo = "/" + "/".join(parts[1:])
2479+
path_in_repo = "/" + "/".join(parts[2:])
23762480
return path_in_repo
23772481

23782482
def find_user(self, name):

setup.py

+1
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"Programming Language :: Python :: 3.9",
4747
"Programming Language :: Python :: 3.10",
4848
"Programming Language :: Python :: 3.11",
49+
"Programming Language :: Python :: 3.12",
4950
"Topic :: Software Development :: Libraries",
5051
"Topic :: System :: Filesystems",
5152
],

0 commit comments

Comments
 (0)