charmlibs-pathops 0.0.0a1__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,104 @@
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_file_exists(msg: str, from_: BaseException | None = None) -> NoReturn:
27
+ e = FileExistsError(errno.EEXIST, os.strerror(errno.EEXIST), msg)
28
+ print(e)
29
+ raise e from from_
30
+
31
+
32
+ def raise_if_matches_file_exists(error: pebble.Error, msg: str) -> None:
33
+ if (
34
+ isinstance(error, pebble.PathError)
35
+ and error.kind == 'generic-file-error'
36
+ and 'file exists' in error.message
37
+ ):
38
+ raise_file_exists(msg, from_=error)
39
+
40
+
41
+ def raise_file_not_found(msg: str, from_: BaseException | None = None) -> NoReturn:
42
+ # pebble will return this error when trying to read_{text,bytes} a socket
43
+ # pathlib raises OSError(errno.ENXIO, os.strerror(errno.ENXIO), path) in this case
44
+ # displaying as "OSError: [Errno 6] No such device or address: '/path'"
45
+ # since FileNotFoundError is a subtype of OSError, and this case should be rare
46
+ # it seems sensible to just raise FileNotFoundError here, without checking
47
+ # if the file in question is a socket
48
+ raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), msg) from from_
49
+
50
+
51
+ def raise_if_matches_file_not_found(error: pebble.Error, msg: str) -> None:
52
+ if (isinstance(error, pebble.APIError) and error.code == 404) or (
53
+ isinstance(error, pebble.PathError) and error.kind == 'not-found'
54
+ ):
55
+ raise_file_not_found(msg, from_=error)
56
+
57
+
58
+ def raise_if_matches_is_a_directory(error: pebble.Error, msg: str) -> None:
59
+ if (
60
+ isinstance(error, pebble.PathError)
61
+ and error.kind == 'generic-file-error'
62
+ and 'can only read a regular file' in error.message
63
+ ):
64
+ raise IsADirectoryError(errno.EISDIR, os.strerror(errno.EISDIR), msg) from error
65
+
66
+
67
+ def raise_if_matches_lookup(error: pebble.Error, msg: str) -> None:
68
+ if (
69
+ isinstance(error, pebble.PathError)
70
+ and error.kind == 'generic-file-error'
71
+ and 'cannot look up user and group' in error.message
72
+ ):
73
+ raise LookupError(msg) from error
74
+
75
+
76
+ def matches_not_a_directory(error: pebble.Error) -> bool:
77
+ return (
78
+ isinstance(error, pebble.PathError)
79
+ and error.kind == 'generic-file-error'
80
+ and 'not a directory' in error.message
81
+ )
82
+
83
+
84
+ def raise_not_a_directory(msg: str, from_: BaseException | None = None) -> NoReturn:
85
+ raise NotADirectoryError(errno.ENOTDIR, os.strerror(errno.ENOTDIR), msg) from from_
86
+
87
+
88
+ def raise_if_matches_not_a_directory(error: pebble.Error, msg: str) -> None:
89
+ if matches_not_a_directory(error):
90
+ raise_not_a_directory(msg, from_=error)
91
+
92
+
93
+ def raise_if_matches_permission(error: pebble.Error, msg: str) -> None:
94
+ if isinstance(error, pebble.PathError) and error.kind == 'permission-denied':
95
+ raise PermissionError(errno.EPERM, os.strerror(errno.EPERM), msg) from error
96
+
97
+
98
+ def raise_if_matches_too_many_levels_of_symlinks(error: pebble.Error, msg: str) -> None:
99
+ if (
100
+ isinstance(error, pebble.APIError)
101
+ and error.code == 400
102
+ and 'too many levels of symbolic links' in error.message
103
+ ):
104
+ raise OSError(errno.ELOOP, os.strerror(errno.ELOOP), msg) from error
@@ -0,0 +1,74 @@
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) -> pebble.FileInfo:
47
+ try:
48
+ (info,) = path._container.list_files(path._path, itself=True)
49
+ except pebble.APIError as e:
50
+ msg = repr(path)
51
+ _errors.raise_if_matches_file_not_found(e, msg=msg)
52
+ _errors.raise_if_matches_too_many_levels_of_symlinks(e, msg=msg)
53
+ raise
54
+ return info
55
+
56
+
57
+ def from_pathlib_path(path: pathlib.Path) -> pebble.FileInfo:
58
+ stat_result = path.stat() # stat because pebble always follows symlinks
59
+ utcoffset = datetime.datetime.now().astimezone().utcoffset()
60
+ timezone = datetime.timezone(utcoffset) if utcoffset is not None else datetime.timezone.utc
61
+ filetype = _FT_MAP.get(stat.S_IFMT(stat_result.st_mode), pebble.FileType.UNKNOWN)
62
+ size = stat_result.st_size if filetype is pebble.FileType.FILE else None
63
+ return pebble.FileInfo(
64
+ path=str(path),
65
+ name=path.name,
66
+ type=filetype,
67
+ size=size,
68
+ permissions=stat.S_IMODE(stat_result.st_mode),
69
+ last_modified=datetime.datetime.fromtimestamp(int(stat_result.st_mtime), tz=timezone),
70
+ user_id=stat_result.st_uid,
71
+ user=pwd.getpwuid(stat_result.st_uid).pw_name,
72
+ group_id=stat_result.st_gid,
73
+ group=grp.getgrgid(stat_result.st_gid).gr_name,
74
+ )
@@ -0,0 +1,102 @@
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(path: str | os.PathLike[str] | PathProtocol) -> pebble.FileInfo:
91
+ if isinstance(path, ContainerPath):
92
+ return _fileinfo.from_container_path(path)
93
+ assert _is_str_pathlike(path)
94
+ return _fileinfo.from_pathlib_path(pathlib.Path(path))
95
+
96
+
97
+ def _as_bytes(source: bytes | str | BinaryIO | TextIO) -> bytes:
98
+ if isinstance(source, bytes):
99
+ return source
100
+ if isinstance(source, str):
101
+ return source.encode()
102
+ return _as_bytes(source.read())
@@ -0,0 +1,199 @@
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 = _constants.DEFAULT_WRITE_MODE,
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 using :meth:`pathlib.PosixPath.chmod`.
64
+ Defaults to 0o644 (-rw-rw-r--).
65
+ user: The name of the user to set for the file using :func:`shutil.chown`.
66
+ Validated to be an existing user before writing.
67
+ group: The name of the group to set for the file using :func:`shutil.chown`.
68
+ Validated to be an existing group before writing.
69
+
70
+ Returns:
71
+ The number of bytes written.
72
+
73
+ Raises:
74
+ FileNotFoundError: if the parent directory does not exist.
75
+ LookupError: if the user or group is unknown.
76
+ NotADirectoryError: if the parent exists as a non-directory file.
77
+ PermissionError: if the local user does not have permissions for the operation.
78
+ """
79
+ _validate_user_and_group(user=user, group=group)
80
+ bytes_written = super().write_bytes(data)
81
+ _chown_if_needed(self, user=user, group=group)
82
+ self.chmod(mode)
83
+ return bytes_written
84
+
85
+ def write_text(
86
+ self,
87
+ data: str,
88
+ encoding: str | None = None,
89
+ errors: str | None = None,
90
+ newline: str | None = None,
91
+ *,
92
+ mode: int = _constants.DEFAULT_WRITE_MODE,
93
+ user: str | None = None,
94
+ group: str | None = None,
95
+ ) -> int:
96
+ r"""Write the provided string to the corresponding local filesystem path.
97
+
98
+ Compared to :meth:`pathlib.Path.write_bytes`, this method adds ``mode``, ``user``
99
+ and ``group`` args. These are used to set the permissions and ownership of the file.
100
+
101
+ .. warning::
102
+ :class:`ContainerPath` and :class:`PathProtocol` do not support the ``encoding``,
103
+ ``errors``, and ``newline`` arguments of :meth:`pathlib.Path.write_text`.
104
+ For :class:`ContainerPath` compatible code, do not use these arguments.
105
+ They are provided to allow :class:`LocalPath` to be used as a drop-in
106
+ replacement for :class:`pathlib.Path` if needed.
107
+
108
+ Args:
109
+ data: The string to write. Newlines are not modified on writing.
110
+ encoding: The encoding to use when writing the data, defaults to 'UTF-8'.
111
+ errors: 'strict' to raise any encoding errors, 'ignore' to ignore them.
112
+ Defaults to 'strict'.
113
+ newline: If ``None``, ``''``, or ``'\n'``, then '\n' will be written as is.
114
+ This is the default behaviour. If ``newline`` is ``'\r'`` or ``'\r\n'``,
115
+ then ``'\n'`` will be replaced with ``newline`` in memory before writing.
116
+ mode: The permissions to set on the file using :meth:`pathlib.PosixPath.chmod`.
117
+ Defaults to 0o644 (-rw-rw-r--).
118
+ user: The name of the user to set for the file using :func:`shutil.chown`.
119
+ Validated to be an existing user before writing.
120
+ group: The name of the group to set for the file using :func:`shutil.chown`.
121
+ Validated to be an existing group before writing.
122
+
123
+ Returns:
124
+ The number of bytes written.
125
+
126
+ Raises:
127
+ FileNotFoundError: if the parent directory does not exist.
128
+ LookupError: if the user or group is unknown.
129
+ NotADirectoryError: if the parent exists as a non-directory file.
130
+ PermissionError: if the local user does not have permissions for the operation.
131
+ ValueError: if ``newline`` is any value other than those documented above.
132
+ """
133
+ _validate_user_and_group(user=user, group=group)
134
+ if newline in ('\r', '\r\n'):
135
+ data = data.replace('\n', newline)
136
+ elif newline not in ('', '\n', None):
137
+ raise ValueError(f'illegal newline value: {newline!r}')
138
+ bytes_written = super().write_text(data, encoding=encoding, errors=errors)
139
+ _chown_if_needed(self, user=user, group=group)
140
+ self.chmod(mode)
141
+ return bytes_written
142
+
143
+ def mkdir(
144
+ self,
145
+ mode: int = _constants.DEFAULT_MKDIR_MODE,
146
+ parents: bool = False,
147
+ exist_ok: bool = False,
148
+ *,
149
+ user: str | None = None,
150
+ group: str | None = None,
151
+ ) -> None:
152
+ """Create a new directory at the corresponding local filesystem path.
153
+
154
+ Compared to :meth:`pathlib.Path.mkdir`, this method adds ``user`` and ``group`` args.
155
+ These are used to set the ownership of the created directory. Any created parents
156
+ will not have their ownership set.
157
+
158
+ Args:
159
+ mode: The permissions to set on the created directory. Any parents created will have
160
+ their permissions set to the default value of 0o755 (drwxr-xr-x).
161
+ parents: Whether to create any missing parent directories as well. If ``False``
162
+ (default) and a parent directory does not exist, a :class:`FileNotFound` error will
163
+ be raised.
164
+ exist_ok: Whether to raise an error if the directory already exists.
165
+ If ``False`` (default) and the directory already exists,
166
+ a :class:`FileExistsError` will be raised.
167
+ user: The name of the user to set for the directory using :func:`shutil.chown`.
168
+ Validated to be an existing user before writing.
169
+ group: The name of the group to set for the directory using :func:`shutil.chown`.
170
+ Validated to be an existing group before writing.
171
+
172
+ Raises:
173
+ FileExistsError: if the directory already exists and ``exist_ok`` is ``False``.
174
+ FileNotFoundError: if the parent directory does not exist and ``parents`` is ``False``.
175
+ LookupError: if the user or group is unknown.
176
+ NotADirectoryError: if the parent exists as a non-directory file.
177
+ PermissionError: if the local user does not have permissions for the operation.
178
+ """
179
+ _validate_user_and_group(user=user, group=group)
180
+ super().mkdir(mode=mode, parents=parents, exist_ok=exist_ok)
181
+ _chown_if_needed(self, user=user, group=group)
182
+
183
+
184
+ def _validate_user_and_group(user: str | None, group: str | None):
185
+ if user is not None:
186
+ pwd.getpwnam(user)
187
+ if group is not None:
188
+ grp.getgrnam(group)
189
+
190
+
191
+ def _chown_if_needed(path: pathlib.Path, user: str | int | None, group: str | int | None) -> None:
192
+ # shutil.chown is happy as long as either user or group is not None
193
+ # but the type checker doesn't like that, so we have to be more explicit
194
+ if user is not None and group is not None:
195
+ shutil.chown(path, user=user, group=group)
196
+ elif user is not None:
197
+ shutil.chown(path, user=user)
198
+ elif group is not None:
199
+ shutil.chown(path, group=group)