pathlib-next 0.2.1__tar.gz → 0.3.5__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 (27) hide show
  1. {pathlib_next-0.2.1 → pathlib_next-0.3.5}/.gitignore +1 -0
  2. {pathlib_next-0.2.1 → pathlib_next-0.3.5}/PKG-INFO +1 -1
  3. {pathlib_next-0.2.1 → pathlib_next-0.3.5}/pyproject.toml +1 -1
  4. {pathlib_next-0.2.1 → pathlib_next-0.3.5}/src/example.py +5 -7
  5. {pathlib_next-0.2.1 → pathlib_next-0.3.5}/src/pathlib_next/mempath.py +5 -5
  6. {pathlib_next-0.2.1 → pathlib_next-0.3.5}/src/pathlib_next/path.py +20 -191
  7. pathlib_next-0.3.5/src/pathlib_next/protocols/__init__.py +2 -0
  8. pathlib_next-0.3.5/src/pathlib_next/protocols/fs.py +117 -0
  9. pathlib_next-0.3.5/src/pathlib_next/protocols/io.py +80 -0
  10. {pathlib_next-0.2.1 → pathlib_next-0.3.5}/src/pathlib_next/uri/schemes/http.py +7 -2
  11. {pathlib_next-0.2.1 → pathlib_next-0.3.5}/src/pathlib_next/utils/stat.py +2 -15
  12. pathlib_next-0.3.5/src/pathlib_next/utils/sync.py +258 -0
  13. pathlib_next-0.2.1/src/pathlib_next/utils/sync.py +0 -104
  14. {pathlib_next-0.2.1 → pathlib_next-0.3.5}/LICENSE +0 -0
  15. {pathlib_next-0.2.1 → pathlib_next-0.3.5}/README.md +0 -0
  16. {pathlib_next-0.2.1 → pathlib_next-0.3.5}/src/pathlib_next/__init__.py +0 -0
  17. {pathlib_next-0.2.1 → pathlib_next-0.3.5}/src/pathlib_next/fspath.py +0 -0
  18. {pathlib_next-0.2.1 → pathlib_next-0.3.5}/src/pathlib_next/uri/__init__.py +0 -0
  19. {pathlib_next-0.2.1 → pathlib_next-0.3.5}/src/pathlib_next/uri/query.py +0 -0
  20. {pathlib_next-0.2.1 → pathlib_next-0.3.5}/src/pathlib_next/uri/schemes/__init__.py +0 -0
  21. {pathlib_next-0.2.1 → pathlib_next-0.3.5}/src/pathlib_next/uri/schemes/file.py +0 -0
  22. {pathlib_next-0.2.1 → pathlib_next-0.3.5}/src/pathlib_next/uri/schemes/sftp.py +0 -0
  23. {pathlib_next-0.2.1 → pathlib_next-0.3.5}/src/pathlib_next/uri/source.py +0 -0
  24. {pathlib_next-0.2.1 → pathlib_next-0.3.5}/src/pathlib_next/utils/__init__.py +0 -0
  25. {pathlib_next-0.2.1 → pathlib_next-0.3.5}/src/pathlib_next/utils/glob.py +0 -0
  26. {pathlib_next-0.2.1 → pathlib_next-0.3.5}/tests/test_local.py +0 -0
  27. {pathlib_next-0.2.1 → pathlib_next-0.3.5}/tests/test_uri.py +0 -0
@@ -1,4 +1,5 @@
1
1
  __pycache__
2
+ .pytest_cache
2
3
  /_*
3
4
  [a-zA-Z]*__
4
5
  [a-zA-Z]*__.*
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pathlib_next
3
- Version: 0.2.1
3
+ Version: 0.3.5
4
4
  Summary: Generic Path Protocol based pathlib
5
5
  Project-URL: Homepage, https://github.com/jose-pr/pathlib_next/
6
6
  Project-URL: Issues, https://github.com/jose-pr/pathlib_next/issues
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pathlib_next"
7
- version = "0.2.1"
7
+ version = "0.3.5"
8
8
  authors = [{ name = "Jose A" }]
9
9
  description = "Generic Path Protocol based pathlib"
10
10
  readme = "README.md"
@@ -3,7 +3,7 @@ import os
3
3
  from pathlib_next import Path, glob
4
4
  from pathlib_next.mempath import MemPath
5
5
  from pathlib_next.uri import Query, Source, Uri, UriPath
6
- from pathlib_next.utils.sync import PathSyncer
6
+ from pathlib_next.utils.sync import PathAndStat, PathSyncer
7
7
 
8
8
  rootless = Uri("sftp://root@sftpexample")
9
9
  rootless.source
@@ -18,8 +18,6 @@ mempath.write_text("test")
18
18
  check = mempath.read_text()
19
19
  mempath.parent.rm(recursive=True)
20
20
 
21
- test = list(os.scandir(local))
22
- print(list(local.iterdir()))
23
21
  query = Query({"test": "://$#!1", "test2&": [1, 2]})
