lvpath 0.3.1__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.
lvpath/__init__.py ADDED
@@ -0,0 +1,48 @@
1
+ """Convert between LabVIEW path strings and native OS paths.
2
+
3
+ The textual surface (:func:`to_os_path`, :func:`to_lv_path`, :func:`path_kind`)
4
+ models LabVIEW's ``Path To String`` / ``String To Path``. The flattened surface
5
+ (:class:`FlatPath`, :func:`to_flat_path`, :func:`from_flat_path` and friends)
6
+ models the binary ``Flatten To String`` ``PTH0`` form. The portable surface
7
+ (:func:`from_portable_path`, :func:`to_portable_path`) models the
8
+ platform-independent form LabVIEW writes into configuration and project files,
9
+ such as ``/C/DIR1/DIR2`` for ``C:\\DIR1\\DIR2``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from ._convert import path_kind, to_lv_path, to_os_path
15
+ from ._flatbridge import from_flat_path, to_flat_path
16
+ from ._flatten import (
17
+ SIGNATURE,
18
+ FlatPath,
19
+ FlatPathError,
20
+ FlatPathType,
21
+ build_flat_path,
22
+ parse_flat_path,
23
+ )
24
+ from ._kinds import NotAPathError, PathKind
25
+ from ._platform import Platform
26
+ from ._portable import PortablePathError, from_portable_path, to_portable_path
27
+
28
+ __version__ = "0.3.1"
29
+
30
+ __all__ = [
31
+ "SIGNATURE",
32
+ "FlatPath",
33
+ "FlatPathError",
34
+ "FlatPathType",
35
+ "NotAPathError",
36
+ "PathKind",
37
+ "Platform",
38
+ "PortablePathError",
39
+ "build_flat_path",
40
+ "from_flat_path",
41
+ "from_portable_path",
42
+ "parse_flat_path",
43
+ "path_kind",
44
+ "to_flat_path",
45
+ "to_lv_path",
46
+ "to_os_path",
47
+ "to_portable_path",
48
+ ]
lvpath/_cli.py ADDED
@@ -0,0 +1,134 @@
1
+ """Command-line interface for LabVIEW path strings and the flattened path form.
2
+
3
+ ``lvpath to-native`` turns LabVIEW's portable path string (``/C/DIR1/DIR2``) into
4
+ a native path; ``lvpath to-labview`` does the reverse. ``lvpath decode`` turns the
5
+ hex of a flattened ``PTH0`` byte string into a native path; ``lvpath encode``
6
+ turns a native path into that hex. Errors are written to standard error and the
7
+ process exits with status ``1``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import io
14
+ import sys
15
+ from collections.abc import Sequence
16
+
17
+ from . import __version__
18
+ from ._flatbridge import from_flat_path, to_flat_path
19
+ from ._portable import from_portable_path, to_portable_path
20
+
21
+ _EXAMPLES = """\
22
+ examples:
23
+ # LabVIEW's portable path string, as written in .ini and .lvproj files, to Windows.
24
+ lvpath to-native --platform windows "/C/Program Files (x86)/Arduino"
25
+ # prints C:\\Program Files (x86)\\Arduino
26
+
27
+ # A UNC share and a relative path.
28
+ lvpath to-native --platform windows //server/share/dir
29
+ lvpath to-native --platform windows "../builds/Main Application"
30
+
31
+ # A native Windows path to the portable string LabVIEW writes.
32
+ lvpath to-labview --platform windows "C:\\DIR1\\DIR2"
33
+ # prints /C/DIR1/DIR2
34
+
35
+ # Decode the NI C:\\File flattened example into a path.
36
+ lvpath decode --platform windows 505448300000000b0000000201430466696c65
37
+
38
+ # Encode a path into its flattened hex form (spaces in the hex are accepted on decode).
39
+ lvpath encode --platform posix /usr/local/bin
40
+ """
41
+
42
+
43
+ def _build_parser() -> argparse.ArgumentParser:
44
+ parser = argparse.ArgumentParser(
45
+ prog="lvpath",
46
+ description=(
47
+ "Convert LabVIEW's portable path strings to and from native paths, and decode "
48
+ "or encode the LabVIEW PTH0 flattened path form."
49
+ ),
50
+ epilog=_EXAMPLES,
51
+ formatter_class=argparse.RawDescriptionHelpFormatter,
52
+ )
53
+ parser.add_argument("--version", action="version", version=f"lvpath {__version__}")
54
+ subparsers = parser.add_subparsers(dest="command", required=True)
55
+ commands = [
56
+ (
57
+ "to-native",
58
+ "text",
59
+ "a LabVIEW portable path string to a native path",
60
+ "Convert a LabVIEW portable path string (/C/DIR1/DIR2, //server/share, a/b) "
61
+ "into a native path. An empty string is <Not A Path> and is an error.",
62
+ ),
63
+ (
64
+ "to-labview",
65
+ "path",
66
+ "a native path to a LabVIEW portable path string",
67
+ "Convert a native path into the portable path string LabVIEW writes into "
68
+ "configuration and project files. An empty path prints an empty line, "
69
+ "LabVIEW's <Not A Path>.",
70
+ ),
71
+ (
72
+ "decode",
73
+ "hex",
74
+ "hex of flattened PTH0 bytes to a native path",
75
+ "Decode the hex of a flattened PTH0 byte string into a native path.",
76
+ ),
77
+ (
78
+ "encode",
79
+ "path",
80
+ "a native path to flattened PTH0 hex",
81
+ "Encode a native path into the hex of its flattened PTH0 byte string.",
82
+ ),
83
+ ]
84
+ for name, argument, short, long in commands:
85
+ sub = subparsers.add_parser(name, help=short, description=long)
86
+ sub.add_argument(argument, help=f"the {argument} to convert")
87
+ _add_platform(sub)
88
+ return parser
89
+
90
+
91
+ def _add_platform(parser: argparse.ArgumentParser) -> None:
92
+ parser.add_argument(
93
+ "--platform",
94
+ choices=["windows", "posix"],
95
+ default=None,
96
+ help="native path flavor to use (default: the current operating system)",
97
+ )
98
+
99
+
100
+ def main(argv: Sequence[str] | None = None) -> int:
101
+ """Run the command line interface. Returns the process exit status."""
102
+ args = _build_parser().parse_args(argv)
103
+ # Paths may hold any Unicode character; a pipe on Windows defaults to a
104
+ # legacy code page, so write UTF-8 to standard output and standard error.
105
+ for stream in (sys.stdout, sys.stderr):
106
+ if isinstance(stream, io.TextIOWrapper):
107
+ stream.reconfigure(encoding="utf-8")
108
+ try:
109
+ print(_run(args.command, args, args.platform))
110
+ except ValueError as error:
111
+ print(f"lvpath: {error}", file=sys.stderr)
112
+ return 1
113
+ return 0
114
+
115
+
116
+ def _run(command: str, args: argparse.Namespace, platform: str | None) -> str:
117
+ if command == "to-native":
118
+ return str(from_portable_path(args.text, platform=platform))
119
+ if command == "to-labview":
120
+ return to_portable_path(args.path, platform=platform)
121
+ if command == "decode":
122
+ return str(from_flat_path(_hex_bytes(args.hex), platform=platform))
123
+ return to_flat_path(args.path, platform=platform).hex()
124
+
125
+
126
+ def _hex_bytes(raw_hex: str) -> bytes:
127
+ try:
128
+ return bytes.fromhex(raw_hex.replace(" ", ""))
129
+ except ValueError as error:
130
+ raise ValueError(f"not valid hex: {error}") from error
131
+
132
+
133
+ if __name__ == "__main__":
134
+ sys.exit(main())
lvpath/_convert.py ADDED
@@ -0,0 +1,48 @@
1
+ """Conversion between LabVIEW path strings and native OS paths."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import PurePath
6
+
7
+ from ._kinds import NotAPathError, PathKind
8
+ from ._platform import Platform, flavor, resolve
9
+
10
+
11
+ def path_kind(lv_path: str, *, platform: Platform | str | None = None) -> PathKind:
12
+ """Classify a LabVIEW path string as absolute, relative or not-a-path.
13
+
14
+ An empty string is LabVIEW's ``<Not A Path>`` value on every platform.
15
+ """
16
+ if lv_path == "":
17
+ return PathKind.NOT_A_PATH
18
+ pure = flavor(resolve(platform))(lv_path)
19
+ return PathKind.ABSOLUTE if pure.is_absolute() else PathKind.RELATIVE
20
+
21
+
22
+ def to_os_path(lv_path: str, *, platform: Platform | str | None = None) -> PurePath:
23
+ """Parse a LabVIEW path string into a ``PurePath`` of the platform flavor.
24
+
25
+ Raises :class:`NotAPathError` for the empty not-a-path value, which has no
26
+ operating-system path. Forward slashes are accepted when parsing a Windows
27
+ path, matching LabVIEW's lenient reader. This reads the host's native text
28
+ form; for the portable form LabVIEW writes into configuration and project
29
+ files (``/C/DIR1/DIR2``) use :func:`lvpath.from_portable_path`.
30
+ """
31
+ if lv_path == "":
32
+ raise NotAPathError("LabVIEW not-a-path has no OS path")
33
+ return flavor(resolve(platform))(lv_path)
34
+
35
+
36
+ def to_lv_path(os_path: PurePath | str | None, *, platform: Platform | str | None = None) -> str:
37
+ """Serialize an OS path into a LabVIEW path string for ``platform``.
38
+
39
+ ``None`` or an empty string becomes LabVIEW's not-a-path value (``""``).
40
+ The components of ``os_path`` are rendered with the separators of the
41
+ target platform, so a relative path crosses platforms cleanly.
42
+ """
43
+ if os_path is None or os_path == "":
44
+ return ""
45
+ target = flavor(resolve(platform))
46
+ if isinstance(os_path, str):
47
+ return str(target(os_path))
48
+ return str(target(*os_path.parts))
lvpath/_flatbridge.py ADDED
@@ -0,0 +1,69 @@
1
+ """Bridge the PTH0 flattened form to and from ``pathlib.PurePath``.
2
+
3
+ Only the two documented path type codes are interpreted here: absolute (``0``)
4
+ and relative (``1``). UNC, driveless and empty / not-a-path forms carry type
5
+ codes the NI documentation does not state, so they are refused with a clear
6
+ error rather than guessed. The low-level :mod:`lvpath._flatten` codec still
7
+ round-trips their raw bytes faithfully.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import PurePath, PurePosixPath, PureWindowsPath
13
+
14
+ from ._flatten import FlatPath, FlatPathError, FlatPathType, parse_flat_path
15
+ from ._platform import Platform, flavor, resolve
16
+
17
+
18
+ def to_flat_path(os_path: PurePath | str, *, platform: Platform | str | None = None) -> bytes:
19
+ """Encode an OS path into its PTH0 flattened byte string.
20
+
21
+ Absolute Windows paths store the drive letter without its colon as the first
22
+ component (matching NI's ``C:\\File`` example); absolute POSIX paths store the
23
+ components after the leading root. UNC, driveless and not-a-path forms are
24
+ refused because their flattened type codes are not documented.
25
+ """
26
+ target = flavor(resolve(platform))
27
+ pure = target(os_path) if isinstance(os_path, str) else target(*os_path.parts)
28
+ if pure.is_absolute():
29
+ return _encode_absolute(pure, target).to_bytes()
30
+ if pure.drive:
31
+ raise FlatPathError(f"drive-relative paths have no documented flattened form: {pure}")
32
+ return FlatPath(path_type=int(FlatPathType.RELATIVE), components=pure.parts).to_bytes()
33
+
34
+
35
+ def from_flat_path(data: bytes, *, platform: Platform | str | None = None) -> PurePath:
36
+ """Decode PTH0 flattened bytes into a ``PurePath`` of the platform flavor.
37
+
38
+ Raises :class:`FlatPathError` for a path type with no documented PurePath
39
+ meaning, so an un-interpretable flattened path is reported rather than
40
+ silently reconstructed. Use :func:`lvpath.parse_flat_path` to inspect its raw
41
+ type and components instead.
42
+ """
43
+ flat = parse_flat_path(data)
44
+ target = flavor(resolve(platform))
45
+ if flat.is_relative:
46
+ return target(*flat.components) if flat.components else target(".")
47
+ if flat.is_absolute:
48
+ return _decode_absolute(flat, target)
49
+ raise FlatPathError(f"path type {flat.path_type} has no documented PurePath meaning")
50
+
51
+
52
+ def _encode_absolute(pure: PurePath, target: type[PurePath]) -> FlatPath:
53
+ if target is PureWindowsPath:
54
+ drive = pure.drive
55
+ if drive.startswith("\\\\") or not drive:
56
+ raise FlatPathError(f"UNC and driveless absolute paths are not supported: {pure}")
57
+ components = (drive.rstrip(":"), *pure.parts[1:])
58
+ else:
59
+ components = pure.parts[1:]
60
+ return FlatPath(path_type=int(FlatPathType.ABSOLUTE), components=components)
61
+
62
+
63
+ def _decode_absolute(flat: FlatPath, target: type[PurePath]) -> PurePath:
64
+ if target is PureWindowsPath:
65
+ if not flat.components:
66
+ raise FlatPathError("absolute Windows flattened path has no drive component")
67
+ drive, *rest = flat.components
68
+ return PureWindowsPath(f"{drive}:\\", *rest)
69
+ return PurePosixPath("/", *flat.components)
lvpath/_flatten.py ADDED
@@ -0,0 +1,139 @@
1
+ """The PTH0 binary flattened form of a LabVIEW path.
2
+
3
+ LabVIEW's ``Flatten To String`` renders a Path value as a small big-endian
4
+ container: the ASCII signature ``PTH0``, a u32 total length, a u16 path type, a
5
+ u16 component count and then one length-prefixed (Pascal) string per component.
6
+ The absolute path documented by NI as ``C:\\File`` flattens to::
7
+
8
+ 50 54 48 30 00 00 00 0B 00 00 00 02 01 43 04 66 69 6C 65
9
+
10
+ This module is a faithful codec for that container. It keeps the numeric path
11
+ type as read so any flattened path round-trips byte for byte, and it never
12
+ invents a meaning for a type code the NI documentation does not state.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import struct
18
+ from collections.abc import Iterable
19
+ from dataclasses import dataclass
20
+ from enum import IntEnum
21
+
22
+ SIGNATURE: bytes = b"PTH0"
23
+ """The four ASCII bytes that open every flattened LabVIEW path."""
24
+
25
+ _HEADER = struct.Struct(">4sIHH") # signature, total length, path type, count
26
+
27
+
28
+ class FlatPathType(IntEnum):
29
+ """The path type codes documented and verifiable from the NI example.
30
+
31
+ Only these two are stated by the public documentation. UNC and the empty /
32
+ not-a-path forms carry their own codes that the documentation does not give,
33
+ so they are deliberately absent here rather than guessed.
34
+ """
35
+
36
+ ABSOLUTE = 0
37
+ RELATIVE = 1
38
+
39
+
40
+ class FlatPathError(ValueError):
41
+ """Raised when flattened path bytes are malformed or cannot be interpreted.
42
+
43
+ The message names the problem: a wrong signature, a truncated stream, a
44
+ declared length that disagrees with the body, a component that overruns the
45
+ buffer, or a path type with no documented meaning.
46
+ """
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class FlatPath:
51
+ """A parsed PTH0 flattened path: the raw path type and its components.
52
+
53
+ ``path_type`` is kept as the integer read from the stream, so a flattened
54
+ path with an un-interpreted type code still round-trips through
55
+ :meth:`to_bytes` byte for byte. ``components`` are the decoded Pascal-string
56
+ parts in order.
57
+ """
58
+
59
+ path_type: int
60
+ components: tuple[str, ...]
61
+
62
+ def __post_init__(self) -> None:
63
+ if not 0 <= self.path_type <= 0xFFFF:
64
+ raise FlatPathError(f"path type out of u16 range: {self.path_type}")
65
+ if len(self.components) > 0xFFFF:
66
+ raise FlatPathError(f"too many components to flatten: {len(self.components)}")
67
+
68
+ @property
69
+ def is_absolute(self) -> bool:
70
+ """Whether the path type is the documented absolute code (``0``)."""
71
+ return self.path_type == FlatPathType.ABSOLUTE
72
+
73
+ @property
74
+ def is_relative(self) -> bool:
75
+ """Whether the path type is the documented relative code (``1``)."""
76
+ return self.path_type == FlatPathType.RELATIVE
77
+
78
+ def to_bytes(self) -> bytes:
79
+ """Serialize this path to its PTH0 flattened byte string."""
80
+ body = bytearray(struct.pack(">HH", self.path_type, len(self.components)))
81
+ for component in self.components:
82
+ encoded = _encode_component(component)
83
+ body.append(len(encoded))
84
+ body += encoded
85
+ return struct.pack(">4sI", SIGNATURE, len(body)) + bytes(body)
86
+
87
+ @classmethod
88
+ def from_bytes(cls, data: bytes) -> FlatPath:
89
+ """Parse a PTH0 flattened byte string into a :class:`FlatPath`."""
90
+ if len(data) < _HEADER.size:
91
+ raise FlatPathError(
92
+ f"flattened path is too short: {len(data)} bytes, need at least {_HEADER.size}"
93
+ )
94
+ signature, total, path_type, count = _HEADER.unpack_from(data)
95
+ if signature != SIGNATURE:
96
+ raise FlatPathError(f"bad signature: expected {SIGNATURE!r}, got {signature!r}")
97
+ body = data[8:]
98
+ if len(body) != total:
99
+ raise FlatPathError(f"declared length {total} does not match body of {len(body)} bytes")
100
+ components, offset = _read_components(body, count)
101
+ if offset != len(body):
102
+ raise FlatPathError(f"{len(body) - offset} trailing byte(s) after {count} component(s)")
103
+ return cls(path_type=path_type, components=tuple(components))
104
+
105
+
106
+ def build_flat_path(path_type: int, components: Iterable[str]) -> bytes:
107
+ """Build PTH0 bytes from a numeric path type and its components."""
108
+ return FlatPath(path_type=path_type, components=tuple(components)).to_bytes()
109
+
110
+
111
+ def parse_flat_path(data: bytes) -> FlatPath:
112
+ """Parse PTH0 bytes into a typed :class:`FlatPath` result."""
113
+ return FlatPath.from_bytes(data)
114
+
115
+
116
+ def _encode_component(component: str) -> bytes:
117
+ try:
118
+ encoded = component.encode("ascii")
119
+ except UnicodeEncodeError as error:
120
+ raise FlatPathError(f"component is not ASCII: {component!r}") from error
121
+ if len(encoded) > 0xFF:
122
+ raise FlatPathError(f"component too long to flatten: {len(encoded)} bytes")
123
+ return encoded
124
+
125
+
126
+ def _read_components(body: bytes, count: int) -> tuple[list[str], int]:
127
+ components: list[str] = []
128
+ offset = 4 # past the path type and count already read from the header
129
+ for index in range(count):
130
+ if offset >= len(body):
131
+ raise FlatPathError(f"stream ends before component {index} length byte")
132
+ length = body[offset]
133
+ offset += 1
134
+ end = offset + length
135
+ if end > len(body):
136
+ raise FlatPathError(f"component {index} of {length} bytes overruns the buffer")
137
+ components.append(body[offset:end].decode("ascii"))
138
+ offset = end
139
+ return components, offset
lvpath/_kinds.py ADDED
@@ -0,0 +1,21 @@
1
+ """The three LabVIEW path kinds and the not-a-path error."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+
7
+
8
+ class PathKind(Enum):
9
+ """Whether a LabVIEW path is absolute, relative or the not-a-path value."""
10
+
11
+ ABSOLUTE = "absolute"
12
+ RELATIVE = "relative"
13
+ NOT_A_PATH = "not_a_path"
14
+
15
+
16
+ class NotAPathError(ValueError):
17
+ """Raised when a LabVIEW not-a-path value is converted to an OS path.
18
+
19
+ LabVIEW's ``<Not A Path>`` has no operating-system equivalent, so there is
20
+ no ``PurePath`` to return. Check :func:`lvpath.path_kind` first to avoid it.
21
+ """
lvpath/_platform.py ADDED
@@ -0,0 +1,36 @@
1
+ """Target platform selection and the matching pure-path flavor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from enum import Enum
7
+ from pathlib import PurePath, PurePosixPath, PureWindowsPath
8
+
9
+
10
+ class Platform(Enum):
11
+ """A LabVIEW host platform whose path convention a string follows."""
12
+
13
+ WINDOWS = "windows"
14
+ POSIX = "posix"
15
+
16
+
17
+ def current_platform() -> Platform:
18
+ """The platform convention of the operating system running this code."""
19
+ return Platform.WINDOWS if os.name == "nt" else Platform.POSIX
20
+
21
+
22
+ def resolve(platform: Platform | str | None) -> Platform:
23
+ """Normalise the ``platform`` argument, defaulting to the current OS."""
24
+ if platform is None:
25
+ return current_platform()
26
+ if isinstance(platform, Platform):
27
+ return platform
28
+ try:
29
+ return Platform(platform.lower())
30
+ except ValueError:
31
+ raise ValueError(f"unknown platform: {platform!r}") from None
32
+
33
+
34
+ def flavor(platform: Platform) -> type[PurePath]:
35
+ """The ``PurePath`` subclass that parses and renders ``platform`` paths."""
36
+ return PureWindowsPath if platform is Platform.WINDOWS else PurePosixPath
lvpath/_portable.py ADDED
@@ -0,0 +1,139 @@
1
+ """LabVIEW's portable path string form and native ``pathlib`` paths.
2
+
3
+ When LabVIEW writes a path value into a configuration file or a project file it
4
+ uses a platform-independent text form instead of the host's native one:
5
+
6
+ - a Windows drive becomes a leading single-letter component, so ``C:\\DIR1\\DIR2``
7
+ is stored as ``/C/DIR1/DIR2`` and a bare drive ``C:\\`` as ``/C``;
8
+ - a UNC share keeps a leading double slash, ``//server/share/dir``;
9
+ - every separator is a forward slash, so relative paths read ``../builds/app``;
10
+ - a POSIX absolute path (Linux, macOS) is already in this form;
11
+ - the empty string is ``<Not A Path>``.
12
+
13
+ This module converts that form to and from ``PureWindowsPath`` and
14
+ ``PurePosixPath``. Values with no faithful counterpart on the target platform
15
+ (a POSIX absolute path read on Windows, a drive-relative ``C:file``, a rooted
16
+ path with no drive, a ``\\\\?\\`` device path) raise :class:`PortablePathError`
17
+ instead of being turned into a different path.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ from pathlib import PurePath, PurePosixPath, PureWindowsPath
24
+
25
+ from ._kinds import NotAPathError
26
+ from ._platform import Platform, flavor, resolve
27
+
28
+ _PORTABLE_DRIVE = re.compile(r"^/([A-Za-z])(?:/(.*))?$", re.DOTALL)
29
+ _MALFORMED_DRIVE = re.compile(r"^/[A-Za-z][:\\]")
30
+ _DEVICE_PREFIXES = ("\\\\?\\", "\\\\.\\")
31
+
32
+
33
+ class PortablePathError(ValueError):
34
+ """Raised when a path has no faithful equivalent in the requested form.
35
+
36
+ The message names the value and why it cannot be converted, for example a
37
+ POSIX absolute path read as a Windows path or a drive-relative ``C:file``.
38
+ """
39
+
40
+
41
+ def from_portable_path(text: str, *, platform: Platform | str | None = None) -> PurePath:
42
+ """Parse LabVIEW's portable path string into a ``PurePath`` for ``platform``.
43
+
44
+ On Windows ``/C/DIR1/DIR2`` becomes ``PureWindowsPath('C:/DIR1/DIR2')``,
45
+ ``//server/share/dir`` becomes a UNC path and ``../a/b`` a relative path.
46
+ A value already written in native Windows form (``C:\\DIR1``) is accepted
47
+ as is. On POSIX the portable form is already native and is parsed directly.
48
+
49
+ Raises :class:`NotAPathError` for the empty ``<Not A Path>`` value and
50
+ :class:`PortablePathError` for a value with no Windows equivalent.
51
+ """
52
+ if text == "":
53
+ raise NotAPathError("LabVIEW not-a-path has no OS path")
54
+ if resolve(platform) is Platform.POSIX:
55
+ return PurePosixPath(text)
56
+ match = _PORTABLE_DRIVE.fullmatch(text)
57
+ if match:
58
+ drive, rest = match.groups()
59
+ return PureWindowsPath(f"{drive}:\\{rest or ''}")
60
+ if _MALFORMED_DRIVE.match(text):
61
+ raise PortablePathError(f"{text!r} has a malformed drive component, expected /C/...")
62
+ if text.startswith("/") and not text.startswith(("//", "/\\")):
63
+ raise PortablePathError(f"{text!r} is a POSIX absolute path with no Windows equivalent")
64
+ pure = PureWindowsPath(text)
65
+ _check_windows(pure, text)
66
+ return pure
67
+
68
+
69
+ def to_portable_path(
70
+ os_path: PurePath | str | None, *, platform: Platform | str | None = None
71
+ ) -> str:
72
+ """Serialize a native path into LabVIEW's portable path string.
73
+
74
+ ``C:\\DIR1\\DIR2`` becomes ``/C/DIR1/DIR2``, ``\\\\server\\share\\dir``
75
+ becomes ``//server/share/dir`` and a relative path joins its parts with
76
+ forward slashes. ``None`` or ``""`` becomes the ``<Not A Path>`` value ``""``;
77
+ an empty ``PurePath`` is pathlib's current directory and becomes ``"."``.
78
+
79
+ A ``str`` is parsed with the flavor of ``platform`` (default: the running
80
+ operating system). A ``PurePath`` keeps its own flavor; when ``platform``
81
+ names the other flavor, only a relative path may be carried across.
82
+
83
+ Raises :class:`PortablePathError` for a Windows path with no portable form.
84
+ """
85
+ if os_path is None or os_path == "":
86
+ return ""
87
+ original = os_path if isinstance(os_path, str) else str(os_path)
88
+ pure = _as_pure(os_path, platform)
89
+ if isinstance(pure, PureWindowsPath):
90
+ return _windows_to_portable(pure, original)
91
+ return str(pure)
92
+
93
+
94
+ def _as_pure(os_path: PurePath | str, platform: Platform | str | None) -> PurePath:
95
+ if isinstance(os_path, str):
96
+ return flavor(resolve(platform))(os_path)
97
+ if platform is None:
98
+ return os_path
99
+ target = flavor(resolve(platform))
100
+ if isinstance(os_path, target):
101
+ return os_path
102
+ if os_path.anchor:
103
+ raise PortablePathError(
104
+ f"cannot carry the anchored path {str(os_path)!r} to the {target.__name__} flavor"
105
+ )
106
+ if target is PureWindowsPath and any("\\" in part for part in os_path.parts):
107
+ raise PortablePathError(
108
+ f"a component of {str(os_path)!r} contains a backslash, a Windows separator"
109
+ )
110
+ return target(*os_path.parts)
111
+
112
+
113
+ def _windows_to_portable(pure: PureWindowsPath, original: str) -> str:
114
+ _check_windows(pure, original)
115
+ if not pure.drive:
116
+ return "/".join(pure.parts) or "."
117
+ rest = "/".join(pure.parts[1:])
118
+ if pure.drive.startswith("\\\\"):
119
+ head = "//" + pure.drive[2:].replace("\\", "/")
120
+ else:
121
+ head = "/" + pure.drive[0]
122
+ return f"{head}/{rest}" if rest else head
123
+
124
+
125
+ def _check_windows(pure: PureWindowsPath, original: str) -> None:
126
+ # The UNC and device forms are judged on the text itself: pathlib parses an
127
+ # incomplete UNC prefix differently across Python versions.
128
+ text = original.replace("/", "\\")
129
+ if text.startswith(_DEVICE_PREFIXES):
130
+ raise PortablePathError(f"{original!r} is a device path with no portable form")
131
+ if text.startswith("\\\\"):
132
+ server, _, rest = text[2:].partition("\\")
133
+ if not server or not rest.partition("\\")[0] or not pure.drive.startswith("\\\\"):
134
+ raise PortablePathError(f"{original!r} is not a //server/share UNC path")
135
+ return
136
+ if pure.drive and not pure.root:
137
+ raise PortablePathError(f"{original!r} is drive-relative and has no portable form")
138
+ if pure.root and not pure.drive:
139
+ raise PortablePathError(f"{original!r} is rooted without a drive or UNC share")
lvpath/py.typed ADDED
File without changes
@@ -0,0 +1,271 @@
1
+ Metadata-Version: 2.5
2
+ Name: lvpath
3
+ Version: 0.3.1
4
+ Summary: Convert LabVIEW path strings, the portable /C/DIR form of config and project files, and the flattened PTH0 form to and from native paths.
5
+ Project-URL: Homepage, https://lianes8studio.com
6
+ Project-URL: Repository, https://github.com/JaviLianes8/lvpath
7
+ Project-URL: Issues, https://github.com/JaviLianes8/lvpath/issues
8
+ Project-URL: Changelog, https://github.com/JaviLianes8/lvpath/blob/main/CHANGELOG.md
9
+ Project-URL: LinkedIn, https://www.linkedin.com/in/jlianes/
10
+ Project-URL: Funding, https://buymeacoffee.com/jlianesglrs
11
+ Author-email: Javier Lianes <javier@lianes8studio.com>
12
+ License-Expression: MIT
13
+ License-File: LICENSE
14
+ Keywords: flatten,ini,labview,lvproj,ni,path,pathlib
15
+ Classifier: Development Status :: 4 - Beta
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.10
20
+ Provides-Extra: dev
21
+ Requires-Dist: build; extra == 'dev'
22
+ Requires-Dist: mypy; extra == 'dev'
23
+ Requires-Dist: pytest; extra == 'dev'
24
+ Requires-Dist: ruff; extra == 'dev'
25
+ Requires-Dist: twine; extra == 'dev'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # lvpath
29
+
30
+ Convert between LabVIEW path strings and native OS paths: the host text form, the
31
+ portable form LabVIEW writes into config and project files (`/C/DIR1/DIR2`), and the
32
+ flattened `PTH0` binary form, with absolute, relative and not-a-path detection.
33
+
34
+ ## Install
35
+
36
+ ```
37
+ pip install lvpath
38
+ ```
39
+
40
+ ## Usage
41
+
42
+ ```python
43
+ from lvpath import to_os_path, to_lv_path, path_kind, PathKind, NotAPathError
44
+
45
+ # Parse a LabVIEW path string into a PurePath of the chosen platform flavor.
46
+ to_os_path("C:\\Users\\test\\data.txt", platform="windows")
47
+ # PureWindowsPath('C:/Users/test/data.txt')
48
+
49
+ to_os_path("/usr/local/bin", platform="posix")
50
+ # PurePosixPath('/usr/local/bin')
51
+
52
+ # Serialize an OS path back into a LabVIEW path string.
53
+ to_lv_path("logs/run.txt", platform="windows")
54
+ # 'logs\\run.txt'
55
+
56
+ # A relative path object crosses platforms cleanly.
57
+ win = to_os_path("reports\\q1\\data.txt", platform="windows")
58
+ to_lv_path(win, platform="posix")
59
+ # 'reports/q1/data.txt'
60
+
61
+ # Detect the kind. The empty string is LabVIEW's <Not A Path> on every platform.
62
+ path_kind("", platform="posix") # PathKind.NOT_A_PATH
63
+ path_kind("sub\\file", platform="windows") # PathKind.RELATIVE
64
+ path_kind("C:\\x", platform="windows") # PathKind.ABSOLUTE
65
+
66
+ # Not-a-path has no OS equivalent, so to_os_path refuses it.
67
+ try:
68
+ to_os_path("", platform="posix")
69
+ except NotAPathError:
70
+ ...
71
+
72
+ # None (or "") serializes back to the not-a-path value.
73
+ to_lv_path(None, platform="windows") # ''
74
+ ```
75
+
76
+ `platform` accepts `"windows"`, `"posix"`, a `Platform` member, or `None` to use
77
+ the convention of the operating system running the code.
78
+
79
+ ## Portable form (config and project files)
80
+
81
+ When LabVIEW writes a path value into a configuration (`.ini`) file or a project
82
+ (`.lvproj`) file, it does not use the native Windows text. It uses a
83
+ platform-independent form: the drive letter becomes a leading component, every
84
+ separator is a forward slash, a UNC share keeps a leading double slash and the empty
85
+ string is `<Not A Path>`. `C:\DIR1\DIR2` is stored as `/C/DIR1/DIR2`.
86
+
87
+ ```python
88
+ from lvpath import from_portable_path, to_portable_path, PortablePathError
89
+
90
+ # Read a stored value into a native path.
91
+ from_portable_path("/C/Program Files (x86)/Arduino", platform="windows")
92
+ # PureWindowsPath('C:/Program Files (x86)/Arduino')
93
+ from_portable_path("//server/share/dir", platform="windows")
94
+ # PureWindowsPath('//server/share/dir')
95
+ from_portable_path("../builds/Main Application", platform="windows")
96
+ # PureWindowsPath('../builds/Main Application')
97
+ from_portable_path("/home/lvuser/natinst/bin", platform="posix")
98
+ # PurePosixPath('/home/lvuser/natinst/bin')
99
+
100
+ # Write a native path back in the stored form.
101
+ to_portable_path("C:\\DIR1\\DIR2", platform="windows") # '/C/DIR1/DIR2'
102
+ to_portable_path("D:\\", platform="windows") # '/D'
103
+ to_portable_path("\\\\server\\share\\dir", platform="windows") # '//server/share/dir'
104
+ to_portable_path(None) # '' (<Not A Path>)
105
+
106
+ # Values with no faithful counterpart are refused, never rewritten into another path.
107
+ try:
108
+ from_portable_path("/home/lvuser", platform="windows")
109
+ except PortablePathError:
110
+ ...
111
+ ```
112
+
113
+ The rules below are checked against path values in files shipped with LabVIEW 2026
114
+ (`tests/fixtures/labview2026_paths.json` records each value with the file and setting it
115
+ was found in; the NI files themselves are not copied).
116
+ No shipped file holds a bare drive or a UNC value, so the `/D` and `//server/share/dir`
117
+ rows are covered by synthetic tests that follow the same convention:
118
+
119
+ | Stored value | Windows | POSIX |
120
+ | -------------------------------- | ------------------------------ | -------------------------------- |
121
+ | `/C/Program Files (x86)/Arduino` | `C:\Program Files (x86)\Arduino` | `/C/Program Files (x86)/Arduino` |
122
+ | `/c/ni-rt/startup` | `c:\ni-rt\startup` | `/c/ni-rt/startup` |
123
+ | `/D` | `D:\` | `/D` |
124
+ | `//server/share/dir` | `\\server\share\dir` | `//server/share/dir` |
125
+ | `../builds/Main Application` | `..\builds\Main Application` | `../builds/Main Application` |
126
+ | `/home/lvuser/natinst/bin` | `PortablePathError` | `/home/lvuser/natinst/bin` |
127
+ | empty | `NotAPathError` | `NotAPathError` |
128
+
129
+ - The drive letter keeps its case, so a value in normal form (no trailing slash,
130
+ repeated separator or `.` component, as LabVIEW writes it) round-trips exactly.
131
+ - A value already in native Windows form (`C:\NI Installers`), which some NI files
132
+ store, is accepted as is when reading on Windows.
133
+ - On POSIX the stored form is already native and is parsed unchanged. A one-letter
134
+ first directory is therefore ambiguous: `/C/x` reads as the drive `C:` on Windows
135
+ and as the directory `/C/x` on POSIX. Pick `platform` for the system that will
136
+ use the path.
137
+ - `PortablePathError` is raised for a POSIX absolute path read as Windows, a
138
+ drive-relative `C:file`, a rooted Windows path without a drive (`\dir`), a UNC
139
+ path without a share and a `\\?\` device path.
140
+ - A `str` is parsed with the flavor of `platform`; a `PurePath` keeps its own
141
+ flavor, and only a relative one may be carried to the other flavor.
142
+ - Text is normalised by `pathlib`: repeated separators collapse, a trailing slash and
143
+ `.` components are dropped, so `/C/x/` writes back as `/C/x`. A backslash inside a
144
+ value read on Windows is a separator (`/C/x\y` reads as `C:\x\y`).
145
+ - The sibling `lvconfig` library (0.3.0 and later) gives the same results on drive,
146
+ UNC and relative values in normal form, and refuses the same values lvpath refuses
147
+ (`C:file`, `D:`, `\dir`, `//server`, a POSIX absolute path on Windows). The one
148
+ difference left: lvpath normalises text through `pathlib`, where lvconfig only
149
+ swaps separators.
150
+
151
+ ## Flattened form
152
+
153
+ `Flatten To String` renders a LabVIEW Path as a small big-endian binary
154
+ container: the ASCII signature `PTH0`, a `u32` total length, a `u16` path type, a
155
+ `u16` component count and then one length-prefixed (Pascal) string per component.
156
+ NI documents the absolute path `C:\File` flattening to the bytes
157
+ `50 54 48 30 00 00 00 0B 00 00 00 02 01 43 04 66 69 6C 65`.
158
+
159
+ ```python
160
+ from lvpath import FlatPath, parse_flat_path, from_flat_path, to_flat_path
161
+
162
+ golden = bytes.fromhex("505448300000000b0000000201430466696c65")
163
+
164
+ # Parse the flattened bytes into a typed result.
165
+ parse_flat_path(golden)
166
+ # FlatPath(path_type=0, components=('C', 'file'))
167
+
168
+ # Round-trips byte for byte.
169
+ parse_flat_path(golden).to_bytes() == golden # True
170
+
171
+ # Bridge to and from pathlib on a chosen platform flavor.
172
+ from_flat_path(golden, platform="windows") # PureWindowsPath('C:/file')
173
+ to_flat_path("C:/file", platform="windows") # the golden bytes
174
+ to_flat_path("/usr/local/bin", platform="posix") # a POSIX absolute flattened path
175
+ ```
176
+
177
+ Only the two documented path type codes are interpreted: `0` absolute and `1`
178
+ relative, exposed as `FlatPathType`. UNC and empty / not-a-path forms carry type
179
+ codes the public NI documentation does not state, so they are not guessed: the
180
+ low-level codec still round-trips their raw bytes faithfully through `FlatPath`,
181
+ while `to_flat_path` and `from_flat_path` refuse an un-interpretable type code
182
+ with a clear `FlatPathError` instead of inventing a meaning.
183
+
184
+ ## Command line
185
+
186
+ Installing the package provides an `lvpath` command for the portable string form and
187
+ the flattened form.
188
+
189
+ ```
190
+ # A stored LabVIEW path string to a native path, and back.
191
+ lvpath to-native --platform windows "/C/Program Files (x86)/Arduino"
192
+ # C:\Program Files (x86)\Arduino
193
+ lvpath to-labview --platform windows "C:\DIR1\DIR2"
194
+ # /C/DIR1/DIR2
195
+
196
+ # Decode the NI C:\File flattened example into a path.
197
+ lvpath decode --platform windows 505448300000000b0000000201430466696c65
198
+ # C:\file
199
+
200
+ # Encode a path into its flattened hex form.
201
+ lvpath encode --platform windows "C:\file"
202
+ # 505448300000000b0000000201430466696c65
203
+
204
+ lvpath --version
205
+ ```
206
+
207
+ `lvpath decode` accepts hex with or without spaces. `lvpath to-labview ""` prints an
208
+ empty line, the `<Not A Path>` value, while `lvpath to-native ""` is an error. Errors
209
+ are written to standard error and the command exits with status 1. Output is UTF-8.
210
+ A value that starts with `-` goes after `--`. Under Git Bash on Windows, set
211
+ `MSYS_NO_PATHCONV=1` so the shell passes POSIX-looking arguments such as `/C/x`
212
+ unchanged. Run `lvpath --help` for the full usage.
213
+
214
+ ## Covered conventions
215
+
216
+ LabVIEW's `Path To String` and `String To Path` render and parse a path in the
217
+ standard textual form of a host platform. `to_os_path`, `to_lv_path` and `path_kind`
218
+ model that native textual form (so `to_os_path("/C/foo", platform="windows")` is the
219
+ rooted Windows path `\C\foo`; read stored values with `from_portable_path` instead):
220
+
221
+ - **Separators:** `\` on Windows, `/` on POSIX. Forward slashes are also accepted
222
+ when parsing a Windows path, matching LabVIEW's lenient reader.
223
+ - **Absolute, Windows:** drive-letter paths (`C:\dir\file`) and UNC paths
224
+ (`\\server\share\dir`).
225
+ - **Absolute, POSIX:** a leading `/` (modern LabVIEW on macOS and Linux).
226
+ - **Relative:** any other non-empty string, including up-level (`..\up`) and
227
+ drive-relative (`C:file`) forms.
228
+ - **Not-a-path:** the empty string, LabVIEW's `<Not A Path>` value, on every
229
+ platform. `None` serializes back to it.
230
+
231
+ ### What it does not do
232
+
233
+ - It does not touch the filesystem, resolve symbolic links, or normalise `.`/`..`
234
+ beyond what `pathlib` does.
235
+ - It does not handle classic Mac OS colon-separated paths (pre-OS X). Modern
236
+ LabVIEW on macOS uses POSIX `/`.
237
+ - The flattened codec interprets only the documented absolute and relative path
238
+ type codes. It does not guess the undocumented UNC or not-a-path type codes, and
239
+ it does not touch the textual-form behaviour above.
240
+ - It does not expand LabVIEW symbolic path prefixes such as `<vilib>` or
241
+ `<instrlib>`, and it does not resolve a relative stored value against the file
242
+ it came from.
243
+
244
+ ## Why
245
+
246
+ LabVIEW uses two text forms for a path: the native form of its host platform for
247
+ `Path To String` / `String To Path`, and a platform-independent form (`/C/DIR1/DIR2`
248
+ for `C:\DIR1\DIR2`) when it writes a path into configuration and project files. Read
249
+ with plain string handling, the stored form names the wrong file on Windows, and a
250
+ native string only means something together with the platform convention it was
251
+ written in, so round-tripping LabVIEW path data in Python needs explicit,
252
+ platform-aware handling.
253
+
254
+ The standard library already does most of this. `pathlib`'s `PureWindowsPath` and
255
+ `PurePosixPath` are built to manipulate foreign-platform paths with no I/O, so they
256
+ cover the bulk of the separator and anchor handling on their own, and this library is
257
+ a thin wrapper over them rather than a reimplementation. What they do not know is the
258
+ genuinely LabVIEW-specific residue: the stored drive and UNC form (`/C/foo` to
259
+ `C:\foo` and back, through `from_portable_path` and `to_portable_path`), the empty
260
+ string standing for `<Not A Path>`, and LabVIEW's exact serialization rules. No PyPI package targets those conventions. `lvpath` adds exactly
261
+ that layer on top of `pathlib`, with a small, typed, zero-dependency surface.
262
+
263
+ ## License
264
+
265
+ MIT. See [LICENSE](LICENSE).
266
+
267
+ ## Links
268
+
269
+ - Website: https://lianes8studio.com
270
+ - GitHub: https://github.com/JaviLianes8
271
+ - LinkedIn: https://www.linkedin.com/in/jlianes/
@@ -0,0 +1,14 @@
1
+ lvpath/__init__.py,sha256=vu-gM9D1hAdkm8ne5gN7766ZQub-LSceU63gJw3Mg58,1391
2
+ lvpath/_cli.py,sha256=dXCnsBD6YCIEbX7qBOExAFSUzjoCs2KUl5bBV6hbqms,4952
3
+ lvpath/_convert.py,sha256=DyxgMwQYMIoxrq_oSTB9HWM8Amcbjm076WO_cbAolmw,1946
4
+ lvpath/_flatbridge.py,sha256=gDnFhyE0fcSaDPYTyLGraGUU94PaNRkt5YppQ2OqJ7o,3155
5
+ lvpath/_flatten.py,sha256=t87NqCOhkVLbdyIUWL5JX2g-M4dbyVrwjJzPa_0PsSM,5496
6
+ lvpath/_kinds.py,sha256=-EUYDTFl8K2bjouBGMwFF6QGK91J3-sqmnaB2VX15V4,585
7
+ lvpath/_platform.py,sha256=QudH7SeeHYMVVNf35wEfo6F9Ha0FxBcRt6rLfqA9jdE,1135
8
+ lvpath/_portable.py,sha256=hHeYICGEXnhO0nBQ_KOYzW82VrGGqOYf9HT2U3L5a9s,6074
9
+ lvpath/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ lvpath-0.3.1.dist-info/METADATA,sha256=L6EcoRRWnV7HQjrh-UrVFDFUv85NiQ-R8W7KfkzL0og,12678
11
+ lvpath-0.3.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
12
+ lvpath-0.3.1.dist-info/entry_points.txt,sha256=G1ZaJMn30SluLurjE2tZROWJ1tK4TcDtss1SGDfQkKA,44
13
+ lvpath-0.3.1.dist-info/licenses/LICENSE,sha256=wirD5nCA2aQa5UusK_1YHLBogI3CGcw23xAnulp3L_U,1091
14
+ lvpath-0.3.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ lvpath = lvpath._cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Javier Lianes
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.