__new_with_path() joins base and path with exactly one '/', assuming a
trailing '/' in base is a path separator. That assumption breaks for
URIs with empty authority, where scheme_plus_authority ends in '://'
(e.g. 'file:///tmp/x'): new_replace_path('/etc/hosts') drops the
leading slash of the path and produces 'file://etc/hosts', which
parses with hostname 'etc' and path '/hosts' instead of path
'/etc/hosts'.
Treat a base ending in '://' separately and keep a leading slash in
the path, adding one if it is missing.
Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.84.2
Signed-off-by: Jan Lindemann <jan@janware.com>
181 lines
5 KiB
Python
181 lines
5 KiB
Python
from __future__ import annotations
|
|
|
|
from functools import cached_property
|
|
from typing import TYPE_CHECKING, override
|
|
|
|
if TYPE_CHECKING:
|
|
import urllib.parse
|
|
|
|
from typing import Self
|
|
|
|
# Make sure URIs are interpreted indentically everywhere
|
|
class Uri:
|
|
|
|
def __assemble(
|
|
self, scheme: bool, credentials: bool, secure: bool, path: bool
|
|
) -> str:
|
|
ret = ''
|
|
if self.__p.path.startswith('~'):
|
|
return self.path
|
|
if scheme:
|
|
ret += f'{self.protocol}://'
|
|
if credentials and self.username:
|
|
ret += self.username
|
|
if self.password:
|
|
ret += ':' + '<hidden>' if secure else self.password
|
|
ret += '@'
|
|
if self.hostname:
|
|
ret += self.hostname
|
|
if self.port_str:
|
|
ret += ':' + self.port_str
|
|
if path:
|
|
ret += self.path
|
|
return ret
|
|
|
|
def __init__(self, string: str) -> None:
|
|
self.__string = string
|
|
self.__username: str | None = None
|
|
self.__password: str | None = None
|
|
|
|
@override
|
|
def __repr__(self) -> str:
|
|
return self.full
|
|
|
|
@override
|
|
def __str__(self) -> str:
|
|
return self.safe_full_with_username
|
|
|
|
@cached_property
|
|
def __p(self) -> urllib.parse.ParseResult:
|
|
from urllib.parse import urlparse
|
|
|
|
return urlparse(self.__string)
|
|
|
|
@classmethod
|
|
def pimp(cls, url: str | Self) -> Uri:
|
|
if isinstance(url, Uri):
|
|
return url
|
|
return Uri(url)
|
|
|
|
@property
|
|
def to_string(self) -> str:
|
|
return self.__string
|
|
|
|
@cached_property
|
|
def scheme(self) -> str:
|
|
ret = self.__p.scheme
|
|
if not ret and not self.__p.path.startswith('~'):
|
|
return 'file'
|
|
return ret
|
|
|
|
@cached_property
|
|
def protocol(self) -> str:
|
|
return self.scheme.replace('://', '')
|
|
|
|
@property
|
|
def username(self) -> str | None:
|
|
if self.__username is None:
|
|
return self.__p.username
|
|
return self.__username
|
|
|
|
def set_username(self, username: str) -> None:
|
|
self.__username = username
|
|
|
|
@property
|
|
def password(self) -> str | None:
|
|
if self.__password is None:
|
|
return self.__p.password
|
|
return self.__password
|
|
|
|
def set_password(self, password: str) -> None:
|
|
self.__password = password
|
|
|
|
@cached_property
|
|
def hostname(self) -> str | None:
|
|
return self.__p.hostname
|
|
|
|
@cached_property
|
|
def port(self) -> int | None:
|
|
return self.__p.port
|
|
|
|
@cached_property
|
|
def port_str(self) -> str | None:
|
|
if self.port is None:
|
|
return None
|
|
return str(self.port)
|
|
|
|
@cached_property
|
|
def path(self) -> str:
|
|
return self.__p.path
|
|
|
|
@cached_property
|
|
def basename(self) -> str:
|
|
return self.__p.path.rsplit('/')[-1]
|
|
|
|
@cached_property
|
|
def authority(self) -> str:
|
|
return self.__assemble(
|
|
scheme = False, credentials = True, secure = False, path = False
|
|
)
|
|
|
|
@cached_property
|
|
def origin(self) -> str:
|
|
return self.__assemble(
|
|
scheme = False, credentials = False, secure = True, path = False
|
|
)
|
|
|
|
@cached_property
|
|
def scheme_plus_authority(self) -> str:
|
|
return self.scheme + '://' + self.authority
|
|
|
|
@cached_property
|
|
def id(self) -> str:
|
|
return self.__assemble(
|
|
scheme = True, credentials = True, secure = True, path = False
|
|
)
|
|
|
|
@cached_property
|
|
def full(self) -> str:
|
|
return self.__assemble(
|
|
scheme = True, credentials = True, secure = False, path = True
|
|
)
|
|
|
|
@cached_property
|
|
def safe_full_with_username(self) -> str:
|
|
return self.__assemble(
|
|
scheme = True, credentials = True, secure = True, path = True
|
|
)
|
|
|
|
def __new_with_path(self, base: str, path: str) -> Self:
|
|
# -- Build a fresh instance rather than copying self: a copy would
|
|
# inherit computed cached_property values (e.g. __p, path, full),
|
|
# which would go stale as soon as __string is replaced below.
|
|
ret = object.__new__(type(self))
|
|
ret.__string = base
|
|
ret.__username = None
|
|
ret.__password = None
|
|
if not path:
|
|
return ret
|
|
if ret.__string.endswith('://'):
|
|
# -- Empty authority: the trailing '/' is part of the '://'
|
|
# separator, so a leading slash in path must be kept to stay
|
|
# an absolute path
|
|
ret.__string += path if path.startswith('/') else '/' + path
|
|
return ret
|
|
if ret.__string[-1] == '/':
|
|
if path[0] == '/':
|
|
ret.__string += path[1:]
|
|
else:
|
|
ret.__string += path
|
|
else:
|
|
if path[0] == '/':
|
|
ret.__string += path
|
|
else:
|
|
ret.__string += '/' + path
|
|
return ret
|
|
|
|
def new_add_path(self, path: str) -> Self:
|
|
return self.__new_with_path(self.__string, path)
|
|
|
|
def new_replace_path(self, path: str) -> Self:
|
|
return self.__new_with_path(self.scheme_plus_authority, path)
|