24
22
  q2 = Query(str(query)).to_dict()
25
23
  for name, value in query:
@@ -53,17 +51,17 @@ authkeys = sftp_root / "root/.ssh/authorized_keys"
53
51
  print(authkeys.as_posix())
54
52
 
55
53
 
56
- def checksum(uri: UriPath):
57
- stat = uri.stat()
54
+ def checksum(uri: PathAndStat):
55
+ stat = uri.stat
58
56
  return hash(stat.st_size)
59
57
 
60
58
 
61
59
  syncer = PathSyncer(checksum, remove_missing=False)
62
- syncer.sync((sftp_root / "root/.ssh"), dest, dry_run=True)
60
+ syncer.sync((sftp_root / "root/.ssh"), dest, dry_run=False)
63
61
 
64
62
  rocky_repo = UriPath("http://dl.rockylinux.org/pub")
65
63
 
66
- glob_test = UriPath("file:./**/*.py")
64
+ glob_test = UriPath("file:src/**/*.py")
67
65
 
68
66
  for path in glob.glob(glob_test, recursive=True):
69
67
  print(path)
@@ -4,16 +4,16 @@ from io import IOBase
4
4
  from urllib.parse import quote as _urlquote
5
5
 
6
6
  from .path import Path, Pathname
7
- from .utils.stat import FileStat, FileStatLike
7
+ from .utils.stat import FileStat
8
8
 
9
9
 
10
10
  class MemPathBackend(dict): ...
11
11
 
12
12
 
13
13
  class MemBytesIO(io.BytesIO):
14
- def __init__(self, initial_bytes: bytearray) -> None:
15
- self._bytes = initial_bytes
16
- super().__init__(initial_bytes)
14
+ def __init__(self, dest: bytearray) -> None:
15
+ self._bytes = dest
16
+ super().__init__()
17
17
 
18
18
  def close(self) -> None:
19
19
  self.seek(0)
@@ -137,7 +137,7 @@ class MemPath(Path):
137
137
  raise IsADirectoryError(self)
138
138
  parent.pop(name)
139
139
 
140
- def stat(self, *, follow_symlinks=True) -> FileStatLike:
140
+ def stat(self, *, follow_symlinks=True):
141
141
  parent, name = self._parent_container()
142
142
  if not name:
143
143
  return FileStat(is_dir=True)
