path-link 0.2.0__py3-none-any.whl → 0.3.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.
- {project_paths → path_link}/__init__.py +21 -15
- {project_paths → path_link}/builder.py +63 -1
- path_link/builtin_validators/__init__.py +6 -0
- {project_paths → path_link}/builtin_validators/sandbox.py +2 -2
- {project_paths → path_link}/builtin_validators/strict.py +2 -2
- {project_paths → path_link}/cli.py +161 -5
- {project_paths → path_link}/docs/ai_guidelines.md +34 -34
- {project_paths → path_link}/docs/developer_guide.md +19 -19
- {project_paths → path_link}/docs/metadata.json +6 -6
- {project_paths → path_link}/get_paths.py +3 -3
- {project_paths → path_link}/model.py +1 -1
- {project_paths → path_link}/project_paths_static.py +2 -2
- path_link/url_factory.py +225 -0
- path_link/url_model.py +151 -0
- path_link/url_static.py +128 -0
- {path_link-0.2.0.dist-info → path_link-0.3.0.dist-info}/METADATA +224 -51
- path_link-0.3.0.dist-info/RECORD +23 -0
- path_link-0.3.0.dist-info/entry_points.txt +2 -0
- path_link-0.3.0.dist-info/top_level.txt +1 -0
- path_link-0.2.0.dist-info/RECORD +0 -20
- path_link-0.2.0.dist-info/entry_points.txt +0 -2
- path_link-0.2.0.dist-info/top_level.txt +0 -1
- project_paths/builtin_validators/__init__.py +0 -6
- {project_paths → path_link}/main.py +0 -0
- {project_paths → path_link}/validation.py +0 -0
- {path_link-0.2.0.dist-info → path_link-0.3.0.dist-info}/WHEEL +0 -0
- {path_link-0.2.0.dist-info → path_link-0.3.0.dist-info}/licenses/LICENSE +0 -0
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|
4
4
|
|
5
5
|
## Project Overview
|
6
6
|
|
7
|
-
`
|
7
|
+
`path-link` is a Python library for type-safe project path management with built-in validation. It uses Pydantic models to dynamically create path configurations from `pyproject.toml` or custom `.paths` files, and can generate static dataclass models for enhanced IDE support.
|
8
8
|
|
9
9
|
## Development Setup
|
10
10
|
|
@@ -43,7 +43,7 @@ uv run ruff format .
|
|
43
43
|
uv run mypy src/
|
44
44
|
|
45
45
|
# Regenerate static model (required after pyproject.toml changes)
|
46
|
-
uv run python -c "from
|
46
|
+
uv run python -c "from path_link import write_dataclass_file; write_dataclass_file()"
|
47
47
|
|
48
48
|
# Or use justfile commands
|
49
49
|
just test
|
@@ -58,7 +58,7 @@ Verify setup is working:
|
|
58
58
|
```bash
|
59
59
|
uv run python scripts/smoke_import.py
|
60
60
|
# Or one-liner:
|
61
|
-
uv run python -c "from
|
61
|
+
uv run python -c "from path_link import ProjectPaths; p = ProjectPaths.from_pyproject(); print('✅ OK:', len(p.to_dict()), 'paths loaded')"
|
62
62
|
```
|
63
63
|
|
64
64
|
## Architecture
|
@@ -69,7 +69,7 @@ uv run python -c "from project_paths import ProjectPaths; p = ProjectPaths.from_
|
|
69
69
|
- Main API for path management
|
70
70
|
- Uses Pydantic dynamic model creation via `create_model()`
|
71
71
|
- Direct instantiation is disabled - use factory methods only:
|
72
|
-
- `ProjectPaths.from_pyproject()` - loads from `[tool.
|
72
|
+
- `ProjectPaths.from_pyproject()` - loads from `[tool.path_link]` in pyproject.toml
|
73
73
|
- `ProjectPaths.from_config(".paths")` - loads from dotenv-style files
|
74
74
|
- All path access returns `pathlib.Path` objects
|
75
75
|
- Supports dict-style access via `__getitem__`
|
@@ -96,7 +96,7 @@ uv run python -c "from project_paths import ProjectPaths; p = ProjectPaths.from_
|
|
96
96
|
5. **Static Model Generation** (`src/project_paths/get_paths.py`)
|
97
97
|
- `write_dataclass_file()` generates `project_paths_static.py` from current config
|
98
98
|
- Uses atomic file replacement via temp file to avoid TOCTOU issues
|
99
|
-
- Must be regenerated after modifying `[tool.
|
99
|
+
- Must be regenerated after modifying `[tool.path_link]` in pyproject.toml
|
100
100
|
|
101
101
|
6. **Documentation Access** (`src/project_paths/__init__.py`)
|
102
102
|
- `get_ai_guidelines()` - Returns AI assistant usage patterns (bundled assistant_context.md)
|
@@ -108,11 +108,11 @@ uv run python -c "from project_paths import ProjectPaths; p = ProjectPaths.from_
|
|
108
108
|
|
109
109
|
In `pyproject.toml`:
|
110
110
|
```toml
|
111
|
-
[tool.
|
111
|
+
[tool.path_link.paths]
|
112
112
|
config_dir = "config"
|
113
113
|
data_dir = "data"
|
114
114
|
|
115
|
-
[tool.
|
115
|
+
[tool.path_link.files]
|
116
116
|
settings_file = "config/settings.json"
|
117
117
|
```
|
118
118
|
|
@@ -127,9 +127,9 @@ settings_file=config/settings.json
|
|
127
127
|
|
128
128
|
The project uses `src` layout. All imports should be absolute from package name:
|
129
129
|
```python
|
130
|
-
from
|
131
|
-
from
|
132
|
-
from
|
130
|
+
from path_link import ProjectPaths, ValidationResult, StrictPathValidator
|
131
|
+
from path_link.model import _ProjectPathsBase
|
132
|
+
from path_link.validation import Finding, Severity
|
133
133
|
```
|
134
134
|
|
135
135
|
## Critical Rules
|
@@ -138,7 +138,7 @@ from project_paths.validation import Finding, Severity
|
|
138
138
|
|
139
139
|
**Correct:**
|
140
140
|
```python
|
141
|
-
from
|
141
|
+
from path_link import ProjectPaths
|
142
142
|
|
143
143
|
# Load from pyproject.toml
|
144
144
|
paths = ProjectPaths.from_pyproject()
|
@@ -159,9 +159,9 @@ paths = ProjectPaths() # Raises NotImplementedError
|
|
159
159
|
|
160
160
|
### Static Model Sync
|
161
161
|
|
162
|
-
After any change to `[tool.
|
162
|
+
After any change to `[tool.path_link]` in pyproject.toml, you MUST regenerate the static model:
|
163
163
|
```bash
|
164
|
-
uv run python -c "from
|
164
|
+
uv run python -c "from path_link import write_dataclass_file; write_dataclass_file()"
|
165
165
|
```
|
166
166
|
|
167
167
|
The CI check `just check-regen` verifies this file is in sync.
|
@@ -193,7 +193,7 @@ Use `SandboxPathValidator` to prevent path traversal attacks and ensure paths st
|
|
193
193
|
|
194
194
|
**Maximum Security (Recommended):**
|
195
195
|
```python
|
196
|
-
from
|
196
|
+
from path_link import ProjectPaths, SandboxPathValidator
|
197
197
|
|
198
198
|
paths = ProjectPaths.from_pyproject()
|
199
199
|
validator = SandboxPathValidator(
|
@@ -259,7 +259,7 @@ uv run pytest tests/test_validators.py::test_strict_validator_required
|
|
259
259
|
|
260
260
|
### Basic Validation
|
261
261
|
```python
|
262
|
-
from
|
262
|
+
from path_link import ProjectPaths, validate_or_raise, StrictPathValidator
|
263
263
|
|
264
264
|
paths = ProjectPaths.from_pyproject()
|
265
265
|
validator = StrictPathValidator(
|
@@ -274,7 +274,7 @@ validate_or_raise(paths, validator)
|
|
274
274
|
|
275
275
|
### Composite Validation
|
276
276
|
```python
|
277
|
-
from
|
277
|
+
from path_link import CompositeValidator
|
278
278
|
|
279
279
|
validator = CompositeValidator(parts=[
|
280
280
|
StrictPathValidator(required=["config_dir"]),
|
@@ -292,7 +292,7 @@ if not result.ok():
|
|
292
292
|
Implement the `PathValidator` protocol:
|
293
293
|
```python
|
294
294
|
from dataclasses import dataclass
|
295
|
-
from
|
295
|
+
from path_link import ValidationResult, Finding, Severity
|
296
296
|
|
297
297
|
@dataclass
|
298
298
|
class CustomValidator:
|
@@ -314,7 +314,7 @@ class CustomValidator:
|
|
314
314
|
The package includes bundled documentation accessible via three helper functions. This enables offline access and is especially useful for AI assistants helping users.
|
315
315
|
|
316
316
|
```python
|
317
|
-
from
|
317
|
+
from path_link import get_ai_guidelines, get_developer_guide, get_metadata
|
318
318
|
import json
|
319
319
|
|
320
320
|
# Access AI assistant guidelines (assistant_context.md)
|
@@ -387,7 +387,7 @@ scripts/
|
|
387
387
|
### Import errors
|
388
388
|
- Ensure virtual environment is activated
|
389
389
|
- Verify editable install: `uv pip install -e ".[test]"`
|
390
|
-
- Check import uses package name: `from
|
390
|
+
- Check import uses package name: `from path_link import ...`
|
391
391
|
|
392
392
|
### Static model out of sync
|
393
393
|
- Run: `just check-regen` to detect
|
@@ -1,5 +1,5 @@
|
|
1
1
|
{
|
2
|
-
"dist_name": "
|
2
|
+
"dist_name": "path-link",
|
3
3
|
"import_name": "project_paths",
|
4
4
|
"version": "0.2.0",
|
5
5
|
"factories": [
|
@@ -26,17 +26,17 @@
|
|
26
26
|
"validators": [
|
27
27
|
{
|
28
28
|
"name": "StrictPathValidator",
|
29
|
-
"module": "
|
29
|
+
"module": "path_link.builtin_validators.strict",
|
30
30
|
"description": "Ensures paths exist and match expected types (file/directory)"
|
31
31
|
},
|
32
32
|
{
|
33
33
|
"name": "SandboxPathValidator",
|
34
|
-
"module": "
|
34
|
+
"module": "path_link.builtin_validators.sandbox",
|
35
35
|
"description": "Prevents path traversal attacks and enforces base directory sandbox"
|
36
36
|
},
|
37
37
|
{
|
38
38
|
"name": "CompositeValidator",
|
39
|
-
"module": "
|
39
|
+
"module": "path_link.validation",
|
40
40
|
"description": "Combines multiple validators into a single validation pipeline"
|
41
41
|
}
|
42
42
|
],
|
@@ -69,10 +69,10 @@
|
|
69
69
|
"last_verified": "2025-10-10"
|
70
70
|
},
|
71
71
|
"critical_commands": {
|
72
|
-
"smoke_test": "uv run python -c \"from
|
72
|
+
"smoke_test": "uv run python -c \"from path_link import ProjectPaths; p = ProjectPaths.from_pyproject(); print('✅ OK:', len(p.to_dict()), 'paths loaded')\"",
|
73
73
|
"run_tests": "uv run pytest",
|
74
74
|
"check_coverage": "uv run pytest --cov=src --cov-report=term-missing:skip-covered",
|
75
|
-
"regenerate_static": "uv run python -c \"from
|
75
|
+
"regenerate_static": "uv run python -c \"from path_link import write_dataclass_file; write_dataclass_file()\"",
|
76
76
|
"verify_static_sync": "just check-regen"
|
77
77
|
},
|
78
78
|
"known_issues": {
|
@@ -3,7 +3,7 @@ import os
|
|
3
3
|
from pathlib import Path
|
4
4
|
from typing import Optional, Union
|
5
5
|
|
6
|
-
from .model import ProjectPaths
|
6
|
+
from path_link.model import ProjectPaths
|
7
7
|
|
8
8
|
|
9
9
|
def generate_static_model_text() -> str:
|
@@ -14,8 +14,8 @@ def generate_static_model_text() -> str:
|
|
14
14
|
"from pathlib import Path",
|
15
15
|
"from dataclasses import dataclass, field",
|
16
16
|
"",
|
17
|
-
"# This file is auto-generated by
|
18
|
-
"# Run `
|
17
|
+
"# This file is auto-generated by path-link. Do not edit manually.",
|
18
|
+
"# Run `pathlink gen-static` or `just regen` to regenerate.",
|
19
19
|
"",
|
20
20
|
"@dataclass(frozen=True)",
|
21
21
|
"class ProjectPathsStatic:",
|
@@ -1,8 +1,8 @@
|
|
1
1
|
from pathlib import Path
|
2
2
|
from dataclasses import dataclass, field
|
3
3
|
|
4
|
-
# This file is auto-generated by
|
5
|
-
# Run `
|
4
|
+
# This file is auto-generated by path-link. Do not edit manually.
|
5
|
+
# Run `pathlink gen-static` or `just regen` to regenerate.
|
6
6
|
|
7
7
|
@dataclass(frozen=True)
|
8
8
|
class ProjectPathsStatic:
|
path_link/url_factory.py
ADDED
@@ -0,0 +1,225 @@
|
|
1
|
+
"""Factory for creating ProjectUrls instances with validation mode support.
|
2
|
+
|
3
|
+
This module provides factory methods to create ProjectUrls instances from
|
4
|
+
various configuration sources (pyproject.toml, .urls files) with configurable
|
5
|
+
validation modes (lenient or strict).
|
6
|
+
"""
|
7
|
+
|
8
|
+
from __future__ import annotations
|
9
|
+
|
10
|
+
import os
|
11
|
+
from pathlib import Path
|
12
|
+
from typing import Union, Any
|
13
|
+
|
14
|
+
from pydantic import create_model, Field
|
15
|
+
|
16
|
+
from path_link.url_model import _ProjectUrlsBase, ValidationMode, validate_url
|
17
|
+
from path_link.builder import (
|
18
|
+
get_urls_from_pyproject,
|
19
|
+
get_urls_from_dot_urls,
|
20
|
+
get_urls_merged,
|
21
|
+
)
|
22
|
+
|
23
|
+
|
24
|
+
def _get_validation_mode_from_env() -> ValidationMode:
|
25
|
+
"""
|
26
|
+
Determine validation mode from environment variable.
|
27
|
+
|
28
|
+
Checks PTOOL_URL_MODE environment variable:
|
29
|
+
- "strict" -> ValidationMode.STRICT
|
30
|
+
- anything else -> ValidationMode.LENIENT (default)
|
31
|
+
|
32
|
+
Returns:
|
33
|
+
ValidationMode enum value
|
34
|
+
"""
|
35
|
+
mode_str = os.getenv("PTOOL_URL_MODE", "lenient").lower()
|
36
|
+
if mode_str == "strict":
|
37
|
+
return ValidationMode.STRICT
|
38
|
+
return ValidationMode.LENIENT
|
39
|
+
|
40
|
+
|
41
|
+
def _make_url_field(url_value: str, mode: ValidationMode) -> tuple[type[str], Any]:
|
42
|
+
"""
|
43
|
+
Create a Pydantic field definition for a URL with validation.
|
44
|
+
|
45
|
+
Args:
|
46
|
+
url_value: The URL string to validate
|
47
|
+
mode: Validation mode (lenient or strict)
|
48
|
+
|
49
|
+
Returns:
|
50
|
+
Tuple of (type, Field) for Pydantic model definition
|
51
|
+
"""
|
52
|
+
# Validate the URL according to the mode
|
53
|
+
validated_url = validate_url(url_value, mode)
|
54
|
+
|
55
|
+
# Return a field with the validated URL as default
|
56
|
+
return (str, Field(default=validated_url))
|
57
|
+
|
58
|
+
|
59
|
+
def _build_url_field_definitions(
|
60
|
+
url_dict: dict[str, str], mode: ValidationMode
|
61
|
+
) -> dict[str, tuple[type[str], Any]]:
|
62
|
+
"""
|
63
|
+
Build Pydantic field definitions from URL dictionary.
|
64
|
+
|
65
|
+
Args:
|
66
|
+
url_dict: Dictionary of URL key-value pairs
|
67
|
+
mode: Validation mode to use for all URLs
|
68
|
+
|
69
|
+
Returns:
|
70
|
+
Dictionary of field definitions for dynamic Pydantic model
|
71
|
+
"""
|
72
|
+
fields = {}
|
73
|
+
|
74
|
+
for key, url_value in url_dict.items():
|
75
|
+
fields[key] = _make_url_field(url_value, mode)
|
76
|
+
|
77
|
+
return fields
|
78
|
+
|
79
|
+
|
80
|
+
class ProjectUrls:
|
81
|
+
"""
|
82
|
+
Main URL management class.
|
83
|
+
|
84
|
+
Do not instantiate this class directly. Use the factory methods:
|
85
|
+
- `ProjectUrls.from_pyproject(mode=ValidationMode.LENIENT)`
|
86
|
+
- `ProjectUrls.from_config("path/to/.urls", mode=ValidationMode.LENIENT)`
|
87
|
+
- `ProjectUrls.from_merged(mode=ValidationMode.LENIENT)`
|
88
|
+
"""
|
89
|
+
|
90
|
+
def __init__(self, **kwargs: Any):
|
91
|
+
raise NotImplementedError(
|
92
|
+
"Direct instantiation of ProjectUrls is not supported. "
|
93
|
+
"Use a factory method: `from_pyproject()`, `from_config()`, or `from_merged()`."
|
94
|
+
)
|
95
|
+
|
96
|
+
@classmethod
|
97
|
+
def from_pyproject(
|
98
|
+
cls, mode: Union[ValidationMode, str, None] = None
|
99
|
+
) -> _ProjectUrlsBase:
|
100
|
+
"""
|
101
|
+
Creates a ProjectUrls instance from pyproject.toml [tool.path_link.urls].
|
102
|
+
|
103
|
+
Args:
|
104
|
+
mode: Validation mode (lenient or strict). If None, reads from
|
105
|
+
PTOOL_URL_MODE environment variable (default: lenient)
|
106
|
+
|
107
|
+
Returns:
|
108
|
+
Dynamic Pydantic model instance with validated URLs
|
109
|
+
|
110
|
+
Raises:
|
111
|
+
ValidationError: If any URL fails validation for the given mode
|
112
|
+
TypeError: If urls section is not a dict in pyproject.toml
|
113
|
+
"""
|
114
|
+
# Determine validation mode
|
115
|
+
if mode is None:
|
116
|
+
validation_mode = _get_validation_mode_from_env()
|
117
|
+
elif isinstance(mode, str):
|
118
|
+
validation_mode = ValidationMode(mode.lower())
|
119
|
+
else:
|
120
|
+
validation_mode = mode
|
121
|
+
|
122
|
+
# Load URLs from pyproject.toml
|
123
|
+
url_dict = get_urls_from_pyproject()
|
124
|
+
|
125
|
+
# Build field definitions with validation
|
126
|
+
field_defs = _build_url_field_definitions(url_dict, validation_mode)
|
127
|
+
|
128
|
+
# Create dynamic model
|
129
|
+
DynamicModel = create_model( # type: ignore[call-overload]
|
130
|
+
"ProjectUrlsDynamic",
|
131
|
+
__base__=(_ProjectUrlsBase,),
|
132
|
+
**field_defs,
|
133
|
+
)
|
134
|
+
|
135
|
+
return DynamicModel() # type: ignore[no-any-return]
|
136
|
+
|
137
|
+
@classmethod
|
138
|
+
def from_config(
|
139
|
+
cls, config_path: Union[str, Path], mode: Union[ValidationMode, str, None] = None
|
140
|
+
) -> _ProjectUrlsBase:
|
141
|
+
"""
|
142
|
+
Creates a ProjectUrls instance from a .urls file.
|
143
|
+
|
144
|
+
Args:
|
145
|
+
config_path: Path to .urls configuration file
|
146
|
+
mode: Validation mode (lenient or strict). If None, reads from
|
147
|
+
PTOOL_URL_MODE environment variable (default: lenient)
|
148
|
+
|
149
|
+
Returns:
|
150
|
+
Dynamic Pydantic model instance with validated URLs
|
151
|
+
|
152
|
+
Raises:
|
153
|
+
FileNotFoundError: If config file doesn't exist
|
154
|
+
ValidationError: If any URL fails validation for the given mode
|
155
|
+
"""
|
156
|
+
# Determine validation mode
|
157
|
+
if mode is None:
|
158
|
+
validation_mode = _get_validation_mode_from_env()
|
159
|
+
elif isinstance(mode, str):
|
160
|
+
validation_mode = ValidationMode(mode.lower())
|
161
|
+
else:
|
162
|
+
validation_mode = mode
|
163
|
+
|
164
|
+
# Load URLs from .urls file
|
165
|
+
resolved_path = Path(config_path)
|
166
|
+
url_dict = get_urls_from_dot_urls(resolved_path)
|
167
|
+
|
168
|
+
# Build field definitions with validation
|
169
|
+
field_defs = _build_url_field_definitions(url_dict, validation_mode)
|
170
|
+
|
171
|
+
# Create dynamic model
|
172
|
+
DynamicModel = create_model( # type: ignore[call-overload]
|
173
|
+
"ProjectUrlsDynamic",
|
174
|
+
__base__=(_ProjectUrlsBase,),
|
175
|
+
**field_defs,
|
176
|
+
)
|
177
|
+
|
178
|
+
return DynamicModel() # type: ignore[no-any-return]
|
179
|
+
|
180
|
+
@classmethod
|
181
|
+
def from_merged(
|
182
|
+
cls,
|
183
|
+
dotenv_path: Union[str, Path, None] = None,
|
184
|
+
mode: Union[ValidationMode, str, None] = None,
|
185
|
+
) -> _ProjectUrlsBase:
|
186
|
+
"""
|
187
|
+
Creates a ProjectUrls instance from merged pyproject.toml and .urls sources.
|
188
|
+
|
189
|
+
Precedence: pyproject.toml > .urls file
|
190
|
+
Missing keys are merged; duplicates resolved by pyproject.toml.
|
191
|
+
|
192
|
+
Args:
|
193
|
+
dotenv_path: Optional path to .urls file (default: ./.urls)
|
194
|
+
mode: Validation mode (lenient or strict). If None, reads from
|
195
|
+
PTOOL_URL_MODE environment variable (default: lenient)
|
196
|
+
|
197
|
+
Returns:
|
198
|
+
Dynamic Pydantic model instance with validated URLs
|
199
|
+
|
200
|
+
Raises:
|
201
|
+
ValidationError: If any URL fails validation for the given mode
|
202
|
+
"""
|
203
|
+
# Determine validation mode
|
204
|
+
if mode is None:
|
205
|
+
validation_mode = _get_validation_mode_from_env()
|
206
|
+
elif isinstance(mode, str):
|
207
|
+
validation_mode = ValidationMode(mode.lower())
|
208
|
+
else:
|
209
|
+
validation_mode = mode
|
210
|
+
|
211
|
+
# Load merged URLs
|
212
|
+
resolved_dotenv = Path(dotenv_path) if dotenv_path else None
|
213
|
+
url_dict = get_urls_merged(resolved_dotenv)
|
214
|
+
|
215
|
+
# Build field definitions with validation
|
216
|
+
field_defs = _build_url_field_definitions(url_dict, validation_mode)
|
217
|
+
|
218
|
+
# Create dynamic model
|
219
|
+
DynamicModel = create_model( # type: ignore[call-overload]
|
220
|
+
"ProjectUrlsDynamic",
|
221
|
+
__base__=(_ProjectUrlsBase,),
|
222
|
+
**field_defs,
|
223
|
+
)
|
224
|
+
|
225
|
+
return DynamicModel() # type: ignore[no-any-return]
|
path_link/url_model.py
ADDED
@@ -0,0 +1,151 @@
|
|
1
|
+
"""URL validation models with lenient and strict modes.
|
2
|
+
|
3
|
+
This module provides Pydantic validators for URL strings with two validation modes:
|
4
|
+
- lenient: Allows localhost, private IPs, custom ports (for development)
|
5
|
+
- strict: RFC-aligned HTTP(S) only (for production)
|
6
|
+
"""
|
7
|
+
|
8
|
+
from __future__ import annotations
|
9
|
+
|
10
|
+
from enum import Enum
|
11
|
+
from typing import Any
|
12
|
+
|
13
|
+
from pydantic import AnyUrl, HttpUrl, field_validator, BaseModel, ConfigDict
|
14
|
+
|
15
|
+
|
16
|
+
class ValidationMode(str, Enum):
|
17
|
+
"""URL validation mode."""
|
18
|
+
|
19
|
+
LENIENT = "lenient"
|
20
|
+
STRICT = "strict"
|
21
|
+
|
22
|
+
|
23
|
+
class _ProjectUrlsBase(BaseModel):
|
24
|
+
"""
|
25
|
+
Internal base class for ProjectUrls.
|
26
|
+
Provides core functionality and should not be used directly by end-users.
|
27
|
+
"""
|
28
|
+
|
29
|
+
model_config = ConfigDict(validate_assignment=True)
|
30
|
+
|
31
|
+
def to_dict(self) -> dict[str, str]:
|
32
|
+
"""Returns a dictionary of all resolved URL attributes as strings."""
|
33
|
+
return {k: str(v) for k, v in self.model_dump(include=set(self.__class__.model_fields.keys())).items()} # type: ignore[no-any-return]
|
34
|
+
|
35
|
+
def get_urls(self) -> list[str]:
|
36
|
+
"""Returns a list of all resolved URL strings."""
|
37
|
+
return [str(v) for v in self.to_dict().values()]
|
38
|
+
|
39
|
+
def __getitem__(self, key: str) -> str:
|
40
|
+
"""Enables dictionary-style access to URL attributes."""
|
41
|
+
if key not in self.__class__.model_fields:
|
42
|
+
raise KeyError(f"'{key}' is not a configured URL.")
|
43
|
+
value = getattr(self, key)
|
44
|
+
return str(value) # type: ignore[no-any-return]
|
45
|
+
|
46
|
+
|
47
|
+
class LenientUrlModel(BaseModel):
|
48
|
+
"""
|
49
|
+
Lenient URL validator that accepts localhost, private IPs, and custom ports.
|
50
|
+
|
51
|
+
Suitable for development environments where URLs like http://localhost:8000
|
52
|
+
are common.
|
53
|
+
"""
|
54
|
+
|
55
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
56
|
+
url: AnyUrl
|
57
|
+
|
58
|
+
@field_validator("url", mode="before")
|
59
|
+
@classmethod
|
60
|
+
def validate_lenient_url(cls, v: Any) -> Any:
|
61
|
+
"""
|
62
|
+
Lenient validation: accept any syntactically valid URL.
|
63
|
+
|
64
|
+
This includes:
|
65
|
+
- localhost and 127.0.0.1
|
66
|
+
- Private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
|
67
|
+
- Custom ports
|
68
|
+
- http and https schemes
|
69
|
+
"""
|
70
|
+
if not isinstance(v, str):
|
71
|
+
v = str(v)
|
72
|
+
|
73
|
+
# Basic checks for obviously invalid URLs
|
74
|
+
if not v or v.isspace():
|
75
|
+
raise ValueError("URL cannot be empty or whitespace")
|
76
|
+
|
77
|
+
# Let Pydantic's AnyUrl handle the rest of the validation
|
78
|
+
# It's permissive enough for development use cases
|
79
|
+
return v
|
80
|
+
|
81
|
+
|
82
|
+
class StrictUrlModel(BaseModel):
|
83
|
+
"""
|
84
|
+
Strict URL validator that only accepts RFC-compliant HTTP(S) URLs.
|
85
|
+
|
86
|
+
Suitable for production environments where URLs must be publicly accessible
|
87
|
+
and follow strict HTTP(S) standards.
|
88
|
+
"""
|
89
|
+
|
90
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
91
|
+
url: HttpUrl
|
92
|
+
|
93
|
+
@field_validator("url", mode="after")
|
94
|
+
@classmethod
|
95
|
+
def validate_strict_url(cls, v: HttpUrl) -> HttpUrl:
|
96
|
+
"""
|
97
|
+
Strict validation: only accept HTTP(S) URLs with proper hosts.
|
98
|
+
|
99
|
+
This rejects:
|
100
|
+
- localhost URLs
|
101
|
+
- Private IP addresses
|
102
|
+
- Non-HTTP(S) schemes
|
103
|
+
- Malformed URLs
|
104
|
+
"""
|
105
|
+
# Check for localhost
|
106
|
+
host = v.host
|
107
|
+
if host in ("localhost", "127.0.0.1", "::1"):
|
108
|
+
raise ValueError("localhost URLs are not allowed in strict mode")
|
109
|
+
|
110
|
+
# Check for private IP ranges
|
111
|
+
if host:
|
112
|
+
# Check IPv4 private ranges
|
113
|
+
if host.startswith("10."):
|
114
|
+
raise ValueError("Private IP addresses (10.0.0.0/8) are not allowed in strict mode")
|
115
|
+
if host.startswith("172.") and len(host.split(".")) == 4:
|
116
|
+
second_octet = int(host.split(".")[1])
|
117
|
+
if 16 <= second_octet <= 31:
|
118
|
+
raise ValueError("Private IP addresses (172.16.0.0/12) are not allowed in strict mode")
|
119
|
+
if host.startswith("192.168."):
|
120
|
+
raise ValueError("Private IP addresses (192.168.0.0/16) are not allowed in strict mode")
|
121
|
+
|
122
|
+
return v
|
123
|
+
|
124
|
+
|
125
|
+
def validate_url(url_string: str, mode: ValidationMode = ValidationMode.LENIENT) -> str:
|
126
|
+
"""
|
127
|
+
Validate a URL string according to the specified mode.
|
128
|
+
|
129
|
+
Args:
|
130
|
+
url_string: The URL string to validate
|
131
|
+
mode: Validation mode (lenient or strict)
|
132
|
+
|
133
|
+
Returns:
|
134
|
+
The validated URL as a string
|
135
|
+
|
136
|
+
Raises:
|
137
|
+
ValidationError: If the URL is invalid for the given mode
|
138
|
+
|
139
|
+
Examples:
|
140
|
+
>>> validate_url("http://localhost:8000", ValidationMode.LENIENT)
|
141
|
+
'http://localhost:8000/'
|
142
|
+
|
143
|
+
>>> validate_url("https://example.com/api", ValidationMode.STRICT)
|
144
|
+
'https://example.com/api'
|
145
|
+
"""
|
146
|
+
if mode == ValidationMode.LENIENT:
|
147
|
+
lenient_model = LenientUrlModel(url=url_string) # type: ignore[arg-type]
|
148
|
+
return str(lenient_model.url)
|
149
|
+
else:
|
150
|
+
strict_model = StrictUrlModel(url=url_string) # type: ignore[arg-type]
|
151
|
+
return str(strict_model.url)
|