tinycontracts 0.1.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.
- tinycontracts/__init__.py +1 -0
- tinycontracts/cli.py +134 -0
- tinycontracts/loader.py +29 -0
- tinycontracts/routes.py +119 -0
- tinycontracts/schema.py +25 -0
- tinycontracts/server.py +24 -0
- tinycontracts-0.1.0.dist-info/METADATA +73 -0
- tinycontracts-0.1.0.dist-info/RECORD +10 -0
- tinycontracts-0.1.0.dist-info/WHEEL +4 -0
- tinycontracts-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# tinycontracts
|
tinycontracts/cli.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# cli.py - typer entry point
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
import uvicorn
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from tinycontracts.server import create_app
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(
|
|
16
|
+
name="tc",
|
|
17
|
+
help="serve a folder of json/csv/parquet files as a rest api",
|
|
18
|
+
no_args_is_help=True,
|
|
19
|
+
add_completion=False,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def print_banner(host: str, port: int, resources: list[str], folder: Path):
|
|
24
|
+
# header
|
|
25
|
+
console.print()
|
|
26
|
+
console.print(
|
|
27
|
+
Panel.fit(
|
|
28
|
+
"[bold cyan]tinycontracts[/bold cyan] [dim]v0.1.0[/dim]",
|
|
29
|
+
border_style="cyan",
|
|
30
|
+
)
|
|
31
|
+
)
|
|
32
|
+
console.print()
|
|
33
|
+
|
|
34
|
+
# server info
|
|
35
|
+
console.print(f" [dim]folder:[/dim] {folder.resolve()}")
|
|
36
|
+
console.print(f" [dim]server:[/dim] [green]http://{host}:{port}[/green]")
|
|
37
|
+
console.print()
|
|
38
|
+
|
|
39
|
+
# endpoints table
|
|
40
|
+
if resources:
|
|
41
|
+
table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2))
|
|
42
|
+
table.add_column("endpoint", style="green")
|
|
43
|
+
table.add_column("description", style="dim")
|
|
44
|
+
|
|
45
|
+
for name in sorted(resources):
|
|
46
|
+
table.add_row(f"/{name}", f"query {name}")
|
|
47
|
+
|
|
48
|
+
table.add_row("", "")
|
|
49
|
+
table.add_row("/docs", "swagger ui")
|
|
50
|
+
table.add_row("/_help", "api help")
|
|
51
|
+
|
|
52
|
+
console.print(table)
|
|
53
|
+
else:
|
|
54
|
+
console.print(" [yellow]no data files found[/yellow]")
|
|
55
|
+
|
|
56
|
+
console.print()
|
|
57
|
+
console.print(" [dim]press ctrl+c to stop[/dim]")
|
|
58
|
+
console.print()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@app.command()
|
|
62
|
+
def serve(
|
|
63
|
+
path: str = typer.Argument(..., help="folder containing data files"),
|
|
64
|
+
port: int = typer.Option(4242, "--port", "-p", help="port to serve on"),
|
|
65
|
+
host: str = typer.Option("127.0.0.1", "--host", "-H", help="host to bind to"),
|
|
66
|
+
):
|
|
67
|
+
"""serve data files as a rest api"""
|
|
68
|
+
folder = Path(path)
|
|
69
|
+
|
|
70
|
+
# validate folder exists
|
|
71
|
+
if not folder.exists():
|
|
72
|
+
console.print(f"\n [red]error:[/red] path not found: [bold]{folder}[/bold]")
|
|
73
|
+
console.print(f" [dim]make sure the path exists and try again[/dim]\n")
|
|
74
|
+
raise typer.Exit(1)
|
|
75
|
+
|
|
76
|
+
# validate it's a directory
|
|
77
|
+
if not folder.is_dir():
|
|
78
|
+
console.print(f"\n [red]error:[/red] not a folder: [bold]{folder}[/bold]")
|
|
79
|
+
console.print(f" [dim]tc needs a folder path, not a file[/dim]\n")
|
|
80
|
+
raise typer.Exit(1)
|
|
81
|
+
|
|
82
|
+
# check for data files
|
|
83
|
+
data_files = (
|
|
84
|
+
list(folder.glob("*.json"))
|
|
85
|
+
+ list(folder.glob("*.csv"))
|
|
86
|
+
+ list(folder.glob("*.parquet"))
|
|
87
|
+
)
|
|
88
|
+
if not data_files:
|
|
89
|
+
console.print(
|
|
90
|
+
f"\n [yellow]warning:[/yellow] no data files in [bold]{folder}[/bold]"
|
|
91
|
+
)
|
|
92
|
+
console.print("[dim]add .json, .csv, or .parquet files[/dim]\n")
|
|
93
|
+
|
|
94
|
+
# create app
|
|
95
|
+
try:
|
|
96
|
+
fastapi_app = create_app(folder)
|
|
97
|
+
resources = (
|
|
98
|
+
list(fastapi_app.state.resources.keys())
|
|
99
|
+
if hasattr(fastapi_app.state, "resources")
|
|
100
|
+
else []
|
|
101
|
+
)
|
|
102
|
+
except PermissionError:
|
|
103
|
+
console.print(f"\n [red]error:[/red] permission denied: [bold]{folder}[/bold]")
|
|
104
|
+
console.print("[dim]check file permissions and try again[/dim]\n")
|
|
105
|
+
raise typer.Exit(1)
|
|
106
|
+
except Exception as e:
|
|
107
|
+
console.print(f"\n [red]error:[/red] failed to load data: {e}\n")
|
|
108
|
+
raise typer.Exit(1)
|
|
109
|
+
|
|
110
|
+
# print startup banner
|
|
111
|
+
print_banner(host, port, resources, folder)
|
|
112
|
+
|
|
113
|
+
# run server
|
|
114
|
+
try:
|
|
115
|
+
uvicorn.run(fastapi_app, host=host, port=port, log_level="warning")
|
|
116
|
+
except KeyboardInterrupt:
|
|
117
|
+
console.print("\n [dim]stopped[/dim]\n")
|
|
118
|
+
except OSError as e:
|
|
119
|
+
if "address already in use" in str(e).lower() or "10048" in str(e):
|
|
120
|
+
console.print(f"\n [red]error:[/red] port {port} already in use")
|
|
121
|
+
console.print(f" [dim]try: tc {path} -p {port + 1}[/dim]\n")
|
|
122
|
+
else:
|
|
123
|
+
console.print(f"\n [red]error:[/red] {e}\n")
|
|
124
|
+
raise typer.Exit(1)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@app.command()
|
|
128
|
+
def version():
|
|
129
|
+
"""show version"""
|
|
130
|
+
console.print("tinycontracts [cyan]v0.1.0[/cyan]")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
if __name__ == "__main__":
|
|
134
|
+
app()
|
tinycontracts/loader.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# loader.py - load json/csv/parquet files
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def load_file(path: Path) -> pd.DataFrame:
|
|
9
|
+
if path.suffix == ".json":
|
|
10
|
+
data = json.load(path.open())
|
|
11
|
+
# handle singleton (single object) vs collection (array)
|
|
12
|
+
if isinstance(data, dict):
|
|
13
|
+
data = [data]
|
|
14
|
+
return pd.DataFrame(data)
|
|
15
|
+
elif path.suffix == ".csv":
|
|
16
|
+
return pd.read_csv(path)
|
|
17
|
+
elif path.suffix == ".parquet":
|
|
18
|
+
return pd.read_parquet(path)
|
|
19
|
+
else:
|
|
20
|
+
raise ValueError(f"Unsupported file type: {path.suffix}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_folder(folder: Path) -> dict[str, pd.DataFrame]:
|
|
24
|
+
resources = {}
|
|
25
|
+
for path in folder.glob("*"):
|
|
26
|
+
if path.suffix in [".json", ".csv", ".parquet"]:
|
|
27
|
+
name = path.stem # filename without extension
|
|
28
|
+
resources[name] = load_file(path)
|
|
29
|
+
return resources
|
tinycontracts/routes.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# routes.py - dynamic route generation
|
|
2
|
+
import pandas as pd
|
|
3
|
+
from fastapi import FastAPI, HTTPException, Query, Request
|
|
4
|
+
from fastapi.responses import PlainTextResponse
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def create_discovery_routes(
|
|
8
|
+
app: FastAPI, resources: dict[str, pd.DataFrame], schemas: dict[str, dict]
|
|
9
|
+
):
|
|
10
|
+
@app.get("/")
|
|
11
|
+
def list_resources():
|
|
12
|
+
return {"resources": list(resources.keys())}
|
|
13
|
+
|
|
14
|
+
@app.get("/_schema")
|
|
15
|
+
def get_all_schemas():
|
|
16
|
+
return schemas
|
|
17
|
+
|
|
18
|
+
@app.get("/_help", response_class=PlainTextResponse)
|
|
19
|
+
def get_help():
|
|
20
|
+
resource_list = "\n".join(f" - /{name}" for name in resources.keys())
|
|
21
|
+
return f"""tinycontracts api
|
|
22
|
+
|
|
23
|
+
resources:
|
|
24
|
+
{resource_list}
|
|
25
|
+
|
|
26
|
+
endpoints per resource:
|
|
27
|
+
GET /{{resource}} list all rows
|
|
28
|
+
GET /{{resource}}/{{id}} get row by id
|
|
29
|
+
GET /{{resource}}/_schema get schema
|
|
30
|
+
GET /{{resource}}/_sample get random sample
|
|
31
|
+
|
|
32
|
+
query parameters:
|
|
33
|
+
?field=value filter by field
|
|
34
|
+
?_limit=N limit results (default 100)
|
|
35
|
+
?_offset=N skip first N results
|
|
36
|
+
?_sort=field sort ascending
|
|
37
|
+
?_sort=-field sort descending
|
|
38
|
+
?n=N sample size (for /_sample)
|
|
39
|
+
|
|
40
|
+
global endpoints:
|
|
41
|
+
GET / list all resources
|
|
42
|
+
GET /_schema all schemas
|
|
43
|
+
GET /_help this help text
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def create_resource_routes(app: FastAPI, name: str, df: pd.DataFrame, schema: dict):
|
|
48
|
+
# store df in closure
|
|
49
|
+
data = df.copy()
|
|
50
|
+
|
|
51
|
+
@app.get(f"/{name}/_schema")
|
|
52
|
+
def get_schema():
|
|
53
|
+
return schema
|
|
54
|
+
|
|
55
|
+
@app.get(f"/{name}/_sample")
|
|
56
|
+
def get_sample(n: int = 10):
|
|
57
|
+
sample_size = min(n, len(data))
|
|
58
|
+
return data.sample(n=sample_size).to_dict(orient="records")
|
|
59
|
+
|
|
60
|
+
@app.get(f"/{name}/{{row_id}}")
|
|
61
|
+
def get_row(row_id: str):
|
|
62
|
+
# try to find id column
|
|
63
|
+
id_col = None
|
|
64
|
+
if "id" in data.columns:
|
|
65
|
+
id_col = "id"
|
|
66
|
+
elif len(data.columns) > 0:
|
|
67
|
+
id_col = data.columns[0]
|
|
68
|
+
|
|
69
|
+
if id_col is None:
|
|
70
|
+
raise HTTPException(status_code=400, detail="no id column found")
|
|
71
|
+
|
|
72
|
+
# try to match as int or string
|
|
73
|
+
try:
|
|
74
|
+
row_id_int = int(row_id)
|
|
75
|
+
match = data[data[id_col] == row_id_int]
|
|
76
|
+
except ValueError:
|
|
77
|
+
match = data[data[id_col] == row_id]
|
|
78
|
+
|
|
79
|
+
if len(match) == 0:
|
|
80
|
+
raise HTTPException(status_code=404, detail="row not found")
|
|
81
|
+
|
|
82
|
+
return match.iloc[0].to_dict()
|
|
83
|
+
|
|
84
|
+
@app.get(f"/{name}")
|
|
85
|
+
def list_rows(
|
|
86
|
+
request: Request,
|
|
87
|
+
_limit: int = Query(100, alias="_limit"),
|
|
88
|
+
_offset: int = Query(0, alias="_offset"),
|
|
89
|
+
_sort: str = Query(None, alias="_sort"),
|
|
90
|
+
):
|
|
91
|
+
result = data.copy()
|
|
92
|
+
|
|
93
|
+
# filter by query params (exclude special params)
|
|
94
|
+
special_params = {"_limit", "_offset", "_sort"}
|
|
95
|
+
for key, value in request.query_params.items():
|
|
96
|
+
if key not in special_params and key in result.columns:
|
|
97
|
+
# try to cast value to column type
|
|
98
|
+
try:
|
|
99
|
+
if result[key].dtype == "int64":
|
|
100
|
+
value = int(value)
|
|
101
|
+
elif result[key].dtype == "float64":
|
|
102
|
+
value = float(value)
|
|
103
|
+
elif result[key].dtype == "bool":
|
|
104
|
+
value = value.lower() in ("true", "1", "yes")
|
|
105
|
+
except ValueError:
|
|
106
|
+
pass
|
|
107
|
+
result = result[result[key] == value]
|
|
108
|
+
|
|
109
|
+
# sort
|
|
110
|
+
if _sort:
|
|
111
|
+
descending = _sort.startswith("-")
|
|
112
|
+
col = _sort.lstrip("-")
|
|
113
|
+
if col in result.columns:
|
|
114
|
+
result = result.sort_values(col, ascending=not descending)
|
|
115
|
+
|
|
116
|
+
# offset and limit
|
|
117
|
+
result = result.iloc[_offset : _offset + _limit]
|
|
118
|
+
|
|
119
|
+
return result.to_dict(orient="records")
|
tinycontracts/schema.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# schema.py - infer schema from dataframes
|
|
2
|
+
import pandas as pd
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def infer_column_schema(series: pd.Series) -> str:
|
|
6
|
+
dtype_str = str(series.dtype)
|
|
7
|
+
|
|
8
|
+
if dtype_str in ("object", "str", "string"):
|
|
9
|
+
return "string"
|
|
10
|
+
elif "int" in dtype_str:
|
|
11
|
+
return "integer"
|
|
12
|
+
elif "float" in dtype_str:
|
|
13
|
+
return "number"
|
|
14
|
+
elif dtype_str == "bool":
|
|
15
|
+
return "boolean"
|
|
16
|
+
else:
|
|
17
|
+
# fallback to string for unknown types
|
|
18
|
+
return "string"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def infer_schema(df: pd.DataFrame) -> dict:
|
|
22
|
+
properties = {}
|
|
23
|
+
for column in df.columns:
|
|
24
|
+
properties[column] = {"type": infer_column_schema(df[column])}
|
|
25
|
+
return {"type": "object", "properties": properties}
|
tinycontracts/server.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# server.py - fastapi app factory
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from fastapi import FastAPI
|
|
5
|
+
|
|
6
|
+
from tinycontracts.loader import load_folder
|
|
7
|
+
from tinycontracts.routes import create_discovery_routes, create_resource_routes
|
|
8
|
+
from tinycontracts.schema import infer_schema
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def create_app(folder: Path) -> FastAPI:
|
|
12
|
+
resources = load_folder(folder)
|
|
13
|
+
schemas = {name: infer_schema(df) for name, df in resources.items()}
|
|
14
|
+
|
|
15
|
+
app = FastAPI(title="tinycontracts", docs_url="/docs", redoc_url=None)
|
|
16
|
+
app.state.resources = resources
|
|
17
|
+
app.state.schemas = schemas
|
|
18
|
+
|
|
19
|
+
create_discovery_routes(app, resources, schemas)
|
|
20
|
+
|
|
21
|
+
for name, df in resources.items():
|
|
22
|
+
create_resource_routes(app, name, df, schemas[name])
|
|
23
|
+
|
|
24
|
+
return app
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tinycontracts
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: serve a folder of json/csv/parquet files as a rest api
|
|
5
|
+
Author-email: Aditya Kumar <adityakuma0308@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: api,cli,csv,fastapi,json,parquet,rest
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Framework :: FastAPI
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
|
+
Requires-Dist: fastapi>=0.128.0
|
|
18
|
+
Requires-Dist: pandas>=3.0.0
|
|
19
|
+
Requires-Dist: pyarrow>=23.0.0
|
|
20
|
+
Requires-Dist: rich>=14.3.2
|
|
21
|
+
Requires-Dist: typer>=0.21.1
|
|
22
|
+
Requires-Dist: uvicorn>=0.40.0
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# tinycontracts
|
|
26
|
+
|
|
27
|
+
Turn JSON, CSV, and Parquet files into REST APIs instantly.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
uv sync
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
tinycontracts serve ./data
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
This scans `./data` for `.json`, `.csv`, and `.parquet` files and serves them as REST endpoints.
|
|
42
|
+
|
|
43
|
+
## Example
|
|
44
|
+
|
|
45
|
+
Given this structure:
|
|
46
|
+
```
|
|
47
|
+
data/
|
|
48
|
+
users.json
|
|
49
|
+
orders.csv
|
|
50
|
+
products.parquet
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
You get:
|
|
54
|
+
```
|
|
55
|
+
GET /users
|
|
56
|
+
GET /orders
|
|
57
|
+
GET /products
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Filtering
|
|
61
|
+
|
|
62
|
+
Query any field:
|
|
63
|
+
```
|
|
64
|
+
GET /users?role=admin
|
|
65
|
+
GET /orders?status=shipped&customer_id=123
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Schema
|
|
69
|
+
|
|
70
|
+
Get the inferred JSON schema:
|
|
71
|
+
```
|
|
72
|
+
GET /users/schema
|
|
73
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
tinycontracts/__init__.py,sha256=WEsinRwkR1RI9izRzpTB0MidHLvvZxFE0S6KvkcK2aE,17
|
|
2
|
+
tinycontracts/cli.py,sha256=kir936bXvhjnVRfz4Gm36HmECq3PZLszKrTOvolhwY8,4078
|
|
3
|
+
tinycontracts/loader.py,sha256=CYgvvdTmuioaQeaTZNxLKy0ndFaCjR-xzQoh61FN5q4,884
|
|
4
|
+
tinycontracts/routes.py,sha256=ZVtr7-Sx1IAJIlK9yHgiFzgUa5Wpodsm90jliopQagM,3840
|
|
5
|
+
tinycontracts/schema.py,sha256=tZjLLvQGKi5H4drZZRNCDWY5SWg4_MnYtJWz5hsw8ng,692
|
|
6
|
+
tinycontracts/server.py,sha256=23gJheNqjPHckSeNlquG-G8viJNrMEVItcYxBg-n-t8,728
|
|
7
|
+
tinycontracts-0.1.0.dist-info/METADATA,sha256=erlssWxzSfTqQ5cf-gOL_kcHuWLRbnDilwUczBcoYkM,1438
|
|
8
|
+
tinycontracts-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
9
|
+
tinycontracts-0.1.0.dist-info/entry_points.txt,sha256=mBH0FJ35wm3PDzo0sK-0XP0NA53XQ93JpMCYXRBEPiI,45
|
|
10
|
+
tinycontracts-0.1.0.dist-info/RECORD,,
|