@@ -6,17 +6,14 @@ operating systems.
6
6
  """
7
7
 
8
8
  import abc as _abc
9
- import io as _io
10
9
  import os as _os
11
10
  import re as _re
12
- import shutil as _shutil
13
- import stat as _stat
14
11
  import typing as _ty
15
- from pathlib import _ignore_error
16
12
 
17
13
  from . import utils as _utils
14
+ from .protocols import BinaryOpen, Chmod, Stat
18
15
  from .utils import glob as _glob
19
- from .utils.stat import FileStatLike
16
+ from .utils.stat import FileStat
20
17
 
21
18
  P = _ty.TypeVar("P", bound="Path")
22
19
  PN = _ty.TypeVar("PN", bound="Pathname")
@@ -128,7 +125,7 @@ class Pathname(FsPathLike, _ty.Generic[_P]):
128
125
 
129
126
  @property
130
127
  @_abc.abstractmethod
131
- def segments(self) -> _ty.Iterable[str]: ...
128
+ def segments(self) -> _ty.Sequence[str]: ...
132
129
 
133
130
  @property
134
131
  @_abc.abstractmethod
@@ -140,8 +137,7 @@ class Pathname(FsPathLike, _ty.Generic[_P]):
140
137
  def with_name(self, name: str) -> _ty.Self:
141
138
  if not self.name:
142
139
  raise ValueError("%r has an empty name" % (self,))
143
- segments = self.segments[:-1] + [name]
144
- return self.with_segments(*segments)
140
+ return self.with_segments(*self.segments[:-1], name)
145
141
 
146
142
  def with_stem(self, stem: str) -> _ty.Self:
147
143
  """Return a new path with the stem changed."""
@@ -191,8 +187,7 @@ class Pathname(FsPathLike, _ty.Generic[_P]):
191
187
 
192
188
  @_utils.notimplemented
193
189
  def is_absolute(self) -> bool:
194
- """True if the path is absolute (has both a root and, if applicable,
195
- a drive)."""
190
+ """True if the path is absolute"""
196
191
  ...
197
192
 
198
193
  def match(self, path_pattern: str | _re.Pattern, *, case_sensitive=None):
@@ -204,7 +199,7 @@ class Pathname(FsPathLike, _ty.Generic[_P]):
204
199
  path = str(self)
205
200
  if not isinstance(path_pattern, _re.Pattern):
206
201
  if isinstance(str, path_pattern):
207
- path_pattern = type(self)(path_pattern).__path__()
202
+ path_pattern = type(self)(path_pattern)
208
203
  path_pattern = _glob.compile_pattern(path_pattern, case_sensitive)
209
204
  return path_pattern.match(path) is not None
210
205
 
@@ -221,7 +216,7 @@ class Pathname(FsPathLike, _ty.Generic[_P]):
221
216
  PurePathLike = str | Pathname
222
217
 
223
218
 
224
- class Path(Pathname):
219
+ class Path(Pathname, Chmod, Stat, BinaryOpen):
225
220
  """Base class for manipulating paths with I/O."""
226
221
 
227
222
  __slots__ = ()
@@ -233,91 +228,9 @@ class Path(Pathname):
233
228
  cls = LocalPath
234
229
  return Pathname.__new__(cls)
235
230
 
236
- @_utils.notimplemented
237
- def stat(self, *, follow_symlinks=True) -> FileStatLike: ...
238
-
239
- def lstat(self) -> FileStatLike:
240
- """
241
- Like stat(), except if the path points to a symlink, the symlink's
242
- status information is returned, rather than its target's.
243
- """
244
- return self.stat(follow_symlinks=False)
245
-
246
- def _st_mode(self, *, follow_symlinks=True):
247
- try:
248
- return self.stat().st_mode
249
- except FileNotFoundError:
250
- return None
251
- except OSError as e:
252
- if not _ignore_error(e):
253
- raise
254
- return None
255
- except ValueError:
256
- return None
257
-
258
- # Convenience functions for querying the stat results
259
- def exists(self, *, follow_symlinks=True):
260
- """
261
- Whether this path exists.
262
- """
263
- return self._st_mode(follow_symlinks=follow_symlinks) != None
264
-
265
231
  def is_hidden(self):
266
232
  return self.name.startswith(".")
267
233
 
268
- def is_dir(self):
269
- """
270
- Whether this path is a directory.
271
- """
272
- try:
273
- return _stat.S_ISDIR(self._st_mode() or 0)
274
- except NotImplementedError:
275
- pass
276
- try:
277
- next(self.iterdir(), None)
278
- return True
279
- except NotADirectoryError:
280
- return False
281
-
282
- raise NotImplementedError("is_dir")
283
-
284
- def is_file(self):
285
- """
286
- Whether this path is a regular file (also True for symlinks pointing
287
- to regular files).
288
- """
289
- return _stat.S_ISREG(self._st_mode() or 0)
290
-
291
- def is_symlink(self):
292
- """
293
- Whether this path is a symbolic link.
294
- """
295
- return _stat.S_ISLNK(self._st_mode(follow_symlinks=True) or 0)
296
-
297
- def is_block_device(self):
298
- """
299
- Whether this path is a block device.
300
- """
301
- return _stat.S_ISBLK(self._st_mode() or 0)
302
-
303
- def is_char_device(self):
304
- """
305
- Whether this path is a character device.
306
- """
307
- return _stat.S_ISCHR(self._st_mode() or 0)
308
-
309
- def is_fifo(self):
310
- """
311
- Whether this path is a FIFO.
312
- """
313
- return _stat.S_ISFIFO(self._st_mode() or 0)
314
-
315
- def is_socket(self):
316
- """
317
- Whether this path is a socket.
318
- """
319
- return _stat.S_ISSOCK(self._st_mode() or 0)
320
-
321
234
  @_utils.notimplemented
322
235
  def samefile(self, other_path: str | _ty.Self):
323
236
  """Return whether other_path is the same or not as this file
