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,758 @@
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
+ """Implementation of ContainerPath class."""
16
+
17
+ from __future__ import annotations
18
+
19
+ import errno
20
+ import pathlib
21
+ import re
22
+ import typing
23
+
24
+ import ops
25
+ from ops import pebble
26
+
27
+ from . import _constants, _errors, _fileinfo
28
+
29
+ if typing.TYPE_CHECKING:
30
+ import os
31
+ from typing import Generator, Literal
32
+
33
+ from typing_extensions import Self, TypeGuard
34
+
35
+
36
+ class RelativePathError(ValueError):
37
+ """ContainerPath only supports absolute paths.
38
+
39
+ This is because Pebble only works with absolute paths. Relative path support
40
+ will likely remain unavailable at least until Pebble supports relative paths.
41
+ In the meantime, use absolute paths.
42
+ """
43
+
44
+
45
+ class ContainerPath:
46
+ r"""Implementation of :class:`PathProtocol` for Pebble-based workload containers.
47
+
48
+ The following examples are all equivalent::
49
+
50
+ container = self.unit.get_container('c')
51
+ ContainerPath('/foo', container=container)
52
+ ContainerPath('/', 'foo', container=container)
53
+ ContainerPath(pathlib.PurePath('/foo'), container=container)
54
+ ContainerPath(pathlib.PurePath('/'), 'foo', container=container)
55
+
56
+ :class:`str` follows the :mod:`pathlib` convention and returns the string representation of
57
+ the path. :class:`ContainerPath` return the string representation of the path in the remote
58
+ filesystem. The string representation is suitable for use with system calls (on the correct
59
+ local or remote system) and Pebble layers.
60
+
61
+ Comparison methods compare by path. A :class:`ContainerPath` is only comparable to another
62
+ object if it is also a :class:`ContainerPath` on the same :class:`ops.Container`. If this is
63
+ not the case, then equality is ``False`` and other comparisons are :class:`NotImplemented`.
64
+
65
+ Args:
66
+ \*parts: components of the path, like :class:`pathlib.Path` constructor.
67
+ container: used to communicate with the workload container. Required.
68
+ Must be provided as a keyword argument.
69
+
70
+ Raises:
71
+ RelativePathError: If instantiated with a relative path.
72
+ """
73
+
74
+ def __init__(self, *parts: str | os.PathLike[str], container: ops.Container) -> None:
75
+ self._container = container
76
+ self._path = pathlib.PurePosixPath(*parts)
77
+ if not self._path.is_absolute():
78
+ raise RelativePathError(
79
+ f'ContainerPath arguments resolve to relative path: {self._path}'
80
+ )
81
+
82
+ #############################
83
+ # protocol PurePath methods #
84
+ #############################
85
+
86
+ def __hash__(self) -> int:
87
+ """Hash the tuple (container-name, path) for efficiency."""
88
+ return hash((self._container.name, self._path))
89
+
90
+ def __repr__(self) -> str:
91
+ """Return a string representation including the class, path string, and container name."""
92
+ container_repr = f'<ops.Container {self._container.name!r}>'
93
+ return f"{type(self).__name__}('{self._path}', container={container_repr})"
94
+
95
+ def __str__(self) -> str:
96
+ """Return the string representation of the path in the container.
97
+
98
+ This is equivalent to the string representation of the :class:`pathlib.PurePath` this
99
+ :class:`ContainerPath` was instantiated with.
100
+ """
101
+ return self._path.__str__()
102
+
103
+ def as_posix(self) -> str:
104
+ """Return the string representation of the path in the container."""
105
+ return self._path.__str__()
106
+
107
+ def __lt__(self, other: Self) -> bool:
108
+ if not self._can_compare(other):
109
+ return NotImplemented
110
+ return self._path < other._path
111
+
112
+ def __le__(self, other: Self) -> bool:
113
+ if not self._can_compare(other):
114
+ return NotImplemented
115
+ return self._path <= other._path
116
+
117
+ def __gt__(self, other: Self) -> bool:
118
+ if not self._can_compare(other):
119
+ return NotImplemented
120
+ return self._path > other._path
121
+
122
+ def __ge__(self, other: Self) -> bool:
123
+ if not self._can_compare(other):
124
+ return NotImplemented
125
+ return self._path >= other._path
126
+
127
+ def __eq__(self, other: object, /) -> bool:
128
+ return self._can_compare(other) and self._path == other._path
129
+
130
+ def _can_compare(self, other: object) -> TypeGuard[Self]:
131
+ return isinstance(other, ContainerPath) and other._container.name == self._container.name
132
+
133
+ def __truediv__(self, key: str | os.PathLike[str]) -> Self:
134
+ """Return a new ``ContainerPath`` with the same container and the joined path.
135
+
136
+ The joined path is equivalent to ``str(self) / pathlib.PurePath(key)``.
137
+
138
+ .. warning::
139
+ ``__rtruediv__`` is not supported, as :class:`ContainerPath` only supports absolute
140
+ paths. You likely wouldn't want to provide an absolute path as the right-hand operand,
141
+ because the absolute path would completely replace the left-hand path.
142
+ """
143
+ return self.with_segments(self._path, key)
144
+
145
+ def is_absolute(self) -> bool:
146
+ """Return whether the path is absolute (has a root), which is always the case.
147
+
148
+ Always ``True``, since initialising a :class:`ContainerPath` with a non-absolute
149
+ path will result in a :class:`RelativePathError`.
150
+ """
151
+ return self._path.is_absolute()
152
+
153
+ def match(self, path_pattern: str) -> bool:
154
+ """Return whether this path matches the given pattern.
155
+
156
+ If the pattern is relative, matching is done from the right; otherwise, the entire path is
157
+ matched. The recursive wildcard ``'**'`` is **not** supported by this method. Matching is
158
+ always case-sensitive. Only the path is matched against, the container is not considered.
159
+ """
160
+ return self._path.match(path_pattern)
161
+
162
+ def with_name(self, name: str) -> Self:
163
+ """Return a new ContainerPath, with the same container, but with the path name replaced.
164
+
165
+ The name is the last component of the path, including any suffixes.
166
+
167
+ ::
168
+
169
+ container = self.unit.get_container('c')
170
+ path = ContainerPath('/', 'foo', 'bar.txt', container=container)
171
+ repr(path.with_name('baz.bin'))
172
+ # ContainerPath('/foo/baz.bin', container=<ops.Container 'c'>)"
173
+ """
174
+ return self.with_segments(self._path.with_name(name))
175
+
176
+ def with_suffix(self, suffix: str) -> Self:
177
+ """Return a new ContainerPath with the same container and the suffix changed.
178
+
179
+ Args:
180
+ suffix: Must start with a ``'.'``, unless it is an empty string, in which case
181
+ any existing suffix will be removed entirely.
182
+
183
+ Returns:
184
+ A new instance of the same type, updated as follows. If it contains no ``'.'``,
185
+ or ends with a ``'.'``, the ``suffix`` argument is appended to its name. Otherwise,
186
+ the last ``'.'`` and any trailing content is replaced with the ``suffix`` argument.
187
+ """
188
+ return self.with_segments(self._path.with_suffix(suffix))
189
+
190
+ def joinpath(self, *other: str | os.PathLike[str]) -> Self:
191
+ r"""Return a new ContainerPath with the same container and the new args joined to its path.
192
+
193
+ Args:
194
+ other: Any number of :class:`str` or :class:`os.PathLike` objects.
195
+ If zero are provided, an effective copy of this :class:`ContainerPath` object is
196
+ returned. \*other is joined to this object's path as with :func:`os.path.join`.
197
+ This means that if any member of other is an absolute path, all the previous
198
+ components, including this object's path, are dropped entirely.
199
+
200
+ Returns:
201
+ A new :class:`ContainerPath` with the same :class:`ops.Container` object, with its path
202
+ updated with \*other as follows. For each item in other, if it is a relative path, it
203
+ is appended to the current path. If it is an absolute path, it replaces the current
204
+ path.
205
+
206
+ .. warning::
207
+ :class:`ContainerPath` is not :class:`os.PathLike`. A :class:`ContainerPath` instance
208
+ is not a valid value for ``other``, and will result in an error.
209
+ """
210
+ return self.with_segments(self._path, *other)
211
+
212
+ @property
213
+ def parents(self) -> tuple[Self, ...]:
214
+ """A sequence of this path's logical parents. Each parent is a :class:`ContainerPath`."""
215
+ return tuple(self.with_segments(p) for p in self._path.parents)
216
+
217
+ @property
218
+ def parent(self) -> Self:
219
+ """The logical parent of this path, as a :class:`ContainerPath`."""
220
+ return self.with_segments(self._path.parent)
221
+
222
+ @property
223
+ def parts(self) -> tuple[str, ...]:
224
+ """A sequence of the components in the filesystem path. The components are strings."""
225
+ return self._path.parts
226
+
227
+ @property
228
+ def name(self) -> str:
229
+ """The final path component, or an empty string if this is the root path."""
230
+ return self._path.name
231
+
232
+ @property
233
+ def suffix(self) -> str:
234
+ """The path name's last suffix (if it has any) including the leading ``'.'``.
235
+
236
+ If the path name doesn't have a suffix, the result is an empty string.
237
+ """
238
+ return self._path.suffix
239
+
240
+ @property
241
+ def suffixes(self) -> list[str]:
242
+ r"""A list of the path name's suffixes.
243
+
244
+ Each suffix includes the leading ``'.'``.
245
+
246
+ If the path name doesn't have any suffixes, the result is an empty list.
247
+ """
248
+ return self._path.suffixes
249
+
250
+ @property
251
+ def stem(self) -> str:
252
+ """The path name, minus its last suffix.
253
+
254
+ Where :meth:`name` == :meth:`stem` + :meth:`suffix`
255
+ """
256
+ return self._path.stem
257
+
258
+ #########################
259
+ # protocol Path methods #
260
+ #########################
261
+
262
+ def read_text(self, *, newline: str | None = None) -> str:
263
+ r"""Read a remote file as text and return the contents as a string.
264
+
265
+ Compared to pathlib.Path.read_text, this method drops the encoding and errors args.
266
+ The encoding is assumed to be UTF-8, and any errors encountered will be raised.
267
+
268
+ Args:
269
+ newline: if ``None`` (default), all newlines ``('\r\n', '\r', '\n')`` are replaced
270
+ with ``'\n'``. Otherwise the file contents are returned unmodified.
271
+
272
+ Returns:
273
+ The contents of the the path as a string.
274
+
275
+ Raises:
276
+ FileNotFoundError: if the parent directory does not exist.
277
+ IsADirectoryError: if the target is a directory.
278
+ PermissionError: if the Pebble user does not have permissions for the operation.
279
+ PebbleConnectionError: if the remote Pebble client cannot be reached.
280
+ """
281
+ text = self._pull(text=True)
282
+ if newline is None:
283
+ return re.sub(r'\r\n|\r', '\n', text)
284
+ return text
285
+
286
+ def read_bytes(self) -> bytes:
287
+ """Read a remote file as bytes and return the contents.
288
+
289
+ Returns:
290
+ The contents of the the path as byes.
291
+
292
+ Raises:
293
+ FileNotFoundError: if the parent directory does not exist.
294
+ IsADirectoryError: if the target is a directory.
295
+ PermissionError: if the Pebble user does not have permissions for the operation.
296
+ PebbleConnectionError: if the remote Pebble client cannot be reached.
297
+ """
298
+ return self._pull(text=False)
299
+
300
+ @typing.overload
301
+ def _pull(self, *, text: Literal[True]) -> str: ...
302
+ @typing.overload
303
+ def _pull(self, *, text: Literal[False] = False) -> bytes: ...
304
+ def _pull(self, *, text: bool = False):
305
+ encoding = 'utf-8' if text else None
306
+ try:
307
+ with self._container.pull(self._path, encoding=encoding) as f:
308
+ return f.read()
309
+ except pebble.PathError as e:
310
+ msg = repr(self)
311
+ _errors.raise_if_matches_file_not_found(e, msg=msg)
312
+ _errors.raise_if_matches_is_a_directory(e, msg=msg)
313
+ _errors.raise_if_matches_permission(e, msg=msg)
314
+ raise
315
+
316
+ def iterdir(self) -> typing.Generator[Self]:
317
+ """Yield :class:`ContainerPath` objects corresponding to the directory's contents.
318
+
319
+ There are no guarantees about the order of the children. The special entries
320
+ ``'.'`` and ``'..'`` are not included.
321
+
322
+ :class:`NotADirectoryError` is raised (if appropriate) when ``iterdir()`` is called.
323
+ This follows the behaviour of :meth:`pathlib.Path.iterdir` in Python 3.13+.
324
+ Previous versions deferred the error until the generator was consumed.
325
+
326
+ Raises:
327
+ FileNotFoundError: If this path does not exist.
328
+ NotADirectoryError: If this path is not a directory.
329
+ PermissionError: If the local or remote user does not have appropriate permissions.
330
+ PebbleConnectionError: If the remote container cannot be reached.
331
+ """
332
+ info = _fileinfo.from_container_path(self) # FileNotFoundError if path doesn't exist
333
+ if info.type != pebble.FileType.DIRECTORY:
334
+ _errors.raise_not_a_directory(repr(self))
335
+ file_infos = self._container.list_files(self._path)
336
+ for f in file_infos:
337
+ yield self.with_segments(f.path)
338
+
339
+ def glob(self, pattern: str | os.PathLike[str]) -> Generator[Self]:
340
+ r"""Iterate over this directory and yield all paths matching the provided pattern.
341
+
342
+ For example, ``path.glob('*.txt')``, ``path.glob('*/foo.txt')``.
343
+
344
+ .. warning::
345
+ Recursive matching using the ``'**'`` pattern is not supported.
346
+
347
+ Args:
348
+ pattern: The pattern must be relative, meaning it cannot begin with ``'/'``.
349
+ Matching is case-sensitive.
350
+
351
+ Returns:
352
+ A generator yielding :class:`ContainerPath` objects, corresponding to those of its
353
+ children which match the pattern. If this path is not a directory, there will be no
354
+ matches.
355
+
356
+ Raises:
357
+ FileNotFoundError: If this path does not exist.
358
+ NotImplementedError: If pattern is an absolute path or it uses the ``'**'`` pattern.
359
+ PermissionError: If the remote user does not have appropriate permissions.
360
+ ValueError: If the pattern is invalid.
361
+ PebbleConnectionError: If the remote container cannot be reached.
362
+ """
363
+ return self._glob(pattern)
364
+
365
+ def _glob(self, pattern: str | os.PathLike[str], skip_is_dir: bool = False) -> Generator[Self]:
366
+ pattern_path = pathlib.PurePosixPath(pattern)
367
+ if pattern_path.is_absolute():
368
+ raise NotImplementedError('Non-relative paths are unsupported.')
369
+ elif pattern_path == pathlib.PurePosixPath('.'):
370
+ raise ValueError(f'Unacceptable pettern: {pattern!r}')
371
+ *pattern_parents, pattern_itself = pattern_path.parts
372
+ if '**' in pattern_parents:
373
+ raise NotImplementedError('Recursive glob is not supported.')
374
+ if '**' in str(pattern):
375
+ raise ValueError("Invalid pattern: '**' can only be an entire path component")
376
+ if not skip_is_dir and not self.is_dir():
377
+ yield from ()
378
+ return
379
+ if not pattern_parents:
380
+ file_infos = self._container.list_files(self._path, pattern=pattern_itself)
381
+ for f in file_infos:
382
+ yield self.with_segments(f.path)
383
+ return
384
+ first, *rest = pattern_parents
385
+ next_pattern = pathlib.PurePosixPath(*rest, pattern_itself)
386
+ if first == '*':
387
+ for container_path in self.iterdir():
388
+ if container_path.is_dir():
389
+ yield from container_path._glob(next_pattern, skip_is_dir=True)
390
+ elif '*' in first:
391
+ for container_path in self._glob(first):
392
+ if container_path.is_dir():
393
+ yield from container_path._glob(next_pattern, skip_is_dir=True)
394
+ else:
395
+ yield from (self / first)._glob(next_pattern)
396
+
397
+ def owner(self) -> str:
398
+ """Return the user name of the file owner.
399
+
400
+ Raises:
401
+ FileNotFoundError: If the path does not exist.
402
+ PebbleConnectionError: If the remote container cannot be reached.
403
+ """
404
+ info = _fileinfo.from_container_path(self) # FileNotFoundError if path doesn't exist
405
+ user = info.user
406
+ assert user is not None
407
+ return user
408
+
409
+ def group(self) -> str:
410
+ """Return the group name of the file.
411
+
412
+ Raises:
413
+ FileNotFoundError: If the path does not exist.
414
+ PebbleConnectionError: If the remote container cannot be reached.
415
+ """
416
+ info = _fileinfo.from_container_path(self) # FileNotFoundError if path doesn't exist
417
+ group = info.group
418
+ assert group is not None
419
+ return group
420
+
421
+ def exists(self) -> bool:
422
+ """Whether this path exists.
423
+
424
+ Will follow symlinks to determine if the symlink target exists. This means that this
425
+ method will return ``False`` for a broken symlink.
426
+
427
+ Raises:
428
+ PebbleConnectionError: If the remote container cannot be reached.
429
+ """
430
+ return self._exists_and_matches(filetype=None)
431
+
432
+ def is_dir(self) -> bool:
433
+ """Whether this path exists and is a directory.
434
+
435
+ Will follow symlinks to determine if the symlink target exists and is a directory.
436
+
437
+ Raises:
438
+ PebbleConnectionError: If the remote container cannot be reached.
439
+ """
440
+ return self._exists_and_matches(pebble.FileType.DIRECTORY)
441
+
442
+ def is_file(self) -> bool:
443
+ """Whether this path exists and is a regular file.
444
+
445
+ Will follow symlinks to determine if the symlink target exists and is a regular file.
446
+
447
+ Raises:
448
+ PebbleConnectionError: If the remote container cannot be reached.
449
+ """
450
+ return self._exists_and_matches(pebble.FileType.FILE)
451
+
452
+ def is_fifo(self) -> bool:
453
+ """Whether this path exists and is a named pipe (also called a FIFO).
454
+
455
+ Will follow symlinks to determine if the symlink target exists and is a named pipe.
456
+
457
+ Raises:
458
+ PebbleConnectionError: If the remote container cannot be reached.
459
+ """
460
+ return self._exists_and_matches(pebble.FileType.NAMED_PIPE)
461
+
462
+ def is_socket(self) -> bool:
463
+ """Whether this path exists and is a socket.
464
+
465
+ Will follow symlinks to determine if the symlink target exists and is a socket.
466
+
467
+ Raises:
468
+ PebbleConnectionError: If the remote container cannot be reached.
469
+ """
470
+ return self._exists_and_matches(pebble.FileType.SOCKET)
471
+
472
+ def is_symlink(self) -> bool:
473
+ """Whether this path is a symbolic link.
474
+
475
+ Raises:
476
+ PebbleConnectionError: If the remote container cannot be reached.
477
+ """
478
+ try:
479
+ info = _fileinfo.from_container_path(self, follow_symlinks=False)
480
+ except FileNotFoundError:
481
+ return False
482
+ return info.type == pebble.FileType.SYMLINK
483
+
484
+ def _exists_and_matches(self, filetype: pebble.FileType | None) -> bool:
485
+ info = self._try_get_fileinfo()
486
+ if info is None:
487
+ return False
488
+ if filetype is None: # we only care if the file exists
489
+ return True
490
+ return info.type is filetype
491
+
492
+ def _try_get_fileinfo(self) -> pebble.FileInfo | None:
493
+ try:
494
+ return _fileinfo.from_container_path(self)
495
+ except FileNotFoundError:
496
+ pass
497
+ except OSError as e:
498
+ if e.errno != errno.ELOOP:
499
+ raise
500
+ # else: too many levels of symbolic links
501
+ return None
502
+
503
+ def rmdir(self) -> None:
504
+ """Remove this path if it is an empty directory.
505
+
506
+ Raises:
507
+ FileNotFoundError: if the path does not exist.
508
+ NotADirectoryError: if the path exists but is not a directory.
509
+ PermissionError: if the Pebble user does not have permissions for the operation.
510
+ PebbleConnectionError: if the remote Pebble client cannot be reached.
511
+ """
512
+ info = _fileinfo.from_container_path(self, follow_symlinks=False)
513
+ if info.type != pebble.FileType.DIRECTORY:
514
+ _errors.raise_not_a_directory(repr(self))
515
+ self._remove_path()
516
+
517
+ def unlink(self) -> None:
518
+ """Remove this path if it is not a directory.
519
+
520
+ Raises:
521
+ FileNotFoundError: if the path does not exist.
522
+ IsADirectoryError: if the path exists but is a directory.
523
+ PermissionError: if the Pebble user does not have permissions for the operation.
524
+ PebbleConnectionError: if the remote Pebble client cannot be reached.
525
+ """
526
+ info = _fileinfo.from_container_path(self, follow_symlinks=False)
527
+ if info.type == pebble.FileType.DIRECTORY:
528
+ _errors.raise_is_a_directory(repr(self))
529
+ self._remove_path()
530
+
531
+ def _remove_path(self) -> None:
532
+ try:
533
+ self._container.remove_path(self._path)
534
+ except pebble.PathError as e:
535
+ msg = repr(self)
536
+ _errors.raise_if_matches_directory_not_empty(e, msg=msg)
537
+ _errors.raise_if_matches_file_not_found(e, msg=msg)
538
+ _errors.raise_if_matches_permission(e, msg=msg)
539
+ raise
540
+
541
+ ##################################################
542
+ # protocol Path methods with extended signatures #
543
+ ##################################################
544
+
545
+ def write_bytes(
546
+ self,
547
+ data: bytes | bytearray | memoryview,
548
+ *,
549
+ mode: int | None = None,
550
+ user: str | None = None,
551
+ group: str | None = None,
552
+ ) -> int:
553
+ """Write the provided data to the corresponding path in the remote container.
554
+
555
+ Compared to :meth:`pathlib.Path.write_bytes`, this method adds ``mode``, ``user``
556
+ and ``group`` args. These are forwarded to Pebble, which sets these on file creation.
557
+
558
+ Args:
559
+ data: The bytes to write. If data is a :class:`bytearray` or :class:`memoryview`, it
560
+ will be converted to :class:`bytes` in memory first.
561
+ mode: The permissions to set on the file. Defaults to 0o644 (-rw-r--r--) for new files.
562
+ If the file already exists, its permissions will be changed,
563
+ unless ``mode`` is ``None`` (default).
564
+ user: The name of the user to set for the file.
565
+ If ``group`` isn't provided, the user's default group is used.
566
+ If the file already exists, its user and group will be changed,
567
+ unless ``user`` is ``None`` (default).
568
+ group: The name of the group to set for the directory.
569
+ It is an error to provide ``group`` if ``user`` isn't provided.
570
+ If the file already exists, its group will be changed,
571
+ unless ``user`` and ``group`` are ``None`` (default).
572
+
573
+ Returns: The number of bytes written.
574
+
575
+ Raises:
576
+ FileNotFoundError: if the parent directory does not exist.
577
+ LookupError: if the user or group is unknown, or a group is provided without a user.
578
+ NotADirectoryError: if the parent exists as a non-directory file.
579
+ PermissionError: if the Pebble user does not have permissions for the operation.
580
+ PebbleConnectionError: if the remote Pebble client cannot be reached.
581
+ """
582
+ if isinstance(data, (bytearray, memoryview)):
583
+ # TODO: update ops to correctly test for bytearray and memoryview in push
584
+ data = bytes(data)
585
+ if mode is None or user is None:
586
+ # if the file already exists, don't change owner or mode unless explicitly requested
587
+ try:
588
+ info = _fileinfo.from_container_path(self)
589
+ except FileNotFoundError:
590
+ pass
591
+ else:
592
+ if mode is None:
593
+ mode = info.permissions
594
+ if user is None:
595
+ user = info.user
596
+ try:
597
+ self._container.push(
598
+ path=self._path,
599
+ source=data,
600
+ make_dirs=False,
601
+ permissions=mode,
602
+ user=user,
603
+ group=group,
604
+ )
605
+ except pebble.PathError as e:
606
+ _errors.raise_if_matches_lookup(e, msg=e.message)
607
+ msg = repr(self)
608
+ _errors.raise_if_matches_file_not_found(e, msg=msg)
609
+ _errors.raise_if_matches_not_a_directory(e, msg=msg)
610
+ _errors.raise_if_matches_permission(e, msg=msg)
611
+ raise
612
+ return len(data)
613
+
614
+ def write_text(
615
+ self,
616
+ data: str,
617
+ *,
618
+ mode: int | None = None,
619
+ user: str | None = None,
620
+ group: str | None = None,
621
+ ) -> int:
622
+ """Write the provided string to the corresponding path in the remote container.
623
+
624
+ Compared to :meth:`pathlib.Path.write_text`, this method drops the ``encoding`` and
625
+ ``errors`` args to simplify the API. The args ``mode``, ``user`` and ``group`` are added,
626
+ and are forwarded to Pebble, which sets these on file creation.
627
+
628
+ Args:
629
+ data: The string to write. Will be encoded to :class:`bytes` in memory as UTF-8,
630
+ raising any errors. Newlines are not modified on writing.
631
+ mode: The permissions to set on the file. Defaults to 0o644 (-rw-r--r--) for new files.
632
+ If the file already exists, its permissions will be changed,
633
+ unless ``mode`` is ``None`` (default).
634
+ user: The name of the user to set for the file.
635
+ If ``group`` isn't provided, the user's default group is used.
636
+ If the file already exists, its user and group will be changed,
637
+ unless ``user`` is ``None`` (default).
638
+ group: The name of the group to set for the directory.
639
+ It is an error to provide ``group`` if ``user`` isn't provided.
640
+ If the file already exists, its group will be changed,
641
+ unless ``user`` and ``group`` are ``None`` (default).
642
+
643
+ Returns: The number of bytes written.
644
+
645
+ Raises:
646
+ FileNotFoundError: if the parent directory does not exist.
647
+ LookupError: if the user or group is unknown, or a group is provided without a user.
648
+ NotADirectoryError: if the parent exists as a non-directory file.
649
+ PermissionError: if the Pebble user does not have permissions for the operation.
650
+ PebbleConnectionError: if the remote Pebble client cannot be reached.
651
+ """
652
+ encoded_data = data.encode()
653
+ return self.write_bytes(encoded_data, mode=mode, user=user, group=group)
654
+
655
+ def mkdir(
656
+ self,
657
+ mode: int = _constants.DEFAULT_MKDIR_MODE,
658
+ parents: bool = False,
659
+ exist_ok: bool = False,
660
+ *,
661
+ user: str | None = None,
662
+ group: str | None = None,
663
+ ) -> None:
664
+ """Create a new directory at the corresponding path in the remote container.
665
+
666
+ Compared to :meth:`pathlib.Path.mkdir`, this method adds ``user`` and ``group`` args.
667
+ These are used to set the ownership of the created directory. Any created parents
668
+ will not have their ownership set.
669
+
670
+ Args:
671
+ mode: The permissions to set on the created directory. Any parents created will have
672
+ their permissions set to the default value of 0o755 (drwxr-xr-x).
673
+ The permissions are not changed if the directory already exists.
674
+ parents: Whether to create any missing parent directories as well. If ``False``
675
+ (default) and a parent directory does not exist, a :class:`FileNotFound` error will
676
+ be raised.
677
+ exist_ok: Whether to raise an error if the directory already exists.
678
+ If ``False`` (default) and the directory already exists,
679
+ a :class:`FileExistsError` will be raised.
680
+ user: The name of the user to set for the directory.
681
+ If ``group`` isn't provided, the user's default group is used.
682
+ The user and group are not changed if the directory already exists.
683
+ group: The name of the group to set for the directory.
684
+ It is an error to provide ``group`` if ``user`` isn't provided.
685
+ The user and group are not changed if the directory already exists.
686
+
687
+ Raises:
688
+ FileExistsError: if the directory already exists and ``exist_ok`` is ``False``.
689
+ FileNotFoundError: if the parent directory does not exist and ``parents`` is ``False``.
690
+ LookupError: if the user or group is unknown, or a group is provided without a user.
691
+ NotADirectoryError: if the parent exists as a non-directory file.
692
+ PermissionError: if the remote user does not have permissions for the operation.
693
+ PebbleConnectionError: if the remote Pebble client cannot be reached.
694
+ """
695
+ if parents and not exist_ok and self.exists():
696
+ _errors.raise_file_exists(repr(self))
697
+ if exist_ok and not parents and not self.parent.exists():
698
+ _errors.raise_file_not_found(repr(self.parent))
699
+ if parents and mode != _constants.DEFAULT_MKDIR_MODE:
700
+ # create parents with default permissions, following pathlib
701
+ self._mkdir(
702
+ path=self._path.parent,
703
+ make_parents=True,
704
+ permissions=_constants.DEFAULT_MKDIR_MODE,
705
+ )
706
+ self._mkdir(
707
+ path=self._path,
708
+ make_parents=exist_ok or parents,
709
+ permissions=mode,
710
+ user=user,
711
+ group=group,
712
+ )
713
+
714
+ def _mkdir(
715
+ self,
716
+ path: str | pathlib.PurePath,
717
+ make_parents: bool,
718
+ permissions: int,
719
+ user: str | None = None,
720
+ group: str | None = None,
721
+ ) -> None:
722
+ try:
723
+ self._container.make_dir(
724
+ path=path,
725
+ make_parents=make_parents,
726
+ permissions=permissions,
727
+ user=user,
728
+ group=group,
729
+ )
730
+ except pebble.PathError as e:
731
+ _errors.raise_if_matches_lookup(e, msg=e.message)
732
+ msg = repr(self)
733
+ if _errors.matches_not_a_directory(e):
734
+ # target exists and isn't a directory, or parent isn't a directory
735
+ if not self.parent.is_dir():
736
+ _errors.raise_not_a_directory(msg=msg, from_=e)
737
+ _errors.raise_file_exists(repr(self), from_=e)
738
+ _errors.raise_if_matches_file_exists(e, msg=msg)
739
+ _errors.raise_if_matches_file_not_found(e, msg=msg)
740
+ _errors.raise_if_matches_permission(e, msg=msg)
741
+ raise
742
+
743
+ #############################
744
+ # non-protocol Path methods #
745
+ #############################
746
+
747
+ def with_segments(self, *pathsegments: str | os.PathLike[str]) -> Self:
748
+ """Construct a new ``ContainerPath`` (with the same container) from path-like objects.
749
+
750
+ You can think of this like a copy of the current :class:`ContainerPath`, with its path
751
+ replaced by ``pathlib.Path(*pathsegments)``.
752
+
753
+ This method is used internally by all :class:`ContainerPath` methods that return new
754
+ :class:`ContainerPath` instances, including :meth:`parent` and :meth:`parents`. Therefore,
755
+ subclasses can customise the behaviour of all such methods by overriding only this method.
756
+ The same is true of :class:`pathlib.Path` in Python 3.12+.
757
+ """
758
+ return type(self)(*pathsegments, container=self._container)