Coverage for pyTooling/GenericPath/__init__.py: 98%

81 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-03 23:02 +0000

1# ==================================================================================================================== # 

2# _____ _ _ ____ _ ____ _ _ # 

3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ / ___| ___ _ __ ___ _ __(_) ___| _ \ __ _| |_| |__ # 

4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` || | _ / _ \ '_ \ / _ \ '__| |/ __| |_) / _` | __| '_ \ # 

5# | |_) | |_| || | (_) | (_) | | | | | | (_| || |_| | __/ | | | __/ | | | (__| __/ (_| | |_| | | | # 

6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____|\___|_| |_|\___|_| |_|\___|_| \__,_|\__|_| |_| # 

7# |_| |___/ |___/ # 

8# ==================================================================================================================== # 

9# Authors: # 

10# Patrick Lehmann # 

11# # 

12# License: # 

13# ==================================================================================================================== # 

14# Copyright 2017-2026 Patrick Lehmann - Bötzingen, Germany # 

15# # 

16# Licensed under the Apache License, Version 2.0 (the "License"); # 

17# you may not use this file except in compliance with the License. # 

18# You may obtain a copy of the License at # 

19# # 

20# http://www.apache.org/licenses/LICENSE-2.0 # 

21# # 

22# Unless required by applicable law or agreed to in writing, software # 

23# distributed under the License is distributed on an "AS IS" BASIS, # 

24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # 

25# See the License for the specific language governing permissions and # 

26# limitations under the License. # 

27# # 

28# SPDX-License-Identifier: Apache-2.0 # 

29# ==================================================================================================================== # 

30# 

31""" 

32A generic path to derive domain specific path libraries. 

33 

34.. seealso:: 

35 

36 :mod:`pyTooling.GenericPath.URL` 

37 |rarr| A URL as a domain-specific path. 

38 :mod:`pyTooling.Configuration` 

39 |rarr| Path expressions addressing a node in a configuration. 

40""" 

41from __future__ import annotations 

42 

43from typing import ClassVar, Optional as Nullable, Union 

44 

45from pyTooling.Common import getFullyQualifiedName 

46from pyTooling.Decorators import export 

47from pyTooling.MetaClasses import ExtendedType 

48 

49 

50@export 

51class Base(metaclass=ExtendedType, mixin=True): 

52 """Base-mixin-class for all :mod:`pyTooling.GenericPath` path elements.""" 

53 

54 DELIMITER: ClassVar[str] = "/" #: Path element delimiter sign. 

55 

56 _parent: Nullable[Base] #: Reference to the parent object. 

57 

58 def __init__(self, parent: Nullable[Base] = None) -> None: 

59 """ 

60 Initialize the base-mixin-class with a parent reference. 

61 

62 :param parent: Optional, parent reference. 

63 """ 

64 self._parent = parent 

65 

66 

67@export 

68class RootMixin(Base, mixin=True): 

69 """Mixin-class for root elements in a path system.""" 

70 

71 def __init__(self) -> None: 

72 """ 

73 Initialize the mixin-class for a root element. 

74 """ 

75 super().__init__(None) 

76 

77 

78@export 

79class ElementMixin(Base, mixin=True): 

80 """Mixin-class for elements in a path system.""" 

81 

82 _elementName: str #: Name of the path element. 

83 

84 def __init__(self, parent: Base, elementName: str) -> None: 

85 """ 

86 Initialize the mixin-class for a path element. 

87 

88 :param parent: Optional, reference to a parent path element. 

89 :param elementName: Name of the path element. 

90 """ 

91 super().__init__(parent) 

92 

93 self._elementName = elementName 

94 

95 def __str__(self) -> str: 

96 """ 

97 Return a string representation of this path element. 

98 

99 :returns: The element's name. 

100 """ 

101 return self._elementName 

102 

103 

104@export 

105class PathMixin(metaclass=ExtendedType, mixin=True): 

106 """Mixin-class for a path.""" 

107 

108 ELEMENT_DELIMITER: ClassVar[str] = "/" #: Path element delimiter sign. 

109 ROOT_DELIMITER: ClassVar[str] = "/" #: Root element delimiter sign. 

110 ELEMENT_TYPE: ClassVar[type[ElementMixin]] #: Type an element of this path flavour has. Every flavour names it. 

111 

112 _isAbsolute: bool #: True, if the path is absolute. 

113 _elements: list[ElementMixin] #: List of path elements. 

114 

115 def __init__(self, elements: list[ElementMixin], isAbsolute: bool) -> None: 

116 """ 

117 Initialize the mixin-class for a path. 

118 

119 :param elements: Reference to a parent path element. 

120 :param isAbsolute: ``True``, if the path is absolute, otherwise ``False``. 

121 """ 

122 self._isAbsolute = isAbsolute 

123 self._elements = elements 

124 