@@ -325,72 +238,6 @@ class Path(Pathname):
325
238
  """
326
239
  ...
327
240
 
328
- @_utils.notimplemented
329
- def _open(
330
- self,
331
- mode="r",
332
- buffering=-1,
333
- ) -> _io.IOBase:
334
- """
335
- All operations should be binary
336
- To be used only by UriPath.open() to obtain binary stream
337
- """
338
- ...
339
-
340
- def open(
341
- self,
342
- mode="r",
343
- buffering=-1,
344
- encoding: str = None,
345
- errors: str = None,
346
- newline: str = None,
347
- ) -> _io.IOBase:
348
- """
349
- Open the file pointed by this path and return a file object, as
350
- the built-in open() function does.
351
- """
352
- fh = self._open(mode.replace("b", ""), buffering)
353
- if "b" not in mode:
354
- encoding = _io.text_encoding(encoding)
355
- fh = _io.TextIOWrapper(fh, encoding, errors, newline)
356
- return fh
357
-
358
- def read_bytes(self) -> bytes:
359
- """
360
- Open the file in bytes mode, read it, and close the file.
361
- """
362
- with self.open(mode="rb") as f:
363
- return f.read()
364
-
365
- def read_text(self, encoding: str = None, errors: str = None) -> str:
366
- """
367
- Open the file in text mode, read it, and close the file.
368
- """
369
- with self.open(mode="r", encoding=encoding, errors=errors) as f:
370
- return f.read()
371
-
372
- def write_bytes(self, data: bytes):
373
- """
374
- Open the file in bytes mode, write to it, and close the file.
375
- """
376
- # type-check for the buffer interface before truncating the file
377
- view = memoryview(data)
378
- with self.open(mode="wb") as f:
379
- return f.write(view)
380
-
381
- def write_text(
382
- self, data: str, encoding: str = None, errors: str = None, newline: str = None
383
- ):
384
- """
385
- Open the file in text mode, write to it, and close the file.
386
- """
387
- if not isinstance(data, str):
388
- raise TypeError("data must be str, not %s" % data.__class__.__name__)
389
- with self.open(
390
- mode="w", encoding=encoding, errors=errors, newline=newline
391
- ) as f:
392
- return f.write(data)
393
-
394
241
  def __iter__(self):
395
242
  return self.iterdir()
396
243
 
@@ -448,13 +295,13 @@ class Path(Pathname):
448
295
  filenames: "list[str]" = []
449
296
  for entry in scandir_it:
450
297
  try:
451
- is_symlink = entry.is_symlink()
452
- is_dir = entry.is_dir()
298
+ stat = FileStat.from_path(entry, follow_symlink=follow_symlinks)
299
+ is_dir = stat.is_dir()
453
300
  except OSError:
454
301
  # Carried over from os.path.isdir().
455
302
  is_dir = False
456
303
 
457
- if is_dir and (follow_symlinks or not is_symlink):
304
+ if is_dir:
458
305
  dirnames.append(entry.name)
459
306
  else:
460
307
  filenames.append(entry.name)
@@ -494,28 +341,12 @@ class Path(Pathname):
494
341
  except FileNotFoundError:
495
342
  if not parents or self.parent == self:
496
343
  raise
497
- self.parent.mkdir(parents=True, exist_ok=True)
498
- self.mkdir(mode, parents=False, exist_ok=exist_ok)
499
- except OSError:
500
- # Cannot rely on checking for EEXIST, since the operating system
501
- # could give priority to other errors like EACCES or EROFS
344
+ self.parent.mkdir(parents=True, exist_ok=False)
345
+ self.mkdir(mode, parents=False, exist_ok=False)
346
+ except FileExistsError:
502
347
  if not exist_ok or not self.is_dir():
503
348
  raise
504
349
 
505
- @_utils.notimplemented
506
- def chmod(self, mode: int, *, follow_symlinks=True):
507
- """
508
- Change the permissions of the path, like os.chmod().
509
- """
510
- ...
511
-
512
- def lchmod(self, mode: int):
513
- """
514
- Like chmod(), except if the path points to a symlink, the symlink's
515
- permissions are changed, rather than its target's.
516
- """
517
- self.chmod(mode, follow_symlinks=False)
518
-
519
350
  @_utils.notimplemented
520
351
  def unlink(self, missing_ok=False):
521
352
  """
@@ -540,11 +371,11 @@ class Path(Pathname):
540
371
  ignore_error if not callable(ignore_error) else ignore_error
541
372
  )
542
373
  try:
543
- if not self.exists():
544
- if missing_ok:
545
- return
546
- raise FileNotFoundError(self)
547
- if self.is_dir():
374
+ stat = FileStat.from_path(self)
375
+ if stat is None:
376
+ if not missing_ok:
377
+ raise FileNotFoundError(self)
378
+ elif stat.is_dir():
548
379
  if recursive:
549
380
  for child in self.iterdir():
550
381
  child.rm(recursive=recursive, ignore_error=ignore_error)
@@ -570,9 +401,7 @@ class Path(Pathname):
570
401
  target.unlink()
571
402
  else:
572
403
  raise FileExistsError(target)
573
-
574
- with target.open("wb") as output, src.open("rb") as input:
575
- _shutil.copyfileobj(input, output)
404
+ BinaryOpen.copy(src, target)
576
405
 
577
406
  try:
578
407
  stat = src.stat()
@@ -598,7 +427,7 @@ class Path(Pathname):
598
427
  except NotImplementedError:
599
428
  pass
600
429
 
601
- src.copy(target)
430
+ BinaryOpen.copy(src, target)
602
431
  src.unlink()
603
432
 
604
433
 
