mocklimit 0.1.0__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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stanislav Kosorin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,156 @@
1
+ Metadata-Version: 2.4
2
+ Name: mocklimit
3
+ Version: 0.1.0
4
+ Summary: Configurable mock API server with realistic rate limiting for testing
5
+ Keywords: mock,rate-limiting,api,testing,openapi
6
+ Author: Stanislav Kosorin
7
+ Author-email: Stanislav Kosorin <stanokosorin4@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Framework :: FastAPI
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Topic :: Software Development :: Testing
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Requires-Dist: fastapi>=0.135.1
19
+ Requires-Dist: jsonref>=1.1.0
20
+ Requires-Dist: pydantic>=2.0
21
+ Requires-Dist: pyyaml>=6.0.3
22
+ Requires-Dist: uvicorn>=0.42.0
23
+ Requires-Python: >=3.11
24
+ Project-URL: Documentation, https://github.com/stano45/mocklimit#readme
25
+ Project-URL: Issues, https://github.com/stano45/mocklimit/issues
26
+ Project-URL: Repository, https://github.com/stano45/mocklimit
27
+ Description-Content-Type: text/markdown
28
+
29
+ # mocklimit
30
+
31
+ [![CI](https://github.com/stano45/mocklimit/actions/workflows/ci.yml/badge.svg)](https://github.com/stano45/mocklimit/actions/workflows/ci.yml)
32
+ [![PyPI](https://img.shields.io/pypi/v/mocklimit)](https://pypi.org/project/mocklimit/)
33
+ [![Python](https://img.shields.io/pypi/pyversions/mocklimit)](https://pypi.org/project/mocklimit/)
34
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
35
+
36
+ Configurable mock API server with realistic rate limiting for testing.
37
+ Point it at an OpenAPI spec, define rate limit policies in YAML, and get a
38
+ local server that behaves like a rate-limited production API, complete with
39
+ correct headers, 429 responses, and token usage estimation.
40
+
41
+ ## Features
42
+
43
+ - **OpenAPI spec auto-routing** - parses your spec and registers all endpoints with dummy responses
44
+ - **Fixed window rate limiting** with sub-second precision
45
+ - **Quantized rate limiter** for aligned reset windows
46
+ - **Composite limits** - stack multiple limits per endpoint (e.g. RPM + TPM)
47
+ - **Provider-accurate headers** - configurable header names (`x-ratelimit-limit-requests`, etc.)
48
+ - **Token usage estimation** for LLM API mocking
49
+ - **Configurable response latency** simulation
50
+ - **Per-key scoping** by API key or IP address
51
+ - **Request statistics** via `/mocklimit/stats`
52
+
53
+ ## Installation
54
+
55
+ ```bash
56
+ pip install mocklimit
57
+ ```
58
+
59
+ Or with [uv](https://docs.astral.sh/uv/):
60
+
61
+ ```bash
62
+ uv add mocklimit
63
+ ```
64
+
65
+ ## Quick start
66
+
67
+ ### 1. Create a rate limit config
68
+
69
+ ```yaml
70
+ # limits.yaml
71
+ policies:
72
+ openai_chat:
73
+ strategy: fixed_window
74
+ limits:
75
+ - max_requests: 5
76
+ window_seconds: 60
77
+ scope: api_key
78
+ response_latency_ms: [0, 0]
79
+ headers:
80
+ limit: x-ratelimit-limit-requests
81
+ remaining: x-ratelimit-remaining-requests
82
+ reset: x-ratelimit-reset-requests
83
+
84
+ endpoints:
85
+ /chat/completions:
86
+ methods: [POST]
87
+ policy: openai_chat
88
+ token_estimation:
89
+ input: characters_div_4
90
+ output: [50, 500]
91
+ ```
92
+
93
+ ### 2. Start the server
94
+
95
+ ```bash
96
+ mocklimit serve --spec openapi.yaml --rate-config limits.yaml
97
+ ```
98
+
99
+ The server reads your OpenAPI spec for route definitions and response schemas,
100
+ then applies rate limiting according to the config. Requests beyond the limit
101
+ get a `429` with appropriate `Retry-After` and rate limit headers.
102
+
103
+ ### 3. Options
104
+
105
+ ```
106
+ mocklimit serve --spec <path> --rate-config <path> [--host HOST] [--port PORT]
107
+ ```
108
+
109
+ | Flag | Default | Description |
110
+ |---|---|---|
111
+ | `--spec` | *(required)* | Path to OpenAPI spec (YAML) |
112
+ | `--rate-config` | *(required)* | Path to rate limit config (YAML) |
113
+ | `--host` | `127.0.0.1` | Host to bind to |
114
+ | `--port` | `8000` | Port to listen on |
115
+
116
+ ## Rate limit config reference
117
+
118
+ ### Policies
119
+
120
+ Each policy defines a rate limiting strategy:
121
+
122
+ | Field | Type | Description |
123
+ |---|---|---|
124
+ | `strategy` | `"fixed_window"` | Rate limiting algorithm |
125
+ | `limits` | list | One or more `{max_requests, window_seconds}` pairs |
126
+ | `scope` | `"api_key"` \| `"ip"` | How to identify clients |
127
+ | `response_latency_ms` | `[min, max]` | Simulated response delay range (ms) |
128
+ | `headers.limit` | string | Header name for the request limit |
129
+ | `headers.remaining` | string | Header name for remaining requests |
130
+ | `headers.reset` | string | Header name for reset time |
131
+
132
+ ### Endpoints
133
+
134
+ Map API paths to policies:
135
+
136
+ | Field | Type | Description |
137
+ |---|---|---|
138
+ | `methods` | list of strings | HTTP methods to rate limit |
139
+ | `policy` | string | Name of the policy to apply |
140
+ | `token_estimation` | object (optional) | `{input: "characters_div_4", output: [min, max]}` |
141
+
142
+ ## Programmatic usage
143
+
144
+ You can also embed the server directly in tests:
145
+
146
+ ```python
147
+ from mocklimit.server import create_app
148
+
149
+ app = create_app("openapi.yaml", "limits.yaml")
150
+ ```
151
+
152
+ This returns a standard FastAPI app that can be used with any ASGI test client.
153
+
154
+ ## License
155
+
156
+ MIT
@@ -0,0 +1,128 @@
1
+ # mocklimit
2
+
3
+ [![CI](https://github.com/stano45/mocklimit/actions/workflows/ci.yml/badge.svg)](https://github.com/stano45/mocklimit/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/mocklimit)](https://pypi.org/project/mocklimit/)
5
+ [![Python](https://img.shields.io/pypi/pyversions/mocklimit)](https://pypi.org/project/mocklimit/)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
7
+
8
+ Configurable mock API server with realistic rate limiting for testing.
9
+ Point it at an OpenAPI spec, define rate limit policies in YAML, and get a
10
+ local server that behaves like a rate-limited production API, complete with
11
+ correct headers, 429 responses, and token usage estimation.
12
+
13
+ ## Features
14
+
15
+ - **OpenAPI spec auto-routing** - parses your spec and registers all endpoints with dummy responses
16
+ - **Fixed window rate limiting** with sub-second precision
17
+ - **Quantized rate limiter** for aligned reset windows
18
+ - **Composite limits** - stack multiple limits per endpoint (e.g. RPM + TPM)
19
+ - **Provider-accurate headers** - configurable header names (`x-ratelimit-limit-requests`, etc.)
20
+ - **Token usage estimation** for LLM API mocking
21
+ - **Configurable response latency** simulation
22
+ - **Per-key scoping** by API key or IP address
23
+ - **Request statistics** via `/mocklimit/stats`
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ pip install mocklimit
29
+ ```
30
+
31
+ Or with [uv](https://docs.astral.sh/uv/):
32
+
33
+ ```bash
34
+ uv add mocklimit
35
+ ```
36
+
37
+ ## Quick start
38
+
39
+ ### 1. Create a rate limit config
40
+
41
+ ```yaml
42
+ # limits.yaml
43
+ policies:
44
+ openai_chat:
45
+ strategy: fixed_window
46
+ limits:
47
+ - max_requests: 5
48
+ window_seconds: 60
49
+ scope: api_key
50
+ response_latency_ms: [0, 0]
51
+ headers:
52
+ limit: x-ratelimit-limit-requests
53
+ remaining: x-ratelimit-remaining-requests
54
+ reset: x-ratelimit-reset-requests
55
+
56
+ endpoints:
57
+ /chat/completions:
58
+ methods: [POST]
59
+ policy: openai_chat
60
+ token_estimation:
61
+ input: characters_div_4
62
+ output: [50, 500]
63
+ ```
64
+
65
+ ### 2. Start the server
66
+
67
+ ```bash
68
+ mocklimit serve --spec openapi.yaml --rate-config limits.yaml
69
+ ```
70
+
71
+ The server reads your OpenAPI spec for route definitions and response schemas,
72
+ then applies rate limiting according to the config. Requests beyond the limit
73
+ get a `429` with appropriate `Retry-After` and rate limit headers.
74
+
75
+ ### 3. Options
76
+
77
+ ```
78
+ mocklimit serve --spec <path> --rate-config <path> [--host HOST] [--port PORT]
79
+ ```
80
+
81
+ | Flag | Default | Description |
82
+ |---|---|---|
83
+ | `--spec` | *(required)* | Path to OpenAPI spec (YAML) |
84
+ | `--rate-config` | *(required)* | Path to rate limit config (YAML) |
85
+ | `--host` | `127.0.0.1` | Host to bind to |
86
+ | `--port` | `8000` | Port to listen on |
87
+
88
+ ## Rate limit config reference
89
+
90
+ ### Policies
91
+
92
+ Each policy defines a rate limiting strategy:
93
+
94
+ | Field | Type | Description |
95
+ |---|---|---|
96
+ | `strategy` | `"fixed_window"` | Rate limiting algorithm |
97
+ | `limits` | list | One or more `{max_requests, window_seconds}` pairs |
98
+ | `scope` | `"api_key"` \| `"ip"` | How to identify clients |
99
+ | `response_latency_ms` | `[min, max]` | Simulated response delay range (ms) |
100
+ | `headers.limit` | string | Header name for the request limit |
101
+ | `headers.remaining` | string | Header name for remaining requests |
102
+ | `headers.reset` | string | Header name for reset time |
103
+
104
+ ### Endpoints
105
+
106
+ Map API paths to policies:
107
+
108
+ | Field | Type | Description |
109
+ |---|---|---|
110
+ | `methods` | list of strings | HTTP methods to rate limit |
111
+ | `policy` | string | Name of the policy to apply |
112
+ | `token_estimation` | object (optional) | `{input: "characters_div_4", output: [min, max]}` |
113
+
114
+ ## Programmatic usage
115
+
116
+ You can also embed the server directly in tests:
117
+
118
+ ```python
119
+ from mocklimit.server import create_app
120
+
121
+ app = create_app("openapi.yaml", "limits.yaml")
122
+ ```
123
+
124
+ This returns a standard FastAPI app that can be used with any ASGI test client.
125
+
126
+ ## License
127
+
128
+ MIT
@@ -0,0 +1,67 @@
1
+ [project]
2
+ name = "mocklimit"
3
+ version = "0.1.0"
4
+ description = "Configurable mock API server with realistic rate limiting for testing"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Stanislav Kosorin", email = "stanokosorin4@gmail.com" }
8
+ ]
9
+ requires-python = ">=3.11"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ keywords = ["mock", "rate-limiting", "api", "testing", "openapi"]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Framework :: FastAPI",
16
+ "Intended Audience :: Developers",
17
+ "Topic :: Software Development :: Testing",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Programming Language :: Python :: 3.14",
22
+ ]
23
+ dependencies = [
24
+ "fastapi>=0.135.1",
25
+ "jsonref>=1.1.0",
26
+ "pydantic>=2.0",
27
+ "pyyaml>=6.0.3",
28
+ "uvicorn>=0.42.0",
29
+ ]
30
+
31
+ [project.urls]
32
+ Repository = "https://github.com/stano45/mocklimit"
33
+ Documentation = "https://github.com/stano45/mocklimit#readme"
34
+ Issues = "https://github.com/stano45/mocklimit/issues"
35
+
36
+ [project.scripts]
37
+ mocklimit = "mocklimit.main:main"
38
+
39
+ [build-system]
40
+ requires = ["uv_build>=0.8.23,<0.9.0"]
41
+ build-backend = "uv_build"
42
+
43
+ [dependency-groups]
44
+ dev = [
45
+ "basedpyright>=1.38.3",
46
+ "httpx>=0.28.1",
47
+ "openai>=1.0.0",
48
+ "pytest>=9.0.2",
49
+ "pytest-asyncio>=1.3.0",
50
+ "ruff>=0.15.6",
51
+ ]
52
+
53
+ [tool.ruff]
54
+ src = ["src"]
55
+
56
+ [tool.ruff.lint]
57
+ select = ["ALL"]
58
+ ignore = ["D203", "D213"]
59
+
60
+ [tool.ruff.lint.per-file-ignores]
61
+ "tests/**/*.py" = ["S101", "PLR2004"]
62
+
63
+ [tool.basedpyright]
64
+ typeCheckingMode = "strict"
65
+
66
+ [tool.pytest.ini_options]
67
+ asyncio_mode = "auto"
@@ -0,0 +1,3 @@
1
+ """mocklimit — configurable mock API server with realistic rate limiting."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """Allow ``python -m mocklimit``."""
2
+
3
+ from mocklimit.main import main
4
+
5
+ main()
@@ -0,0 +1,52 @@
1
+ """CLI entry point for the mocklimit server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ import uvicorn
9
+
10
+ from .server.app import create_app
11
+
12
+
13
+ def _build_parser() -> argparse.ArgumentParser:
14
+ """Construct the top-level argument parser."""
15
+ parser = argparse.ArgumentParser(
16
+ prog="mocklimit",
17
+ description="Configurable mock API server with realistic rate limiting",
18
+ )
19
+ sub = parser.add_subparsers(dest="command")
20
+
21
+ serve = sub.add_parser("serve", help="Start the mock server")
22
+ serve.add_argument("--spec", required=True, help="Path to the OpenAPI spec YAML")
23
+ serve.add_argument(
24
+ "--rate-config",
25
+ required=True,
26
+ help="Path to the rate-limit config YAML",
27
+ )
28
+ serve.add_argument(
29
+ "--port",
30
+ type=int,
31
+ default=8000,
32
+ help="Port to listen on (default: 8000)",
33
+ )
34
+ serve.add_argument(
35
+ "--host",
36
+ default="127.0.0.1",
37
+ help="Host to bind to (default: 127.0.0.1)",
38
+ )
39
+ return parser
40
+
41
+
42
+ def main(argv: list[str] | None = None) -> None:
43
+ """Parse arguments and run the requested command."""
44
+ parser = _build_parser()
45
+ args = parser.parse_args(argv)
46
+
47
+ if args.command != "serve":
48
+ parser.print_help()
49
+ sys.exit(1)
50
+
51
+ app = create_app(args.spec, args.rate_config)
52
+ uvicorn.run(app, host=args.host, port=args.port)
@@ -0,0 +1,12 @@
1
+ """OpenAPI spec parsing."""
2
+
3
+ from .models import RouteDefinition
4
+ from .parser import parse_spec
5
+ from .response_generator import generate_all_responses, generate_dummy_response
6
+
7
+ __all__ = [
8
+ "RouteDefinition",
9
+ "generate_all_responses",
10
+ "generate_dummy_response",
11
+ "parse_spec",
12
+ ]
@@ -0,0 +1,18 @@
1
+ """OpenAPI route definition models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+ __all__ = ["RouteDefinition"]
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class RouteDefinition:
13
+ """A single API route extracted from an OpenAPI spec."""
14
+
15
+ path: str
16
+ method: str
17
+ response_schema: dict[str, Any] = field(default_factory=dict)
18
+ operation_id: str | None = None
@@ -0,0 +1,88 @@
1
+ """OpenAPI spec parser for extracting route definitions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any, cast
7
+
8
+ import jsonref
9
+ import yaml
10
+
11
+ from .models import RouteDefinition
12
+
13
+ __all__ = ["parse_spec"]
14
+
15
+ _HTTP_METHODS = frozenset({
16
+ "get", "put", "post", "delete",
17
+ "options", "head", "patch", "trace",
18
+ })
19
+
20
+
21
+ def _as_str_dict(value: object) -> dict[str, Any] | None:
22
+ """Cast *value* to a string-keyed dict if it is one, else ``None``."""
23
+ if isinstance(value, dict):
24
+ return cast("dict[str, Any]", value)
25
+ return None
26
+
27
+
28
+ def _extract_response_schema(operation: dict[str, Any]) -> dict[str, Any]:
29
+ """Return the JSON schema for the first 2xx response, or empty dict."""
30
+ responses = _as_str_dict(operation.get("responses"))
31
+ if not responses:
32
+ return {}
33
+
34
+ for status_code in sorted(responses):
35
+ if not status_code.startswith("2"):
36
+ continue
37
+ response_dict = _as_str_dict(responses[status_code])
38
+ if response_dict is None:
39
+ continue
40
+ content = _as_str_dict(response_dict.get("content"))
41
+ if content is None:
42
+ continue
43
+ json_media = _as_str_dict(content.get("application/json"))
44
+ if json_media is None:
45
+ continue
46
+ schema = _as_str_dict(json_media.get("schema"))
47
+ if schema is not None:
48
+ return schema
49
+
50
+ return {}
51
+
52
+
53
+ def parse_spec(path: str) -> list[RouteDefinition]:
54
+ """Parse an OpenAPI YAML/JSON file and return route definitions.
55
+
56
+ Iterates over all paths and HTTP methods, extracting the response schema
57
+ from the first 2xx response with ``application/json`` content. Missing
58
+ responses or schemas are represented as empty dicts.
59
+ """
60
+ raw = Path(path).read_text(encoding="utf-8")
61
+ spec: dict[str, Any] = jsonref.replace_refs(yaml.safe_load(raw))
62
+
63
+ paths: dict[str, Any] | None = spec.get("paths")
64
+ if not paths:
65
+ return []
66
+
67
+ routes: list[RouteDefinition] = []
68
+ for route_path, path_item_raw in paths.items():
69
+ path_item = _as_str_dict(path_item_raw)
70
+ if path_item is None:
71
+ continue
72
+ for method, operation_raw in path_item.items():
73
+ if method not in _HTTP_METHODS:
74
+ continue
75
+ operation = _as_str_dict(operation_raw)
76
+ if operation is None:
77
+ continue
78
+ op_id: str | None = operation.get("operationId")
79
+ routes.append(
80
+ RouteDefinition(
81
+ path=route_path,
82
+ method=method.upper(),
83
+ response_schema=_extract_response_schema(operation),
84
+ operation_id=op_id,
85
+ ),
86
+ )
87
+
88
+ return routes