pathapi-uri 0.1.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.
Files changed (48) hide show
  1. pathapi_uri-0.1.0.dist-info/METADATA +259 -0
  2. pathapi_uri-0.1.0.dist-info/RECORD +48 -0
  3. pathapi_uri-0.1.0.dist-info/WHEEL +4 -0
  4. pathapi_uri-0.1.0.dist-info/entry_points.txt +17 -0
  5. pathapi_uri-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. uripath/AGENTS.md +1094 -0
  7. uripath/README.md +213 -0
  8. uripath/__init__.py +27 -0
  9. uripath/__main__.py +7 -0
  10. uripath/_pathtext.py +32 -0
  11. uripath/_rfc3986.py +67 -0
  12. uripath/_transfer.py +108 -0
  13. uripath/_uri.py +1007 -0
  14. uripath/_version.py +11 -0
  15. uripath/cli/__init__.py +54 -0
  16. uripath/cli/_common.py +48 -0
  17. uripath/cli/_root.py +70 -0
  18. uripath/cli/cp.py +61 -0
  19. uripath/cli/read.py +22 -0
  20. uripath/cli/rm.py +38 -0
  21. uripath/cli/sync.py +78 -0
  22. uripath/cli/write.py +49 -0
  23. uripath/exceptions.py +38 -0
  24. uripath/py.typed +1 -0
  25. uripath/query.py +240 -0
  26. uripath/schemes/__init__.py +120 -0
  27. uripath/schemes/_archive.py +338 -0
  28. uripath/schemes/_data.py +99 -0
  29. uripath/schemes/_dav.py +459 -0
  30. uripath/schemes/_delegate.py +127 -0
  31. uripath/schemes/_elsewhere.py +102 -0
  32. uripath/schemes/_file.py +186 -0
  33. uripath/schemes/_ftp.py +200 -0
  34. uripath/schemes/_http/__init__.py +4 -0
  35. uripath/schemes/_http/_backend.py +132 -0
  36. uripath/schemes/_http/_headers.py +8 -0
  37. uripath/schemes/_http/_listing.py +403 -0
  38. uripath/schemes/_http/_path.py +371 -0
  39. uripath/schemes/_http/_settings.py +6 -0
  40. uripath/schemes/_http/_streams.py +230 -0
  41. uripath/schemes/_httpdate.py +48 -0
  42. uripath/schemes/_path.py +629 -0
  43. uripath/schemes/_registry.py +134 -0
  44. uripath/source/__init__.py +20 -0
  45. uripath/source/_compose.py +212 -0
  46. uripath/source/_locality.py +44 -0
  47. uripath/source/_parse.py +183 -0
  48. uripath/source/_source.py +239 -0