@@ -0,0 +1,2 @@
1
+ from .fs import *
2
+ from .io import *
@@ -0,0 +1,117 @@
1
+ import abc as _abc
2
+ import stat as _stat
3
+ import typing as _ty
4
+
5
+ from .. import utils as _utils
6
+
7
+
8
+ class FileStatLike(_ty.Protocol):
9
+ """Minimum properties stat like object shouuld provide"""
10
+
11
+ __slots__ = ()
12
+
13
+ @property
14
+ @_abc.abstractmethod
15
+ def st_mode(self) -> int: ...
16
+ @property
17
+ @_abc.abstractmethod
18
+ def st_size(self) -> int: ...
19
+ @property
20
+ @_abc.abstractmethod
21
+ def st_mtime(self) -> int: ...
22
+
23
+
24
+ class Stat(_ty.Protocol):
25
+ """Any object that can implement Stat and utilities functions based on it"""
26
+
27
+ __slots__ = ()
28
+
29
+ @_utils.notimplemented
30
+ def stat(self, *, follow_symlinks=True) -> FileStatLike: ...
31
+
32
+ def lstat(self) -> FileStatLike:
33
+ """
34
+ Like stat(), except if the path points to a symlink, the symlink's
35
+ status information is returned, rather than its target's.
36
+ """
37
+ return self.stat(follow_symlinks=False)
38
+
39
+ def _st_mode(self, *, follow_symlinks=True):
40
+ """
41
+ Utility function only for internal use if this object,
42
+ not required nor to be expected in any implementations of the protocol
43
+ """
44
+ try:
45
+ return self.stat().st_mode
46
+ except FileNotFoundError:
47
+ return None
48
+ except ValueError:
49
+ return None
50
+
51
+ # Convenience functions for querying the stat results
52
+ def exists(self, *, follow_symlinks=True):
53
+ """
54
+ Whether this path exists.
55
+ """
56
+ return self._st_mode(follow_symlinks=follow_symlinks) != None
57
+
58
+ def is_dir(self):
59
+ """
60
+ Whether this path is a directory.
61
+ """
62
+ return _stat.S_ISDIR(self._st_mode() or 0)
63
+
64
+ def is_file(self):
65
+ """
66
+ Whether this path is a regular file (also True for symlinks pointing
67
+ to regular files).
68
+ """
69
+ return _stat.S_ISREG(self._st_mode() or 0)
70
+
71
+ def is_symlink(self):
72
+ """
73
+ Whether this path is a symbolic link.
74
+ """
75
+ return _stat.S_ISLNK(self._st_mode(follow_symlinks=False) or 0)
76
+
77
+ def is_block_device(self):
78
+ """
79
+ Whether this path is a block device.
80
+ """
81
+ return _stat.S_ISBLK(self._st_mode() or 0)
82
+
83
+ def is_char_device(self):
84
+ """
85
+ Whether this path is a character device.
86
+ """
87
+ return _stat.S_ISCHR(self._st_mode() or 0)
88
+
89
+ def is_fifo(self):
90
+ """
91
+ Whether this path is a FIFO.
92
+ """
93
+ return _stat.S_ISFIFO(self._st_mode() or 0)
94
+
95
+ def is_socket(self):
96
+ """
97
+ Whether this path is a socket.
98
+ """
99
+ return _stat.S_ISSOCK(self._st_mode() or 0)
100
+
101
+
102
+ class Chmod(_ty.Protocol):
103
+ __slots__ = ()
104
+
105
+ @_utils.notimplemented
106
+ def chmod(self, mode: int, *, follow_symlinks=True):
107
+ """
108
+ Change the permissions of the path, like os.chmod().
109
+ """
110
+ ...
111
+
112
+ def lchmod(self, mode: int):
113
+ """
114
+ Like chmod(), except if the path points to a symlink, the symlink's
115
+ permissions are changed, rather than its target's.
116
+ """
117
+ self.chmod(mode, follow_symlinks=False)
@@ -0,0 +1,80 @@
1
+ import io as _io
2
+ import shutil as _shutil
3
+ import typing as _ty
4
+
5
+ from .. import utils as _utils
6
+
7
+
8
+ class BinaryOpen(_ty.Protocol):
9
+ """Protocol for objects that support open->io.IoBase"""
10
+
11
+ __slots__ = ()
12
+
13
+ @_utils.notimplemented
14
+ def _open(
15
+ self,
16
+ mode="r",
17
+ buffering=-1,
18
+ ) -> _io.IOBase:
19
+ """
20
+ All operations should be binary
21
+ To be used only by open() to obtain binary stream to provide implementations for all methods
22
+ """
23
+ ...
24
+
25
+ def open(
26
+ self,
27
+ mode="r",
28
+ buffering=-1,
29
+ encoding: str = None,
30
+ errors: str = None,
31
+ newline: str = None,
32
+ ) -> _io.IOBase:
33
+ """
34
+ Open the a handle to an object that implement io.IOBase
35
+ """
36
+ fh = self._open(mode.replace("b", ""), buffering)
37
+ if "b" not in mode:
38
+ encoding = _io.text_encoding(encoding)
39
+ fh = _io.TextIOWrapper(fh, encoding, errors, newline)
40
+ return fh
41
+
42
+ def read_bytes(self) -> bytes:
43
+ """
44
+ Open in bytes mode, read it, and close the file.
45
+ """
46
+ with self.open(mode="rb") as f:
47
+ return f.read()
48
+
49
+ def read_text(self, encoding: str = None, errors: str = None) -> str:
50
+ """
51
+ Open in text mode, read it, and close the file.
52
+ """
53
+ with self.open(mode="r", encoding=encoding, errors=errors) as f:
54
+ return f.read()
55
+
56
+ def write_bytes(self, data: bytes):
57
+ """
58
+ Open in bytes mode, write to it, and close the file.
59
+ """
60
+ # type-check for the buffer interface before truncating the file
61
+ view = memoryview(data)
62
+ with self.open(mode="wb") as f:
63
+ return f.write(view)
64
+
65
+ def write_text(
66
+ self, data: str, encoding: str = None, errors: str = None, newline: str = None
67
+ ):
68
+ """
69
+ Open in text mode, write to it, and close the file.
70
+ """
71
+ if not isinstance(data, str):
72
+ raise TypeError("data must be str, not %s" % data.__class__.__name__)
73
+ with self.open(
74
+ mode="w", encoding=encoding, errors=errors, newline=newline
75
+ ) as f:
76
+ return f.write(data)
77
+
78
+ def copy(self, target: "BinaryOpen"):
79
+ with target.open("wb") as output, self.open("rb") as input:
80
+ _shutil.copyfileobj(input, output)
@@ -6,6 +6,9 @@ import bs4 as _bs4
6
6
  import requests as _req
