PyLinks 0.0.0.dev24__tar.gz → 0.0.0.dev26__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/PKG-INFO +1 -1
- {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/pyproject.toml +1 -1
- {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/src/PyLinks.egg-info/PKG-INFO +1 -1
- {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/src/PyLinks.egg-info/SOURCES.txt +9 -1
- {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/src/pylinks/__init__.py +1 -1
- {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/src/pylinks/api/github.py +27 -4
- pylinks-0.0.0.dev26/src/pylinks/exception/__init__.py +2 -0
- pylinks-0.0.0.dev26/src/pylinks/exception/base.py +23 -0
- pylinks-0.0.0.dev26/src/pylinks/exception/media_type.py +20 -0
- pylinks-0.0.0.dev26/src/pylinks/exception/uri.py +13 -0
- {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/src/pylinks/http.py +21 -18
- pylinks-0.0.0.dev26/src/pylinks/media_type.py +111 -0
- pylinks-0.0.0.dev26/src/pylinks/string.py +39 -0
- pylinks-0.0.0.dev26/src/pylinks/uri/__init__.py +1 -0
- pylinks-0.0.0.dev26/src/pylinks/uri/data.py +200 -0
- {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/src/pylinks/url.py +7 -25
- {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/setup.cfg +0 -0
- {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/src/PyLinks.egg-info/dependency_links.txt +0 -0
- {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/src/PyLinks.egg-info/not-zip-safe +0 -0
- {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/src/PyLinks.egg-info/requires.txt +0 -0
- {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/src/PyLinks.egg-info/top_level.txt +0 -0
- {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/src/pylinks/_settings.py +0 -0
- {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/src/pylinks/api/__init__.py +0 -0
- {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/src/pylinks/api/doi.py +0 -0
- {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/src/pylinks/api/orcid.py +0 -0
- {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/src/pylinks/exceptions.py +0 -0
- {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/src/pylinks/site/__init__.py +0 -0
- {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/src/pylinks/site/binder.py +0 -0
- {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/src/pylinks/site/conda.py +0 -0
- {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/src/pylinks/site/github.py +0 -0
- {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/src/pylinks/site/lib_io.py +0 -0
- {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/src/pylinks/site/pypi.py +0 -0
- {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/src/pylinks/site/readthedocs.py +0 -0
|
@@ -17,7 +17,7 @@ namespaces = true
|
|
|
17
17
|
# ----------------------------------------- Project Metadata -------------------------------------
|
|
18
18
|
#
|
|
19
19
|
[project]
|
|
20
|
-
version = "0.0.0.
|
|
20
|
+
version = "0.0.0.dev26"
|
|
21
21
|
name = "PyLinks"
|
|
22
22
|
dependencies = [
|
|
23
23
|
"requests >= 2.31.0, < 3",
|
|
@@ -9,15 +9,23 @@ src/pylinks/__init__.py
|
|
|
9
9
|
src/pylinks/_settings.py
|
|
10
10
|
src/pylinks/exceptions.py
|
|
11
11
|
src/pylinks/http.py
|
|
12
|
+
src/pylinks/media_type.py
|
|
13
|
+
src/pylinks/string.py
|
|
12
14
|
src/pylinks/url.py
|
|
13
15
|
src/pylinks/api/__init__.py
|
|
14
16
|
src/pylinks/api/doi.py
|
|
15
17
|
src/pylinks/api/github.py
|
|
16
18
|
src/pylinks/api/orcid.py
|
|
19
|
+
src/pylinks/exception/__init__.py
|
|
20
|
+
src/pylinks/exception/base.py
|
|
21
|
+
src/pylinks/exception/media_type.py
|
|
22
|
+
src/pylinks/exception/uri.py
|
|
17
23
|
src/pylinks/site/__init__.py
|
|
18
24
|
src/pylinks/site/binder.py
|
|
19
25
|
src/pylinks/site/conda.py
|
|
20
26
|
src/pylinks/site/github.py
|
|
21
27
|
src/pylinks/site/lib_io.py
|
|
22
28
|
src/pylinks/site/pypi.py
|
|
23
|
-
src/pylinks/site/readthedocs.py
|
|
29
|
+
src/pylinks/site/readthedocs.py
|
|
30
|
+
src/pylinks/uri/__init__.py
|
|
31
|
+
src/pylinks/uri/data.py
|
|
@@ -99,8 +99,9 @@ class User:
|
|
|
99
99
|
extra_headers: dict | None = None,
|
|
100
100
|
endpoint: Literal['api', 'upload'] = "api"
|
|
101
101
|
):
|
|
102
|
+
query_part = f"/{query}" if query else ""
|
|
102
103
|
return self._github.rest_query(
|
|
103
|
-
query=f"users/{self.username}
|
|
104
|
+
query=f"users/{self.username}{query_part}",
|
|
104
105
|
verb=verb,
|
|
105
106
|
data=data,
|
|
106
107
|
json=json,
|
|
@@ -143,8 +144,9 @@ class Repo:
|
|
|
143
144
|
extra_headers: dict | None = None,
|
|
144
145
|
endpoint: Literal['api', 'upload'] = "api"
|
|
145
146
|
):
|
|
147
|
+
query_part = f"/{query}" if query else ""
|
|
146
148
|
return self._github.rest_query(
|
|
147
|
-
f"repos/{self._username}/{self._name}
|
|
149
|
+
f"repos/{self._username}/{self._name}{query_part}",
|
|
148
150
|
verb=verb,
|
|
149
151
|
data=data,
|
|
150
152
|
json=json,
|
|
@@ -369,7 +371,7 @@ class Repo:
|
|
|
369
371
|
)
|
|
370
372
|
return full_download_path
|
|
371
373
|
|
|
372
|
-
def semantic_versions(self, tag_prefix: str = "v") -> list[
|
|
374
|
+
def semantic_versions(self, tag_prefix: str = "v") -> list[str]:
|
|
373
375
|
"""
|
|
374
376
|
Get a list of all tags from a GitHub repository that represent SemVer version numbers,
|
|
375
377
|
i.e. 'X.Y.Z' where X, Y, and Z are integers.
|
|
@@ -385,7 +387,7 @@ class Repo:
|
|
|
385
387
|
`[(0, 1, 0), (0, 1, 1), (0, 2, 0), (1, 0, 0), (1, 1, 0)]`
|
|
386
388
|
"""
|
|
387
389
|
tags = self.tag_names(pattern=rf"^{tag_prefix}(\d+\.\d+\.\d+)$")
|
|
388
|
-
return sorted([tuple(map(int,
|
|
390
|
+
return sorted((tag[0] for tag in tags), key=lambda x: tuple(map(int, x.split("."))))
|
|
389
391
|
|
|
390
392
|
def discussion_categories(self) -> list[dict[str, str]]:
|
|
391
393
|
"""Get discussion categories for a repository.
|
|
@@ -846,6 +848,12 @@ class Repo:
|
|
|
846
848
|
return output
|
|
847
849
|
|
|
848
850
|
def repo_topics_replace(self, topics: list[str]):
|
|
851
|
+
"""Replace all repository topics.
|
|
852
|
+
|
|
853
|
+
References
|
|
854
|
+
----------
|
|
855
|
+
- [GitHub API Docs](https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#replace-all-repository-topics)
|
|
856
|
+
"""
|
|
849
857
|
topic_pattern = re.compile(r"^[a-z0-9][a-z0-9\-]*$")
|
|
850
858
|
for topic in topics:
|
|
851
859
|
if not isinstance(topic, str):
|
|
@@ -1502,6 +1510,21 @@ class Repo:
|
|
|
1502
1510
|
raise ValueError("At least one of the ruleset parameters must be specified.")
|
|
1503
1511
|
return self._rest_query(query=f"rulesets/{ruleset_id}", verb="PUT", json=data)
|
|
1504
1512
|
|
|
1513
|
+
def ruleset_delete(self, ruleset_id: int) -> dict:
|
|
1514
|
+
"""
|
|
1515
|
+
Delete a ruleset.
|
|
1516
|
+
|
|
1517
|
+
Parameters
|
|
1518
|
+
----------
|
|
1519
|
+
ruleset_id : int
|
|
1520
|
+
The ID of the ruleset.
|
|
1521
|
+
|
|
1522
|
+
References
|
|
1523
|
+
----------
|
|
1524
|
+
- [GitHub API Docs](https://docs.github.com/en/rest/repos/rules?apiVersion=2022-11-28#delete-a-repository-ruleset)
|
|
1525
|
+
"""
|
|
1526
|
+
return self._rest_query(query=f"rulesets/{ruleset_id}", verb="DELETE")
|
|
1527
|
+
|
|
1505
1528
|
def actions_permissions_workflow_default(self) -> dict:
|
|
1506
1529
|
"""
|
|
1507
1530
|
Get default workflow permissions granted to the GITHUB_TOKEN when running workflows in the repository,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""PyLinks base exception."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path as _Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PyLinksException(Exception):
|
|
7
|
+
"""Base exception for PyLinks.
|
|
8
|
+
|
|
9
|
+
All exceptions raised by PyLinks inherit from this class.
|
|
10
|
+
"""
|
|
11
|
+
def __init__(self, message: str):
|
|
12
|
+
self.message = message
|
|
13
|
+
super().__init__(message)
|
|
14
|
+
return
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PyLinksFileNotFoundError(PyLinksException):
|
|
18
|
+
"""File not found error."""
|
|
19
|
+
def __init__(self, path: str | _Path):
|
|
20
|
+
msg = f"No file found at input path '{path}.'"
|
|
21
|
+
super().__init__(message=msg)
|
|
22
|
+
self.path = path
|
|
23
|
+
return
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from pylinks.exception import PyLinksException as _PyLinksException
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class PyLinksMediaTypeParseError(_PyLinksException):
|
|
5
|
+
"""Error parsing a media type."""
|
|
6
|
+
def __init__(self, message: str, media_type: str):
|
|
7
|
+
msg = f"Failed to parse media type '{media_type}': {message}"
|
|
8
|
+
super().__init__(message=msg)
|
|
9
|
+
self.message = message
|
|
10
|
+
self.media_type = media_type
|
|
11
|
+
return
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PyLinksMediaTypeGuessError(_PyLinksException):
|
|
15
|
+
"""Error guessing the media type of a data URI."""
|
|
16
|
+
def __init__(self, path: str):
|
|
17
|
+
msg = f"Failed to guess the media type of '{path}'."
|
|
18
|
+
super().__init__(message=msg)
|
|
19
|
+
self.path = path
|
|
20
|
+
return
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from pathlib import Path as _Path
|
|
2
|
+
|
|
3
|
+
from pylinks.exception import PyLinksException as _PyLinksException
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PyLinksDataURIParseError(_PyLinksException):
|
|
7
|
+
"""Error parsing a data URI."""
|
|
8
|
+
def __init__(self, message: str, data_uri: str):
|
|
9
|
+
msg = f"Failed to parse data URI '{data_uri}': {message}"
|
|
10
|
+
super().__init__(message=msg)
|
|
11
|
+
self.message = message
|
|
12
|
+
self.data_uri = data_uri
|
|
13
|
+
return
|
|
@@ -150,24 +150,27 @@ def request(
|
|
|
150
150
|
|
|
151
151
|
def get_response_value():
|
|
152
152
|
def get_response():
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
153
|
+
try:
|
|
154
|
+
response = requests.request(
|
|
155
|
+
method=verb,
|
|
156
|
+
url=str(url),
|
|
157
|
+
params=params,
|
|
158
|
+
data=data,
|
|
159
|
+
headers=headers,
|
|
160
|
+
cookies=cookies,
|
|
161
|
+
files=files,
|
|
162
|
+
auth=auth,
|
|
163
|
+
timeout=timeout,
|
|
164
|
+
allow_redirects=allow_redirects,
|
|
165
|
+
proxies=proxies,
|
|
166
|
+
hooks=hooks,
|
|
167
|
+
stream=stream,
|
|
168
|
+
verify=verify,
|
|
169
|
+
cert=cert,
|
|
170
|
+
json=json,
|
|
171
|
+
)
|
|
172
|
+
except requests.exceptions.RequestException as e:
|
|
173
|
+
raise _pylinks.exceptions.WebAPIRequestError(e) from e
|
|
171
174
|
_raise_for_status_code(
|
|
172
175
|
response=response,
|
|
173
176
|
temporary_error_status_codes=(
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Create and parse [media types](https://en.wikipedia.org/wiki/Media_type).
|
|
2
|
+
|
|
3
|
+
References
|
|
4
|
+
----------
|
|
5
|
+
- [Offical IANA Media Types](https://www.iana.org/assignments/media-types/media-types.xml)
|
|
6
|
+
- [Common MIME types](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types)
|
|
7
|
+
- [Database of mime types](https://github.com/jshttp/mime-db/tree/master)
|
|
8
|
+
- [Database of mime types](https://github.com/patrickmccallum/mimetype-io/blob/master/src/mimeData.json)
|
|
9
|
+
- [JSON list of file extensions with their mime types](https://github.com/micnic/mime.json)
|
|
10
|
+
- [Python mimetypes](https://docs.python.org/3/library/mimetypes.html)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import mimetypes as _mimetypes
|
|
14
|
+
import dataclasses as _dataclasses
|
|
15
|
+
import re as _re
|
|
16
|
+
|
|
17
|
+
from pylinks import exception as _exception
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@_dataclasses.dataclass
|
|
21
|
+
class MediaType:
|
|
22
|
+
"""A [Media Type](https://www.iana.org/assignments/media-types/media-types.xhtml) (aka MIME type).
|
|
23
|
+
|
|
24
|
+
A media type consists of a type, a subtype with optional tree prefix,
|
|
25
|
+
optional suffixes, and optional parameters in the following format:
|
|
26
|
+
```
|
|
27
|
+
media-type = type "/" [tree "."] subtype ["+" suffix]* [";" parameter];
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Attributes
|
|
31
|
+
----------
|
|
32
|
+
type : str, default: ""
|
|
33
|
+
Type of the media. Standard types are
|
|
34
|
+
'application', 'audio', 'example', 'font', 'haptics',
|
|
35
|
+
'image', 'message', 'model', 'multipart', 'text', and 'video'.
|
|
36
|
+
tree : str, default: ""
|
|
37
|
+
Tree prefix of the media type, e.g., 'vnd', 'prs', 'x'.
|
|
38
|
+
subtype : str, default: ""
|
|
39
|
+
Subtype of the media (without the tree prefix), e.g.,
|
|
40
|
+
'json', 'ms-excel', 'oasis.opendocument.text'.
|
|
41
|
+
suffixes : list[str], default: []
|
|
42
|
+
Type suffixes, e.g., 'json', 'xml', 'zip'.
|
|
43
|
+
parameters : dict[str, str | None], default: {}
|
|
44
|
+
Additional parameters, e.g., `{"charset": "UTF-8"}`.
|
|
45
|
+
"""
|
|
46
|
+
type: str
|
|
47
|
+
subtype: str
|
|
48
|
+
tree: str = ""
|
|
49
|
+
suffixes: list[str] = _dataclasses.field(default_factory=list)
|
|
50
|
+
parameters: dict[str, str | None] = _dataclasses.field(default_factory=dict)
|
|
51
|
+
|
|
52
|
+
def __str__(self) -> str:
|
|
53
|
+
suffixes = "".join(f"+{suffix}" for suffix in self.suffixes)
|
|
54
|
+
if self.parameters:
|
|
55
|
+
joined = "; ".join(f"{k}={v}" if v else k for k, v in self.parameters.items())
|
|
56
|
+
params = f"; {joined}"
|
|
57
|
+
else:
|
|
58
|
+
params = ""
|
|
59
|
+
full_subtype = f"{self.tree}.{self.subtype}" if self.tree else self.subtype
|
|
60
|
+
return f"{self.type}/{full_subtype}{suffixes}{params}"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def parse(media_type: str) -> MediaType:
|
|
64
|
+
regex = _re.compile(
|
|
65
|
+
r"""
|
|
66
|
+
^
|
|
67
|
+
(?P<type>[\w\-]+)
|
|
68
|
+
/
|
|
69
|
+
(?:(?P<tree>[\w\-]+)\.)?
|
|
70
|
+
(?P<subtype>[\w\-.]+)
|
|
71
|
+
(?P<suffixes>(\+[\w\-.]+)*)?
|
|
72
|
+
(?:\s*;\s*(?P<parameters>.*))?
|
|
73
|
+
$
|
|
74
|
+
""",
|
|
75
|
+
_re.VERBOSE
|
|
76
|
+
)
|
|
77
|
+
match = regex.match(media_type)
|
|
78
|
+
if not match:
|
|
79
|
+
raise _exception.media_type.PyLinksMediaTypeParseError(
|
|
80
|
+
f"The input does not match the regex pattern '{regex.pattern}'.",
|
|
81
|
+
media_type
|
|
82
|
+
)
|
|
83
|
+
mime = match.groupdict()
|
|
84
|
+
mime["suffixes"] = [suffix for suffix in mime.get("suffixes", "").split("+") if suffix]
|
|
85
|
+
params = {}
|
|
86
|
+
if mime["parameters"]:
|
|
87
|
+
for param in mime["parameters"].split(";"):
|
|
88
|
+
key, *value = param.split("=", 1)
|
|
89
|
+
params[key.strip()] = value[0].strip() if value else None
|
|
90
|
+
mime["parameters"] = params
|
|
91
|
+
return MediaType(**mime)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def guess_from_uri(uri: str) -> MediaType:
|
|
95
|
+
"""Guess the media type of a given URI (e.g. URL or filepath).
|
|
96
|
+
|
|
97
|
+
Parameters
|
|
98
|
+
----------
|
|
99
|
+
uri : str
|
|
100
|
+
The URI to guess the media type from.
|
|
101
|
+
|
|
102
|
+
Returns
|
|
103
|
+
-------
|
|
104
|
+
MediaType
|
|
105
|
+
The guessed media type.
|
|
106
|
+
"""
|
|
107
|
+
uri = str(uri)
|
|
108
|
+
mimetype = _mimetypes.guess_type(uri)[0]
|
|
109
|
+
if mimetype is None:
|
|
110
|
+
raise _exception.media_type.PyLinksMediaTypeGuessError(uri)
|
|
111
|
+
return parse(mimetype)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import re as _re
|
|
2
|
+
import unicodedata as _unicodedata
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def to_slug(string: str) -> str:
|
|
6
|
+
"""Convert a string to a URL-friendly slug.
|
|
7
|
+
|
|
8
|
+
This function performs unicode-normalization on the string,
|
|
9
|
+
converts it to lowercase,
|
|
10
|
+
replaces any non-alphanumeric characters with hyphens, and
|
|
11
|
+
removes leading and trailing hyphens.
|
|
12
|
+
"""
|
|
13
|
+
# Normalize the string to decompose special characters
|
|
14
|
+
normalized_string = _unicodedata.normalize('NFKD', string)
|
|
15
|
+
# Encode the string to ASCII bytes, ignoring non-ASCII characters
|
|
16
|
+
ascii_bytes = normalized_string.encode('ascii', 'ignore')
|
|
17
|
+
# Decode back to a string
|
|
18
|
+
ascii_string = ascii_bytes.decode('ascii')
|
|
19
|
+
lower_case_string = ascii_string.lower()
|
|
20
|
+
return _re.sub(r'[^a-z0-9]+', '-', lower_case_string).strip('-')
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def camel_to_title(string: str) -> str:
|
|
24
|
+
"""Convert a 'CamelCase' string to a 'Title Case' string.
|
|
25
|
+
|
|
26
|
+
This function inserts spaces before each uppercase letter (except the first letter)
|
|
27
|
+
and capitalizes the first letter of each word.
|
|
28
|
+
"""
|
|
29
|
+
# Insert spaces before each uppercase letter (except the first letter)
|
|
30
|
+
spaced_str = _re.sub(r'(?<!^)(?=[A-Z])', ' ', string)
|
|
31
|
+
# Convert to title-case
|
|
32
|
+
title_str = spaced_str.title()
|
|
33
|
+
return title_str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def snake_to_camel(string):
|
|
37
|
+
components = string.split('_')
|
|
38
|
+
return ''.join([components[0]] + [x.title() for x in components[1:]])
|
|
39
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from pylinks.uri import data
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Generate and process [data URIs](https://en.wikipedia.org/wiki/Data_URI_scheme).
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import base64 as _base64
|
|
6
|
+
import re as _re
|
|
7
|
+
from pathlib import Path as _Path
|
|
8
|
+
import dataclasses as _dataclasses
|
|
9
|
+
from typing import Literal as _Literal
|
|
10
|
+
|
|
11
|
+
from pylinks.url import URL as _URL
|
|
12
|
+
from pylinks.http import request as _request
|
|
13
|
+
from pylinks import media_type as _media_type
|
|
14
|
+
from pylinks.exception.uri import PyLinksDataURIParseError as _PyLinksDataURIParseError
|
|
15
|
+
from pylinks.exception.base import PyLinksFileNotFoundError as _PyLinksFileNotFoundError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@_dataclasses.dataclass
|
|
19
|
+
class DataURI:
|
|
20
|
+
"""A data URI.
|
|
21
|
+
|
|
22
|
+
Attributes
|
|
23
|
+
----------
|
|
24
|
+
media_type : pylinks.media_type.MediaType, default: ""
|
|
25
|
+
The media type of the data.
|
|
26
|
+
data : str, default: ""
|
|
27
|
+
The data.
|
|
28
|
+
base64 : bool, default: False
|
|
29
|
+
Whether the data is base64 encoded.
|
|
30
|
+
"""
|
|
31
|
+
media_type: _media_type.MediaType | None = None
|
|
32
|
+
data: str | None = None
|
|
33
|
+
base64: bool = False
|
|
34
|
+
|
|
35
|
+
def __str__(self) -> str:
|
|
36
|
+
media_type = str(self.media_type) if self.media_type else ""
|
|
37
|
+
if self.base64:
|
|
38
|
+
media_type += ";base64"
|
|
39
|
+
return f"data:{media_type},{self.data}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse(data_uri: str) -> DataURI:
|
|
43
|
+
"""Parse a data URI.
|
|
44
|
+
|
|
45
|
+
Parameters
|
|
46
|
+
----------
|
|
47
|
+
data_uri : str
|
|
48
|
+
The data URI to parse.
|
|
49
|
+
|
|
50
|
+
Returns
|
|
51
|
+
-------
|
|
52
|
+
DataURI
|
|
53
|
+
The parsed data URI.
|
|
54
|
+
"""
|
|
55
|
+
regex = _re.compile(
|
|
56
|
+
r"^data:(?P<media_type>.*?)(?P<base64>\s*;\s*base64)?\s*,(?P<data>.*)$"
|
|
57
|
+
)
|
|
58
|
+
match = regex.match(data_uri)
|
|
59
|
+
if not match:
|
|
60
|
+
raise _PyLinksDataURIParseError(
|
|
61
|
+
f"The input does not match the regex pattern '{regex.pattern}'.",
|
|
62
|
+
data_uri,
|
|
63
|
+
)
|
|
64
|
+
components = match.groupdict()
|
|
65
|
+
media_type = components["media_type"] or None
|
|
66
|
+
if media_type:
|
|
67
|
+
media_type = _media_type.parse(media_type)
|
|
68
|
+
components["media_type"] = media_type
|
|
69
|
+
components["base64"] = bool(components["base64"])
|
|
70
|
+
return DataURI(**components)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def create_from_path(
|
|
74
|
+
path_type: _Literal["file", "url"],
|
|
75
|
+
path: str,
|
|
76
|
+
media_type: _media_type.MediaType | None = None,
|
|
77
|
+
guess_media_type: bool = True,
|
|
78
|
+
base64: bool = False,
|
|
79
|
+
) -> DataURI:
|
|
80
|
+
if path_type == "file":
|
|
81
|
+
return create_from_filepath(
|
|
82
|
+
filepath=path,
|
|
83
|
+
media_type=media_type,
|
|
84
|
+
guess_media_type=guess_media_type,
|
|
85
|
+
base64=base64,
|
|
86
|
+
)
|
|
87
|
+
elif path_type == "url":
|
|
88
|
+
return create_from_url(
|
|
89
|
+
url=path,
|
|
90
|
+
media_type=media_type,
|
|
91
|
+
guess_media_type=guess_media_type,
|
|
92
|
+
base64=base64,
|
|
93
|
+
)
|
|
94
|
+
raise ValueError(f"path_type '{path_type}' is invalid.")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def create_from_url(
|
|
98
|
+
url: str | _URL,
|
|
99
|
+
media_type: _media_type.MediaType | None = None,
|
|
100
|
+
guess_media_type: bool = True,
|
|
101
|
+
base64: bool = False,
|
|
102
|
+
) -> DataURI:
|
|
103
|
+
"""Create a data URI from a URL.
|
|
104
|
+
|
|
105
|
+
Parameters
|
|
106
|
+
----------
|
|
107
|
+
url : str | pylinks.url.URL
|
|
108
|
+
The URL of the data.
|
|
109
|
+
media_type : pylinks.media_type.MediaType | str | None, optional
|
|
110
|
+
Media (MIME) Type of the data.
|
|
111
|
+
guess_media_type : bool, default: True
|
|
112
|
+
Whether to guess the media type from the URL.
|
|
113
|
+
This is only done if the media type is not provided,
|
|
114
|
+
and will raise an error if the media type cannot be guessed.
|
|
115
|
+
base64 : bool, default: False
|
|
116
|
+
Whether to base64 encode the data.
|
|
117
|
+
|
|
118
|
+
Returns
|
|
119
|
+
-------
|
|
120
|
+
pylinks.uri.data.DataURI
|
|
121
|
+
The data URI.
|
|
122
|
+
"""
|
|
123
|
+
url = str(url)
|
|
124
|
+
if media_type is None and guess_media_type:
|
|
125
|
+
media_type = _media_type.guess_from_uri(url)
|
|
126
|
+
data = _request(url, response_type="str" if not base64 else "bytes")
|
|
127
|
+
return create_from_data(data=data, media_type=media_type, base64=base64)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def create_from_filepath(
|
|
131
|
+
filepath: str | _Path,
|
|
132
|
+
media_type: _media_type.MediaType | None = None,
|
|
133
|
+
guess_media_type: bool = True,
|
|
134
|
+
base64: bool = False,
|
|
135
|
+
) -> DataURI:
|
|
136
|
+
"""Create a data URI from a file.
|
|
137
|
+
|
|
138
|
+
Parameters
|
|
139
|
+
----------
|
|
140
|
+
filepath : str | pathlib.Path
|
|
141
|
+
Path to the file.
|
|
142
|
+
media_type : pylinks.media_type.MediaType | str | None, optional
|
|
143
|
+
Media (MIME) Type of the data.
|
|
144
|
+
guess_media_type : bool, default: True
|
|
145
|
+
Whether to guess the media type from the URL.
|
|
146
|
+
This is only done if the media type is not provided,
|
|
147
|
+
and will raise an error if the media type cannot be guessed.
|
|
148
|
+
base64 : bool, default: False
|
|
149
|
+
Whether to base64 encode the data.
|
|
150
|
+
|
|
151
|
+
Returns
|
|
152
|
+
-------
|
|
153
|
+
pylinks.uri.data.DataURI
|
|
154
|
+
The data URI.
|
|
155
|
+
"""
|
|
156
|
+
filepath = _Path(filepath).resolve()
|
|
157
|
+
if not filepath.is_file():
|
|
158
|
+
raise _PyLinksFileNotFoundError(filepath)
|
|
159
|
+
if media_type is None and guess_media_type:
|
|
160
|
+
media_type = _media_type.guess_from_uri(str(filepath))
|
|
161
|
+
data = filepath.read_bytes() if base64 else filepath.read_text()
|
|
162
|
+
return create_from_data(data=data, media_type=media_type, base64=base64)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def create_from_data(
|
|
166
|
+
data: str | bytes,
|
|
167
|
+
media_type: _media_type.MediaType | None = None,
|
|
168
|
+
base64: bool = False,
|
|
169
|
+
) -> DataURI:
|
|
170
|
+
"""Create a data URI.
|
|
171
|
+
|
|
172
|
+
Parameters
|
|
173
|
+
----------
|
|
174
|
+
media_type : str | list[str | tuple[str, str]] | dict[str, str | None], optional
|
|
175
|
+
The media type of the data.
|
|
176
|
+
This can be either a fully formed media type as a string,
|
|
177
|
+
a dictionary of parameter name and value pairs (using None for parameters without values),
|
|
178
|
+
or an iterable where the elements are either strings (for parameters without values)
|
|
179
|
+
or tuples of parameter name and value.
|
|
180
|
+
For example, all of the following are valid and equivalent:
|
|
181
|
+
- As a string: "text/plain;charset=UTF-8"
|
|
182
|
+
- As a list of strings (attribute-value pairs not separated): `["text/plain", "charset=UTF-8"]`
|
|
183
|
+
- As a list of strings and tuples: `["text/plain", ("charset", "UTF-8")]`
|
|
184
|
+
- As a dictionary: `{"text/plain": None, "charset": "UTF-8"}`
|
|
185
|
+
data : str, optional
|
|
186
|
+
The data to include in the URI.
|
|
187
|
+
base64 : bool, default: False
|
|
188
|
+
Whether the data is base64 encoded.
|
|
189
|
+
|
|
190
|
+
Returns
|
|
191
|
+
-------
|
|
192
|
+
str
|
|
193
|
+
The data URI.
|
|
194
|
+
"""
|
|
195
|
+
if base64:
|
|
196
|
+
data_bytes = data.encode() if isinstance(data, str) else data
|
|
197
|
+
data_str = _base64.b64encode(data_bytes).decode()
|
|
198
|
+
else:
|
|
199
|
+
data_str = data
|
|
200
|
+
return DataURI(media_type=media_type, data=data_str, base64=base64)
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
"""Create and modify URLs."""
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
# Standard libraries
|
|
3
|
+
from __future__ import annotations
|
|
5
4
|
import re
|
|
6
5
|
import urllib
|
|
7
6
|
import webbrowser
|
|
@@ -59,14 +58,9 @@ class URL:
|
|
|
59
58
|
Add a path at the end of the base URL (while preserving the query string and fragment),
|
|
60
59
|
and return a new copy.
|
|
61
60
|
"""
|
|
62
|
-
|
|
63
|
-
raise TypeError("Adding a path can only be performed on strings.")
|
|
64
|
-
if path.startswith("/"):
|
|
65
|
-
path = path[1:]
|
|
66
|
-
if path.endswith("/"):
|
|
67
|
-
path = path[:-1]
|
|
61
|
+
path = str(path or "")
|
|
68
62
|
return URL(
|
|
69
|
-
base=f"{self.base}/{path}",
|
|
63
|
+
base=f"{self.base.removesuffix("/")}/{path.removeprefix("/")}" if path else self.base,
|
|
70
64
|
queries=self.queries.copy(),
|
|
71
65
|
fragment=self.fragment,
|
|
72
66
|
query_delimiter=self.query_delimiter,
|
|
@@ -99,31 +93,19 @@ class URL:
|
|
|
99
93
|
for key, val in self.queries.items():
|
|
100
94
|
if val is None:
|
|
101
95
|
continue
|
|
102
|
-
q_key = urllib.parse.quote(str(key), safe=self.quote_safe)
|
|
96
|
+
q_key = urllib.parse.quote(urllib.parse.unquote(str(key)), safe=self.quote_safe)
|
|
103
97
|
if isinstance(val, bool) and val is True:
|
|
104
98
|
queries.append(q_key)
|
|
105
99
|
else:
|
|
106
100
|
q_val = (
|
|
107
101
|
val.decode("utf8")
|
|
108
102
|
if isinstance(val, bytes)
|
|
109
|
-
else urllib.parse.quote(str(val), safe=self.quote_safe)
|
|
103
|
+
else urllib.parse.quote(urllib.parse.unquote(str(val)), safe=self.quote_safe)
|
|
110
104
|
)
|
|
111
105
|
queries.append(f"{q_key}={q_val}")
|
|
112
106
|
return self.query_delimiter.join(queries)
|
|
113
107
|
|
|
114
|
-
def
|
|
115
|
-
"""
|
|
116
|
-
Add a path to the end of the base URL, while preserving the query string and fragment.
|
|
117
|
-
This modifies the current instance in place.
|
|
118
|
-
"""
|
|
119
|
-
if path.startswith("/"):
|
|
120
|
-
path = path[1:]
|
|
121
|
-
if path.endswith("/"):
|
|
122
|
-
path = path[:-1]
|
|
123
|
-
self.base += f"/{path}"
|
|
124
|
-
return
|
|
125
|
-
|
|
126
|
-
def copy(self) -> "URL":
|
|
108
|
+
def copy(self) -> URL:
|
|
127
109
|
"""Create a new copy."""
|
|
128
110
|
return self.__copy__()
|
|
129
111
|
|
|
@@ -172,7 +154,7 @@ def create(
|
|
|
172
154
|
Delimiter for the query string.
|
|
173
155
|
"""
|
|
174
156
|
base, base_queries, base_fragment = _process_url(url, query_delimiter=query_delimiter)
|
|
175
|
-
queries = base_queries | queries
|
|
157
|
+
queries = base_queries | (queries or {})
|
|
176
158
|
fragment = fragment if fragment else base_fragment
|
|
177
159
|
return URL(
|
|
178
160
|
base=base,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|