uripath/_version.py ADDED
@@ -0,0 +1,11 @@
1
+ """The version of the installed distribution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib import metadata as _metadata
6
+
7
+ try:
8
+ __version__ = _metadata.version("pathapi-uri")
9
+ except _metadata.PackageNotFoundError:
10
+ # Run from a source tree that was never installed.
11
+ __version__ = "0+unknown"
@@ -0,0 +1,54 @@
1
+ """The `uripath` command line, built on `duho`.
2
+
3
+ `main` is the entry point of the `uripath` console script and of `python -m
4
+ uripath`. It needs the `cli` extra (`pip install "pathapi-uri[cli]"`); importing
5
+ this package does not, so a bare install gets one line naming the extra in
6
+ place of an `ImportError`. The root command, `Uripath`, is built on first use
7
+ because its base class comes from `duho`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import sys as _sys
13
+ import typing as _ty
14
+
15
+ if _ty.TYPE_CHECKING:
16
+ from ._root import Uripath
17
+
18
+ __all__ = ["main"]
19
+
20
+ _NEEDS_EXTRA = (
21
+ 'uripath: error: the command line needs the "cli" extra: '
22
+ 'pip install "pathapi-uri[cli]"'
23
+ )
24
+
25
+
26
+ def main(argv: "_ty.Optional[_ty.Sequence[str]]" = None) -> int:
27
+ """Run one `uripath` command and return its exit status.
28
+
29
+ `argv` is the arguments after the program name (default `sys.argv[1:]`).
30
+ Returns 0 on success, 1 when the operation failed, 2 for a wrong
31
+ invocation, 130 on Ctrl-C and 141 when the reader of standard output went
32
+ away. Results go to stdout and one `uripath: <Type>: <message>` line per
33
+ failure to stderr. Without `duho` it prints which extra to install and
34
+ returns 1.
35
+ """
36
+ try:
37
+ import duho # noqa: F401
38
+ except ModuleNotFoundError as error:
39
+ if error.name != "duho":
40
+ raise
41
+ print(_NEEDS_EXTRA, file=_sys.stderr)
42
+ return 1
43
+ from ._root import run
44
+
45
+ return run(argv)
46
+
47
+
48
+ def __getattr__(name: str) -> "_ty.Any":
49
+ """`Uripath`, the root command, imported on first use."""
50
+ if name == "Uripath":
51
+ from ._root import Uripath
52
+
53
+ return Uripath
54
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
uripath/cli/_common.py ADDED
@@ -0,0 +1,48 @@
1
+ """What the commands share: the base class and the standard streams."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys as _sys
6
+ import typing as _ty
7
+
8
+ from duho import Cmd, LoggingArgs, UsageError, running_mode
9
+
10
+
11
+ class Command(LoggingArgs, Cmd):
12
+ """The base of every command: `-v`, `-q` and `--loglevel`, and the logger
13
+ they raise, `uripath`."""
14
+
15
+ _logger_name_ = "uripath"
16
+ # A tail after `--` would be ignored; a path that starts with `-` is
17
+ # written `./-name`.
18
+ _allow_passthrough_ = False
19
+
20
+ @property
21
+ def wants_detail(self) -> bool:
22
+ """`-v` given more often than `-q`."""
23
+ return self.verbose > self.quiet
24
+
25
+
26
+ def stdout() -> "_ty.BinaryIO":
27
+ """The binary standard output of the process."""
28
+ return _sys.stdout.buffer
29
+
30
+
31
+ def stdin() -> "_ty.BinaryIO":
32
+ """The binary standard input of the process. A command served as a tool
33
+ has none: the server reads its requests from it."""
34
+ if running_mode() == "mcp":
35
+ raise UsageError("standard input is not available to a tool: name a file")
36
+ return _sys.stdin.buffer
37
+
38
+
39
+ def source_of(text: str) -> "_ty.Union[str, _ty.BinaryIO]":
40
+ """Where a command reads bytes from: `-` is standard input, anything else
41
+ the path or URI it names."""
42
+ return stdin() if text == "-" else text
43
+
44
+
45
+ def target_of(text: str) -> "_ty.Union[str, _ty.BinaryIO]":
46
+ """Where a command writes bytes to: `-` is standard output, anything else
47
+ the path or URI it names."""
48
+ return stdout() if text == "-" else text
uripath/cli/_root.py ADDED
@@ -0,0 +1,70 @@
1
+ """The root command and the run around it: exceptions become statuses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os as _os
6
+ import sys as _sys
7
+ import typing as _ty
8
+
9
+ from duho import AUTO, Cli, DefaultsFormatter
10
+ from duho import main as _main
11
+
12
+ from ..exceptions import OutputClosedError
13
+ from .cp import Cp
14
+ from .read import Read
15
+ from .rm import Rm
16
+ from .sync import Sync
17
+ from .write import Write
18
+
19
+ #: The status of a tool killed by SIGPIPE (128 + 13), and of Ctrl-C (128 + 2).
20
+ _EXIT_BROKEN_PIPE = 141
21
+ _EXIT_INTERRUPTED = 130
22
+
23
+
24
+ class Uripath(Cli):
25
+ """Read, write, copy, remove and sync local paths and URIs. A PATH is a local path or a URI; - is standard input or output where bytes are read or written."""
26
+
27
+ _parsername_ = "uripath"
28
+ _version_ = AUTO
29
+ _distribution_ = "pathapi-uri"
30
+ _help_formatter_ = DefaultsFormatter
31
+ _exit_codes_ = {
32
+ 1: "the operation failed",
33
+ 2: "the invocation was wrong",
34
+ 130: "interrupted",
35
+ 141: "the reader of standard output went away",
36
+ }
37
+ _subcommands_ = [Read, Write, Rm, Cp, Sync]
38
+
39
+
40
+ def _discard_stdout() -> None:
41
+ """Point the real standard output at the null device, so that the flush
42
+ the interpreter makes at exit cannot fail again."""
43
+ try:
44
+ devnull = _os.open(_os.devnull, _os.O_WRONLY)
45
+ _os.dup2(devnull, _sys.stdout.fileno())
46
+ _os.close(devnull)
47
+ except (OSError, ValueError, AttributeError):
48
+ pass
49
+
50
+
51
+ def run(argv: "_ty.Optional[_ty.Sequence[str]]") -> int:
52
+ """Parse `argv`, run the command it names and return its exit status."""
53
+ try:
54
+ status = _main(Uripath, None if argv is None else list(argv))
55
+ except SystemExit as stop:
56
+ # The parser exits through SystemExit: 0 after --help and --version,
57
+ # 2 on a usage error.
58
+ if stop.code is None:
59
+ return 0
60
+ return stop.code if isinstance(stop.code, int) else 2
61
+ except OutputClosedError:
62
+ # Quiet, like a POSIX tool killed by SIGPIPE.
63
+ _discard_stdout()
64
+ return _EXIT_BROKEN_PIPE
65
+ except KeyboardInterrupt:
66
+ return _EXIT_INTERRUPTED
67
+ except Exception as error:
68
+ print(f"uripath: {type(error).__name__}: {error}", file=_sys.stderr)
69
+ return 1
70
+ return 0 if status is None else int(status)
uripath/cli/cp.py ADDED
@@ -0,0 +1,61 @@
1
+ """`uripath cp`: copy a path, a tree or a stream."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from duho import UsageError
6
+
7
+ from .. import as_path, copy_stream
8
+ from ._common import Command, source_of, target_of
9
+
10
+ __all__ = ["Cp"]
11
+
12
+
13
+ class Cp(Command):
14
+ """Copy a file, or with --recursive a directory, from SOURCE to TARGET; either may be a URI."""
15
+
16
+ _parsername_ = "cp"
17
+
18
+ source: str
19
+ "The file, directory or URI to copy; - reads standard input. Required"
20
+ ("source",)
21
+
22
+ target: str
23
+ "Where to copy it to; - writes standard output. Required"
24
+ ("target",)
25
+
26
+ recursive: bool = False
27
+ "Copy a directory with everything in it; not with -. Omitted: a single file"
28
+ ("--recursive", "-r")
29
+
30
+ overwrite: bool = False
31
+ "Replace an existing TARGET. Omitted: an existing TARGET is refused"
32
+ ("--overwrite",)
33
+
34
+ follow_symlinks: bool = True
35
+ "Copy what a symbolic link points to. With --no-follow-symlinks: copy the link itself"
36
+ ("--follow-symlinks",)
37
+
38
+ preserve_metadata: bool = True
39
+ "Copy modes and times where the target can hold them. With --no-preserve-metadata: leave them out"
40
+ ("--preserve-metadata",)
41
+
42
+ def __call__(self) -> int:
43
+ streamed = "-" in (self.source, self.target)
44
+ if streamed and self.recursive:
45
+ raise UsageError("--recursive cannot copy from or to '-'")
46
+ if streamed:
47
+ # An exclusive create: no check-then-write race on the target.
48
+ copy_stream(
49
+ source_of(self.source),
50
+ target_of(self.target),
51
+ exclusive=not self.overwrite,
52
+ )
53
+ return 0
54
+ as_path(self.source).copy(
55
+ as_path(self.target),
56
+ overwrite=self.overwrite,
57
+ follow_symlinks=self.follow_symlinks,
58
+ preserve_metadata=self.preserve_metadata,
59
+ recursive=self.recursive,
60
+ )
61
+ return 0
uripath/cli/read.py ADDED
@@ -0,0 +1,22 @@
1
+ """`uripath read`: the bytes of a path to standard output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .. import copy_stream
6
+ from ._common import Command, source_of, stdout
7
+
8
+ __all__ = ["Read"]
9
+
10
+
11
+ class Read(Command):
12
+ """Write the bytes of a path or URI to standard output, in chunks."""
13
+
14
+ _parsername_ = "read"
15
+
16
+ path: str
17
+ "The file or URI to read; - reads standard input. Required"
18
+ ("path",)
19
+
20
+ def __call__(self) -> int:
21
+ copy_stream(source_of(self.path), stdout())
22
+ return 0
uripath/cli/rm.py ADDED
@@ -0,0 +1,38 @@
1
+ """`uripath rm`: remove a path."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .. import as_path
6
+ from ._common import Command
7
+
8
+ __all__ = ["Rm"]
9
+
10
+
11
+ class Rm(Command):
12
+ """Remove a file, an empty directory or a URI; with --recursive, a directory and everything in it."""
13
+
14
+ _parsername_ = "rm"
15
+
16
+ path: str
17
+ "The file, empty directory or URI to remove. Required"
18
+ ("path",)
19
+
20
+ recursive: bool = False
21
+ "Remove a directory with everything in it. Omitted: a directory must be empty"
22
+ ("--recursive", "-r")
23
+
24
+ missing_ok: bool = False
25
+ "Succeed when the path does not exist. Omitted: a missing path fails"
26
+ ("--missing-ok",)
27
+
28
+ ignore_error: bool = False
29
+ "Keep removing after a failure. Omitted: stop at the first"
30
+ ("--ignore-error",)
31
+
32
+ def __call__(self) -> int:
33
+ as_path(self.path).rm(
34
+ recursive=self.recursive,
35
+ missing_ok=self.missing_ok,
36
+ ignore_error=self.ignore_error,
37
+ )
38
+ return 0
uripath/cli/sync.py ADDED
@@ -0,0 +1,78 @@
1
+ """`uripath sync`: make a target tree match a source tree."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io as _io
6
+
7
+ from pathapi.sync import PathSyncer, SyncEvent
8
+
9
+ from .. import as_path, copy_stream
10
+ from ._common import Command, stdout
11
+
12
+ __all__ = ["Sync"]
13
+
14
+
15
+ _ACTIONS = {
16
+ SyncEvent.Copy: "copy",
17
+ SyncEvent.RemovedMissing: "remove",
18
+ SyncEvent.CreatedDirectory: "mkdir",
19
+ SyncEvent.TypeMismatch: "replace",
20
+ SyncEvent.Symlink: "symlink",
21
+ }
22
+
23
+
24
+ class Sync(Command):
25
+ """Make TARGET match SOURCE, comparing file content; nothing is deleted unless --remove-missing."""
26
+
27
+ _parsername_ = "sync"
28
+
29
+ source: str
30
+ "The directory or URI to copy from. Required"
31
+ ("source",)
32
+
33
+ target: str
34
+ "The directory or URI to make match SOURCE. Required"
35
+ ("target",)
36
+
37
+ dry_run: bool = False
38
+ "Print what would change without changing anything. Omitted: make the changes"
39
+ ("--dry-run",)
40
+
41
+ remove_missing: bool = False
42
+ "Delete the entries of TARGET that SOURCE does not have. Omitted: keep them"
43
+ ("--remove-missing",)
44
+
45
+ size_only: bool = False
46
+ "Compare file sizes only, which misses same-size edits. Omitted: compare content"
47
+ ("--size-only",)
48
+
49
+ follow_symlinks: bool = True
50
+ "Copy what a symbolic link points to. With --no-follow-symlinks: recreate the link"
51
+ ("--follow-symlinks",)
52
+
53
+ follow_mounts: bool = True
54
+ "Sync into a mount point inside TARGET. With --no-follow-mounts: fail on one"
55
+ ("--follow-mounts",)
56
+
57
+ def __call__(self) -> int:
58
+ PathSyncer(
59
+ (lambda entry: entry.stat.st_size) if self.size_only else None,
60
+ remove_missing=self.remove_missing,
61
+ follow_symlinks=self.follow_symlinks,
62
+ follow_mounts=self.follow_mounts,
63
+ hook=self._report,
64
+ ).sync(as_path(self.source), as_path(self.target), dry_run=self.dry_run)
65
+ return 0
66
+
67
+ def _report(self, source, target, event, dry_run) -> None:
68
+ """Print one line for a change: a planned one under --dry-run, a made one under -v."""
69
+ action = _ACTIONS.get(event)
70
+ if action is None or not (dry_run or self.wants_detail):
71
+ return
72
+ prefix = "would " if dry_run else ""
73
+ if event is SyncEvent.Copy:
74
+ line = f"{prefix}{action} {source.path} -> {target.path}"
75
+ else:
76
+ line = f"{prefix}{action} {target.path}"
77
+ text = line.encode("utf-8", "backslashreplace") + b"\n"
78
+ copy_stream(_io.BytesIO(text), stdout())
uripath/cli/write.py ADDED
@@ -0,0 +1,49 @@
1
+ """`uripath write`: standard input, or the text given, into a path."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io as _io
6
+ import typing as _ty
7
+
8
+ from duho import Arg, Meta
9
+
10
+ from .. import as_path, copy_stream
11
+ from ._common import Command, stdin, stdout, target_of
12
+
13
+ __all__ = ["Write"]
14
+
15
+
16
+ def _encoding(name: str) -> str:
17
+ """A text encoding the interpreter knows (not a bytes-to-bytes codec)."""
18
+ try:
19
+ "".encode(name)
20
+ except LookupError:
21
+ raise ValueError(f"unknown text encoding: {name!r}") from None
22
+ return name
23
+
24
+
25
+ class Write(Command):
26
+ """Write standard input, or the text DATA, to a path or URI, replacing what is there."""
27
+
28
+ _parsername_ = "write"
29
+
30
+ path: str
31
+ "The file or URI to write, replacing it; - writes standard output. Required"
32
+ ("path",)
33
+
34
+ data: _ty.Optional[str] = None
35
+ "The text to write, encoded with --encoding. Omitted: the bytes of standard input"
36
+ ("data",)
37
+
38
+ encoding: Arg[str, Meta(type=_encoding)] = "utf-8"
39
+ "The text encoding of DATA"
40
+ ("--encoding",)
41
+
42
+ def __call__(self) -> int:
43
+ if self.data is None:
44
+ copy_stream(stdin(), target_of(self.path))
45
+ elif self.path == "-":
46
+ copy_stream(_io.BytesIO(self.data.encode(self.encoding)), stdout())
47
+ else:
48
+ as_path(self.path).write_bytes(self.data.encode(self.encoding))
49
+ return 0
uripath/exceptions.py ADDED
@@ -0,0 +1,38 @@
1
+ """The exceptions of uripath.
2
+
3
+ `URIPathError` is the base of what goes wrong at run time. A caller's bad
4
+ argument is a plain `ValueError` or `TypeError`, and a failed read or write
5
+ is the `OSError` of the kind `pathlib` raises.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ __all__ = [
11
+ "OutputClosedError",
12
+ "URIPathError",
13
+ "URISyntaxError",
14
+ "UnsupportedSchemeError",
15
+ ]
16
+
17
+
18
+ class URIPathError(Exception):
19
+ """The base of the errors uripath raises."""
20
+
21
+
22
+ class URISyntaxError(URIPathError, ValueError):
23
+ """Text that is not a URI, or not the part of one that was asked for: a port
24
+ that is not 0-65535, a malformed host, a `zip:` URI without the scheme of
25
+ its archive. The message names what is wrong and never the text, which can
26
+ carry a password."""
27
+
28
+
29
+ class UnsupportedSchemeError(URIPathError, ValueError):
30
+ """A scheme that no class registered. The message lists the registered
31
+ ones."""
32
+
33
+
34
+ class OutputClosedError(URIPathError, BrokenPipeError):
35
+ """The reader of a stream that `copy_stream()` was writing went away: a
36
+ pipe whose other end closed (`uripath read big | head`). It is raised for
37
+ a stream passed as the target, never for a failure on a path's own
38
+ connection."""
uripath/py.typed ADDED
@@ -0,0 +1 @@
1
+ # Marker file for PEP 561.