peekapi 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.
- peekapi-0.1.0/.editorconfig +9 -0
- peekapi-0.1.0/.git +1 -0
- peekapi-0.1.0/.github/FUNDING.yml +1 -0
- peekapi-0.1.0/.github/ISSUE_TEMPLATE/bug_report.yml +39 -0
- peekapi-0.1.0/.github/ISSUE_TEMPLATE/feature_request.yml +25 -0
- peekapi-0.1.0/.github/PULL_REQUEST_TEMPLATE.md +8 -0
- peekapi-0.1.0/.github/workflows/ci.yml +21 -0
- peekapi-0.1.0/.gitignore +8 -0
- peekapi-0.1.0/CHANGELOG.md +9 -0
- peekapi-0.1.0/CODE_OF_CONDUCT.md +5 -0
- peekapi-0.1.0/LICENSE +21 -0
- peekapi-0.1.0/PKG-INFO +166 -0
- peekapi-0.1.0/README.md +143 -0
- peekapi-0.1.0/SECURITY.md +7 -0
- peekapi-0.1.0/pyproject.toml +49 -0
- peekapi-0.1.0/src/peekapi/__init__.py +17 -0
- peekapi-0.1.0/src/peekapi/_consumer.py +27 -0
- peekapi-0.1.0/src/peekapi/_ssrf.py +80 -0
- peekapi-0.1.0/src/peekapi/_version.py +1 -0
- peekapi-0.1.0/src/peekapi/client.py +445 -0
- peekapi-0.1.0/src/peekapi/middleware/__init__.py +5 -0
- peekapi-0.1.0/src/peekapi/middleware/asgi.py +90 -0
- peekapi-0.1.0/src/peekapi/middleware/django.py +101 -0
- peekapi-0.1.0/src/peekapi/middleware/wsgi.py +158 -0
- peekapi-0.1.0/src/peekapi/types.py +36 -0
- peekapi-0.1.0/tests/__init__.py +0 -0
- peekapi-0.1.0/tests/conftest.py +105 -0
- peekapi-0.1.0/tests/test_asgi.py +231 -0
- peekapi-0.1.0/tests/test_client.py +437 -0
- peekapi-0.1.0/tests/test_consumer.py +48 -0
- peekapi-0.1.0/tests/test_django.py +176 -0
- peekapi-0.1.0/tests/test_ssrf.py +75 -0
- peekapi-0.1.0/tests/test_wsgi.py +189 -0
- peekapi-0.1.0/uv.lock +209 -0
peekapi-0.1.0/.git
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
gitdir: ../../.git/modules/packages/sdk-python
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
github: peekapi-dev
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
name: Bug Report
|
|
2
|
+
description: Report a bug
|
|
3
|
+
labels: [bug]
|
|
4
|
+
body:
|
|
5
|
+
- type: textarea
|
|
6
|
+
id: description
|
|
7
|
+
attributes:
|
|
8
|
+
label: Description
|
|
9
|
+
description: A clear description of the bug.
|
|
10
|
+
validations:
|
|
11
|
+
required: true
|
|
12
|
+
- type: textarea
|
|
13
|
+
id: steps
|
|
14
|
+
attributes:
|
|
15
|
+
label: Steps to Reproduce
|
|
16
|
+
description: Steps to reproduce the behavior.
|
|
17
|
+
validations:
|
|
18
|
+
required: true
|
|
19
|
+
- type: textarea
|
|
20
|
+
id: expected
|
|
21
|
+
attributes:
|
|
22
|
+
label: Expected Behavior
|
|
23
|
+
description: What you expected to happen.
|
|
24
|
+
validations:
|
|
25
|
+
required: true
|
|
26
|
+
- type: input
|
|
27
|
+
id: sdk-version
|
|
28
|
+
attributes:
|
|
29
|
+
label: SDK Version
|
|
30
|
+
placeholder: "0.1.0"
|
|
31
|
+
validations:
|
|
32
|
+
required: true
|
|
33
|
+
- type: input
|
|
34
|
+
id: runtime-version
|
|
35
|
+
attributes:
|
|
36
|
+
label: Python Version
|
|
37
|
+
placeholder: "3.13"
|
|
38
|
+
validations:
|
|
39
|
+
required: true
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
name: Feature Request
|
|
2
|
+
description: Suggest a feature
|
|
3
|
+
labels: [enhancement]
|
|
4
|
+
body:
|
|
5
|
+
- type: textarea
|
|
6
|
+
id: description
|
|
7
|
+
attributes:
|
|
8
|
+
label: Description
|
|
9
|
+
description: What would you like to see added?
|
|
10
|
+
validations:
|
|
11
|
+
required: true
|
|
12
|
+
- type: textarea
|
|
13
|
+
id: use-case
|
|
14
|
+
attributes:
|
|
15
|
+
label: Use Case
|
|
16
|
+
description: Why do you need this feature?
|
|
17
|
+
validations:
|
|
18
|
+
required: true
|
|
19
|
+
- type: textarea
|
|
20
|
+
id: proposed-solution
|
|
21
|
+
attributes:
|
|
22
|
+
label: Proposed Solution
|
|
23
|
+
description: How would you like it to work?
|
|
24
|
+
validations:
|
|
25
|
+
required: false
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
- uses: actions/setup-python@v5
|
|
15
|
+
with:
|
|
16
|
+
python-version: "3.13"
|
|
17
|
+
- run: pip install uv
|
|
18
|
+
- run: uv sync --no-install-project
|
|
19
|
+
- run: uv run pytest -v
|
|
20
|
+
- run: uv run ruff check src/ tests/
|
|
21
|
+
- run: uv run ruff format --check src/ tests/
|
peekapi-0.1.0/.gitignore
ADDED
peekapi-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 PeekAPI
|
|
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.
|
peekapi-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: peekapi
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Zero-dependency Python SDK for PeekAPI
|
|
5
|
+
Project-URL: Homepage, https://github.com/peekapi-dev/sdk-python
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: analytics,api,dashboard,middleware
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Framework :: Django
|
|
11
|
+
Classifier: Framework :: FastAPI
|
|
12
|
+
Classifier: Framework :: Flask
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# PeekAPI — Python SDK
|
|
25
|
+
|
|
26
|
+
[](https://pypi.org/project/peekapi/)
|
|
27
|
+
[](./LICENSE)
|
|
28
|
+
[](https://github.com/peekapi-dev/sdk-python/actions/workflows/ci.yml)
|
|
29
|
+
|
|
30
|
+
Zero-dependency Python SDK for [PeekAPI](https://peekapi.dev). Built-in middleware for ASGI (FastAPI, Starlette, Litestar), WSGI (Flask, Bottle), and Django.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install peekapi
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Quick Start
|
|
39
|
+
|
|
40
|
+
### FastAPI / Starlette (ASGI)
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from fastapi import FastAPI
|
|
44
|
+
from peekapi import PeekApiClient
|
|
45
|
+
from peekapi.middleware import PeekApiASGI
|
|
46
|
+
|
|
47
|
+
client = PeekApiClient({"api_key": "ak_live_xxx"})
|
|
48
|
+
|
|
49
|
+
app = FastAPI()
|
|
50
|
+
app.add_middleware(PeekApiASGI, client=client)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Flask (WSGI)
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from flask import Flask
|
|
57
|
+
from peekapi import PeekApiClient
|
|
58
|
+
from peekapi.middleware import PeekApiWSGI
|
|
59
|
+
|
|
60
|
+
client = PeekApiClient({"api_key": "ak_live_xxx"})
|
|
61
|
+
|
|
62
|
+
app = Flask(__name__)
|
|
63
|
+
app.wsgi_app = PeekApiWSGI(app.wsgi_app, client=client)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Django
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
# settings.py
|
|
70
|
+
PEEKAPI = {
|
|
71
|
+
"api_key": "ak_live_xxx",
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
MIDDLEWARE = [
|
|
75
|
+
"peekapi.middleware.django.PeekApiMiddleware",
|
|
76
|
+
# ... other middleware
|
|
77
|
+
]
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Standalone Client
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from peekapi import PeekApiClient
|
|
84
|
+
|
|
85
|
+
client = PeekApiClient({"api_key": "ak_live_xxx"})
|
|
86
|
+
|
|
87
|
+
client.track({
|
|
88
|
+
"method": "GET",
|
|
89
|
+
"path": "/api/users",
|
|
90
|
+
"status_code": 200,
|
|
91
|
+
"response_time_ms": 42,
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
# Graceful shutdown (flushes remaining events)
|
|
95
|
+
client.shutdown()
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Configuration
|
|
99
|
+
|
|
100
|
+
| Option | Default | Description |
|
|
101
|
+
|---|---|---|
|
|
102
|
+
| `api_key` | required | Your PeekAPI key |
|
|
103
|
+
| `endpoint` | PeekAPI cloud | Ingestion endpoint URL |
|
|
104
|
+
| `flush_interval` | `10.0` | Seconds between automatic flushes |
|
|
105
|
+
| `batch_size` | `100` | Events per HTTP POST (triggers flush) |
|
|
106
|
+
| `max_buffer_size` | `10000` | Max events held in memory |
|
|
107
|
+
| `max_storage_bytes` | `5242880` | Max disk fallback file size (5MB) |
|
|
108
|
+
| `max_event_bytes` | `65536` | Per-event size limit (64KB) |
|
|
109
|
+
| `storage_path` | auto | Custom path for JSONL persistence file |
|
|
110
|
+
| `debug` | `False` | Enable debug logging |
|
|
111
|
+
| `on_error` | `None` | Callback `(Exception) -> None` for flush errors |
|
|
112
|
+
|
|
113
|
+
## How It Works
|
|
114
|
+
|
|
115
|
+
1. Middleware intercepts every request/response
|
|
116
|
+
2. Captures method, path, status code, response time, request/response sizes, consumer ID
|
|
117
|
+
3. Events are buffered in memory and flushed in batches on a daemon thread
|
|
118
|
+
4. On network failure: exponential backoff with jitter, up to 5 retries
|
|
119
|
+
5. After max retries: events are persisted to a JSONL file on disk
|
|
120
|
+
6. On next startup: persisted events are recovered and re-sent
|
|
121
|
+
7. On SIGTERM/SIGINT: remaining buffer is flushed or persisted to disk
|
|
122
|
+
|
|
123
|
+
## Consumer Identification
|
|
124
|
+
|
|
125
|
+
By default, consumers are identified by:
|
|
126
|
+
|
|
127
|
+
1. `X-API-Key` header — stored as-is
|
|
128
|
+
2. `Authorization` header — hashed with SHA-256 (stored as `hash_<hex>`)
|
|
129
|
+
|
|
130
|
+
Override with the `identify_consumer` option to use any header or request property:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
client = PeekApiClient({
|
|
134
|
+
"api_key": "...",
|
|
135
|
+
"identify_consumer": lambda headers: headers.get("x-tenant-id"),
|
|
136
|
+
})
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The callback receives a `dict[str, str]` of lowercase header names and should return a consumer ID string or `None`.
|
|
140
|
+
|
|
141
|
+
## Features
|
|
142
|
+
|
|
143
|
+
- **Zero runtime dependencies** — uses only Python stdlib
|
|
144
|
+
- **Background flush** — daemon thread with configurable interval and batch size
|
|
145
|
+
- **Disk persistence** — undelivered events saved to JSONL, recovered on restart
|
|
146
|
+
- **Exponential backoff** — with jitter, max 5 consecutive failures before disk fallback
|
|
147
|
+
- **SSRF protection** — private IP blocking, HTTPS enforcement (HTTP only for localhost)
|
|
148
|
+
- **Input sanitization** — path (2048), method (16), consumer_id (256) truncation
|
|
149
|
+
- **Per-event size limit** — strips metadata first, drops if still too large (default 64KB)
|
|
150
|
+
- **Graceful shutdown** — signal handlers (SIGTERM/SIGINT) with disk persistence
|
|
151
|
+
|
|
152
|
+
## Requirements
|
|
153
|
+
|
|
154
|
+
- Python >= 3.10
|
|
155
|
+
|
|
156
|
+
## Contributing
|
|
157
|
+
|
|
158
|
+
1. Fork & clone the repo
|
|
159
|
+
2. Install dev dependencies — `uv sync --no-install-project`
|
|
160
|
+
3. Run tests — `uv run pytest -v`
|
|
161
|
+
4. Lint & format — `uv run ruff check src/ tests/` / `uv run ruff format src/ tests/`
|
|
162
|
+
5. Submit a PR
|
|
163
|
+
|
|
164
|
+
## License
|
|
165
|
+
|
|
166
|
+
MIT
|
peekapi-0.1.0/README.md
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# PeekAPI — Python SDK
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/peekapi/)
|
|
4
|
+
[](./LICENSE)
|
|
5
|
+
[](https://github.com/peekapi-dev/sdk-python/actions/workflows/ci.yml)
|
|
6
|
+
|
|
7
|
+
Zero-dependency Python SDK for [PeekAPI](https://peekapi.dev). Built-in middleware for ASGI (FastAPI, Starlette, Litestar), WSGI (Flask, Bottle), and Django.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install peekapi
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Quick Start
|
|
16
|
+
|
|
17
|
+
### FastAPI / Starlette (ASGI)
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from fastapi import FastAPI
|
|
21
|
+
from peekapi import PeekApiClient
|
|
22
|
+
from peekapi.middleware import PeekApiASGI
|
|
23
|
+
|
|
24
|
+
client = PeekApiClient({"api_key": "ak_live_xxx"})
|
|
25
|
+
|
|
26
|
+
app = FastAPI()
|
|
27
|
+
app.add_middleware(PeekApiASGI, client=client)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Flask (WSGI)
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from flask import Flask
|
|
34
|
+
from peekapi import PeekApiClient
|
|
35
|
+
from peekapi.middleware import PeekApiWSGI
|
|
36
|
+
|
|
37
|
+
client = PeekApiClient({"api_key": "ak_live_xxx"})
|
|
38
|
+
|
|
39
|
+
app = Flask(__name__)
|
|
40
|
+
app.wsgi_app = PeekApiWSGI(app.wsgi_app, client=client)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Django
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
# settings.py
|
|
47
|
+
PEEKAPI = {
|
|
48
|
+
"api_key": "ak_live_xxx",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
MIDDLEWARE = [
|
|
52
|
+
"peekapi.middleware.django.PeekApiMiddleware",
|
|
53
|
+
# ... other middleware
|
|
54
|
+
]
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Standalone Client
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from peekapi import PeekApiClient
|
|
61
|
+
|
|
62
|
+
client = PeekApiClient({"api_key": "ak_live_xxx"})
|
|
63
|
+
|
|
64
|
+
client.track({
|
|
65
|
+
"method": "GET",
|
|
66
|
+
"path": "/api/users",
|
|
67
|
+
"status_code": 200,
|
|
68
|
+
"response_time_ms": 42,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
# Graceful shutdown (flushes remaining events)
|
|
72
|
+
client.shutdown()
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Configuration
|
|
76
|
+
|
|
77
|
+
| Option | Default | Description |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| `api_key` | required | Your PeekAPI key |
|
|
80
|
+
| `endpoint` | PeekAPI cloud | Ingestion endpoint URL |
|
|
81
|
+
| `flush_interval` | `10.0` | Seconds between automatic flushes |
|
|
82
|
+
| `batch_size` | `100` | Events per HTTP POST (triggers flush) |
|
|
83
|
+
| `max_buffer_size` | `10000` | Max events held in memory |
|
|
84
|
+
| `max_storage_bytes` | `5242880` | Max disk fallback file size (5MB) |
|
|
85
|
+
| `max_event_bytes` | `65536` | Per-event size limit (64KB) |
|
|
86
|
+
| `storage_path` | auto | Custom path for JSONL persistence file |
|
|
87
|
+
| `debug` | `False` | Enable debug logging |
|
|
88
|
+
| `on_error` | `None` | Callback `(Exception) -> None` for flush errors |
|
|
89
|
+
|
|
90
|
+
## How It Works
|
|
91
|
+
|
|
92
|
+
1. Middleware intercepts every request/response
|
|
93
|
+
2. Captures method, path, status code, response time, request/response sizes, consumer ID
|
|
94
|
+
3. Events are buffered in memory and flushed in batches on a daemon thread
|
|
95
|
+
4. On network failure: exponential backoff with jitter, up to 5 retries
|
|
96
|
+
5. After max retries: events are persisted to a JSONL file on disk
|
|
97
|
+
6. On next startup: persisted events are recovered and re-sent
|
|
98
|
+
7. On SIGTERM/SIGINT: remaining buffer is flushed or persisted to disk
|
|
99
|
+
|
|
100
|
+
## Consumer Identification
|
|
101
|
+
|
|
102
|
+
By default, consumers are identified by:
|
|
103
|
+
|
|
104
|
+
1. `X-API-Key` header — stored as-is
|
|
105
|
+
2. `Authorization` header — hashed with SHA-256 (stored as `hash_<hex>`)
|
|
106
|
+
|
|
107
|
+
Override with the `identify_consumer` option to use any header or request property:
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
client = PeekApiClient({
|
|
111
|
+
"api_key": "...",
|
|
112
|
+
"identify_consumer": lambda headers: headers.get("x-tenant-id"),
|
|
113
|
+
})
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The callback receives a `dict[str, str]` of lowercase header names and should return a consumer ID string or `None`.
|
|
117
|
+
|
|
118
|
+
## Features
|
|
119
|
+
|
|
120
|
+
- **Zero runtime dependencies** — uses only Python stdlib
|
|
121
|
+
- **Background flush** — daemon thread with configurable interval and batch size
|
|
122
|
+
- **Disk persistence** — undelivered events saved to JSONL, recovered on restart
|
|
123
|
+
- **Exponential backoff** — with jitter, max 5 consecutive failures before disk fallback
|
|
124
|
+
- **SSRF protection** — private IP blocking, HTTPS enforcement (HTTP only for localhost)
|
|
125
|
+
- **Input sanitization** — path (2048), method (16), consumer_id (256) truncation
|
|
126
|
+
- **Per-event size limit** — strips metadata first, drops if still too large (default 64KB)
|
|
127
|
+
- **Graceful shutdown** — signal handlers (SIGTERM/SIGINT) with disk persistence
|
|
128
|
+
|
|
129
|
+
## Requirements
|
|
130
|
+
|
|
131
|
+
- Python >= 3.10
|
|
132
|
+
|
|
133
|
+
## Contributing
|
|
134
|
+
|
|
135
|
+
1. Fork & clone the repo
|
|
136
|
+
2. Install dev dependencies — `uv sync --no-install-project`
|
|
137
|
+
3. Run tests — `uv run pytest -v`
|
|
138
|
+
4. Lint & format — `uv run ruff check src/ tests/` / `uv run ruff format src/ tests/`
|
|
139
|
+
5. Submit a PR
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
MIT
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "peekapi"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Zero-dependency Python SDK for PeekAPI"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
keywords = ["api", "analytics", "middleware", "dashboard"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
23
|
+
"Framework :: Django",
|
|
24
|
+
"Framework :: FastAPI",
|
|
25
|
+
"Framework :: Flask",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://github.com/peekapi-dev/sdk-python"
|
|
30
|
+
|
|
31
|
+
[tool.hatch.version]
|
|
32
|
+
path = "src/peekapi/_version.py"
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.wheel]
|
|
35
|
+
packages = ["src/peekapi"]
|
|
36
|
+
|
|
37
|
+
[dependency-groups]
|
|
38
|
+
dev = ["pytest>=8.0", "pytest-asyncio>=0.25", "ruff>=0.11"]
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
testpaths = ["tests"]
|
|
42
|
+
pythonpath = ["src"]
|
|
43
|
+
|
|
44
|
+
[tool.ruff]
|
|
45
|
+
target-version = "py310"
|
|
46
|
+
line-length = 100
|
|
47
|
+
|
|
48
|
+
[tool.ruff.lint]
|
|
49
|
+
select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF"]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""PeekAPI — Python SDK."""
|
|
2
|
+
|
|
3
|
+
from ._consumer import default_identify_consumer, hash_consumer_id
|
|
4
|
+
from .client import PeekApiClient
|
|
5
|
+
from .middleware import PeekApiASGI, PeekApiMiddleware, PeekApiWSGI
|
|
6
|
+
from .types import Options, RequestEvent
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Options",
|
|
10
|
+
"PeekApiASGI",
|
|
11
|
+
"PeekApiClient",
|
|
12
|
+
"PeekApiMiddleware",
|
|
13
|
+
"PeekApiWSGI",
|
|
14
|
+
"RequestEvent",
|
|
15
|
+
"default_identify_consumer",
|
|
16
|
+
"hash_consumer_id",
|
|
17
|
+
]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def hash_consumer_id(raw: str) -> str:
|
|
7
|
+
"""SHA-256 hash truncated to 12 hex chars, prefixed with 'hash_'."""
|
|
8
|
+
digest = hashlib.sha256(raw.encode()).hexdigest()[:12]
|
|
9
|
+
return f"hash_{digest}"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def default_identify_consumer(headers: dict[str, str]) -> str | None:
|
|
13
|
+
"""Identify consumer from request headers.
|
|
14
|
+
|
|
15
|
+
Priority:
|
|
16
|
+
1. x-api-key (stored as-is)
|
|
17
|
+
2. Authorization (hashed — contains credentials)
|
|
18
|
+
"""
|
|
19
|
+
api_key = headers.get("x-api-key")
|
|
20
|
+
if api_key:
|
|
21
|
+
return api_key
|
|
22
|
+
|
|
23
|
+
auth = headers.get("authorization")
|
|
24
|
+
if auth:
|
|
25
|
+
return hash_consumer_id(auth)
|
|
26
|
+
|
|
27
|
+
return None
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
import re
|
|
5
|
+
from urllib.parse import urlparse
|
|
6
|
+
|
|
7
|
+
# Matches private/reserved IPv4 ranges (fast path)
|
|
8
|
+
_PRIVATE_IP_RE = re.compile(
|
|
9
|
+
r"^(?:"
|
|
10
|
+
r"10\.\d{1,3}\.\d{1,3}\.\d{1,3}"
|
|
11
|
+
r"|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}"
|
|
12
|
+
r"|192\.168\.\d{1,3}\.\d{1,3}"
|
|
13
|
+
r"|0\.0\.0\.0"
|
|
14
|
+
r")$"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
# CGNAT range not covered by ipaddress.is_private in all Python versions
|
|
18
|
+
_CGNAT_NETWORK = ipaddress.IPv4Network("100.64.0.0/10")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def is_private_ip(host: str) -> bool:
|
|
22
|
+
"""Check if a hostname/IP is a private or reserved address.
|
|
23
|
+
|
|
24
|
+
Covers: RFC 1918, CGNAT (100.64/10), loopback, link-local,
|
|
25
|
+
IPv6 ULA/link-local, IPv4-mapped IPv6.
|
|
26
|
+
"""
|
|
27
|
+
# Fast path regex
|
|
28
|
+
if _PRIVATE_IP_RE.match(host):
|
|
29
|
+
return True
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
addr = ipaddress.ip_address(host)
|
|
33
|
+
except ValueError:
|
|
34
|
+
return False
|
|
35
|
+
|
|
36
|
+
if isinstance(addr, ipaddress.IPv6Address):
|
|
37
|
+
# Check IPv4-mapped IPv6 (::ffff:x.x.x.x)
|
|
38
|
+
mapped = addr.ipv4_mapped
|
|
39
|
+
if mapped is not None:
|
|
40
|
+
return mapped.is_private or mapped.is_loopback or mapped.is_link_local
|
|
41
|
+
return addr.is_private or addr.is_loopback or addr.is_link_local
|
|
42
|
+
|
|
43
|
+
# IPv4 — is_private covers RFC 1918 + loopback + link-local; add CGNAT explicitly
|
|
44
|
+
return addr.is_private or addr.is_loopback or addr.is_link_local or addr in _CGNAT_NETWORK
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def validate_endpoint(endpoint: str) -> str:
|
|
48
|
+
"""Validate and normalize the ingestion endpoint URL.
|
|
49
|
+
|
|
50
|
+
Raises ValueError for:
|
|
51
|
+
- Non-HTTPS URLs (except localhost)
|
|
52
|
+
- Private/reserved IP addresses (SSRF protection)
|
|
53
|
+
- Embedded credentials in URL
|
|
54
|
+
- Malformed URLs
|
|
55
|
+
"""
|
|
56
|
+
if not endpoint:
|
|
57
|
+
raise ValueError("endpoint is required")
|
|
58
|
+
|
|
59
|
+
parsed = urlparse(endpoint)
|
|
60
|
+
|
|
61
|
+
if not parsed.scheme or not parsed.hostname:
|
|
62
|
+
raise ValueError(f"Invalid endpoint URL: {endpoint}")
|
|
63
|
+
|
|
64
|
+
hostname = parsed.hostname.lower()
|
|
65
|
+
|
|
66
|
+
# Allow HTTP only for localhost
|
|
67
|
+
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
|
|
68
|
+
|
|
69
|
+
if parsed.scheme != "https" and not is_localhost:
|
|
70
|
+
raise ValueError(f"HTTPS required for non-localhost endpoint: {endpoint}")
|
|
71
|
+
|
|
72
|
+
# Reject embedded credentials
|
|
73
|
+
if parsed.username or parsed.password:
|
|
74
|
+
raise ValueError("Endpoint URL must not contain credentials")
|
|
75
|
+
|
|
76
|
+
# SSRF check — skip for localhost
|
|
77
|
+
if not is_localhost and is_private_ip(hostname):
|
|
78
|
+
raise ValueError(f"Endpoint resolves to private/reserved IP: {hostname}")
|
|
79
|
+
|
|
80
|
+
return endpoint
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|