7
7
  from htmllistparse import parse as _htmlparse
8
8
 
9
+ if _ty.TYPE_CHECKING:
10
+ from urllib3.response import HTTPResponse
11
+
9
12
  from ... import utils as _utils
10
13
  from ...utils.stat import FileStat
11
14
  from .. import UriPath
@@ -119,10 +122,12 @@ class HttpPath(UriPath):
119
122
  raise NotImplementedError(mode)
120
123
  buffer_size = _io.DEFAULT_BUFFER_SIZE if buffering < 0 else buffering
121
124
  req = self.backend.request("GET", self.as_uri(), stream=True)
125
+ resp: "HTTPResponse" = req.raw
126
+ resp.auto_close = False
122
127
  return (
123
- req.raw
128
+ resp
124
129
  if buffer_size == 0
125
- else _io.BufferedReader(req.raw, buffer_size=buffer_size)
130
+ else _io.BufferedReader(resp, buffer_size=buffer_size)
126
131
  )
127
132
 
128
133
  def is_dir(self):
@@ -3,6 +3,7 @@ import stat as _stat
3
3
  import typing as _ty
4
4
 
5
5
  from .. import utils as _utils
6
+ from ..protocols import FileStatLike as _FStat
6
7
 
7
8
  if _ty.TYPE_CHECKING:
8
9
  from os import stat_result as _os_stat
@@ -10,21 +11,7 @@ if _ty.TYPE_CHECKING:
10
11
  from ..path import Path
11
12
 
12
13
 
