mockstack 0.0.3__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,114 @@
1
+ """Templates related functionality."""
2
+
3
+ from collections import OrderedDict
4
+ from pathlib import Path
5
+ from typing import Generator
6
+
7
+ from fastapi import Request
8
+
9
+ from mockstack.identifiers import looks_like_id, prefixes
10
+
11
+
12
+ def missing_template_detail(request: Request, *, templates_dir: Path) -> str:
13
+ """Return a detailed message for a missing template."""
14
+ return (
15
+ "Template not found for given request. "
16
+ f"path: {request.url.path}, "
17
+ f"query: {request.query_params}, "
18
+ f"templates_dir: {templates_dir}, "
19
+ )
20
+
21
+
22
+ def iter_possible_template_arguments(
23
+ request: Request,
24
+ default_identifier_key: str = "id",
25
+ default_media_type: str = "application/json",
26
+ default_template_name: str = "index.j2",
27
+ template_file_separator: str = "-",
28
+ template_file_extension: str = ".j2",
29
+ ) -> Generator[dict, None, None]:
30
+ """Infer the template arguments for a given request.
31
+
32
+ This includes:
33
+
34
+ - Inferring the name for the template file from the URL
35
+ - Inferring the context variables available for the template from the URL and request body.
36
+ - Inferring the response (media) type for the template from the URL and request body.
37
+
38
+ There is a fair amount of extrapolation happening here. The philosophy is to provide
39
+ a behavior that "just works" for the majority of the cases encountered in practice.
40
+
41
+ """
42
+ path = request.url.path
43
+
44
+ name_segments, context = parse_template_name_segments_and_context(
45
+ path,
46
+ default_identifier_key=default_identifier_key,
47
+ )
48
+ media_type = request.headers.get("Content-Type", default_media_type)
49
+
50
+ template_name_kwargs = dict(
51
+ template_file_separator=template_file_separator,
52
+ template_file_extension=template_file_extension,
53
+ default_template_name=default_template_name,
54
+ )
55
+ for name in iter_possible_template_filenames(
56
+ name_segments, context, **template_name_kwargs
57
+ ):
58
+ yield dict(
59
+ name=name,
60
+ context=context,
61
+ media_type=media_type,
62
+ )
63
+
64
+
65
+ def parse_template_name_segments_and_context(
66
+ path: str, *, default_identifier_key: str
67
+ ) -> tuple[list[str], dict[str, str]]:
68
+ """Infer the template name segments and the template context for a given URI path."""
69
+ name_segments: list[str] = []
70
+ context: OrderedDict[str, str] = OrderedDict()
71
+ for segment in (s for s in path.split("/") if s):
72
+ if looks_like_id(segment):
73
+ if name_segments:
74
+ # this is a nested identifier, use the last name segment as the key
75
+ context[name_segments[-1]] = segment
76
+ else:
77
+ # this identifier is unscoped, use our default identifier key
78
+ context[default_identifier_key] = segment
79
+ else:
80
+ name_segments.append(segment)
81
+
82
+ return name_segments, context
83
+
84
+
85
+ def iter_possible_template_filenames(
86
+ name_segments: list[str],
87
+ context: dict[str, str],
88
+ *,
89
+ template_file_separator: str,
90
+ template_file_extension: str,
91
+ default_template_name: str,
92
+ ) -> Generator[str, None, None]:
93
+ """Infer the template filename from the name segments and context.
94
+
95
+ We have a cascade of possible filename formats:
96
+
97
+ - <n>.<id>.<id>.j2
98
+ - <n>.<id>.j2
99
+ - <n>.j2
100
+
101
+ The first option is the most specific, and the last option is the least specific.
102
+ The IDs correspond to any identifiers found in the path of the request, in order.
103
+
104
+ """
105
+ if name_segments:
106
+ if context:
107
+ for prefix in prefixes(context.values(), reverse=True):
108
+ yield (
109
+ f"{template_file_separator.join(name_segments)}.{'.'.join(prefix)}{template_file_extension}"
110
+ )
111
+
112
+ yield template_file_separator.join(name_segments) + template_file_extension
113
+
114
+ yield default_template_name
File without changes
@@ -0,0 +1,29 @@
1
+ """Shared fixtures for the unit-tests."""
2
+
3
+ import os
4
+
5
+ import pytest
6
+ from fastapi import FastAPI
7
+
8
+ from mockstack.config import OpenTelemetrySettings, Settings
9
+
10
+
11
+ @pytest.fixture
12
+ def app():
13
+ """Create a FastAPI app for testing."""
14
+ return FastAPI()
15
+
16
+
17
+ @pytest.fixture
18
+ def templates_dir():
19
+ """Return the path to the test templates directory."""
20
+ return os.path.join(os.path.dirname(__file__), "fixtures", "templates")
21
+
22
+
23
+ @pytest.fixture
24
+ def settings(templates_dir):
25
+ """Return a Settings object for testing."""
26
+ return Settings(
27
+ templates_dir=templates_dir,
28
+ opentelemetry=OpenTelemetrySettings(enabled=False),
29
+ )
File without changes
File without changes
@@ -0,0 +1,46 @@
1
+ """Tests for the catchall router module."""
2
+
3
+ from unittest.mock import AsyncMock
4
+
5
+ import pytest
6
+ from fastapi.responses import JSONResponse
7
+ from starlette.testclient import TestClient
8
+
9
+ from mockstack.routers.catchall import catchall_router_provider
10
+
11
+
12
+ @pytest.fixture
13
+ def mock_strategy():
14
+ """Create a mock strategy for testing."""
15
+ strategy = AsyncMock()
16
+ response_data = {"message": "Mock response"}
17
+ strategy.apply.return_value = JSONResponse(content=response_data)
18
+ return strategy
19
+
20
+
21
+ @pytest.mark.asyncio
22
+ async def test_catchall_router_provider(app, settings, mock_strategy):
23
+ """Test that the catchall router provider sets up routes correctly."""
24
+ # Set up the app state with the mock strategy
25
+ app.state.strategy = mock_strategy
26
+
27
+ # Apply the router provider
28
+ catchall_router_provider(app, settings)
29
+
30
+ # Create a test client
31
+ client = TestClient(app)
32
+
33
+ # Test each HTTP method
34
+ for method in ["GET", "POST", "PUT", "DELETE", "PATCH"]:
35
+ # Make the request using the test client
36
+ response = client.request(method, "/test/path")
37
+
38
+ # Verify the response
39
+ assert response.status_code == 200
40
+ assert response.json() == {"message": "Mock response"}
41
+
42
+ # Verify the strategy was called
43
+ mock_strategy.apply.assert_called_once()
44
+
45
+ # Reset the mock for the next iteration
46
+ mock_strategy.reset_mock()
@@ -0,0 +1,27 @@
1
+ """Tests for the homepage router module."""
2
+
3
+ import pytest
4
+
5
+ from mockstack.routers.homepage import homepage_router_provider
6
+
7
+
8
+ @pytest.mark.asyncio
9
+ async def test_homepage_router_provider(app, settings):
10
+ """Test that the homepage router provider sets up routes correctly."""
11
+ # Apply the router provider
12
+ router = homepage_router_provider(app, settings)
13
+
14
+ # Include the router in the app
15
+ app.include_router(router)
16
+
17
+ # Create a test client
18
+ from fastapi.testclient import TestClient
19
+
20
+ client = TestClient(app)
21
+
22
+ # Test the homepage endpoint
23
+ response = client.get("/")
24
+
25
+ # Verify the response
26
+ assert response.status_code == 200
27
+ assert response.json() == {"Hello": "World"}
@@ -0,0 +1,312 @@
1
+ """Unit tests for the filefixtures strategy module."""
2
+
3
+ from unittest.mock import MagicMock, patch
4
+
5
+ import pytest
6
+ from fastapi import HTTPException, Request
7
+
8
+ from mockstack.strategies.filefixtures import (
9
+ FileFixturesStrategy,
10
+ )
11
+ from mockstack.templating import (
12
+ iter_possible_template_arguments,
13
+ iter_possible_template_filenames,
14
+ parse_template_name_segments_and_context,
15
+ )
16
+
17
+
18
+ @pytest.mark.parametrize(
19
+ "path,expected_results",
20
+ [
21
+ (
22
+ "/api/v1/projects/1234",
23
+ [
24
+ {
25
+ "name": "api-v1-projects.1234.j2",
26
+ "context": {"projects": "1234"},
27
+ "media_type": "application/json",
28
+ },
29
+ {
30
+ "name": "api-v1-projects.j2",
31
+ "context": {"projects": "1234"},
32
+ "media_type": "application/json",
33
+ },
34
+ {
35
+ "name": "index.j2",
36
+ "context": {"projects": "1234"},
37
+ "media_type": "application/json",
38
+ },
39
+ ],
40
+ ),
41
+ (
42
+ "/api/v1/users/3a4e5ad9-17ee-41af-972f-864dfccd4856",
43
+ [
44
+ {
45
+ "name": "api-v1-users.3a4e5ad9-17ee-41af-972f-864dfccd4856.j2",
46
+ "context": {"users": "3a4e5ad9-17ee-41af-972f-864dfccd4856"},
47
+ "media_type": "application/json",
48
+ },
49
+ {
50
+ "name": "api-v1-users.j2",
51
+ "context": {"users": "3a4e5ad9-17ee-41af-972f-864dfccd4856"},
52
+ "media_type": "application/json",
53
+ },
54
+ {
55
+ "name": "index.j2",
56
+ "context": {"users": "3a4e5ad9-17ee-41af-972f-864dfccd4856"},
57
+ "media_type": "application/json",
58
+ },
59
+ ],
60
+ ),
61
+ (
62
+ "/api/v1/projects",
63
+ [
64
+ {
65
+ "name": "api-v1-projects.j2",
66
+ "context": {},
67
+ "media_type": "application/json",
68
+ },
69
+ {
70
+ "name": "index.j2",
71
+ "context": {},
72
+ "media_type": "application/json",
73
+ },
74
+ ],
75
+ ),
76
+ (
77
+ "/1234",
78
+ [
79
+ {
80
+ "name": "index.j2",
81
+ "context": {"id": "1234"},
82
+ "media_type": "application/json",
83
+ },
84
+ ],
85
+ ),
86
+ ],
87
+ )
88
+ def test_iter_possible_template_arguments(
89
+ path: str,
90
+ expected_results: list,
91
+ ) -> None:
92
+ """Test the iter_possible_template_arguments function with various paths."""
93
+ request = Request(
94
+ scope={
95
+ "type": "http",
96
+ "method": "GET",
97
+ "path": path,
98
+ "query_string": b"",
99
+ "headers": [],
100
+ }
101
+ )
102
+
103
+ results = list(iter_possible_template_arguments(request))
104
+ assert len(results) == len(expected_results)
105
+
106
+ for actual, expected in zip(results, expected_results):
107
+ assert actual["name"] == expected["name"]
108
+ assert actual["context"] == expected["context"]
109
+ assert actual["media_type"] == expected["media_type"]
110
+
111
+
112
+ def test_iter_possible_template_arguments_with_custom_media_type():
113
+ """Test that custom media type from headers is respected."""
114
+ request = Request(
115
+ scope={
116
+ "type": "http",
117
+ "method": "GET",
118
+ "path": "/api/v1/projects",
119
+ "query_string": b"",
120
+ "headers": [(b"content-type", b"application/xml")],
121
+ }
122
+ )
123
+
124
+ results = list(iter_possible_template_arguments(request))
125
+ assert len(results) == 2
126
+ assert results[0]["media_type"] == "application/xml"
127
+ assert results[1]["media_type"] == "application/xml"
128
+
129
+
130
+ def test_parse_template_name_segments_and_context():
131
+ """Test the parse_template_name_segments_and_context function."""
132
+ # Test with a simple path
133
+ name_segments, context = parse_template_name_segments_and_context(
134
+ "/api/v1/projects/1234", default_identifier_key="id"
135
+ )
136
+ assert name_segments == ["api", "v1", "projects"]
137
+ assert context == {"projects": "1234"}
138
+
139
+ # Test with a path with no identifiers
140
+ name_segments, context = parse_template_name_segments_and_context(
141
+ "/api/v1/projects", default_identifier_key="id"
142
+ )
143
+ assert name_segments == ["api", "v1", "projects"]
144
+ assert context == {}
145
+
146
+ # Test with a path with only an identifier
147
+ name_segments, context = parse_template_name_segments_and_context(
148
+ "/1234", default_identifier_key="id"
149
+ )
150
+ assert name_segments == []
151
+ assert context == {"id": "1234"}
152
+
153
+ # Test with a path with multiple identifiers
154
+ name_segments, context = parse_template_name_segments_and_context(
155
+ "/api/v1/projects/1234/tasks/5678", default_identifier_key="id"
156
+ )
157
+ assert name_segments == ["api", "v1", "projects", "tasks"]
158
+ assert context == {"projects": "1234", "tasks": "5678"}
159
+
160
+
161
+ def test_iter_possible_template_filenames():
162
+ """Test the iter_possible_template_filenames function."""
163
+ # Test with name segments and context
164
+ filenames = list(
165
+ iter_possible_template_filenames(
166
+ ["api", "v1", "projects"],
167
+ context={"projects": "1234"},
168
+ template_file_separator="-",
169
+ template_file_extension=".j2",
170
+ default_template_name="index.j2",
171
+ )
172
+ )
173
+ assert filenames == ["api-v1-projects.1234.j2", "api-v1-projects.j2", "index.j2"]
174
+
175
+ # Test with name segments and no context
176
+ filenames = list(
177
+ iter_possible_template_filenames(
178
+ ["api", "v1", "projects"],
179
+ context={},
180
+ template_file_separator="-",
181
+ template_file_extension=".j2",
182
+ default_template_name="index.j2",
183
+ )
184
+ )
185
+ assert filenames == ["api-v1-projects.j2", "index.j2"]
186
+
187
+ # Test with no name segments and context
188
+ filenames = list(
189
+ iter_possible_template_filenames(
190
+ [],
191
+ context={"id": "1234"},
192
+ template_file_separator="-",
193
+ template_file_extension=".j2",
194
+ default_template_name="index.j2",
195
+ )
196
+ )
197
+ assert filenames == ["index.j2"]
198
+
199
+ # Test with no name segments and no context
200
+ filenames = list(
201
+ iter_possible_template_filenames(
202
+ [],
203
+ context={},
204
+ template_file_separator="-",
205
+ template_file_extension=".j2",
206
+ default_template_name="index.j2",
207
+ )
208
+ )
209
+ assert filenames == ["index.j2"]
210
+
211
+ # Test with custom separator and extension
212
+ filenames = list(
213
+ iter_possible_template_filenames(
214
+ ["api", "v1", "projects"],
215
+ context={"projects": "1234"},
216
+ template_file_separator="_",
217
+ template_file_extension=".html",
218
+ default_template_name="default.html",
219
+ )
220
+ )
221
+ assert filenames == [
222
+ "api_v1_projects.1234.html",
223
+ "api_v1_projects.html",
224
+ "default.html",
225
+ ]
226
+
227
+
228
+ def test_filefixtures_strategy_init(settings):
229
+ """Test the FileFixturesStrategy initialization."""
230
+ strategy = FileFixturesStrategy(settings)
231
+ assert strategy.templates_dir == settings.templates_dir
232
+ assert strategy.env is not None
233
+
234
+
235
+ @pytest.mark.asyncio
236
+ async def test_filefixtures_strategy_apply(settings):
237
+ """Test the FileFixturesStrategy apply method."""
238
+ strategy = FileFixturesStrategy(settings)
239
+ request = Request(
240
+ scope={
241
+ "type": "http",
242
+ "method": "GET",
243
+ "path": "/api/v1/projects/1234",
244
+ "query_string": b"",
245
+ "headers": [],
246
+ }
247
+ )
248
+
249
+ with pytest.raises(HTTPException) as exc_info:
250
+ await strategy.apply(request)
251
+
252
+ assert exc_info.value.status_code == 404
253
+
254
+
255
+ @pytest.mark.asyncio
256
+ async def test_file_fixtures_strategy_apply_success(settings):
257
+ """Test the FileFixturesStrategy apply method when template exists."""
258
+ # Setup
259
+ strategy = FileFixturesStrategy(settings)
260
+
261
+ # Create a mock template
262
+ mock_template = MagicMock()
263
+ mock_template.render.return_value = '{"status": "success"}'
264
+
265
+ # Patch the environment to return our mock template
266
+ with (
267
+ patch.object(strategy.env, "get_template", return_value=mock_template),
268
+ patch("os.path.exists", return_value=True),
269
+ ):
270
+ request = Request(
271
+ scope={
272
+ "type": "http",
273
+ "method": "GET",
274
+ "path": "/api/v1/projects/1234",
275
+ "query_string": b"",
276
+ "headers": [],
277
+ }
278
+ )
279
+
280
+ # Execute
281
+ response = await strategy.apply(request)
282
+
283
+ # Assert
284
+ assert response.media_type == "application/json"
285
+ assert response.body.decode() == '{"status": "success"}'
286
+ mock_template.render.assert_called_once()
287
+
288
+
289
+ @pytest.mark.asyncio
290
+ async def test_file_fixtures_strategy_apply_template_not_found(settings):
291
+ """Test the FileFixturesStrategy apply method when template doesn't exist."""
292
+ # Setup
293
+ strategy = FileFixturesStrategy(settings)
294
+
295
+ # Mock os.path.exists to return False for all template files
296
+ with patch("os.path.exists", return_value=False):
297
+ request = Request(
298
+ scope={
299
+ "type": "http",
300
+ "method": "GET",
301
+ "path": "/api/v1/projects/1234",
302
+ "query_string": b"",
303
+ "headers": [],
304
+ }
305
+ )
306
+
307
+ # Execute and Assert
308
+ with pytest.raises(HTTPException) as exc_info:
309
+ await strategy.apply(request)
310
+
311
+ assert exc_info.value.status_code == 404
312
+ assert "Template not found" in str(exc_info.value.detail)
@@ -0,0 +1,33 @@
1
+ """Tests for the display module."""
2
+
3
+ from unittest.mock import patch
4
+
5
+ from mockstack.display import ANSIColors, announce
6
+
7
+
8
+ def test_ansicolors_constants():
9
+ """Test that ANSIColors constants are defined correctly."""
10
+ assert ANSIColors.HEADER == "\033[95m"
11
+ assert ANSIColors.OKBLUE == "\033[94m"
12
+ assert ANSIColors.OKCYAN == "\033[96m"
13
+ assert ANSIColors.OKGREEN == "\033[92m"
14
+ assert ANSIColors.WARNING == "\033[93m"
15
+ assert ANSIColors.FAIL == "\033[91m"
16
+ assert ANSIColors.ENDC == "\033[0m"
17
+ assert ANSIColors.BOLD == "\033[1m"
18
+ assert ANSIColors.UNDERLINE == "\033[4m"
19
+
20
+
21
+ def test_announce(settings):
22
+ """Test the announce function logs the correct message."""
23
+ with patch("mockstack.display.logging") as mock_logging:
24
+ mock_logger = mock_logging.getLogger.return_value
25
+
26
+ announce(settings)
27
+
28
+ mock_logging.getLogger.assert_called_once_with("uvicorn")
29
+ mock_logger.info.assert_called()
30
+
31
+ # Check that the log message contains the expected information
32
+ first_log_message = mock_logger.info.call_args[0][0]
33
+ assert "OpenTelemetry enabled:" in first_log_message
@@ -0,0 +1,94 @@
1
+ """Unit tests for the identifiers module."""
2
+
3
+ import pytest
4
+
5
+ from mockstack.identifiers import looks_like_id, prefixes
6
+
7
+
8
+ def test_prefixes():
9
+ # Test basic functionality
10
+ assert list(prefixes([1, 2, 3])) == [(1,), (1, 2), (1, 2, 3)]
11
+
12
+ # Test with reverse=True
13
+ assert list(prefixes([1, 2, 3], reverse=True)) == [(1, 2, 3), (1, 2), (1,)]
14
+
15
+ # Test with empty list
16
+ assert list(prefixes([])) == []
17
+
18
+ # Test with single element
19
+ assert list(prefixes([1])) == [(1,)]
20
+
21
+ # Test with strings
22
+ assert list(prefixes(["a", "b", "c"])) == [("a",), ("a", "b"), ("a", "b", "c")]
23
+
24
+
25
+ @pytest.mark.parametrize(
26
+ "chunk,expected,reason",
27
+ [
28
+ # Even length numeric IDs
29
+ ("1234", True, "Even length numeric"),
30
+ ("12", True, "Even length numeric"),
31
+ ("1234567890", True, "Even length numeric"),
32
+ # Odd length numeric IDs
33
+ ("123", False, "Odd length numeric"),
34
+ ("12345", False, "Odd length numeric"),
35
+ # Even length hexadecimal IDs
36
+ ("abcd", True, "Even length hex"),
37
+ ("1234abcd", True, "Even length hex"),
38
+ ("1234567890abcdef", True, "Even length hex"),
39
+ # Odd length hexadecimal IDs
40
+ ("abc", False, "Odd length hex"),
41
+ ("1234567890abcde", False, "Odd length hex"),
42
+ ("1234567890abcdefg", False, "Invalid hex character"),
43
+ ("xyz", False, "Not hex"),
44
+ # UUID format (36 characters with dashes)
45
+ ("3a4e5ad9-17ee-41af-972f-864dfccd4856", True, "Valid UUID"),
46
+ ("3A4E5AD9-17EE-41AF-972F-864DFCCD4856", True, "UUID with uppercase"),
47
+ ("3a4e5ad917ee41af972f864dfccd4856", True, "UUID without dashes"),
48
+ ("3a4e5ad9-17ee-41af-972f-864dfccd485", False, "UUID too short"),
49
+ ("3a4e5ad9-17ee-41af-972f-864dfccd4856-", False, "UUID too long"),
50
+ ("3a4e5ad9-17ee-41af-972f-864dfccd485g", False, "UUID with invalid char"),
51
+ # Common non-ID path segments
52
+ ("api", False, "Common path segment"),
53
+ ("v1", False, "API version"),
54
+ ("users", False, "Resource name"),
55
+ ("projects", False, "Resource name"),
56
+ ("index", False, "Page name"),
57
+ ("create", False, "Action name"),
58
+ ("update", False, "Action name"),
59
+ ("delete", False, "Action name"),
60
+ ],
61
+ )
62
+ def test_looks_like_id(chunk: str, expected: bool, reason: str) -> None:
63
+ """Test the looks_like_id function with various inputs.
64
+
65
+ The test cases cover:
66
+ 1. Even and odd length numeric IDs
67
+ 2. Even and odd length hexadecimal IDs
68
+ 3. Valid and invalid UUID formats
69
+ 4. Common non-ID path segments
70
+ """
71
+ assert looks_like_id(chunk) == expected, f"Failed for {chunk} ({reason})"
72
+
73
+
74
+ def test_looks_like_id_empty_string():
75
+ """Test that empty string is not considered an ID."""
76
+ assert not looks_like_id("")
77
+
78
+
79
+ def test_looks_like_id_whitespace():
80
+ """Test that whitespace is not considered an ID."""
81
+ assert not looks_like_id(" ")
82
+ assert not looks_like_id("\t")
83
+ assert not looks_like_id("\n")
84
+ assert not looks_like_id(" ")
85
+
86
+
87
+ def test_looks_like_id_special_chars():
88
+ """Test that strings with special characters are not considered IDs."""
89
+ assert not looks_like_id("12-34") # Hyphen in wrong place
90
+ assert not looks_like_id("12_34") # Underscore
91
+ assert not looks_like_id("12.34") # Period
92
+ assert not looks_like_id("12/34") # Slash
93
+ assert not looks_like_id("12+34") # Plus
94
+ assert not looks_like_id("@1234") # At symbol