path-link 0.2.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.
- path_link-0.2.0.dist-info/METADATA +643 -0
- path_link-0.2.0.dist-info/RECORD +20 -0
- path_link-0.2.0.dist-info/WHEEL +5 -0
- path_link-0.2.0.dist-info/entry_points.txt +2 -0
- path_link-0.2.0.dist-info/licenses/LICENSE +23 -0
- path_link-0.2.0.dist-info/top_level.txt +1 -0
- project_paths/__init__.py +165 -0
- project_paths/builder.py +84 -0
- project_paths/builtin_validators/__init__.py +6 -0
- project_paths/builtin_validators/sandbox.py +159 -0
- project_paths/builtin_validators/strict.py +126 -0
- project_paths/cli.py +201 -0
- project_paths/docs/ai_guidelines.md +668 -0
- project_paths/docs/developer_guide.md +399 -0
- project_paths/docs/metadata.json +119 -0
- project_paths/get_paths.py +104 -0
- project_paths/main.py +2 -0
- project_paths/model.py +76 -0
- project_paths/project_paths_static.py +14 -0
- project_paths/validation.py +94 -0
@@ -0,0 +1,165 @@
|
|
1
|
+
"""
|
2
|
+
ptool-serena: Type-safe path configuration for Python projects
|
3
|
+
|
4
|
+
A powerful library for managing project paths with built-in validation,
|
5
|
+
static model generation, and comprehensive security features.
|
6
|
+
|
7
|
+
Quick Start
|
8
|
+
-----------
|
9
|
+
>>> from project_paths import ProjectPaths
|
10
|
+
>>>
|
11
|
+
>>> # Load paths from pyproject.toml
|
12
|
+
>>> paths = ProjectPaths.from_pyproject()
|
13
|
+
>>>
|
14
|
+
>>> # Access paths (all are pathlib.Path objects)
|
15
|
+
>>> config_dir = paths.config_dir
|
16
|
+
>>> settings = paths["settings_file"]
|
17
|
+
|
18
|
+
Key Features
|
19
|
+
------------
|
20
|
+
- Dynamic path management from pyproject.toml or .paths files
|
21
|
+
- Type-safe static model generation for IDE support
|
22
|
+
- Built-in validators: StrictPathValidator, SandboxPathValidator
|
23
|
+
- Path traversal attack prevention
|
24
|
+
- CLI tool for validation and inspection
|
25
|
+
- Offline-accessible documentation
|
26
|
+
|
27
|
+
Documentation Access (Offline)
|
28
|
+
-------------------------------
|
29
|
+
Access comprehensive documentation programmatically, even in airgapped environments:
|
30
|
+
|
31
|
+
>>> from project_paths import get_ai_guidelines, get_developer_guide, get_metadata
|
32
|
+
>>> import json
|
33
|
+
>>>
|
34
|
+
>>> # Get AI assistant usage patterns and best practices
|
35
|
+
>>> ai_docs = get_ai_guidelines()
|
36
|
+
>>> print(f"AI Guidelines: {len(ai_docs):,} characters")
|
37
|
+
>>>
|
38
|
+
>>> # Get architecture and development guide
|
39
|
+
>>> dev_docs = get_developer_guide()
|
40
|
+
>>>
|
41
|
+
>>> # Get machine-readable metadata
|
42
|
+
>>> metadata = json.loads(get_metadata())
|
43
|
+
>>> print(f"Version: {metadata['version']}")
|
44
|
+
>>> print(f"Public APIs: {len(metadata['public_api'])}")
|
45
|
+
|
46
|
+
Validation Example
|
47
|
+
------------------
|
48
|
+
>>> from project_paths import validate_or_raise, StrictPathValidator
|
49
|
+
>>>
|
50
|
+
>>> paths = ProjectPaths.from_pyproject()
|
51
|
+
>>> validator = StrictPathValidator(
|
52
|
+
... required=["config_dir", "data_dir"],
|
53
|
+
... must_be_dir=["config_dir"],
|
54
|
+
... allow_symlinks=False # Security: block symlinks
|
55
|
+
... )
|
56
|
+
>>> validate_or_raise(paths, validator)
|
57
|
+
|
58
|
+
CLI Commands
|
59
|
+
------------
|
60
|
+
$ ptool print # Show all configured paths
|
61
|
+
$ ptool validate --strict # Validate project structure
|
62
|
+
$ ptool gen-static # Generate static model
|
63
|
+
$ ptool --help # Full CLI reference
|
64
|
+
|
65
|
+
Critical Rules
|
66
|
+
--------------
|
67
|
+
- NEVER instantiate ProjectPaths() directly (raises NotImplementedError)
|
68
|
+
- ALWAYS use factory methods: from_pyproject() or from_config()
|
69
|
+
- Regenerate static model after pyproject.toml changes
|
70
|
+
- Use validators before filesystem operations
|
71
|
+
|
72
|
+
See Also
|
73
|
+
--------
|
74
|
+
- Full documentation: README.md in package root
|
75
|
+
- AI guidelines: get_ai_guidelines()
|
76
|
+
- Developer guide: get_developer_guide()
|
77
|
+
- Package metadata: get_metadata()
|
78
|
+
- GitHub: https://github.com/yourusername/ptool-serena
|
79
|
+
"""
|
80
|
+
|
81
|
+
from importlib.resources import files
|
82
|
+
|
83
|
+
from .model import ProjectPaths
|
84
|
+
from .get_paths import write_dataclass_file
|
85
|
+
from .validation import (
|
86
|
+
Severity,
|
87
|
+
Finding,
|
88
|
+
ValidationResult,
|
89
|
+
PathValidator,
|
90
|
+
PathValidationError,
|
91
|
+
validate_or_raise,
|
92
|
+
CompositeValidator,
|
93
|
+
)
|
94
|
+
from .builtin_validators.strict import StrictPathValidator
|
95
|
+
from .builtin_validators.sandbox import SandboxPathValidator
|
96
|
+
|
97
|
+
|
98
|
+
def get_ai_guidelines() -> str:
|
99
|
+
"""
|
100
|
+
Return AI assistant guidelines for working with this package.
|
101
|
+
|
102
|
+
This provides comprehensive guidance for AI agents helping users with ptool-serena,
|
103
|
+
including usage patterns, critical rules, validation patterns, and troubleshooting.
|
104
|
+
|
105
|
+
Returns:
|
106
|
+
str: Full content of AI guidelines (formerly assistant_context.md)
|
107
|
+
|
108
|
+
Example:
|
109
|
+
>>> guidelines = get_ai_guidelines()
|
110
|
+
>>> print(guidelines[:100])
|
111
|
+
"""
|
112
|
+
return files("project_paths.docs").joinpath("ai_guidelines.md").read_text()
|
113
|
+
|
114
|
+
|
115
|
+
def get_developer_guide() -> str:
|
116
|
+
"""
|
117
|
+
Return developer guide for contributing to this package.
|
118
|
+
|
119
|
+
This provides architecture details, development setup, testing patterns,
|
120
|
+
and contribution guidelines for developers working on ptool-serena.
|
121
|
+
|
122
|
+
Returns:
|
123
|
+
str: Full content of developer guide (formerly CLAUDE.md)
|
124
|
+
|
125
|
+
Example:
|
126
|
+
>>> guide = get_developer_guide()
|
127
|
+
>>> print(guide[:100])
|
128
|
+
"""
|
129
|
+
return files("project_paths.docs").joinpath("developer_guide.md").read_text()
|
130
|
+
|
131
|
+
|
132
|
+
def get_metadata() -> str:
|
133
|
+
"""
|
134
|
+
Return machine-readable project metadata.
|
135
|
+
|
136
|
+
This provides structured information about the package including version,
|
137
|
+
public API, validators, CLI commands, and architecture notes.
|
138
|
+
|
139
|
+
Returns:
|
140
|
+
str: JSON content of project metadata (formerly assistant_handoff.json)
|
141
|
+
|
142
|
+
Example:
|
143
|
+
>>> import json
|
144
|
+
>>> metadata = json.loads(get_metadata())
|
145
|
+
>>> print(metadata['version'])
|
146
|
+
"""
|
147
|
+
return files("project_paths.docs").joinpath("metadata.json").read_text()
|
148
|
+
|
149
|
+
|
150
|
+
__all__ = [
|
151
|
+
"ProjectPaths",
|
152
|
+
"write_dataclass_file",
|
153
|
+
"Severity",
|
154
|
+
"Finding",
|
155
|
+
"ValidationResult",
|
156
|
+
"PathValidator",
|
157
|
+
"PathValidationError",
|
158
|
+
"validate_or_raise",
|
159
|
+
"CompositeValidator",
|
160
|
+
"StrictPathValidator",
|
161
|
+
"SandboxPathValidator",
|
162
|
+
"get_ai_guidelines",
|
163
|
+
"get_developer_guide",
|
164
|
+
"get_metadata",
|
165
|
+
]
|
project_paths/builder.py
ADDED
@@ -0,0 +1,84 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
import os
|
3
|
+
from pathlib import Path
|
4
|
+
from tomllib import load as toml_load
|
5
|
+
from typing import Callable, Dict, Any, Optional
|
6
|
+
|
7
|
+
from dotenv import dotenv_values
|
8
|
+
from pydantic import Field
|
9
|
+
|
10
|
+
|
11
|
+
def get_paths_from_pyproject() -> Dict[str, str]:
|
12
|
+
"""Load path variables from pyproject.toml."""
|
13
|
+
pyproject_path = Path.cwd() / "pyproject.toml"
|
14
|
+
if not pyproject_path.is_file():
|
15
|
+
return {}
|
16
|
+
|
17
|
+
with pyproject_path.open("rb") as f:
|
18
|
+
pyproject_data = toml_load(f)
|
19
|
+
|
20
|
+
tool_config = pyproject_data.get("tool", {}).get("project_paths", {})
|
21
|
+
if not tool_config:
|
22
|
+
return {}
|
23
|
+
|
24
|
+
paths = tool_config.get("paths", {})
|
25
|
+
files = tool_config.get("files", {})
|
26
|
+
|
27
|
+
if not isinstance(paths, dict) or not isinstance(files, dict):
|
28
|
+
raise TypeError("`paths` and `files` must be tables in pyproject.toml")
|
29
|
+
|
30
|
+
return {**paths, **files}
|
31
|
+
|
32
|
+
|
33
|
+
def get_paths_from_dot_paths(path_to_config: Path) -> Dict[str, str]:
|
34
|
+
"""Load path variables from a .paths file."""
|
35
|
+
if not path_to_config.is_file():
|
36
|
+
raise FileNotFoundError(f"Configuration file not found: {path_to_config}")
|
37
|
+
|
38
|
+
values = dotenv_values(path_to_config)
|
39
|
+
|
40
|
+
# Filter out None values which can occur with empty lines
|
41
|
+
return {k: v for k, v in values.items() if v is not None}
|
42
|
+
|
43
|
+
|
44
|
+
def make_path_factory(base: Path, rel_path: str):
|
45
|
+
"""Creates a lambda function to resolve a path relative to a base."""
|
46
|
+
# Expand environment variables and user home directory
|
47
|
+
expanded_path = os.path.expandvars(os.path.expanduser(rel_path))
|
48
|
+
return lambda: base / expanded_path
|
49
|
+
|
50
|
+
|
51
|
+
def build_field_definitions(
|
52
|
+
loader_func: Callable[..., Dict[str, str]] = get_paths_from_pyproject,
|
53
|
+
config_path: Optional[Path] = None,
|
54
|
+
) -> dict[str, tuple[type[Path], Any]]:
|
55
|
+
"""
|
56
|
+
Builds a dictionary of Pydantic field definitions from a configuration source.
|
57
|
+
|
58
|
+
Args:
|
59
|
+
loader_func: The function used to load the raw path strings.
|
60
|
+
config_path: The optional path to the configuration file.
|
61
|
+
|
62
|
+
Returns:
|
63
|
+
A dictionary of field definitions for the dynamic Pydantic model.
|
64
|
+
"""
|
65
|
+
if config_path:
|
66
|
+
env_values = loader_func(config_path)
|
67
|
+
base_dir = config_path.parent.resolve()
|
68
|
+
else:
|
69
|
+
env_values = loader_func()
|
70
|
+
base_dir = Path.cwd()
|
71
|
+
|
72
|
+
fields = {
|
73
|
+
"root": (Path, Field(default_factory=Path.home)),
|
74
|
+
"base_dir": (Path, Field(default_factory=lambda: base_dir)),
|
75
|
+
}
|
76
|
+
|
77
|
+
for key, val in env_values.items():
|
78
|
+
if key not in fields:
|
79
|
+
fields[key] = (
|
80
|
+
Path,
|
81
|
+
Field(default_factory=make_path_factory(base_dir, val)),
|
82
|
+
)
|
83
|
+
|
84
|
+
return fields
|
@@ -0,0 +1,159 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
from dataclasses import dataclass
|
3
|
+
from pathlib import Path
|
4
|
+
from typing import Iterable, TYPE_CHECKING
|
5
|
+
|
6
|
+
from ..validation import Finding, Severity, ValidationResult
|
7
|
+
|
8
|
+
if TYPE_CHECKING:
|
9
|
+
from ..model import _ProjectPathsBase
|
10
|
+
|
11
|
+
|
12
|
+
@dataclass
|
13
|
+
class SandboxPathValidator:
|
14
|
+
"""
|
15
|
+
Validates that paths stay within a base directory sandbox.
|
16
|
+
|
17
|
+
This is a security-focused validator that prevents path traversal attacks
|
18
|
+
and ensures all paths remain within the project's base directory.
|
19
|
+
|
20
|
+
Features:
|
21
|
+
- Detects '..' path escape attempts
|
22
|
+
- Validates paths stay within base_dir
|
23
|
+
- Optionally allows absolute paths (with validation)
|
24
|
+
- Configurable strict mode for maximum security
|
25
|
+
"""
|
26
|
+
|
27
|
+
base_dir_key: str = "base_dir"
|
28
|
+
"""The key in ProjectPaths that represents the base directory."""
|
29
|
+
|
30
|
+
check_paths: Iterable[str] = ()
|
31
|
+
"""Specific path keys to check. If empty, checks all paths."""
|
32
|
+
|
33
|
+
allow_absolute: bool = False
|
34
|
+
"""Allow absolute paths that are within base_dir."""
|
35
|
+
|
36
|
+
strict_mode: bool = True
|
37
|
+
"""In strict mode, block all attempts at path traversal, even if they resolve safely."""
|
38
|
+
|
39
|
+
def validate(self, p: "_ProjectPathsBase") -> ValidationResult:
|
40
|
+
"""
|
41
|
+
Validates that all paths stay within the base directory sandbox.
|
42
|
+
|
43
|
+
Args:
|
44
|
+
p: The ProjectPaths instance to validate.
|
45
|
+
|
46
|
+
Returns:
|
47
|
+
A ValidationResult containing all findings.
|
48
|
+
"""
|
49
|
+
vr = ValidationResult()
|
50
|
+
all_paths = p.to_dict()
|
51
|
+
|
52
|
+
# Get base directory
|
53
|
+
if self.base_dir_key not in all_paths:
|
54
|
+
vr.add(
|
55
|
+
Finding(
|
56
|
+
severity=Severity.ERROR,
|
57
|
+
code="SANDBOX_BASE_MISSING",
|
58
|
+
field=self.base_dir_key,
|
59
|
+
message=f"Sandbox base directory key '{self.base_dir_key}' not found in ProjectPaths.",
|
60
|
+
)
|
61
|
+
)
|
62
|
+
return vr
|
63
|
+
|
64
|
+
base_dir = Path(all_paths[self.base_dir_key])
|
65
|
+
|
66
|
+
# Resolve base_dir to handle symlinks and get absolute path
|
67
|
+
try:
|
68
|
+
base_dir_resolved = base_dir.resolve()
|
69
|
+
except (OSError, RuntimeError) as e:
|
70
|
+
vr.add(
|
71
|
+
Finding(
|
72
|
+
severity=Severity.ERROR,
|
73
|
+
code="SANDBOX_BASE_UNRESOLVABLE",
|
74
|
+
field=self.base_dir_key,
|
75
|
+
path=str(base_dir),
|
76
|
+
message=f"Cannot resolve base directory: {e}",
|
77
|
+
)
|
78
|
+
)
|
79
|
+
return vr
|
80
|
+
|
81
|
+
# Determine which paths to check
|
82
|
+
if self.check_paths:
|
83
|
+
keys_to_check = set(self.check_paths)
|
84
|
+
else:
|
85
|
+
# Check all paths except base_dir itself
|
86
|
+
keys_to_check = set(all_paths.keys()) - {self.base_dir_key}
|
87
|
+
|
88
|
+
for key in sorted(keys_to_check):
|
89
|
+
if key not in all_paths:
|
90
|
+
continue # Skip missing keys - that's StrictPathValidator's job
|
91
|
+
|
92
|
+
path = Path(all_paths[key])
|
93
|
+
path_str = str(path)
|
94
|
+
|
95
|
+
# Check 1: Detect suspicious '..' patterns in strict mode
|
96
|
+
if self.strict_mode:
|
97
|
+
parts = path.parts
|
98
|
+
if ".." in parts:
|
99
|
+
vr.add(
|
100
|
+
Finding(
|
101
|
+
severity=Severity.ERROR,
|
102
|
+
code="PATH_TRAVERSAL_ATTEMPT",
|
103
|
+
field=key,
|
104
|
+
path=path_str,
|
105
|
+
message="Path contains '..' traversal pattern (blocked in strict mode)",
|
106
|
+
)
|
107
|
+
)
|
108
|
+
continue # Don't proceed with further checks for this path
|
109
|
+
|
110
|
+
# Check 2: Validate absolute paths if not allowed
|
111
|
+
if path.is_absolute() and not self.allow_absolute:
|
112
|
+
vr.add(
|
113
|
+
Finding(
|
114
|
+
severity=Severity.ERROR,
|
115
|
+
code="ABSOLUTE_PATH_BLOCKED",
|
116
|
+
field=key,
|
117
|
+
path=path_str,
|
118
|
+
message="Absolute paths not allowed (set allow_absolute=True to permit)",
|
119
|
+
)
|
120
|
+
)
|
121
|
+
continue
|
122
|
+
|
123
|
+
# Check 3: Resolve path and verify it's within base_dir
|
124
|
+
try:
|
125
|
+
# For relative paths, resolve relative to base_dir
|
126
|
+
if not path.is_absolute():
|
127
|
+
full_path = base_dir / path
|
128
|
+
else:
|
129
|
+
full_path = path
|
130
|
+
|
131
|
+
path_resolved = full_path.resolve()
|
132
|
+
except (OSError, RuntimeError) as e:
|
133
|
+
vr.add(
|
134
|
+
Finding(
|
135
|
+
severity=Severity.WARNING,
|
136
|
+
code="PATH_UNRESOLVABLE",
|
137
|
+
field=key,
|
138
|
+
path=path_str,
|
139
|
+
message=f"Cannot resolve path: {e}",
|
140
|
+
)
|
141
|
+
)
|
142
|
+
continue
|
143
|
+
|
144
|
+
# Check if resolved path is within base_dir
|
145
|
+
try:
|
146
|
+
# relative_to() raises ValueError if path is not relative to base
|
147
|
+
path_resolved.relative_to(base_dir_resolved)
|
148
|
+
except ValueError:
|
149
|
+
vr.add(
|
150
|
+
Finding(
|
151
|
+
severity=Severity.ERROR,
|
152
|
+
code="PATH_ESCAPES_SANDBOX",
|
153
|
+
field=key,
|
154
|
+
path=path_str,
|
155
|
+
message=f"Path escapes sandbox (resolves outside {base_dir_resolved})",
|
156
|
+
)
|
157
|
+
)
|
158
|
+
|
159
|
+
return vr
|
@@ -0,0 +1,126 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
from dataclasses import dataclass
|
3
|
+
from pathlib import Path
|
4
|
+
from typing import Iterable, TYPE_CHECKING
|
5
|
+
|
6
|
+
from ..validation import Finding, Severity, ValidationResult
|
7
|
+
|
8
|
+
if TYPE_CHECKING:
|
9
|
+
from ..model import _ProjectPathsBase
|
10
|
+
|
11
|
+
|
12
|
+
@dataclass
|
13
|
+
class StrictPathValidator:
|
14
|
+
"""
|
15
|
+
Validates path existence, type (file/directory), and symlinks based on configured rules.
|
16
|
+
"""
|
17
|
+
|
18
|
+
required: Iterable[str]
|
19
|
+
must_be_dir: Iterable[str] = ()
|
20
|
+
must_be_file: Iterable[str] = ()
|
21
|
+
allow_symlinks: bool = False
|
22
|
+
|
23
|
+
def validate(self, p: "_ProjectPathsBase") -> ValidationResult:
|
24
|
+
"""
|
25
|
+
Validates paths against the configured rules.
|
26
|
+
|
27
|
+
Args:
|
28
|
+
p: The ProjectPaths instance to validate.
|
29
|
+
|
30
|
+
Returns:
|
31
|
+
A ValidationResult containing all findings.
|
32
|
+
"""
|
33
|
+
vr = ValidationResult()
|
34
|
+
all_paths = p.to_dict()
|
35
|
+
|
36
|
+
req_set = set(self.required)
|
37
|
+
dir_set = set(self.must_be_dir)
|
38
|
+
file_set = set(self.must_be_file)
|
39
|
+
|
40
|
+
# Configuration conflict guard
|
41
|
+
conflict = dir_set & file_set
|
42
|
+
if conflict:
|
43
|
+
for k in sorted(conflict):
|
44
|
+
vr.add(
|
45
|
+
Finding(
|
46
|
+
severity=Severity.ERROR,
|
47
|
+
code="CONFLICTING_KIND_RULES",
|
48
|
+
field=k,
|
49
|
+
message="Field listed as both must_be_dir and must_be_file",
|
50
|
+
)
|
51
|
+
)
|
52
|
+
return vr
|
53
|
+
|
54
|
+
keys_to_check = req_set | dir_set | file_set
|
55
|
+
|
56
|
+
for k in keys_to_check:
|
57
|
+
if k not in all_paths:
|
58
|
+
is_required = k in req_set
|
59
|
+
vr.add(
|
60
|
+
Finding(
|
61
|
+
severity=Severity.ERROR if is_required else Severity.WARNING,
|
62
|
+
code="KEY_NOT_FOUND",
|
63
|
+
field=k,
|
64
|
+
message=f"Path key '{k}' not found in ProjectPaths model.",
|
65
|
+
)
|
66
|
+
)
|
67
|
+
continue
|
68
|
+
|
69
|
+
path = Path(all_paths[k])
|
70
|
+
|
71
|
+
if not self.allow_symlinks and path.is_symlink():
|
72
|
+
vr.add(
|
73
|
+
Finding(
|
74
|
+
severity=Severity.ERROR,
|
75
|
+
code="SYMLINK_BLOCKED",
|
76
|
+
field=k,
|
77
|
+
path=str(path),
|
78
|
+
message="Symlinks not permitted",
|
79
|
+
)
|
80
|
+
)
|
81
|
+
|
82
|
+
exists = path.exists()
|
83
|
+
|
84
|
+
if k in req_set and not exists:
|
85
|
+
vr.add(
|
86
|
+
Finding(
|
87
|
+
severity=Severity.ERROR,
|
88
|
+
code="MISSING_REQUIRED",
|
89
|
+
field=k,
|
90
|
+
path=str(path),
|
91
|
+
message="Required path missing",
|
92
|
+
)
|
93
|
+
)
|
94
|
+
elif not exists and k in (dir_set | file_set):
|
95
|
+
vr.add(
|
96
|
+
Finding(
|
97
|
+
severity=Severity.WARNING,
|
98
|
+
code="MISSING_OPTIONAL",
|
99
|
+
field=k,
|
100
|
+
path=str(path),
|
101
|
+
message="Optional path missing",
|
102
|
+
)
|
103
|
+
)
|
104
|
+
|
105
|
+
if exists:
|
106
|
+
if k in dir_set and not path.is_dir():
|
107
|
+
vr.add(
|
108
|
+
Finding(
|
109
|
+
severity=Severity.ERROR,
|
110
|
+
code="NOT_A_DIRECTORY",
|
111
|
+
field=k,
|
112
|
+
path=str(path),
|
113
|
+
message="Expected directory",
|
114
|
+
)
|
115
|
+
)
|
116
|
+
if k in file_set and not path.is_file():
|
117
|
+
vr.add(
|
118
|
+
Finding(
|
119
|
+
severity=Severity.ERROR,
|
120
|
+
code="NOT_A_FILE",
|
121
|
+
field=k,
|
122
|
+
path=str(path),
|
123
|
+
message="Expected file",
|
124
|
+
)
|
125
|
+
)
|
126
|
+
return vr
|