125 def __len__(self) -> int: 

126 """ 

127 Returns the number of path elements. 

128 

129 :returns: Number of path elements. 

130 """ 

131 return len(self._elements) 

132 

133 def __str__(self) -> str: 

134 """ 

135 Return a string representation of this path. 

136 

137 :returns: The path's elements, joined by the delimiter, prefixed by the root delimiter if the path is absolute. 

138 """ 

139 result = self.ROOT_DELIMITER if self._isAbsolute else "" 

140 

141 if len(self._elements) > 0: 141 ↛ 147line 141 didn't jump to line 147 because the condition on line 141 was always true

142 result = result + str(self._elements[0]) 

143 

144 for element in self._elements[1:]: 

145 result = result + self.ELEMENT_DELIMITER + str(element) 

146 

147 return result 

148 

149 def __truediv__(self, other: Union[str, PathMixin]) -> PathMixin: 

150 """ 

151 Return this path with another path below it. 

152 

153 A trailing delimiter is dropped before appending, so composing ``/api/`` with ``things`` names 

154 ``/api/things`` and not an empty element between them. An absolute path names where it starts itself, so it 

155 replaces this one rather than being appended - as :rfc:`3986` resolves a reference and :mod:`pathlib` joins a 

156 path. 

157 

158 :param other: The path to append, as a string to parse or as a path. 

159 :returns: A new path, or ``other``, if that one is absolute. 

160 :raises TypeError: If parameter 'other' is neither of type :class:`str` nor of type :class:`PathMixin`. 

161 """ 

162 if isinstance(other, str): 

163 isAbsolute = other.startswith(self.ROOT_DELIMITER) 

164 names = (other[len(self.ROOT_DELIMITER):] if isAbsolute else other).split(self.ELEMENT_DELIMITER) 

165 elif isinstance(other, PathMixin): 

166 isAbsolute = other._isAbsolute 

167 names = [str(element) for element in other._elements] 

168 else: 

169 ex = TypeError("Second operand is not supported by / operator.") 

170 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.") 

171 ex.add_note(f"Supported types for second operand: 'str' or '{getFullyQualifiedName(self)}'.") 

172 raise ex 

173 

174 if isAbsolute: 

175 path = self 

176 elements = [] 

177 else: 

178 path = self.WithoutTrailingDelimiter() 

179 elements = list(path._elements) 

180 

181 parent = elements[-1] if len(elements) > 0 else None 

182 for name in names: 

183 elements.append(parent := self.ELEMENT_TYPE(parent, name)) 

184 

185 return self.__class__(elements, isAbsolute or path._isAbsolute) 

186 

187 def WithoutTrailingDelimiter(self) -> PathMixin: 

188 """ 

189 Return a path that doesn't end in :attr:`ELEMENT_DELIMITER`. 

190 

191 A trailing delimiter is an empty last element, so ``/api/v3/`` and ``/api/v3`` are different paths although 

192 they usually name the same thing. A path that something is appended to wants the latter, or the composition 

193 yields two delimiters in a row. 

194 

195 Only one trailing delimiter is removed: a path ending in two of them names an empty element and then another, 

196 which isn't the same as naming neither. 

197 

198 :returns: A new path without a trailing delimiter, or this path, if it has none. 

199 """ 

200 if (elementCount := len(self._elements)) == 0 or str(self._elements[-1]) != "": 

201 return self 

202 elif elementCount > 1: 

203 return self.__class__(self._elements[:-1], self._isAbsolute) 

204 

205 # A path of nothing but the empty element is the root: it has no element to drop, so it stops being absolute. 

206 # The same path that isn't absolute is the empty path, which has no trailing delimiter to begin with. 

207 return self if not self._isAbsolute else self.__class__(self._elements, False) 

208 

209 @classmethod 

210 def Parse(cls, path: str, root: Nullable[RootMixin] = None) -> PathMixin: 

211 """ 

212 Parses a string representation of a path and returns a path instance. 

213 

214 The path and its elements are of this flavour's types - the class this is called on, and the 

215 :attr:`ELEMENT_TYPE` it names. 

216 

217 :param path: Path to be parsed. 

218 :param root: Optional, root element the parsed path is relative to. Default: no root. 

219 :returns: A path instance of this class. 

220 """ 

221 if path.startswith(cls.ROOT_DELIMITER): 

222 isAbsolute = True 

223 path = path[len(cls.ROOT_DELIMITER):] 

224 else: 

225 isAbsolute = False 

226 

227 parent = root 

228 elements = [] 

229 for part in path.split(cls.ELEMENT_DELIMITER): 

230 elements.append(parent := cls.ELEMENT_TYPE(parent, part)) 

231 

232 return cls(elements, isAbsolute) 

233 

234 

235@export 

236class SystemMixin(metaclass=ExtendedType, mixin=True): 

237 """Mixin-class for a path system."""