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.
@@ -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()
@@ -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
@@ -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")
@@ -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}
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tc = tinycontracts.cli:app