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.
@@ -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
+ ]
@@ -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,6 @@
1
+ """Built-in validators for common validation scenarios."""
2
+
3
+ from .strict import StrictPathValidator
4
+ from .sandbox import SandboxPathValidator
5
+
6
+ __all__ = ["StrictPathValidator", "SandboxPathValidator"]
@@ -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