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.
Files changed (33) hide show
  1. {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/PKG-INFO +1 -1
  2. {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/pyproject.toml +1 -1
  3. {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/src/PyLinks.egg-info/PKG-INFO +1 -1
  4. {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/src/PyLinks.egg-info/SOURCES.txt +9 -1
  5. {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/src/pylinks/__init__.py +1 -1
  6. {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/src/pylinks/api/github.py +27 -4
  7. pylinks-0.0.0.dev26/src/pylinks/exception/__init__.py +2 -0
  8. pylinks-0.0.0.dev26/src/pylinks/exception/base.py +23 -0
  9. pylinks-0.0.0.dev26/src/pylinks/exception/media_type.py +20 -0
  10. pylinks-0.0.0.dev26/src/pylinks/exception/uri.py +13 -0
  11. {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/src/pylinks/http.py +21 -18
  12. pylinks-0.0.0.dev26/src/pylinks/media_type.py +111 -0
  13. pylinks-0.0.0.dev26/src/pylinks/string.py +39 -0
  14. pylinks-0.0.0.dev26/src/pylinks/uri/__init__.py +1 -0
  15. pylinks-0.0.0.dev26/src/pylinks/uri/data.py +200 -0
  16. {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/src/pylinks/url.py +7 -25
  17. {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/setup.cfg +0 -0
  18. {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/src/PyLinks.egg-info/dependency_links.txt +0 -0
  19. {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/src/PyLinks.egg-info/not-zip-safe +0 -0
  20. {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/src/PyLinks.egg-info/requires.txt +0 -0
  21. {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/src/PyLinks.egg-info/top_level.txt +0 -0
  22. {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/src/pylinks/_settings.py +0 -0
  23. {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/src/pylinks/api/__init__.py +0 -0
  24. {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/src/pylinks/api/doi.py +0 -0
  25. {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/src/pylinks/api/orcid.py +0 -0
  26. {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/src/pylinks/exceptions.py +0 -0
  27. {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/src/pylinks/site/__init__.py +0 -0
  28. {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/src/pylinks/site/binder.py +0 -0
  29. {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/src/pylinks/site/conda.py +0 -0
  30. {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/src/pylinks/site/github.py +0 -0
  31. {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/src/pylinks/site/lib_io.py +0 -0
  32. {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/src/pylinks/site/pypi.py +0 -0
  33. {pylinks-0.0.0.dev24 → pylinks-0.0.0.dev26}/src/pylinks/site/readthedocs.py +0 -0
@@ -1,5 +1,5 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: PyLinks
3
- Version: 0.0.0.dev24
3
+ Version: 0.0.0.dev26
4
4
  Requires-Python: >=3.10
5
5
  Requires-Dist: requests<3,>=2.31.0
@@ -17,7 +17,7 @@ namespaces = true
17
17
  # ----------------------------------------- Project Metadata -------------------------------------
18
18
  #
19
19
  [project]
20
- version = "0.0.0.dev24"
20
+ version = "0.0.0.dev26"
21
21
  name = "PyLinks"
22
22
  dependencies = [
23
23
  "requests >= 2.31.0, < 3",
@@ -1,5 +1,5 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: PyLinks
3
- Version: 0.0.0.dev24
3
+ Version: 0.0.0.dev26
4
4
  Requires-Python: >=3.10
5
5
  Requires-Dist: requests<3,>=2.31.0
@@ -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
@@ -12,4 +12,4 @@ Other available modules offer shortcuts for creating useful URLs for popular onl
12
12
  """
13
13
 
14
14
  from pylinks._settings import settings
15
- from pylinks import url, http, api, site, exceptions
15
+ from pylinks import url, http, api, site, exceptions, uri, media_type
@@ -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}/{query}",
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}/{query}",
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[tuple[int, int, int]]:
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, tag[0].split("."))) for tag in tags])
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,2 @@
1
+ from pylinks.exception.base import PyLinksException
2
+ from pylinks.exception import media_type
@@ -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
- response = requests.request(
154
- method=verb,
155
- url=str(url),
156
- params=params,
157
- data=data,
158
- headers=headers,
159
- cookies=cookies,
160
- files=files,
161
- auth=auth,
162
- timeout=timeout,
163
- allow_redirects=allow_redirects,
164
- proxies=proxies,
165
- hooks=hooks,
166
- stream=stream,
167
- verify=verify,
168
- cert=cert,
169
- json=json,
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
- if not isinstance(path, str):
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 add_path(self, path: str) -> None:
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 if queries else base_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