13
- class FileStatLike(_ty.Protocol):
14
- __slots__ = ()
15
-
16
- @property
17
- @_abc.abstractmethod
18
- def st_mode(self) -> int: ...
19
- @property
20
- @_abc.abstractmethod
21
- def st_size(self) -> int: ...
22
- @property
23
- @_abc.abstractmethod
24
- def st_mtime(self) -> int: ...
25
-
26
-
27
- class FileStat(FileStatLike):
14
+ class FileStat(_FStat):
28
15
  __slots__ = (
29
16
  "st_mode",
30
17
  "st_nlink",
@@ -0,0 +1,258 @@
1
+ import enum as _enum
2
+ import typing as _ty
3
+
4
+ from ..path import Path
5
+ from ..utils.stat import FileStat
6
+
7
+
8
+ class SyncEvent(_enum.Enum):
9
+ Copy = 1
10
+ RemovedMissing = 2
11
+ TypeMismatch = 6
12
+ SyncStart = 5
13
+ Synced = 3
14
+ CreatedDirectory = 4
15
+ CheckTargetChild = _enum.auto()
16
+ CheckTargetChildren = _enum.auto()
17
+ SyncChild = _enum.auto()
18
+ SyncChildren = _enum.auto()
19
+
20
+
21
+ class PathAndStat(object):
22
+ __slots__ = ("_path", "_stat")
23
+
24
+ def __init__(self, path: Path, *, follow_symlink=None) -> None:
25
+ self._path = path
26
+ self.refresh(follow_symlink)
27
+
28
+ def __str__(self) -> str:
29
+ return str(self.path)
30
+
31
+ def __repr__(self) -> str:
32
+ return str((self.path, self._stat))
33
+
34
+ @property
35
+ def path(self):
36
+ return self._path
37
+
38
+ @property
39
+ def stat(self):
40
+ return self._stat
41
+
42
+ def exists(self):
43
+ return self.stat != None
44
+
45
+ def refresh(self, follow_symlink: bool):
46
+ self._stat = FileStat.from_path(self.path, follow_symlink=follow_symlink)
47
+
48
+ def __getattr__(self, name: str):
49
+ if name.startswith("is_"):
50
+ if self.stat:
51
+ return getattr(self.stat, name)
52
+ else:
53
+ return lambda *args, **kwargs: False
54
+
55
+
56
+ if _ty.TYPE_CHECKING:
57
+
58
+ class PathAndStat(PathAndStat, FileStat): ...
59
+
60
+
61
+ class _OnPathSyncerError(_ty.Protocol):
62
+ def __call__(
63
+ self,
64
+ error: Exception,
65
+ source: PathAndStat,
66
+ target: PathAndStat,
67
+ event: SyncEvent,
68
+ ) -> bool: ...
69
+
70
+
71
+ class PathSyncer(object):
72
+ __slots__ = (
73
+ "checksum",
74
+ "_hook",
75
+ "remove_missing",
76
+ "follow_symlinks",
77
+ "ignore_error",
78
+ )
79
+ EVENT_LOG_FORMAT = "[{event}] Source:{source} Target:{target} DryRun:{dry_run}"
80
+
81
+ def __init__(
82
+ self,
83
+ checksum: _ty.Callable[[PathAndStat], int],
84
+ /,
85
+ remove_missing: bool = False,
86
+ follow_symlinks: bool = True,
87
+ hook: _ty.Callable[[PathAndStat, PathAndStat, SyncEvent, bool], None] = None,
88
+ ignore_error: _OnPathSyncerError | bool = False,
89
+ ) -> None:
90
+ self.checksum = checksum
91
+ self.remove_missing = remove_missing
92
+ self._hook = hook
93
+ self.follow_symlinks = follow_symlinks
94
+ if not callable(ignore_error):
95
+ _ignore_error = lambda *args, **kwargs: bool(ignore_error)
96
+ else:
97
+ _ignore_error = ignore_error
98
+ self.ignore_error = _ty.cast(_OnPathSyncerError, _ignore_error)
99
+
100
+ def log(self, msg: str, **kwargs: str):
101
+ print(msg.format_map(kwargs))
102
+
103
+ def hook(
104
+ self,
105
+ source: PathAndStat,
106
+ target: PathAndStat,
107
+ event: SyncEvent,
108
+ dry_run: bool,
109
+ do: _ty.Callable[[], None] = None,
110
+ ):
111
+ if not dry_run and do:
112
+ try:
113
+ do()
114
+ except Exception as e:
115
+ if self.ignore_error(e, source, target, event):
116
+ return e
117
+ raise
118
+ if self._hook:
119
+ self._hook(source, target, event, dry_run)
120
+ self.log(
121
+ self.EVENT_LOG_FORMAT,
122
+ event=event,
123
+ source=source,
124
+ target=target,
125
+ dry_run=dry_run,
126
+ )
127
+
128
+ def sync(
129
+ self,
130
+ source: Path | PathAndStat,
131
+ target: Path | PathAndStat,
132
+ /,
133
+ dry_run: bool = False,
134
+ ignore_error: (
135
+ bool | _ty.Callable[[Exception, PathAndStat, PathAndStat], None]
136
+ ) = False,
137
+ ):
138
+ checksum = self.checksum
139
+
140
+ def start():
141
+ nonlocal source, target
142
+ source = (
143
+ PathAndStat(source, follow_symlink=self.follow_symlinks)
144
+ if not isinstance(source, PathAndStat)
145
+ else source
146
+ )
147
+ target = (
148
+ PathAndStat(target, follow_symlink=self.follow_symlinks)
149
+ if not isinstance(target, PathAndStat)
150
+ else target
151
+ )
152
+
153
+ if self.hook(source, target, SyncEvent.SyncStart, False, start):
154
+ return
155
+
156
+ if not source.exists():
157
+ if self.remove_missing:
158
+ if self.hook(
159
+ source,
160
+ target,
161
+ SyncEvent.RemovedMissing,
162
+ dry_run,
163
+ lambda: target.path.rm(recursive=True, missing_ok=True),
164
+ ):
165
+ return
166
+ elif source.is_symlink():
167
+ error = NotImplementedError("symlink sync not implemented yet")
168
+ if not ignore_error(error, source, target, None):
169
+ raise error
170
+ return
171
+ elif source.is_file():
172
+ synced = False
173
+ if target.is_file():
174
+ if checksum(target) == checksum(source):
175
+ synced = True
176
+ if not synced:
177
+
178
+ def copy():
179
+ if target.is_file() or target.is_symlink():
180
+ target.path.unlink()
181
+ else:
182
+ if target.exists():
183
+ target.path.rm(recursive=target.is_dir())
184
+ source.path.copy(target.path)
185
+
186
+ if self.hook(source, target, SyncEvent.Copy, dry_run, copy):
187
+ return
188
+ else:
189
+ if target.is_file():
190
+ if self.hook(
191
+ source,
192
+ target,
193
+ SyncEvent.TypeMismatch,
194
+ dry_run,
195
+ lambda: target.path.unlink(),
196
+ ):
197
+ return
198
+
199
+ target._stat = None
200
+
201
+ if not target.exists():
202
+ if self.hook(
203
+ source,
204
+ target,
205
+ SyncEvent.CreatedDirectory,
206
+ dry_run,
207
+ lambda: target.path.mkdir(),
208
+ ):
209
+ return
210
+
211
+ if self.remove_missing:
212
+
213
+ def checkchildren():
214
+ for child in target.path.iterdir():
215
+
216
+ def checkchild():
217
+ if not (source.path / child.name).exists():
218
+ self.hook(
219
+ source,
220
+ target,
221
+ SyncEvent.RemovedMissing,
222
+ dry_run,
223
+ lambda: child.rm(recursive=True),
224
+ )
225
+
226
+ self.hook(
227
+ source,
228
+ target,
229
+ SyncEvent.CheckTargetChild,
230
+ False,
231
+ checkchild,
232
+ )
233
+
234
+ self.hook(
235
+ source,
236
+ target,
237
+ SyncEvent.CheckTargetChildren,
238
+ False,
239
+ checkchildren,
240
+ )
241
+
242
+ def sync_children():
243
+ for child in source.path.iterdir():
244
+ self.hook(
245
+ source,
246
+ target,
247
+ SyncEvent.SyncChild,
248
+ False,
249
+ lambda: self.sync(
250
+ child,
251
+ target.path / (child.name or child.parent.name),
252
+ dry_run,
253
+ ),
254
+ )
255
+
256
+ self.hook(source, target, SyncEvent.SyncChildren, False, sync_children)
257
+
258
+ self.hook(source, target, SyncEvent.Synced, dry_run)
@@ -1,104 +0,0 @@
1
- import enum as _enum
2
- import typing as _ty
3
-
4
- from ..path import Path
5
- from ..utils.stat import FileStat
6
-
7
-
8
- class SyncEvent(_enum.Enum):
9
- Copy = 1
10
- RemovedMissing = 2
11
- SyncStart = 5
12
- Synced = 3
13
- CreatedDirectory = 4
14
-
15
-
16
- class PathSyncer(object):
17
- __slots__ = ("checksum", "_hook", "remove_missing", "follow_symlinks")
18
- EVENT_LOG_FORMAT = "[{event}] Source:{source} Target:{target} DryRun:{dry_run}"
19
-
20
- def __init__(
21
- self,
22
- checksum: _ty.Callable[[Path], int],
23
- /,
24
- remove_missing: bool = False,
25
- follow_symlinks: bool = True,
26
- hook: _ty.Callable[[Path, Path, SyncEvent, bool], None] = None,
27
- ) -> None:
28
- self.checksum = checksum
29
- self.remove_missing = remove_missing
30
- self._hook = hook
31
- self.follow_symlinks = follow_symlinks
32
-
33
- def log(self, msg: str, **kwargs: str):
34
- print(msg.format_map(kwargs))
35
-
36
- def hook(
37
- self,
38
- source: Path,
39
- target: Path,
40
- event: SyncEvent,
41
- dry_run: bool,
42
- ):
43
- if self._hook:
44
- self._hook(source, target, event, dry_run)
45
- self.log(
46
- self.EVENT_LOG_FORMAT,
47
- event=event,
48
- source=source,
49
- target=target,
50
- dry_run=dry_run,
51
- )
52
-
53
- def sync(self, source: Path, target: Path, /, dry_run: bool = False):
54
- checksum = self.checksum
55
- self.hook(source, target, SyncEvent.SyncStart, dry_run)
56
-
57
- src_stat = FileStat.from_path(source, follow_symlink=self.follow_symlinks)
58
- tgt_stat = FileStat.from_path(target, follow_symlink=self.follow_symlinks)
59
-
60
- if src_stat is None:
61
- if self.remove_missing:
62
- if not dry_run:
63
- target.rm(recursive=True, missing_ok=True)
64
- self.hook(source, target, SyncEvent.RemovedMissing, dry_run)
65
- elif src_stat.is_symlink():
66
- raise NotImplementedError("symlink sync not implemented yet")
67
- elif src_stat.is_file():
68
- synced = False
69
- if tgt_stat and tgt_stat.is_file():
70
- if checksum(target) == checksum(source):
71
- synced = True
72
- if not synced:
73
- if not dry_run:
74
- if tgt_stat:
75
- if tgt_stat.is_file() or tgt_stat.is_symlink():
76
- target.unlink()
77
- else:
78
- if target:
79
- target.rm(recursive=tgt_stat.is_dir())
80
- source.copy(target)
81
- self.hook(source, target, SyncEvent.Copy, dry_run)
82
- else:
83
- t_exists = tgt_stat != None
84
- if tgt_stat and tgt_stat.is_file():
85
- if not dry_run:
86
- target.unlink()
87
- t_exists = False
88
-
89
- if not t_exists:
90
- if not dry_run:
91
- target.mkdir()
92
- self.hook(source, target, SyncEvent.CreatedDirectory, dry_run)
93
-
94
- if self.remove_missing:
95
- for child in target.iterdir():
96
- if not (source / child.name).exists():
97
- if not dry_run:
98
- child.rm(recursive=True)
99
- self.hook(source, target, SyncEvent.RemovedMissing, dry_run)
100
-
101
- for child in source.iterdir():
102
- self.sync(child, target / child.name, dry_run)
103
-
104
- self.hook(source, target, SyncEvent.Synced, dry_run)
File without changes
File without changes