vcti-path 1.0.3__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.
@@ -0,0 +1,8 @@
1
+ Copyright (c) 2018-2026 Visual Collaboration Technologies Inc.
2
+ All Rights Reserved.
3
+
4
+ This software is proprietary and confidential. Unauthorized copying,
5
+ distribution, or use of this software, via any medium, is strictly
6
+ prohibited. Access is granted only to authorized VCollab developers
7
+ and individuals explicitly authorized by Visual Collaboration
8
+ Technologies Inc.
@@ -0,0 +1,131 @@
1
+ Metadata-Version: 2.4
2
+ Name: vcti-path
3
+ Version: 1.0.3
4
+ Summary: Safe path handling for VCollab applications
5
+ Author: Visual Collaboration Technologies Inc.
6
+ Requires-Python: <3.15,>=3.12
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Provides-Extra: test
10
+ Requires-Dist: pytest; extra == "test"
11
+ Requires-Dist: pytest-cov; extra == "test"
12
+ Provides-Extra: lint
13
+ Requires-Dist: ruff; extra == "lint"
14
+ Dynamic: license-file
15
+
16
+ # Path Utilities
17
+
18
+ ## Purpose
19
+
20
+ Safe path handling for VCollab applications -- filename validation, file
21
+ identity (POSIX-style relative paths as stable IDs), and path resolution
22
+ with access validation.
23
+
24
+ This package has **zero external dependencies**.
25
+
26
+ ---
27
+
28
+ ## Installation
29
+
30
+ ```bash
31
+ pip install vcti-path
32
+ ```
33
+
34
+ ### In `requirements.txt`
35
+
36
+ ```
37
+ vcti-path>=1.0.3
38
+ ```
39
+
40
+ ### In `pyproject.toml` dependencies
41
+
42
+ ```toml
43
+ dependencies = [
44
+ "vcti-path>=1.0.3",
45
+ ]
46
+ ```
47
+
48
+ ---
49
+
50
+ ## Quick Start
51
+
52
+ ### Validate filenames
53
+
54
+ ```python
55
+ from vcti.path import FileNameValidator
56
+
57
+ FileNameValidator.is_valid("report.pdf") # True
58
+ FileNameValidator.is_valid("bad|name") # False
59
+ FileNameValidator.is_valid("..") # False
60
+
61
+ FileNameValidator.validate("data.json") # OK
62
+ FileNameValidator.validate("bad/name") # Raises ValueError
63
+ ```
64
+
65
+ ### File IDs -- stable POSIX-style identifiers
66
+
67
+ A file ID is a POSIX-format relative path that uniquely identifies a
68
+ file or directory within a base directory. No leading `/`, `./`, or `../`.
69
+
70
+ ```python
71
+ from pathlib import Path
72
+ from vcti.path import FileId
73
+
74
+ # Validate
75
+ FileId.is_valid("scripts/setup.sh") # True
76
+ FileId.is_valid("/absolute/path") # False
77
+ FileId.is_valid("../escape") # False
78
+
79
+ # Resolve to filesystem path
80
+ base = Path("/project")
81
+ FileId.resolve_path("src/main.py", base)
82
+ # -> Path("/project/src/main.py")
83
+
84
+ # Extract file ID from a path
85
+ src = Path("/project/src/main.py")
86
+ FileId.get_file_id(src, base)
87
+ # -> "src/main.py"
88
+ ```
89
+
90
+ ### Path resolution and access validation
91
+
92
+ ```python
93
+ from vcti.path import abs_path, validate_file_access, resolve_path
94
+
95
+ # Absolute path resolution
96
+ abs_path("relative/file.txt")
97
+ # -> Path("<cwd>/relative/file.txt")
98
+
99
+ # Validate that a file exists and is readable
100
+ validate_file_access("config.yaml")
101
+ # Raises FileNotFoundError, IsADirectoryError, or PermissionError
102
+
103
+ # Resolve relative to a base directory
104
+ resolve_path("data/input.csv", "/project")
105
+ # -> Path("/project/data/input.csv")
106
+ ```
107
+
108
+ ---
109
+
110
+ ## Public API
111
+
112
+ | Symbol | Type | Purpose |
113
+ |--------|------|---------|
114
+ | `FileNameValidator.is_valid(name)` | classmethod | Check if a filename is valid |
115
+ | `FileNameValidator.validate(name)` | classmethod | Validate filename, raise `ValueError` if invalid |
116
+ | `FileId.is_valid(file_id)` | staticmethod | Check if a POSIX file ID is valid |
117
+ | `FileId.validate(file_id)` | staticmethod | Validate file ID, raise `ValueError` if invalid |
118
+ | `FileId.resolve_path(file_id, base_dir)` | staticmethod | Resolve file ID to absolute path |
119
+ | `FileId.get_file_id(file_path, base_dir)` | staticmethod | Extract file ID from absolute path |
120
+ | `abs_path(fp)` | function | Convert to absolute resolved `Path` |
121
+ | `validate_file_access(fp)` | function | Validate file exists and is readable |
122
+ | `validate_folder_access(fp)` | function | Validate directory exists |
123
+ | `resolve_path(path, base_dir)` | function | Resolve path relative to base directory |
124
+
125
+ ---
126
+
127
+ ## Documentation
128
+
129
+ - [Design](docs/design.md) -- Concepts, architecture decisions, and rationale
130
+ - [Source Guide](docs/source-guide.md) -- File descriptions and execution flow traces
131
+ - [API Reference](docs/api.md) -- Autodoc for all modules
@@ -0,0 +1,116 @@
1
+ # Path Utilities
2
+
3
+ ## Purpose
4
+
5
+ Safe path handling for VCollab applications -- filename validation, file
6
+ identity (POSIX-style relative paths as stable IDs), and path resolution
7
+ with access validation.
8
+
9
+ This package has **zero external dependencies**.
10
+
11
+ ---
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ pip install vcti-path
17
+ ```
18
+
19
+ ### In `requirements.txt`
20
+
21
+ ```
22
+ vcti-path>=1.0.3
23
+ ```
24
+
25
+ ### In `pyproject.toml` dependencies
26
+
27
+ ```toml
28
+ dependencies = [
29
+ "vcti-path>=1.0.3",
30
+ ]
31
+ ```
32
+
33
+ ---
34
+
35
+ ## Quick Start
36
+
37
+ ### Validate filenames
38
+
39
+ ```python
40
+ from vcti.path import FileNameValidator
41
+
42
+ FileNameValidator.is_valid("report.pdf") # True
43
+ FileNameValidator.is_valid("bad|name") # False
44
+ FileNameValidator.is_valid("..") # False
45
+
46
+ FileNameValidator.validate("data.json") # OK
47
+ FileNameValidator.validate("bad/name") # Raises ValueError
48
+ ```
49
+
50
+ ### File IDs -- stable POSIX-style identifiers
51
+
52
+ A file ID is a POSIX-format relative path that uniquely identifies a
53
+ file or directory within a base directory. No leading `/`, `./`, or `../`.
54
+
55
+ ```python
56
+ from pathlib import Path
57
+ from vcti.path import FileId
58
+
59
+ # Validate
60
+ FileId.is_valid("scripts/setup.sh") # True
61
+ FileId.is_valid("/absolute/path") # False
62
+ FileId.is_valid("../escape") # False
63
+
64
+ # Resolve to filesystem path
65
+ base = Path("/project")
66
+ FileId.resolve_path("src/main.py", base)
67
+ # -> Path("/project/src/main.py")
68
+
69
+ # Extract file ID from a path
70
+ src = Path("/project/src/main.py")
71
+ FileId.get_file_id(src, base)
72
+ # -> "src/main.py"
73
+ ```
74
+
75
+ ### Path resolution and access validation
76
+
77
+ ```python
78
+ from vcti.path import abs_path, validate_file_access, resolve_path
79
+
80
+ # Absolute path resolution
81
+ abs_path("relative/file.txt")
82
+ # -> Path("<cwd>/relative/file.txt")
83
+
84
+ # Validate that a file exists and is readable
85
+ validate_file_access("config.yaml")
86
+ # Raises FileNotFoundError, IsADirectoryError, or PermissionError
87
+
88
+ # Resolve relative to a base directory
89
+ resolve_path("data/input.csv", "/project")
90
+ # -> Path("/project/data/input.csv")
91
+ ```
92
+
93
+ ---
94
+
95
+ ## Public API
96
+
97
+ | Symbol | Type | Purpose |
98
+ |--------|------|---------|
99
+ | `FileNameValidator.is_valid(name)` | classmethod | Check if a filename is valid |
100
+ | `FileNameValidator.validate(name)` | classmethod | Validate filename, raise `ValueError` if invalid |
101
+ | `FileId.is_valid(file_id)` | staticmethod | Check if a POSIX file ID is valid |
102
+ | `FileId.validate(file_id)` | staticmethod | Validate file ID, raise `ValueError` if invalid |
103
+ | `FileId.resolve_path(file_id, base_dir)` | staticmethod | Resolve file ID to absolute path |
104
+ | `FileId.get_file_id(file_path, base_dir)` | staticmethod | Extract file ID from absolute path |
105
+ | `abs_path(fp)` | function | Convert to absolute resolved `Path` |
106
+ | `validate_file_access(fp)` | function | Validate file exists and is readable |
107
+ | `validate_folder_access(fp)` | function | Validate directory exists |
108
+ | `resolve_path(path, base_dir)` | function | Resolve path relative to base directory |
109
+
110
+ ---
111
+
112
+ ## Documentation
113
+
114
+ - [Design](docs/design.md) -- Concepts, architecture decisions, and rationale
115
+ - [Source Guide](docs/source-guide.md) -- File descriptions and execution flow traces
116
+ - [API Reference](docs/api.md) -- Autodoc for all modules
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "vcti-path"
7
+ version = "1.0.3"
8
+ description = "Safe path handling for VCollab applications"
9
+ readme = "README.md"
10
+ authors = [
11
+ {name = "Visual Collaboration Technologies Inc."}
12
+ ]
13
+ requires-python = ">=3.12,<3.15"
14
+ dependencies = []
15
+
16
+ [project.optional-dependencies]
17
+ test = ["pytest", "pytest-cov"]
18
+ lint = ["ruff"]
19
+
20
+ [tool.setuptools.packages.find]
21
+ where = ["src"]
22
+ include = ["vcti.path", "vcti.path.*"]
23
+
24
+ [tool.setuptools]
25
+ zip-safe = true
26
+
27
+ [tool.pytest.ini_options]
28
+ addopts = "--cov=vcti.path --cov-report=term-missing --cov-fail-under=95"
29
+
30
+ [tool.ruff]
31
+ target-version = "py312"
32
+ line-length = 99
33
+
34
+ [tool.ruff.lint]
35
+ select = ["E", "F", "W", "I", "UP"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,25 @@
1
+ # Copyright Visual Collaboration Technologies Inc. All Rights Reserved.
2
+ # See LICENSE for details.
3
+ """Safe path handling for VCollab applications.
4
+
5
+ Provides filename validation, file identity (POSIX-style relative paths
6
+ as stable IDs), and path resolution with access validation.
7
+ """
8
+
9
+ from importlib.metadata import version
10
+
11
+ from .file_id_utils import FileId
12
+ from .filename_validator import FileNameValidator
13
+ from .path_utils import abs_path, resolve_path, validate_file_access, validate_folder_access
14
+
15
+ __version__ = version("vcti-path")
16
+
17
+ __all__ = [
18
+ "__version__",
19
+ "FileNameValidator",
20
+ "FileId",
21
+ "abs_path",
22
+ "validate_file_access",
23
+ "validate_folder_access",
24
+ "resolve_path",
25
+ ]
@@ -0,0 +1,160 @@
1
+ # Copyright Visual Collaboration Technologies Inc. All Rights Reserved.
2
+ # See LICENSE for details.
3
+ """Utilities for validating and resolving file identifiers relative to a base directory."""
4
+
5
+ from pathlib import Path
6
+
7
+ from .filename_validator import FileNameValidator
8
+
9
+
10
+ class FileId:
11
+ """
12
+ Utilities to work with file identifiers--POSIX-style relative paths
13
+ representing files or directories inside a base directory.
14
+
15
+ Rules for a valid file ID:
16
+ - Must not start with "/", "./", "../"
17
+ - Must use "/" as the path separator (POSIX format)
18
+ - Each part must be a valid file/folder name (see FileNameValidator)
19
+
20
+ Example:
21
+ file_id = "scripts/setup.sh" -> base_dir / "scripts/setup.sh"
22
+
23
+ This utility supports:
24
+ - Validating file IDs
25
+ - Resolving file IDs to full paths
26
+ - Extracting file IDs from full paths
27
+ """
28
+
29
+ @staticmethod
30
+ def is_valid(file_id: str) -> bool:
31
+ """
32
+ Check if a file ID is valid.
33
+
34
+ Args:
35
+ file_id (str): A relative POSIX-style file ID.
36
+
37
+ Returns:
38
+ bool: True if valid, False otherwise.
39
+ """
40
+ if not isinstance(file_id, str):
41
+ raise TypeError(f"file_id must be a str, got {type(file_id).__name__}")
42
+
43
+ if not file_id or file_id.strip() == "":
44
+ return False
45
+
46
+ if file_id.startswith(("/", "./", "../")):
47
+ return False
48
+
49
+ if file_id.endswith("/") and len(file_id) > 1:
50
+ file_id = file_id.rstrip("/")
51
+
52
+ parts = file_id.split("/")
53
+ return all(FileNameValidator.is_valid(part) for part in parts)
54
+
55
+ @staticmethod
56
+ def validate(file_id: str) -> None:
57
+ """
58
+ Validate a file ID and raise ValueError if invalid.
59
+
60
+ Args:
61
+ file_id (str): File ID to validate.
62
+
63
+ Raises:
64
+ TypeError: If file_id is not a str.
65
+ ValueError: If the file ID is invalid.
66
+ """
67
+ if not FileId.is_valid(file_id):
68
+ raise ValueError(f"Invalid file ID: '{file_id}'")
69
+
70
+ @staticmethod
71
+ def resolve_path(
72
+ file_id: str | None,
73
+ base_dir: Path,
74
+ *,
75
+ must_exist: bool = False,
76
+ must_be_dir: bool = False,
77
+ must_be_file: bool = False,
78
+ ) -> Path:
79
+ """
80
+ Resolve a file ID to an absolute path inside the base directory.
81
+
82
+ Args:
83
+ file_id: Relative file ID in POSIX format. If None or empty string,
84
+ returns base_dir.
85
+ base_dir: The root directory to resolve from.
86
+ must_exist: Raise FileNotFoundError if path doesn't exist.
87
+ must_be_dir: Raise NotADirectoryError if path isn't a directory.
88
+ must_be_file: Raise IsADirectoryError if path isn't a file.
89
+
90
+ Returns:
91
+ Path: Absolute resolved path.
92
+
93
+ Raises:
94
+ ValueError: If file ID is invalid.
95
+ RuntimeError: If resolved path is a symbolic link.
96
+ FileNotFoundError, NotADirectoryError, IsADirectoryError: Based on flags.
97
+
98
+ Note:
99
+ Symbolic links are rejected to prevent escaping the base directory.
100
+ For general-purpose path validation that follows symlinks, use
101
+ ``validate_file_access`` or ``validate_folder_access`` instead.
102
+ """
103
+ # Validate file ID before joining to base_dir
104
+ if file_id:
105
+ FileId.validate(file_id)
106
+
107
+ target = base_dir.joinpath(Path(file_id)) if file_id else base_dir
108
+
109
+ if must_exist and not target.exists():
110
+ raise FileNotFoundError(f"Path does not exist: {target}")
111
+
112
+ if target.exists():
113
+ if target.is_symlink():
114
+ raise RuntimeError(f"Symbolic links are not supported: {target}")
115
+ if must_be_dir and not target.is_dir():
116
+ raise NotADirectoryError(f"Expected a directory: {target}")
117
+ if must_be_file and not target.is_file():
118
+ raise IsADirectoryError(f"Expected a file: {target}")
119
+
120
+ return target
121
+
122
+ @staticmethod
123
+ def get_file_id(file_path: Path, base_dir: Path) -> str:
124
+ """
125
+ Get the POSIX-style file ID for a file path inside the base directory.
126
+
127
+ Args:
128
+ file_path: The absolute or relative path to convert.
129
+ base_dir: The root directory to compute relative to.
130
+
131
+ Returns:
132
+ str: File ID in POSIX format.
133
+
134
+ Raises:
135
+ TypeError: If file_path or base_dir is not a Path.
136
+ ValueError: If file_path is outside the base_dir.
137
+ """
138
+ if not isinstance(file_path, Path):
139
+ raise TypeError(f"file_path must be a Path, got {type(file_path).__name__}")
140
+ if not isinstance(base_dir, Path):
141
+ raise TypeError(f"base_dir must be a Path, got {type(base_dir).__name__}")
142
+
143
+ try:
144
+ rel_path = file_path.relative_to(base_dir)
145
+ except ValueError:
146
+ raise ValueError(f"Path '{file_path}' is not under base directory '{base_dir}'")
147
+
148
+ if rel_path == Path("."):
149
+ return ""
150
+
151
+ parts = rel_path.parts
152
+ for part in parts:
153
+ FileNameValidator.validate(part)
154
+
155
+ file_id = rel_path.as_posix()
156
+
157
+ if file_path.exists() and file_path.is_dir() and rel_path != Path("."):
158
+ return file_id + "/"
159
+
160
+ return file_id
@@ -0,0 +1,69 @@
1
+ # Copyright Visual Collaboration Technologies Inc. All Rights Reserved.
2
+ # See LICENSE for details.
3
+ """Utility class for validating file and folder names."""
4
+
5
+ import re
6
+
7
+
8
+ class FileNameValidator:
9
+ """
10
+ Validates basic file or folder names based on common restrictions.
11
+
12
+ Rules:
13
+ - Must not be "." or ".."
14
+ - Must not be empty or whitespace only
15
+ - Must not contain illegal characters: / \\ : * ? " < > |
16
+
17
+ This class is useful for validating user-supplied names for files or
18
+ directories before creating them on the filesystem.
19
+
20
+ Example:
21
+ >>> FileNameValidator.is_valid("file.txt") # Returns True
22
+ >>> FileNameValidator.is_valid("invalid|file") # Returns False
23
+ >>> FileNameValidator.validate("data.json") # OK
24
+ >>> FileNameValidator.validate("bad/name") # Raises ValueError
25
+ """
26
+
27
+ INVALID_CHARS = r'<>:"/\\|?*'
28
+ INVALID_PATTERN = re.compile(rf"[{re.escape(INVALID_CHARS)}]")
29
+ RESERVED_NAMES = {".", ".."}
30
+
31
+ @classmethod
32
+ def is_valid(cls, name: str) -> bool:
33
+ """
34
+ Returns whether the given file or folder name is valid.
35
+
36
+ Args:
37
+ name (str): Name to check.
38
+
39
+ Returns:
40
+ bool: True if valid, False otherwise.
41
+ """
42
+ if not isinstance(name, str):
43
+ raise TypeError(f"name must be a str, got {type(name).__name__}")
44
+
45
+ if not name or name.strip() == "":
46
+ return False
47
+
48
+ if name in cls.RESERVED_NAMES:
49
+ return False
50
+
51
+ if cls.INVALID_PATTERN.search(name):
52
+ return False
53
+
54
+ return True
55
+
56
+ @classmethod
57
+ def validate(cls, name: str) -> None:
58
+ """
59
+ Validates the given file or folder name.
60
+
61
+ Args:
62
+ name (str): The filename to validate.
63
+
64
+ Raises:
65
+ TypeError: If name is not a str.
66
+ ValueError: If the name is reserved, empty, or contains illegal characters.
67
+ """
68
+ if not cls.is_valid(name):
69
+ raise ValueError(f"Invalid file or directory name: {name!r}")
@@ -0,0 +1,111 @@
1
+ # Copyright Visual Collaboration Technologies Inc. All Rights Reserved.
2
+ # See LICENSE for details.
3
+ """Utility functions for handling file and folder paths."""
4
+
5
+ import errno
6
+ import os
7
+ from pathlib import Path
8
+
9
+
10
+ def abs_path(fp: Path | str) -> Path:
11
+ """
12
+ Converts a given file path to an absolute, resolved `Path` object.
13
+
14
+ Args:
15
+ fp: The input file path.
16
+
17
+ Returns:
18
+ Path: The absolute resolved file path.
19
+ """
20
+ return Path(fp).resolve()
21
+
22
+
23
+ def validate_file_access(fp: Path | str) -> None:
24
+ """
25
+ Validates that the specified file path exists and is a readable file.
26
+
27
+ Args:
28
+ fp: The file path to validate.
29
+
30
+ Raises:
31
+ FileNotFoundError: If the file does not exist.
32
+ IsADirectoryError: If the path points to a directory instead of a file.
33
+ PermissionError: If the file exists but is not readable.
34
+ """
35
+ path = Path(fp)
36
+
37
+ if not path.exists():
38
+ raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), str(path))
39
+
40
+ if path.is_dir():
41
+ raise IsADirectoryError(errno.EISDIR, os.strerror(errno.EISDIR), str(path))
42
+
43
+ if not path.is_file():
44
+ raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), str(path))
45
+
46
+ if not os.access(path, os.R_OK):
47
+ raise PermissionError(errno.EACCES, os.strerror(errno.EACCES), str(path))
48
+
49
+
50
+ def validate_folder_access(fp: Path | str) -> None:
51
+ """
52
+ Validates that the specified folder path exists and is a directory.
53
+
54
+ Args:
55
+ fp: The folder path to validate.
56
+
57
+ Raises:
58
+ FileNotFoundError: If the directory does not exist.
59
+ NotADirectoryError: If the path is not a directory.
60
+ """
61
+ path = Path(fp)
62
+
63
+ if not path.exists():
64
+ raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), str(path))
65
+
66
+ if not path.is_dir():
67
+ raise NotADirectoryError(errno.ENOTDIR, os.strerror(errno.ENOTDIR), str(path))
68
+
69
+
70
+ def resolve_path(path: Path | str, base_dir: Path | str) -> Path:
71
+ """
72
+ Resolves a path relative to a base directory, handling both absolute and relative paths.
73
+
74
+ Args:
75
+ path: The path to resolve (absolute or relative)
76
+ base_dir: The base directory for relative path resolution
77
+
78
+ Returns:
79
+ Path: The fully resolved absolute path
80
+
81
+ Raises:
82
+ FileNotFoundError: If the base_dir does not exist
83
+ NotADirectoryError: If base_dir is not a directory
84
+
85
+ Note:
86
+ This function does not prevent directory traversal via ``..``.
87
+ For untrusted input that must stay within a base directory, use
88
+ ``FileId.resolve_path`` which validates the path format first.
89
+ """
90
+ if not isinstance(path, (str, Path)):
91
+ raise TypeError(f"path must be a str or Path, got {type(path).__name__}")
92
+ if not isinstance(base_dir, (str, Path)):
93
+ raise TypeError(f"base_dir must be a str or Path, got {type(base_dir).__name__}")
94
+
95
+ path = Path(path) if isinstance(path, str) else path
96
+ base_dir = Path(base_dir) if isinstance(base_dir, str) else base_dir
97
+
98
+ # Resolve the path
99
+ if not path.is_absolute():
100
+ validate_folder_access(base_dir)
101
+ path = base_dir / path
102
+
103
+ return path.resolve()
104
+
105
+
106
+ __all__ = [
107
+ "abs_path",
108
+ "validate_file_access",
109
+ "validate_folder_access",
110
+ "resolve_path",
111
+ ]
File without changes
@@ -0,0 +1,131 @@
1
+ Metadata-Version: 2.4
2
+ Name: vcti-path
3
+ Version: 1.0.3
4
+ Summary: Safe path handling for VCollab applications
5
+ Author: Visual Collaboration Technologies Inc.
6
+ Requires-Python: <3.15,>=3.12
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Provides-Extra: test
10
+ Requires-Dist: pytest; extra == "test"
11
+ Requires-Dist: pytest-cov; extra == "test"
12
+ Provides-Extra: lint
13
+ Requires-Dist: ruff; extra == "lint"
14
+ Dynamic: license-file
15
+
16
+ # Path Utilities
17
+
18
+ ## Purpose
19
+
20
+ Safe path handling for VCollab applications -- filename validation, file
21
+ identity (POSIX-style relative paths as stable IDs), and path resolution
22
+ with access validation.
23
+
24
+ This package has **zero external dependencies**.
25
+
26
+ ---
27
+
28
+ ## Installation
29
+
30
+ ```bash
31
+ pip install vcti-path
32
+ ```
33
+
34
+ ### In `requirements.txt`
35
+
36
+ ```
37
+ vcti-path>=1.0.3
38
+ ```
39
+
40
+ ### In `pyproject.toml` dependencies
41
+
42
+ ```toml
43
+ dependencies = [
44
+ "vcti-path>=1.0.3",
45
+ ]
46
+ ```
47
+
48
+ ---
49
+
50
+ ## Quick Start
51
+
52
+ ### Validate filenames
53
+
54
+ ```python
55
+ from vcti.path import FileNameValidator
56
+
57
+ FileNameValidator.is_valid("report.pdf") # True
58
+ FileNameValidator.is_valid("bad|name") # False
59
+ FileNameValidator.is_valid("..") # False
60
+
61
+ FileNameValidator.validate("data.json") # OK
62
+ FileNameValidator.validate("bad/name") # Raises ValueError
63
+ ```
64
+
65
+ ### File IDs -- stable POSIX-style identifiers
66
+
67
+ A file ID is a POSIX-format relative path that uniquely identifies a
68
+ file or directory within a base directory. No leading `/`, `./`, or `../`.
69
+
70
+ ```python
71
+ from pathlib import Path
72
+ from vcti.path import FileId
73
+
74
+ # Validate
75
+ FileId.is_valid("scripts/setup.sh") # True
76
+ FileId.is_valid("/absolute/path") # False
77
+ FileId.is_valid("../escape") # False
78
+
79
+ # Resolve to filesystem path
80
+ base = Path("/project")
81
+ FileId.resolve_path("src/main.py", base)
82
+ # -> Path("/project/src/main.py")
83
+
84
+ # Extract file ID from a path
85
+ src = Path("/project/src/main.py")
86
+ FileId.get_file_id(src, base)
87
+ # -> "src/main.py"
88
+ ```
89
+
90
+ ### Path resolution and access validation
91
+
92
+ ```python
93
+ from vcti.path import abs_path, validate_file_access, resolve_path
94
+
95
+ # Absolute path resolution
96
+ abs_path("relative/file.txt")
97
+ # -> Path("<cwd>/relative/file.txt")
98
+
99
+ # Validate that a file exists and is readable
100
+ validate_file_access("config.yaml")
101
+ # Raises FileNotFoundError, IsADirectoryError, or PermissionError
102
+
103
+ # Resolve relative to a base directory
104
+ resolve_path("data/input.csv", "/project")
105
+ # -> Path("/project/data/input.csv")
106
+ ```
107
+
108
+ ---
109
+
110
+ ## Public API
111
+
112
+ | Symbol | Type | Purpose |
113
+ |--------|------|---------|
114
+ | `FileNameValidator.is_valid(name)` | classmethod | Check if a filename is valid |
115
+ | `FileNameValidator.validate(name)` | classmethod | Validate filename, raise `ValueError` if invalid |
116
+ | `FileId.is_valid(file_id)` | staticmethod | Check if a POSIX file ID is valid |
117
+ | `FileId.validate(file_id)` | staticmethod | Validate file ID, raise `ValueError` if invalid |
118
+ | `FileId.resolve_path(file_id, base_dir)` | staticmethod | Resolve file ID to absolute path |
119
+ | `FileId.get_file_id(file_path, base_dir)` | staticmethod | Extract file ID from absolute path |
120
+ | `abs_path(fp)` | function | Convert to absolute resolved `Path` |
121
+ | `validate_file_access(fp)` | function | Validate file exists and is readable |
122
+ | `validate_folder_access(fp)` | function | Validate directory exists |
123
+ | `resolve_path(path, base_dir)` | function | Resolve path relative to base directory |
124
+
125
+ ---
126
+
127
+ ## Documentation
128
+
129
+ - [Design](docs/design.md) -- Concepts, architecture decisions, and rationale
130
+ - [Source Guide](docs/source-guide.md) -- File descriptions and execution flow traces
131
+ - [API Reference](docs/api.md) -- Autodoc for all modules
@@ -0,0 +1,17 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/vcti/path/__init__.py
5
+ src/vcti/path/file_id_utils.py
6
+ src/vcti/path/filename_validator.py
7
+ src/vcti/path/path_utils.py
8
+ src/vcti/path/py.typed
9
+ src/vcti_path.egg-info/PKG-INFO
10
+ src/vcti_path.egg-info/SOURCES.txt
11
+ src/vcti_path.egg-info/dependency_links.txt
12
+ src/vcti_path.egg-info/requires.txt
13
+ src/vcti_path.egg-info/top_level.txt
14
+ src/vcti_path.egg-info/zip-safe
15
+ tests/test_file_id_utils.py
16
+ tests/test_filename_validator.py
17
+ tests/test_path_utils.py
@@ -0,0 +1,7 @@
1
+
2
+ [lint]
3
+ ruff
4
+
5
+ [test]
6
+ pytest
7
+ pytest-cov
@@ -0,0 +1,158 @@
1
+ import os
2
+
3
+ import pytest
4
+
5
+ from vcti.path.file_id_utils import FileId
6
+
7
+
8
+ class TestFileIdIsValid:
9
+ """Tests for FileId.is_valid."""
10
+
11
+ def test_simple_filename(self):
12
+ assert FileId.is_valid("file.txt") is True
13
+
14
+ def test_nested_path(self):
15
+ assert FileId.is_valid("scripts/setup.sh") is True
16
+
17
+ def test_deeply_nested(self):
18
+ assert FileId.is_valid("a/b/c/d.txt") is True
19
+
20
+ def test_empty_string_invalid(self):
21
+ assert FileId.is_valid("") is False
22
+
23
+ def test_whitespace_only_invalid(self):
24
+ assert FileId.is_valid(" ") is False
25
+
26
+ def test_absolute_path_invalid(self):
27
+ assert FileId.is_valid("/absolute/path") is False
28
+
29
+ def test_dot_slash_prefix_invalid(self):
30
+ assert FileId.is_valid("./relative") is False
31
+
32
+ def test_dotdot_slash_prefix_invalid(self):
33
+ assert FileId.is_valid("../parent") is False
34
+
35
+ def test_trailing_slash_valid(self):
36
+ assert FileId.is_valid("folder/") is True
37
+
38
+ def test_name_with_illegal_chars_invalid(self):
39
+ assert FileId.is_valid("bad|name") is False
40
+
41
+ def test_name_with_colon_invalid(self):
42
+ assert FileId.is_valid("bad:name") is False
43
+
44
+ def test_backslash_path_invalid(self):
45
+ assert FileId.is_valid("folder\\file.txt") is False
46
+
47
+ @pytest.mark.parametrize("invalid_input", [None, 123, b"bytes", ["list"]])
48
+ def test_non_string_raises_type_error(self, invalid_input):
49
+ with pytest.raises(TypeError, match="file_id must be a str"):
50
+ FileId.is_valid(invalid_input)
51
+
52
+
53
+ class TestFileIdValidate:
54
+ """Tests for FileId.validate."""
55
+
56
+ def test_valid_raises_nothing(self):
57
+ FileId.validate("scripts/run.sh") # Should not raise
58
+
59
+ def test_invalid_raises_value_error(self):
60
+ with pytest.raises(ValueError, match="Invalid file ID"):
61
+ FileId.validate("/absolute")
62
+
63
+ def test_empty_raises_value_error(self):
64
+ with pytest.raises(ValueError):
65
+ FileId.validate("")
66
+
67
+
68
+ class TestFileIdResolvePath:
69
+ """Tests for FileId.resolve_path."""
70
+
71
+ def test_none_returns_base_dir(self, tmp_path):
72
+ result = FileId.resolve_path(None, tmp_path)
73
+ assert result == tmp_path
74
+
75
+ def test_empty_string_returns_base_dir(self, tmp_path):
76
+ result = FileId.resolve_path("", tmp_path)
77
+ assert result == tmp_path
78
+
79
+ def test_resolves_relative_id(self, tmp_path):
80
+ result = FileId.resolve_path("subdir/file.txt", tmp_path)
81
+ assert result == tmp_path / "subdir" / "file.txt"
82
+
83
+ def test_must_exist_raises_if_missing(self, tmp_path):
84
+ with pytest.raises(FileNotFoundError):
85
+ FileId.resolve_path("nonexistent.txt", tmp_path, must_exist=True)
86
+
87
+ def test_must_exist_passes_if_file_exists(self, tmp_path):
88
+ f = tmp_path / "real.txt"
89
+ f.write_text("data")
90
+ result = FileId.resolve_path("real.txt", tmp_path, must_exist=True)
91
+ assert result == f
92
+
93
+ def test_must_be_file_raises_for_dir(self, tmp_path):
94
+ d = tmp_path / "mydir"
95
+ d.mkdir()
96
+ with pytest.raises(IsADirectoryError):
97
+ FileId.resolve_path("mydir", tmp_path, must_be_file=True)
98
+
99
+ def test_must_be_dir_raises_for_file(self, tmp_path):
100
+ f = tmp_path / "file.txt"
101
+ f.write_text("data")
102
+ with pytest.raises(NotADirectoryError):
103
+ FileId.resolve_path("file.txt", tmp_path, must_be_dir=True)
104
+
105
+ def test_invalid_file_id_raises_value_error(self, tmp_path):
106
+ with pytest.raises(ValueError):
107
+ FileId.resolve_path("/absolute", tmp_path)
108
+
109
+ # Note: Windows junctions/reparse points are not tested
110
+ @pytest.mark.skipif(os.name != "posix", reason="POSIX symlinks only")
111
+ def test_symlink_raises_runtime_error(self, tmp_path):
112
+ target = tmp_path / "real.txt"
113
+ target.write_text("data")
114
+ link = tmp_path / "link.txt"
115
+ link.symlink_to(target)
116
+ with pytest.raises(RuntimeError, match="Symbolic links are not supported"):
117
+ FileId.resolve_path("link.txt", tmp_path)
118
+
119
+
120
+ class TestFileIdGetFileId:
121
+ """Tests for FileId.get_file_id."""
122
+
123
+ def test_file_returns_posix_id(self, tmp_path):
124
+ f = tmp_path / "data.txt"
125
+ f.write_text("x")
126
+ result = FileId.get_file_id(f, tmp_path)
127
+ assert result == "data.txt"
128
+
129
+ def test_nested_file_returns_posix_id(self, tmp_path):
130
+ sub = tmp_path / "sub"
131
+ sub.mkdir()
132
+ f = sub / "file.txt"
133
+ f.write_text("x")
134
+ result = FileId.get_file_id(f, tmp_path)
135
+ assert result == "sub/file.txt"
136
+
137
+ def test_directory_returns_trailing_slash(self, tmp_path):
138
+ d = tmp_path / "mydir"
139
+ d.mkdir()
140
+ result = FileId.get_file_id(d, tmp_path)
141
+ assert result == "mydir/"
142
+
143
+ def test_base_dir_itself_returns_empty(self, tmp_path):
144
+ result = FileId.get_file_id(tmp_path, tmp_path)
145
+ assert result == ""
146
+
147
+ def test_outside_base_dir_raises(self, tmp_path):
148
+ outside = tmp_path.parent / "other.txt"
149
+ with pytest.raises(ValueError, match="not under base directory"):
150
+ FileId.get_file_id(outside, tmp_path)
151
+
152
+ def test_non_path_file_path_raises_type_error(self, tmp_path):
153
+ with pytest.raises(TypeError, match="file_path must be a Path"):
154
+ FileId.get_file_id("string/path", tmp_path)
155
+
156
+ def test_non_path_base_dir_raises_type_error(self, tmp_path):
157
+ with pytest.raises(TypeError, match="base_dir must be a Path"):
158
+ FileId.get_file_id(tmp_path, "/string/base")
@@ -0,0 +1,101 @@
1
+ import re
2
+
3
+ import pytest
4
+
5
+ import vcti.path
6
+ from vcti.path.filename_validator import FileNameValidator
7
+
8
+
9
+ class TestVersion:
10
+ """Tests for package version metadata."""
11
+
12
+ def test_version_exists(self):
13
+ assert hasattr(vcti.path, "__version__")
14
+
15
+ def test_version_is_valid_semver(self):
16
+ assert re.match(r"^\d+\.\d+\.\d+", vcti.path.__version__)
17
+
18
+
19
+ class TestPublicAPI:
20
+ """Tests that all public symbols are re-exported from vcti.path."""
21
+
22
+ @pytest.mark.parametrize(
23
+ "symbol",
24
+ [
25
+ "FileNameValidator",
26
+ "FileId",
27
+ "abs_path",
28
+ "validate_file_access",
29
+ "validate_folder_access",
30
+ "resolve_path",
31
+ ],
32
+ )
33
+ def test_public_symbol_exported(self, symbol):
34
+ assert hasattr(vcti.path, symbol)
35
+ assert symbol in vcti.path.__all__
36
+
37
+
38
+ class TestFileNameValidatorIsValid:
39
+ """Tests for FileNameValidator.is_valid."""
40
+
41
+ def test_valid_simple_name(self):
42
+ assert FileNameValidator.is_valid("file.txt") is True
43
+
44
+ def test_valid_name_no_extension(self):
45
+ assert FileNameValidator.is_valid("myfile") is True
46
+
47
+ def test_valid_name_with_spaces(self):
48
+ assert FileNameValidator.is_valid("my file.txt") is True
49
+
50
+ def test_valid_name_with_underscores_dashes(self):
51
+ assert FileNameValidator.is_valid("my-file_name.txt") is True
52
+
53
+ def test_empty_string_invalid(self):
54
+ assert FileNameValidator.is_valid("") is False
55
+
56
+ def test_whitespace_only_invalid(self):
57
+ assert FileNameValidator.is_valid(" ") is False
58
+
59
+ def test_dot_reserved_invalid(self):
60
+ assert FileNameValidator.is_valid(".") is False
61
+
62
+ def test_dotdot_reserved_invalid(self):
63
+ assert FileNameValidator.is_valid("..") is False
64
+
65
+ @pytest.mark.parametrize("char", ["/", "\\", ":", "*", "?", '"', "<", ">", "|"])
66
+ def test_illegal_characters_invalid(self, char):
67
+ assert FileNameValidator.is_valid(f"file{char}name") is False
68
+
69
+ def test_valid_hidden_file_unix(self):
70
+ assert FileNameValidator.is_valid(".hidden") is True
71
+
72
+ def test_valid_numeric_name(self):
73
+ assert FileNameValidator.is_valid("12345") is True
74
+
75
+ @pytest.mark.parametrize("invalid_input", [None, 123, b"bytes", ["list"]])
76
+ def test_non_string_raises_type_error(self, invalid_input):
77
+ with pytest.raises(TypeError, match="name must be a str"):
78
+ FileNameValidator.is_valid(invalid_input)
79
+
80
+
81
+ class TestFileNameValidatorValidate:
82
+ """Tests for FileNameValidator.validate."""
83
+
84
+ def test_valid_name_no_exception(self):
85
+ FileNameValidator.validate("data.json") # Should not raise
86
+
87
+ def test_empty_raises_value_error(self):
88
+ with pytest.raises(ValueError, match="Invalid file or directory name"):
89
+ FileNameValidator.validate("")
90
+
91
+ def test_dot_raises_value_error(self):
92
+ with pytest.raises(ValueError):
93
+ FileNameValidator.validate(".")
94
+
95
+ def test_illegal_char_raises_value_error(self):
96
+ with pytest.raises(ValueError):
97
+ FileNameValidator.validate("bad/name")
98
+
99
+ def test_dotdot_raises_value_error(self):
100
+ with pytest.raises(ValueError):
101
+ FileNameValidator.validate("..")
@@ -0,0 +1,193 @@
1
+ import os
2
+ from pathlib import Path
3
+ from unittest.mock import patch
4
+
5
+ import pytest
6
+
7
+ from vcti.path.path_utils import (
8
+ abs_path,
9
+ resolve_path,
10
+ validate_file_access,
11
+ validate_folder_access,
12
+ )
13
+
14
+
15
+ class TestPathUtils:
16
+ """Test cases for path utility functions."""
17
+
18
+ @pytest.fixture(autouse=True)
19
+ def setup_teardown(self, tmp_path):
20
+ """Create temporary files and directories for testing."""
21
+ self.test_dir = tmp_path / "test_directory"
22
+ self.test_dir.mkdir()
23
+
24
+ self.test_file = tmp_path / "test_file.txt"
25
+ self.test_file.write_text("Sample content")
26
+
27
+ self.non_existent_path = tmp_path / "non_existent.txt"
28
+ self.subdir = self.test_dir / "subdir"
29
+ self.subdir.mkdir()
30
+
31
+ # Store original permissions to restore later
32
+ self.original_mode = self.test_file.stat().st_mode
33
+ yield # This is where the testing happens
34
+
35
+ # Restore original permissions (in case they were modified)
36
+ self.test_file.chmod(self.original_mode)
37
+
38
+ def test_abs_path_with_str(self):
39
+ """Test absolute path resolution with string input."""
40
+ relative_path = str(self.test_file)
41
+ result = abs_path(relative_path)
42
+ assert result == Path(relative_path).resolve()
43
+ assert isinstance(result, Path)
44
+
45
+ def test_abs_path_with_path(self):
46
+ """Test absolute path resolution with Path object input."""
47
+ result = abs_path(self.test_file)
48
+ assert result == self.test_file.resolve()
49
+ assert isinstance(result, Path)
50
+
51
+ def test_validate_file_access_valid(self):
52
+ """Test validate_file_access with valid file."""
53
+ validate_file_access(self.test_file) # Should not raise
54
+
55
+ def test_validate_file_access_non_existent(self):
56
+ """Test validate_file_access with non-existent file."""
57
+ with pytest.raises(FileNotFoundError):
58
+ validate_file_access(self.non_existent_path)
59
+
60
+ def test_validate_file_access_is_directory(self):
61
+ """Test validate_file_access with directory."""
62
+ with pytest.raises(IsADirectoryError):
63
+ validate_file_access(self.test_dir)
64
+
65
+ # Note: Windows ACL-based permission testing is beyond scope
66
+ @pytest.mark.skipif(os.name != "posix", reason="POSIX permissions only")
67
+ def test_validate_file_access_no_permission(self):
68
+ """Test validate_file_access with unreadable file (POSIX only)."""
69
+ self.test_file.chmod(0o000) # Remove all permissions
70
+ with pytest.raises(PermissionError):
71
+ validate_file_access(self.test_file)
72
+
73
+ def test_validate_folder_access_valid(self):
74
+ """Test validate_folder_access with valid directory."""
75
+ validate_folder_access(self.test_dir) # Should not raise
76
+
77
+ def test_validate_folder_access_non_existent(self):
78
+ """Test validate_folder_access with non-existent directory."""
79
+ with pytest.raises(FileNotFoundError):
80
+ validate_folder_access(self.non_existent_path)
81
+
82
+ def test_validate_folder_access_not_directory(self):
83
+ """Test validate_folder_access with file."""
84
+ with pytest.raises(NotADirectoryError):
85
+ validate_folder_access(self.test_file)
86
+
87
+ @pytest.mark.parametrize(
88
+ "path_input",
89
+ [
90
+ b"bytes_path", # bytes input
91
+ 123, # integer input
92
+ ],
93
+ )
94
+ def test_abs_path_with_wrong_types(self, path_input):
95
+ """Test abs_path with unusual input types."""
96
+ with pytest.raises(TypeError):
97
+ abs_path(path_input)
98
+
99
+ @patch("pathlib.Path.exists", return_value=True)
100
+ @patch("pathlib.Path.is_file", return_value=True)
101
+ @patch("vcti.path.path_utils.os.access")
102
+ def test_validate_file_access_mocked(self, mock_access, mock_is_file, mock_exists):
103
+ """Test validate_file_access with mocked permissions."""
104
+ mock_access.return_value = True # Readable
105
+ validate_file_access("any_path") # Should pass with mocked permissions
106
+
107
+ mock_access.return_value = False # Not readable
108
+ with pytest.raises(PermissionError):
109
+ validate_file_access("any_path")
110
+
111
+ # Note: Windows junctions/reparse points are not tested
112
+ @pytest.mark.skipif(os.name != "posix", reason="POSIX symlinks only")
113
+ def test_symlink_valid_target(self):
114
+ """Test validate_file_access follows symlink to valid target."""
115
+ symlink_path = self.test_dir / "symlink.txt"
116
+ symlink_path.symlink_to(self.test_file)
117
+ validate_file_access(symlink_path)
118
+
119
+ @pytest.mark.skipif(os.name != "posix", reason="POSIX symlinks only")
120
+ def test_symlink_broken_raises_file_not_found(self):
121
+ """Test validate_file_access raises for broken symlink."""
122
+ broken_symlink = self.test_dir / "broken_symlink"
123
+ broken_symlink.symlink_to("nonexistent_target")
124
+ with pytest.raises(FileNotFoundError):
125
+ validate_file_access(broken_symlink)
126
+
127
+ def test_resolve_path_absolute(self, tmp_path):
128
+ """Test resolve_path with absolute path input."""
129
+ abs_test_file = self.test_file.resolve()
130
+ result = resolve_path(abs_test_file, tmp_path)
131
+ assert result == abs_test_file
132
+
133
+ def test_resolve_path_relative(self, tmp_path):
134
+ """Test resolve_path with relative path input."""
135
+ rel_path = "test_directory/subdir"
136
+ result = resolve_path(rel_path, tmp_path)
137
+ expected = (tmp_path / rel_path).resolve()
138
+ assert result == expected
139
+
140
+ def test_resolve_path_with_str_input(self, tmp_path):
141
+ """Test resolve_path with string inputs."""
142
+ result = resolve_path("test_file.txt", str(tmp_path))
143
+ expected = (tmp_path / "test_file.txt").resolve()
144
+ assert result == expected
145
+
146
+ def test_resolve_path_invalid_base(self):
147
+ """Test resolve_path with non-existent base path."""
148
+ with pytest.raises(FileNotFoundError):
149
+ resolve_path("any_path", "/non/existent/base")
150
+
151
+ def test_resolve_path_base_not_dir(self, tmp_path):
152
+ """Test resolve_path when base path is not a directory."""
153
+ with pytest.raises(NotADirectoryError):
154
+ resolve_path("any_path", self.test_file)
155
+
156
+ def test_resolve_path_dot_dot(self, tmp_path):
157
+ """Test resolve_path with parent directory references."""
158
+ result = resolve_path("../test_file.txt", self.subdir)
159
+ expected = (self.test_dir / "test_file.txt").resolve()
160
+ assert result == expected
161
+
162
+ def test_resolve_path_empty_path(self, tmp_path):
163
+ """Test resolve_path with empty path string."""
164
+ result = resolve_path("", tmp_path)
165
+ assert result == tmp_path.resolve()
166
+
167
+ def test_resolve_path_dot(self, tmp_path):
168
+ """Test resolve_path with '.' path."""
169
+ result = resolve_path(".", tmp_path)
170
+ assert result == tmp_path.resolve()
171
+
172
+ def test_resolve_path_relative_to_cwd(self, tmp_path, monkeypatch):
173
+ """Test that relative paths without base_path use cwd."""
174
+ monkeypatch.chdir(tmp_path)
175
+ rel_path = "test_file.txt"
176
+ result = resolve_path(rel_path, ".")
177
+ expected = (tmp_path / rel_path).resolve()
178
+ assert result == expected
179
+
180
+ @pytest.mark.parametrize(
181
+ "invalid_input",
182
+ [
183
+ None,
184
+ 123,
185
+ b"bytes_path",
186
+ ],
187
+ )
188
+ def test_resolve_path_invalid_types(self, invalid_input, tmp_path):
189
+ """Test resolve_path with invalid input types."""
190
+ with pytest.raises(TypeError):
191
+ resolve_path(invalid_input, tmp_path)
192
+ with pytest.raises(TypeError):
193
+ resolve_path("valid_path", invalid_input)