charmlibs-pathops 0.0.0__py3-none-any.whl

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.
@@ -0,0 +1,120 @@
1
+ # Copyright 2025 Canonical Ltd.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Methods for matching Python Exceptions to Pebble Errors and creating Exception objects."""
16
+
17
+ from __future__ import annotations
18
+
19
+ import errno
20
+ import os
21
+ from typing import NoReturn
22
+
23
+ from ops import pebble
24
+
25
+
26
+ def raise_if_matches_directory_not_empty(error: pebble.Error, msg: str) -> None:
27
+ if (
28
+ isinstance(error, pebble.PathError)
29
+ and error.kind == 'generic-file-error'
30
+ and 'directory not empty' in error.message
31
+ ):
32
+ raise OSError(errno.ENOTEMPTY, os.strerror(errno.ENOTEMPTY), msg) from error
33
+
34
+
35
+ def raise_file_exists(msg: str, from_: BaseException | None = None) -> NoReturn:
36
+ e = FileExistsError(errno.EEXIST, os.strerror(errno.EEXIST), msg)
37
+ raise e from from_
38
+
39
+
40
+ def raise_if_matches_file_exists(error: pebble.Error, msg: str) -> None:
41
+ if (
42
+ isinstance(error, pebble.PathError)
43
+ and error.kind == 'generic-file-error'
44
+ and 'file exists' in error.message
45
+ ):
46
+ raise_file_exists(msg, from_=error)
47
+
48
+
49
+ def raise_file_not_found(msg: str, from_: BaseException | None = None) -> NoReturn:
50
+ # pebble will return this error when trying to read_{text,bytes} a socket
51
+ # pathlib raises OSError(errno.ENXIO, os.strerror(errno.ENXIO), path) in this case
52
+ # displaying as "OSError: [Errno 6] No such device or address: '/path'"
53
+ # since FileNotFoundError is a subtype of OSError, and this case should be rare
54
+ # it seems sensible to just raise FileNotFoundError here, without checking
55
+ # if the file in question is a socket
56
+ raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), msg) from from_
57
+
58
+
59
+ def raise_if_matches_file_not_found(error: pebble.Error, msg: str) -> None:
60
+ if (isinstance(error, pebble.APIError) and error.code == 404) or (
61
+ isinstance(error, pebble.PathError) and error.kind == 'not-found'
62
+ ):
63
+ raise_file_not_found(msg, from_=error)
64
+
65
+
66
+ def raise_is_a_directory(msg: str, from_: BaseException | None = None) -> None:
67
+ raise IsADirectoryError(errno.EISDIR, os.strerror(errno.EISDIR), msg) from from_
68
+
69
+
70
+ def raise_if_matches_is_a_directory(error: pebble.Error, msg: str) -> None:
71
+ if (
72
+ isinstance(error, pebble.PathError)
73
+ and error.kind == 'generic-file-error'
74
+ and 'can only read a regular file' in error.message
75
+ ):
76
+ raise_is_a_directory(msg, from_=error)
77
+
78
+
79
+ def raise_if_matches_lookup(error: pebble.Error, msg: str) -> None:
80
+ if (
81
+ isinstance(error, pebble.PathError)
82
+ and error.kind == 'generic-file-error'
83
+ and 'cannot look up user and group' in error.message
84
+ ):
85
+ raise LookupError(msg) from error
86
+
87
+
88
+ def matches_not_a_directory(error: pebble.Error) -> bool:
89
+ return (
90
+ isinstance(error, pebble.APIError)
91
+ and error.code == 400
92
+ and 'not a directory' in error.message
93
+ ) or (
94
+ isinstance(error, pebble.PathError)
95
+ and error.kind == 'generic-file-error'
96
+ and 'not a directory' in error.message
97
+ )
98
+
99
+
100
+ def raise_not_a_directory(msg: str, from_: BaseException | None = None) -> NoReturn:
101
+ raise NotADirectoryError(errno.ENOTDIR, os.strerror(errno.ENOTDIR), msg) from from_
102
+
103
+
104
+ def raise_if_matches_not_a_directory(error: pebble.Error, msg: str) -> None:
105
+ if matches_not_a_directory(error):
106
+ raise_not_a_directory(msg, from_=error)
107
+
108
+
109
+ def raise_if_matches_permission(error: pebble.Error, msg: str) -> None:
110
+ if isinstance(error, pebble.PathError) and error.kind == 'permission-denied':
111
+ raise PermissionError(errno.EPERM, os.strerror(errno.EPERM), msg) from error
112
+
113
+
114
+ def raise_if_matches_too_many_levels_of_symlinks(error: pebble.Error, msg: str) -> None:
115
+ if (
116
+ isinstance(error, pebble.APIError)
117
+ and error.code == 400
118
+ and 'too many levels of symbolic links' in error.message
119
+ ):
120
+ raise OSError(errno.ELOOP, os.strerror(errno.ELOOP), msg) from error
@@ -0,0 +1,97 @@
1
+ # Copyright 2024 Canonical Ltd.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Utilities for creating and working with :class:`ops.pebble.FileInfo` objects."""
16
+
17
+ from __future__ import annotations
18
+
19
+ import datetime
20
+ import grp
21
+ import pwd
22
+ import stat
23
+ import typing
24
+
25
+ from ops import pebble
26
+
27
+ from . import _errors
28
+
29
+ if typing.TYPE_CHECKING:
30
+ import pathlib
31
+
32
+ from ._container_path import ContainerPath
33
+
34
+
35
+ _FT_MAP: dict[int, pebble.FileType] = {
36
+ stat.S_IFREG: pebble.FileType.FILE,
37
+ stat.S_IFDIR: pebble.FileType.DIRECTORY,
38
+ stat.S_IFLNK: pebble.FileType.SYMLINK,
39
+ stat.S_IFSOCK: pebble.FileType.SOCKET,
40
+ stat.S_IFIFO: pebble.FileType.NAMED_PIPE,
41
+ stat.S_IFBLK: pebble.FileType.DEVICE, # block device
42
+ stat.S_IFCHR: pebble.FileType.DEVICE, # character device
43
+ }
44
+
45
+
46
+ def from_container_path(path: ContainerPath, follow_symlinks: bool = True) -> pebble.FileInfo:
47
+ if follow_symlinks:
48
+ return _get_fileinfo_directly(path)
49
+ return _get_fileinfo_from_parent(path)
50
+
51
+
52
+ def _get_fileinfo_directly(path: ContainerPath) -> pebble.FileInfo:
53
+ try:
54
+ info_list = path._container.list_files(path._path, itself=True)
55
+ except (pebble.APIError, pebble.PathError) as e:
56
+ msg = repr(path)
57
+ _errors.raise_if_matches_file_not_found(e, msg=msg)
58
+ _errors.raise_if_matches_not_a_directory(e, msg=msg)
59
+ _errors.raise_if_matches_permission(e, msg=msg)
60
+ _errors.raise_if_matches_too_many_levels_of_symlinks(e, msg=msg)
61
+ raise
62
+ assert len(info_list) == 1, 'ops.Container.list_files with itself=True returns 1 item'
63
+ return info_list[0]
64
+
65
+
66
+ def _get_fileinfo_from_parent(path: ContainerPath) -> pebble.FileInfo:
67
+ try:
68
+ info_list = path._container.list_files(path._path.parent, pattern=path.name)
69
+ except (pebble.APIError, pebble.PathError) as e:
70
+ msg = repr(path)
71
+ _errors.raise_if_matches_file_not_found(e, msg=msg)
72
+ _errors.raise_if_matches_permission(e, msg=msg)
73
+ raise
74
+ if not info_list:
75
+ _errors.raise_file_not_found(repr(path))
76
+ assert len(info_list) == 1, 'ops.Container.list_files with non-* pattern should return 1 item'
77
+ return info_list[0]
78
+
79
+
80
+ def from_pathlib_path(path: pathlib.Path, follow_symlinks: bool = True) -> pebble.FileInfo:
81
+ stat_result = path.stat() if follow_symlinks else path.lstat()
82
+ utcoffset = datetime.datetime.now().astimezone().utcoffset()
83
+ timezone = datetime.timezone(utcoffset) if utcoffset is not None else datetime.timezone.utc
84
+ filetype = _FT_MAP.get(stat.S_IFMT(stat_result.st_mode), pebble.FileType.UNKNOWN)
85
+ size = stat_result.st_size if filetype is pebble.FileType.FILE else None
86
+ return pebble.FileInfo(
87
+ path=str(path),
88
+ name=path.name,
89
+ type=filetype,
90
+ size=size,
91
+ permissions=stat.S_IMODE(stat_result.st_mode),
92
+ last_modified=datetime.datetime.fromtimestamp(int(stat_result.st_mtime), tz=timezone),
93
+ user_id=stat_result.st_uid,
94
+ user=pwd.getpwuid(stat_result.st_uid).pw_name,
95
+ group_id=stat_result.st_gid,
96
+ group=grp.getgrgid(stat_result.st_gid).gr_name,
97
+ )
@@ -0,0 +1,104 @@
1
+ # Copyright 2024 Canonical Ltd.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Public helper functions exported by this package."""
16
+
17
+ from __future__ import annotations
18
+
19
+ import pathlib
20
+ import typing
21
+
22
+ from . import _constants, _fileinfo
23
+ from ._container_path import ContainerPath
24
+ from ._local_path import LocalPath
25
+
26
+ if typing.TYPE_CHECKING:
27
+ import os
28
+ from typing import BinaryIO, TextIO
29
+
30
+ from ops import pebble
31
+ from typing_extensions import TypeIs
32
+
33
+ from ._types import PathProtocol
34
+
35
+
36
+ def ensure_contents(
37
+ path: str | os.PathLike[str] | PathProtocol,
38
+ source: bytes | str | BinaryIO | TextIO,
39
+ *,
40
+ mode: int = _constants.DEFAULT_WRITE_MODE,
41
+ user: str | None = None,
42
+ group: str | None = None,
43
+ ) -> bool:
44
+ """Ensure ``source`` can be read from ``path``. Return True if any changes were made.
45
+
46
+ Ensure that ``path`` exists, contains ``source``, has the correct permissions (``mode``),
47
+ and has the correct file ownership (``user`` and ``group``).
48
+
49
+ Args:
50
+ path: A local or remote filesystem path.
51
+ source: The desired contents in ``str`` or ``bytes`` form, or an object with a ``.read()``
52
+ method which returns a ``str`` or ``bytes`` object.
53
+ mode: The desired file permissions.
54
+ user: The desired file owner, or ``None`` to not change the owner.
55
+ group: The desired group, or ``None`` to not change the group.
56
+
57
+ Returns:
58
+ ``True`` if any changes were made, including permissions or ownership, otherwise ``False``.
59
+
60
+ Raises:
61
+ LookupError: if the user or group is unknown.
62
+ NotADirectoryError: if the parent exists as a non-directory file.
63
+ PermissionError: if the user does not have permissions for the operation.
64
+ :class:`PebbleConnectionError`: if the remote Pebble client cannot be reached.
65
+ """
66
+ if _is_str_pathlike(path):
67
+ path = LocalPath(path)
68
+ source = _as_bytes(source)
69
+ try:
70
+ info = _get_fileinfo(path)
71
+ except FileNotFoundError:
72
+ pass # file doesn't exist, so writing is required
73
+ else: # check if metadata and contents already match
74
+ if (
75
+ (info.permissions == mode)
76
+ and (user is None or info.user == user)
77
+ and (group is None or info.group == group)
78
+ and (path.read_bytes() == source)
79
+ ):
80
+ return False # everything matches, so writing is not required
81
+ path.parent.mkdir(parents=True, exist_ok=True)
82
+ path.write_bytes(source, mode=mode, user=user, group=group)
83
+ return True
84
+
85
+
86
+ def _is_str_pathlike(obj: object) -> TypeIs[str | os.PathLike[str]]:
87
+ return isinstance(obj, str) or hasattr(obj, '__fspath__')
88
+
89
+
90
+ def _get_fileinfo(
91
+ path: str | os.PathLike[str] | PathProtocol, follow_symlinks: bool = True
92
+ ) -> pebble.FileInfo:
93
+ if isinstance(path, ContainerPath):
94
+ return _fileinfo.from_container_path(path, follow_symlinks=follow_symlinks)
95
+ assert _is_str_pathlike(path)
96
+ return _fileinfo.from_pathlib_path(pathlib.Path(path), follow_symlinks=follow_symlinks)
97
+
98
+
99
+ def _as_bytes(source: bytes | str | BinaryIO | TextIO) -> bytes:
100
+ if isinstance(source, bytes):
101
+ return source
102
+ if isinstance(source, str):
103
+ return source.encode()
104
+ return _as_bytes(source.read())
@@ -0,0 +1,224 @@
1
+ # Copyright 2024 Canonical Ltd.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Implementation of LocalPath class."""
16
+
17
+ from __future__ import annotations
18
+
19
+ import grp
20
+ import pathlib
21
+ import pwd
22
+ import shutil
23
+ import typing
24
+
25
+ from . import _constants
26
+
27
+ if typing.TYPE_CHECKING:
28
+ from typing_extensions import Buffer
29
+
30
+
31
+ class LocalPath(pathlib.PosixPath):
32
+ r""":class:`pathlib.PosixPath` subclass with extended file-creation method arguments.
33
+
34
+ .. note::
35
+ The :meth:`write_bytes`, :meth:`write_text`, and :meth:`mkdir` methods are extended with
36
+ file permission and ownership arguments, for compatibility with :class:`PathProtocol`.
37
+
38
+ Args:
39
+ \*parts: :class:`str` or :class:`os.PathLike`. ``LocalPath`` takes no keyword arguments.
40
+
41
+ ::
42
+
43
+ LocalPath(pathlib.Path('/foo'))
44
+ LocalPath('/', 'foo')
45
+ """
46
+
47
+ def write_bytes(
48
+ self,
49
+ data: Buffer,
50
+ *,
51
+ mode: int | None = None,
52
+ user: str | None = None,
53
+ group: str | None = None,
54
+ ) -> int:
55
+ """Write the provided data to the corresponding local filesystem path.
56
+
57
+ Compared to :meth:`pathlib.Path.write_bytes`, this method adds ``mode``, ``user``
58
+ and ``group`` args. These are used to set the permissions and ownership of the file.
59
+
60
+ Args:
61
+ data: The bytes to write, typically a :class:`bytes` object, but may also be a
62
+ :class:`bytearray` or :class:`memoryview`.
63
+ mode: The permissions to set on the file. Defaults to 0o644 (-rw-r--r--) for new files.
64
+ If the file already exists, its permissions will be changed, using
65
+ :meth:`pathlib.PosixPath.chmod`, unless ``mode`` is ``None`` (default).
66
+ user: The name of the user to set for the file using :func:`shutil.chown`.
67
+ Validated to be an existing user before writing.
68
+ If the file already exists, its user and group will be changed,
69
+ unless ``user`` is ``None`` (default).
70
+ group: The name of the group to set for the file using :func:`shutil.chown`.
71
+ Validated to be an existing group before writing.
72
+ If the file already exists, its group will be changed,
73
+ unless ``user`` and ``group`` are ``None`` (default).
74
+
75
+ Returns:
76
+ The number of bytes written.
77
+
78
+ Raises:
79
+ FileNotFoundError: if the parent directory does not exist.
80
+ LookupError: if the user or group is unknown.
81
+ NotADirectoryError: if the parent exists as a non-directory file.
82
+ PermissionError: if the local user does not have permissions for the operation.
83
+ """
84
+ _validate_user_and_group(user=user, group=group)
85
+ if mode is None:
86
+ # create the file with Pebble's default write mode if it doesn't exist
87
+ # doesn't change the mode if the file already exists
88
+ self.touch(mode=_constants.DEFAULT_WRITE_MODE)
89
+ bytes_written = super().write_bytes(data)
90
+ _chown_if_needed(self, user=user, group=group)
91
+ if mode is not None:
92
+ # explicitly set the mode if the user requested it
93
+ self.chmod(mode)
94
+ return bytes_written
95
+
96
+ def write_text(
97
+ self,
98
+ data: str,
99
+ encoding: str | None = None,
100
+ errors: str | None = None,
101
+ newline: str | None = None,
102
+ *,
103
+ mode: int | None = None,
104
+ user: str | None = None,
105
+ group: str | None = None,
106
+ ) -> int:
107
+ r"""Write the provided string to the corresponding local filesystem path.
108
+
109
+ Compared to :meth:`pathlib.Path.write_bytes`, this method adds ``mode``, ``user``
110
+ and ``group`` args. These are used to set the permissions and ownership of the file.
111
+
112
+ .. warning::
113
+ :class:`ContainerPath` and :class:`PathProtocol` do not support the ``encoding``,
114
+ ``errors``, and ``newline`` arguments of :meth:`pathlib.Path.write_text`.
115
+ For :class:`ContainerPath` compatible code, do not use these arguments.
116
+ They are provided to allow :class:`LocalPath` to be used as a drop-in
117
+ replacement for :class:`pathlib.Path` if needed.
118
+
119
+ Args:
120
+ data: The string to write. Newlines are not modified on writing.
121
+ encoding: The encoding to use when writing the data, defaults to 'UTF-8'.
122
+ errors: 'strict' to raise any encoding errors, 'ignore' to ignore them.
123
+ Defaults to 'strict'.
124
+ newline: If ``None``, ``''``, or ``'\n'``, then '\n' will be written as is.
125
+ This is the default behaviour. If ``newline`` is ``'\r'`` or ``'\r\n'``,
126
+ then ``'\n'`` will be replaced with ``newline`` in memory before writing.
127
+ mode: The permissions to set on the file. Defaults to 0o644 (-rw-r--r--) for new files.
128
+ If the file already exists, its permissions will be changed, using
129
+ :meth:`pathlib.PosixPath.chmod`, unless ``mode`` is ``None`` (default).
130
+ user: The name of the user to set for the file using :func:`shutil.chown`.
131
+ Validated to be an existing user before writing.
132
+ If the file already exists, its user and group will be changed,
133
+ unless ``user`` is ``None`` (default).
134
+ group: The name of the group to set for the file using :func:`shutil.chown`.
135
+ Validated to be an existing group before writing.
136
+ If the file already exists, its group will be changed,
137
+ unless ``user`` and ``group`` are ``None`` (default).
138
+
139
+ Returns:
140
+ The number of bytes written.
141
+
142
+ Raises:
143
+ FileNotFoundError: if the parent directory does not exist.
144
+ LookupError: if the user or group is unknown.
145
+ NotADirectoryError: if the parent exists as a non-directory file.
146
+ PermissionError: if the local user does not have permissions for the operation.
147
+ ValueError: if ``newline`` is any value other than those documented above.
148
+ """
149
+ _validate_user_and_group(user=user, group=group)
150
+ if newline in ('\r', '\r\n'):
151
+ data = data.replace('\n', newline)
152
+ elif newline not in ('', '\n', None):
153
+ raise ValueError(f'illegal newline value: {newline!r}')
154
+ if mode is None:
155
+ # create the file with Pebble's default write mode
156
+ self.touch(mode=_constants.DEFAULT_WRITE_MODE)
157
+ bytes_written = super().write_text(data, encoding=encoding, errors=errors)
158
+ _chown_if_needed(self, user=user, group=group)
159
+ if mode is not None:
160
+ # explicitly set the mode if the user requested it
161
+ self.chmod(mode)
162
+ return bytes_written
163
+
164
+ def mkdir(
165
+ self,
166
+ mode: int = _constants.DEFAULT_MKDIR_MODE,
167
+ parents: bool = False,
168
+ exist_ok: bool = False,
169
+ *,
170
+ user: str | None = None,
171
+ group: str | None = None,
172
+ ) -> None:
173
+ """Create a new directory at the corresponding local filesystem path.
174
+
175
+ Compared to :meth:`pathlib.Path.mkdir`, this method adds ``user`` and ``group`` args.
176
+ These are used to set the ownership of the created directory. Any created parents
177
+ will not have their ownership set.
178
+
179
+ Args:
180
+ mode: The permissions to set on the created directory. Any parents created will have
181
+ their permissions set to the default value of 0o755 (drwxr-xr-x).
182
+ The permissions are not changed if the directory already exists.
183
+ parents: Whether to create any missing parent directories as well. If ``False``
184
+ (default) and a parent directory does not exist, a :class:`FileNotFound` error will
185
+ be raised.
186
+ exist_ok: Whether to raise an error if the directory already exists.
187
+ If ``False`` (default) and the directory already exists,
188
+ a :class:`FileExistsError` will be raised.
189
+ user: The name of the user to set for the directory using :func:`shutil.chown`.
190
+ Validated to be an existing user before writing.
191
+ The user and group are not changed if the directory already exists.
192
+ group: The name of the group to set for the directory using :func:`shutil.chown`.
193
+ Validated to be an existing group before writing.
194
+ The user and group are not changed if the directory already exists.
195
+
196
+ Raises:
197
+ FileExistsError: if the directory already exists and ``exist_ok`` is ``False``.
198
+ FileNotFoundError: if the parent directory does not exist and ``parents`` is ``False``.
199
+ LookupError: if the user or group is unknown.
200
+ NotADirectoryError: if the parent exists as a non-directory file.
201
+ PermissionError: if the local user does not have permissions for the operation.
202
+ """
203
+ _validate_user_and_group(user=user, group=group)
204
+ already_exists = self.exists()
205
+ super().mkdir(mode=mode, parents=parents, exist_ok=exist_ok)
206
+ if not already_exists:
207
+ _chown_if_needed(self, user=user, group=group)
208
+
209
+
210
+ def _validate_user_and_group(user: str | None, group: str | None):
211
+ if user is not None:
212
+ pwd.getpwnam(user)
213
+ if group is not None:
214
+ grp.getgrnam(group)
215
+
216
+
217
+ def _chown_if_needed(path: pathlib.Path, user: str | int | None, group: str | int | None) -> None:
218
+ if user is not None:
219
+ if group is None: # use the user's group, following Pebble
220
+ info = pwd.getpwnam(user) if isinstance(user, str) else pwd.getpwuid(user)
221
+ group = info.pw_gid
222
+ shutil.chown(path, user=user, group=group)
223
+ elif group is not None:
224
+ shutil.chown(path, group=group)