commonconfig.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457
  1. # -*- coding: utf-8 -*-
  2. # MinIO Python Library for Amazon S3 Compatible Cloud Storage, (C)
  3. # 2020 MinIO, Inc.
  4. #
  5. # Licensed under the Apache License, Version 2.0 (the "License");
  6. # you may not use this file except in compliance with the License.
  7. # You may obtain a copy of the License at
  8. #
  9. # http://www.apache.org/licenses/LICENSE-2.0
  10. #
  11. # Unless required by applicable law or agreed to in writing, software
  12. # distributed under the License is distributed on an "AS IS" BASIS,
  13. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  14. # See the License for the specific language governing permissions and
  15. # limitations under the License.
  16. """Common request/response configuration of S3 APIs."""
  17. # pylint: disable=invalid-name
  18. from __future__ import absolute_import, annotations
  19. from abc import ABC, abstractmethod
  20. from dataclasses import dataclass, field
  21. from datetime import datetime
  22. from typing import IO, Optional, Type, TypeVar, cast
  23. from xml.etree import ElementTree as ET
  24. from .error import MinioException
  25. from .helpers import quote
  26. from .sse import SseCustomerKey
  27. from .time import to_http_header
  28. from .xml import SubElement, find, findall, findtext
  29. COPY = "COPY"
  30. REPLACE = "REPLACE"
  31. DISABLED = "Disabled"
  32. ENABLED = "Enabled"
  33. GOVERNANCE = "GOVERNANCE"
  34. COMPLIANCE = "COMPLIANCE"
  35. _MAX_KEY_LENGTH = 128
  36. _MAX_VALUE_LENGTH = 256
  37. _MAX_OBJECT_TAG_COUNT = 10
  38. _MAX_TAG_COUNT = 50
  39. A = TypeVar("A", bound="Tags")
  40. class Tags(dict):
  41. """dict extended to bucket/object tags."""
  42. def __init__(self, for_object: bool = False):
  43. self._for_object = for_object
  44. super().__init__()
  45. def __setitem__(self, key: str, value: str):
  46. limit = _MAX_OBJECT_TAG_COUNT if self._for_object else _MAX_TAG_COUNT
  47. if len(self) == limit:
  48. tag_type = "object" if self._for_object else "bucket"
  49. raise ValueError(f"only {limit} {tag_type} tags are allowed")
  50. if not key or len(key) > _MAX_KEY_LENGTH or "&" in key:
  51. raise ValueError(f"invalid tag key '{key}'")
  52. if value is None or len(value) > _MAX_VALUE_LENGTH or "&" in value:
  53. raise ValueError(f"invalid tag value '{value}'")
  54. super().__setitem__(key, value)
  55. @classmethod
  56. def new_bucket_tags(cls: Type[A]) -> A:
  57. """Create new bucket tags."""
  58. return cls()
  59. @classmethod
  60. def new_object_tags(cls: Type[A]) -> A:
  61. """Create new object tags."""
  62. return cls(True)
  63. @classmethod
  64. def fromxml(cls: Type[A], element: ET.Element) -> A:
  65. """Create new object with values from XML element."""
  66. elements = findall(element, "Tag")
  67. obj = cls()
  68. for tag in elements:
  69. key = cast(str, findtext(tag, "Key", True))
  70. value = cast(str, findtext(tag, "Value", True))
  71. obj[key] = value
  72. return obj
  73. def toxml(self, element: Optional[ET.Element]) -> ET.Element:
  74. """Convert to XML."""
  75. if element is None:
  76. raise ValueError("element must be provided")
  77. for key, value in self.items():
  78. tag = SubElement(element, "Tag")
  79. SubElement(tag, "Key", key)
  80. SubElement(tag, "Value", value)
  81. return element
  82. B = TypeVar("B", bound="Tag")
  83. @dataclass(frozen=True)
  84. class Tag:
  85. """Tag."""
  86. key: str
  87. value: str
  88. def __post_init__(self):
  89. if not self.key:
  90. raise ValueError("key must be provided")
  91. if self.value is None:
  92. raise ValueError("value must be provided")
  93. @classmethod
  94. def fromxml(cls: Type[B], element: ET.Element) -> B:
  95. """Create new object with values from XML element."""
  96. element = cast(ET.Element, find(element, "Tag", True))
  97. key = cast(str, findtext(element, "Key", True))
  98. value = cast(str, findtext(element, "Value", True))
  99. return cls(key, value)
  100. def toxml(self, element: Optional[ET.Element]) -> ET.Element:
  101. """Convert to XML."""
  102. if element is None:
  103. raise ValueError("element must be provided")
  104. element = SubElement(element, "Tag")
  105. SubElement(element, "Key", self.key)
  106. SubElement(element, "Value", self.value)
  107. return element
  108. C = TypeVar("C", bound="AndOperator")
  109. @dataclass(frozen=True)
  110. class AndOperator:
  111. """AND operator."""
  112. prefix: Optional[str] = None
  113. tags: Optional[Tags] = None
  114. def __post_init__(self):
  115. if self.prefix is None and not self.tags:
  116. raise ValueError("at least prefix or tags must be provided")
  117. @classmethod
  118. def fromxml(cls: Type[C], element: ET.Element) -> C:
  119. """Create new object with values from XML element."""
  120. element = cast(ET.Element, find(element, "And", True))
  121. prefix = findtext(element, "Prefix")
  122. tags = (
  123. None if find(element, "Tag") is None
  124. else Tags.fromxml(element)
  125. )
  126. return cls(prefix, tags)
  127. def toxml(self, element: Optional[ET.Element]) -> ET.Element:
  128. """Convert to XML."""
  129. if element is None:
  130. raise ValueError("element must be provided")
  131. element = SubElement(element, "And")
  132. if self.prefix is not None:
  133. SubElement(element, "Prefix", self.prefix)
  134. if self.tags is not None:
  135. self.tags.toxml(element)
  136. return element
  137. D = TypeVar("D", bound="Filter")
  138. @dataclass(frozen=True)
  139. class Filter:
  140. """Lifecycle rule filter."""
  141. and_operator: Optional[AndOperator] = None
  142. prefix: Optional[str] = None
  143. tag: Optional[Tag] = None
  144. def __post_init__(self):
  145. valid = (
  146. (self.and_operator is not None) ^
  147. (self.prefix is not None) ^
  148. (self.tag is not None)
  149. )
  150. if not valid:
  151. raise ValueError("only one of and, prefix or tag must be provided")
  152. @classmethod
  153. def fromxml(cls: Type[D], element: ET.Element) -> D:
  154. """Create new object with values from XML element."""
  155. element = cast(ET.Element, find(element, "Filter", True))
  156. and_operator = (
  157. None if find(element, "And") is None
  158. else AndOperator.fromxml(element)
  159. )
  160. prefix = findtext(element, "Prefix")
  161. tag = None if find(element, "Tag") is None else Tag.fromxml(element)
  162. return cls(and_operator, prefix, tag)
  163. def toxml(self, element: Optional[ET.Element]) -> ET.Element:
  164. """Convert to XML."""
  165. if element is None:
  166. raise ValueError("element must be provided")
  167. element = SubElement(element, "Filter")
  168. if self.and_operator:
  169. self.and_operator.toxml(element)
  170. if self.prefix is not None:
  171. SubElement(element, "Prefix", self.prefix)
  172. if self.tag is not None:
  173. self.tag.toxml(element)
  174. return element
  175. @dataclass(frozen=True)
  176. class BaseRule(ABC):
  177. """Base rule class for Replication and Lifecycle."""
  178. status: str
  179. rule_filter: Optional[Filter] = None
  180. rule_id: Optional[str] = None
  181. def __post_init__(self):
  182. check_status(self.status)
  183. if self.rule_id is not None:
  184. self.rule_id = self.rule_id.strip()
  185. if not self.rule_id:
  186. raise ValueError("rule ID must be non-empty string")
  187. if len(self.rule_id) > 255:
  188. raise ValueError("rule ID must not exceed 255 characters")
  189. @abstractmethod
  190. def _require_subclass_implementation(self) -> None:
  191. """Dummy abstract method to enforce abstract class behavior."""
  192. @staticmethod
  193. def parsexml(
  194. element: ET.Element,
  195. ) -> tuple[str, Optional[Filter], Optional[str]]:
  196. """Parse XML and return filter and ID."""
  197. return (
  198. cast(str, findtext(element, "Status", True)),
  199. (
  200. None if find(element, "Filter") is None
  201. else Filter.fromxml(element)
  202. ),
  203. findtext(element, "ID"),
  204. )
  205. def toxml(self, element: Optional[ET.Element]) -> ET.Element:
  206. """Convert to XML."""
  207. if element is None:
  208. raise ValueError("element must be provided")
  209. SubElement(element, "Status", self.status)
  210. if self.rule_filter:
  211. self.rule_filter.toxml(element)
  212. if self.rule_id is not None:
  213. SubElement(element, "ID", self.rule_id)
  214. return element
  215. def check_status(status: str):
  216. """Validate status."""
  217. if status not in [ENABLED, DISABLED]:
  218. raise ValueError("status must be 'Enabled' or 'Disabled'")
  219. @dataclass
  220. class ObjectConditionalReadArgs(ABC):
  221. """Base argument class holds condition properties for reading object."""
  222. bucket_name: str
  223. object_name: str
  224. region: Optional[str] = None
  225. version_id: Optional[str] = None
  226. ssec: Optional[SseCustomerKey] = None
  227. offset: Optional[int] = None
  228. length: Optional[int] = None
  229. match_etag: Optional[str] = None
  230. not_match_etag: Optional[str] = None
  231. modified_since: Optional[datetime] = None
  232. unmodified_since: Optional[datetime] = None
  233. def __post_init__(self):
  234. if (
  235. self.ssec is not None and
  236. not isinstance(self.ssec, SseCustomerKey)
  237. ):
  238. raise ValueError("ssec must be SseCustomerKey type")
  239. if self.offset is not None and self.offset < 0:
  240. raise ValueError("offset should be zero or greater")
  241. if self.length is not None and self.length <= 0:
  242. raise ValueError("length should be greater than zero")
  243. if self.match_etag is not None and self.match_etag == "":
  244. raise ValueError("match_etag must not be empty")
  245. if self.not_match_etag is not None and self.not_match_etag == "":
  246. raise ValueError("not_match_etag must not be empty")
  247. if (
  248. self.modified_since is not None and
  249. not isinstance(self.modified_since, datetime)
  250. ):
  251. raise ValueError("modified_since must be datetime type")
  252. if (
  253. self.unmodified_since is not None and
  254. not isinstance(self.unmodified_since, datetime)
  255. ):
  256. raise ValueError("unmodified_since must be datetime type")
  257. @abstractmethod
  258. def _require_subclass_implementation(self) -> None:
  259. """Dummy abstract method to enforce abstract class behavior."""
  260. def gen_copy_headers(self) -> dict[str, str]:
  261. """Generate copy source headers."""
  262. copy_source = quote("/" + self.bucket_name + "/" + self.object_name)
  263. if self.version_id:
  264. copy_source += "?versionId=" + quote(self.version_id)
  265. headers = {"x-amz-copy-source": copy_source}
  266. if self.ssec:
  267. headers.update(self.ssec.copy_headers())
  268. if self.match_etag:
  269. headers["x-amz-copy-source-if-match"] = self.match_etag
  270. if self.not_match_etag:
  271. headers["x-amz-copy-source-if-none-match"] = self.not_match_etag
  272. if self.modified_since:
  273. headers["x-amz-copy-source-if-modified-since"] = (
  274. to_http_header(self.modified_since)
  275. )
  276. if self.unmodified_since:
  277. headers["x-amz-copy-source-if-unmodified-since"] = (
  278. to_http_header(self.unmodified_since)
  279. )
  280. return headers
  281. E = TypeVar("E", bound="CopySource")
  282. @dataclass
  283. class CopySource(ObjectConditionalReadArgs):
  284. """A source object definition for copy_object method."""
  285. def _require_subclass_implementation(self) -> None:
  286. """Dummy abstract method to enforce abstract class behavior."""
  287. @classmethod
  288. def of(cls: Type[E], src: ObjectConditionalReadArgs) -> E:
  289. """Create CopySource from another source."""
  290. return cls(
  291. bucket_name=src.bucket_name,
  292. object_name=src.object_name,
  293. region=src.region,
  294. version_id=src.version_id,
  295. ssec=src.ssec,
  296. offset=src.offset,
  297. length=src.length,
  298. match_etag=src.match_etag,
  299. not_match_etag=src.not_match_etag,
  300. modified_since=src.modified_since,
  301. unmodified_since=src.unmodified_since,
  302. )
  303. F = TypeVar("F", bound="ComposeSource")
  304. @dataclass
  305. class ComposeSource(ObjectConditionalReadArgs):
  306. """A source object definition for compose_object method."""
  307. _object_size: Optional[int] = field(default=None, init=False)
  308. _headers: Optional[dict[str, str]] = field(default=None, init=False)
  309. def _require_subclass_implementation(self) -> None:
  310. """Dummy abstract method to enforce abstract class behavior."""
  311. def _validate_size(self, object_size: int):
  312. """Validate object size with offset and length."""
  313. def make_error(name, value):
  314. ver = ("?versionId="+self.version_id) if self.version_id else ""
  315. return ValueError(
  316. f"Source {self.bucket_name}/{self.object_name}{ver}: "
  317. f"{name} {value} is beyond object size {object_size}"
  318. )
  319. if self.offset is not None and self.offset >= object_size:
  320. raise make_error("offset", self.offset)
  321. if self.length is not None:
  322. if self.length > object_size:
  323. raise make_error("length", self.length)
  324. offset = self.offset or 0
  325. if offset+self.length > object_size:
  326. raise make_error("compose size", offset+self.length)
  327. def build_headers(self, object_size: int, etag: str):
  328. """Build headers."""
  329. self._validate_size(object_size)
  330. self._object_size = object_size
  331. headers = self.gen_copy_headers()
  332. headers["x-amz-copy-source-if-match"] = self.match_etag or etag
  333. self._headers = headers
  334. @property
  335. def object_size(self) -> Optional[int]:
  336. """Get object size."""
  337. if self._object_size is None:
  338. raise MinioException(
  339. "build_headers() must be called prior to "
  340. "this method invocation",
  341. )
  342. return self._object_size
  343. @property
  344. def headers(self) -> dict[str, str]:
  345. """Get headers."""
  346. if self._headers is None:
  347. raise MinioException(
  348. "build_headers() must be called prior to "
  349. "this method invocation",
  350. )
  351. return self._headers.copy()
  352. @classmethod
  353. def of(cls: Type[F], src: ObjectConditionalReadArgs) -> F:
  354. """Create ComposeSource from another source."""
  355. return cls(
  356. bucket_name=src.bucket_name,
  357. object_name=src.object_name,
  358. region=src.region,
  359. version_id=src.version_id,
  360. ssec=src.ssec,
  361. offset=src.offset,
  362. length=src.length,
  363. match_etag=src.match_etag,
  364. not_match_etag=src.not_match_etag,
  365. modified_since=src.modified_since,
  366. unmodified_since=src.unmodified_since,
  367. )
  368. @dataclass(frozen=True)
  369. class SnowballObject:
  370. """A source object definition for upload_snowball_objects method."""
  371. object_name: str
  372. filename: Optional[str] = None
  373. data: Optional[IO[bytes]] = None
  374. length: Optional[int] = None
  375. mod_time: Optional[datetime] = None
  376. def __post_init__(self):
  377. if not (self.filename is not None) ^ (self.data is not None):
  378. raise ValueError("only one of filename or data must be provided")
  379. if self.data is not None and self.length is None:
  380. raise ValueError("length must be provided for data")
  381. if (
  382. self.mod_time is not None and
  383. not isinstance(self.mod_time, datetime)
  384. ):
  385. raise ValueError("mod_time must be datetime type")