flatapicli 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.
- flatapicli-0.1.0/LICENSE +21 -0
- flatapicli-0.1.0/MANIFEST.in +2 -0
- flatapicli-0.1.0/PKG-INFO +127 -0
- flatapicli-0.1.0/README.md +102 -0
- flatapicli-0.1.0/flatapicli/__init__.py +2 -0
- flatapicli-0.1.0/flatapicli/cli.py +170 -0
- flatapicli-0.1.0/flatapicli/parsers/__init__.py +30 -0
- flatapicli-0.1.0/flatapicli/parsers/csv_parser.py +8 -0
- flatapicli-0.1.0/flatapicli/parsers/json_parser.py +25 -0
- flatapicli-0.1.0/flatapicli/parsers/xlsx_parser.py +8 -0
- flatapicli-0.1.0/flatapicli/server.py +454 -0
- flatapicli-0.1.0/flatapicli/store.py +74 -0
- flatapicli-0.1.0/flatapicli/watcher.py +59 -0
- flatapicli-0.1.0/flatapicli.egg-info/PKG-INFO +127 -0
- flatapicli-0.1.0/flatapicli.egg-info/SOURCES.txt +19 -0
- flatapicli-0.1.0/flatapicli.egg-info/dependency_links.txt +1 -0
- flatapicli-0.1.0/flatapicli.egg-info/entry_points.txt +2 -0
- flatapicli-0.1.0/flatapicli.egg-info/requires.txt +7 -0
- flatapicli-0.1.0/flatapicli.egg-info/top_level.txt +1 -0
- flatapicli-0.1.0/pyproject.toml +39 -0
- flatapicli-0.1.0/setup.cfg +4 -0
flatapicli-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Avinash Prajapati
|
|
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,127 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flatapicli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Serve flat files (CSV, XLSX, JSON) as a local REST API instantly.
|
|
5
|
+
Author: Avinash Prajapati
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/avinash/flatapicli
|
|
8
|
+
Project-URL: Issues, https://github.com/avinash/flatapicli/issues
|
|
9
|
+
Keywords: csv,xlsx,json,api,fastapi,local,prototyping
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Topic :: Utilities
|
|
13
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: fastapi>=0.100.0
|
|
18
|
+
Requires-Dist: uvicorn[standard]>=0.23.0
|
|
19
|
+
Requires-Dist: pandas>=2.0.0
|
|
20
|
+
Requires-Dist: openpyxl>=3.1.0
|
|
21
|
+
Requires-Dist: typer>=0.9.0
|
|
22
|
+
Requires-Dist: rich>=13.0.0
|
|
23
|
+
Requires-Dist: watchdog>=4.0.0
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# ⚡ flatapicli
|
|
27
|
+
|
|
28
|
+
> Serve flat files (CSV, XLSX, JSON) as a local REST API instantly.
|
|
29
|
+
|
|
30
|
+

|
|
31
|
+

|
|
32
|
+

|
|
33
|
+

|
|
34
|
+

|
|
35
|
+

|
|
36
|
+

|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install flatapicli
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
# Start the server pointing at a directory
|
|
50
|
+
flatapicli serve --dir ./data
|
|
51
|
+
|
|
52
|
+
# Custom port
|
|
53
|
+
flatapicli serve --dir ./data --port 8080
|
|
54
|
+
|
|
55
|
+
# Expose to network (VPS / LAN)
|
|
56
|
+
flatapicli serve --dir ./data --host 0.0.0.0
|
|
57
|
+
|
|
58
|
+
# Auto-reload store on file change (no server restart)
|
|
59
|
+
flatapicli serve --dir ./data --watch
|
|
60
|
+
|
|
61
|
+
# Inspect files without starting server
|
|
62
|
+
flatapicli list-files --dir ./data
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**While running:**
|
|
66
|
+
- `r` + Enter → reload data files without restarting server
|
|
67
|
+
- `q` + Enter → clean shutdown
|
|
68
|
+
|
|
69
|
+
## URL Structure
|
|
70
|
+
|
|
71
|
+
Given a directory with `products.csv`, `orders.xlsx` (sheets: `jan`, `feb`), and `users.json`:
|
|
72
|
+
|
|
73
|
+
| URL | Response |
|
|
74
|
+
|-----|----------|
|
|
75
|
+
| `GET /` | All loaded files |
|
|
76
|
+
| `GET /products/api` | Schema, columns, row count |
|
|
77
|
+
| `GET /products/api/rows` | All rows |
|
|
78
|
+
| `GET /products/api/rows?limit=10&offset=0` | Paginated rows |
|
|
79
|
+
| `GET /products/api/name` | All values in column `name` |
|
|
80
|
+
| `GET /orders/api` | Schema for all sheets |
|
|
81
|
+
| `GET /orders/api/jan/rows` | All rows in sheet `jan` |
|
|
82
|
+
| `GET /orders/api/jan/amount` | Column `amount` from sheet `jan` |
|
|
83
|
+
| `GET /orders/api/jan/amount/0` | Single cell: row 0, column `amount`, sheet `jan` |
|
|
84
|
+
|
|
85
|
+
## Supported Formats
|
|
86
|
+
|
|
87
|
+
| Format | Extension | Multi-sheet |
|
|
88
|
+
|--------|-----------|-------------|
|
|
89
|
+
| CSV | `.csv` | No |
|
|
90
|
+
| Excel | `.xlsx`, `.xls` | Yes |
|
|
91
|
+
| JSON | `.json` | Yes (object of arrays) |
|
|
92
|
+
|
|
93
|
+
### JSON format
|
|
94
|
+
|
|
95
|
+
Single sheet (array of objects):
|
|
96
|
+
```json
|
|
97
|
+
[{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Multi-sheet (object of arrays):
|
|
101
|
+
```json
|
|
102
|
+
{
|
|
103
|
+
"users": [{"id": 1, "name": "Alice"}],
|
|
104
|
+
"admins": [{"id": 99, "name": "Root"}]
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Interactive Docs
|
|
109
|
+
|
|
110
|
+
Custom Swagger UI with chained dropdowns (File → Sheet → Column) available at `http://localhost:3000/docs`.
|
|
111
|
+
|
|
112
|
+
## Deployment (VPS)
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
pip install flatapicli
|
|
116
|
+
flatapicli serve --dir /data/files --host 0.0.0.0 --port 3000
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Run behind nginx or use a systemd service for production stability.
|
|
120
|
+
|
|
121
|
+
## Contributing
|
|
122
|
+
|
|
123
|
+
TOON (Token-Oriented Object Notation) parser contributions welcome. Add a parser in `flatapicli/parsers/` following the existing pattern and register it in `flatapicli/parsers/__init__.py`.
|
|
124
|
+
|
|
125
|
+
## License
|
|
126
|
+
|
|
127
|
+
MIT
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# ⚡ flatapicli
|
|
2
|
+
|
|
3
|
+
> Serve flat files (CSV, XLSX, JSON) as a local REST API instantly.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+

|
|
7
|
+

|
|
8
|
+

|
|
9
|
+

|
|
10
|
+

|
|
11
|
+

|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install flatapicli
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# Start the server pointing at a directory
|
|
25
|
+
flatapicli serve --dir ./data
|
|
26
|
+
|
|
27
|
+
# Custom port
|
|
28
|
+
flatapicli serve --dir ./data --port 8080
|
|
29
|
+
|
|
30
|
+
# Expose to network (VPS / LAN)
|
|
31
|
+
flatapicli serve --dir ./data --host 0.0.0.0
|
|
32
|
+
|
|
33
|
+
# Auto-reload store on file change (no server restart)
|
|
34
|
+
flatapicli serve --dir ./data --watch
|
|
35
|
+
|
|
36
|
+
# Inspect files without starting server
|
|
37
|
+
flatapicli list-files --dir ./data
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
**While running:**
|
|
41
|
+
- `r` + Enter → reload data files without restarting server
|
|
42
|
+
- `q` + Enter → clean shutdown
|
|
43
|
+
|
|
44
|
+
## URL Structure
|
|
45
|
+
|
|
46
|
+
Given a directory with `products.csv`, `orders.xlsx` (sheets: `jan`, `feb`), and `users.json`:
|
|
47
|
+
|
|
48
|
+
| URL | Response |
|
|
49
|
+
|-----|----------|
|
|
50
|
+
| `GET /` | All loaded files |
|
|
51
|
+
| `GET /products/api` | Schema, columns, row count |
|
|
52
|
+
| `GET /products/api/rows` | All rows |
|
|
53
|
+
| `GET /products/api/rows?limit=10&offset=0` | Paginated rows |
|
|
54
|
+
| `GET /products/api/name` | All values in column `name` |
|
|
55
|
+
| `GET /orders/api` | Schema for all sheets |
|
|
56
|
+
| `GET /orders/api/jan/rows` | All rows in sheet `jan` |
|
|
57
|
+
| `GET /orders/api/jan/amount` | Column `amount` from sheet `jan` |
|
|
58
|
+
| `GET /orders/api/jan/amount/0` | Single cell: row 0, column `amount`, sheet `jan` |
|
|
59
|
+
|
|
60
|
+
## Supported Formats
|
|
61
|
+
|
|
62
|
+
| Format | Extension | Multi-sheet |
|
|
63
|
+
|--------|-----------|-------------|
|
|
64
|
+
| CSV | `.csv` | No |
|
|
65
|
+
| Excel | `.xlsx`, `.xls` | Yes |
|
|
66
|
+
| JSON | `.json` | Yes (object of arrays) |
|
|
67
|
+
|
|
68
|
+
### JSON format
|
|
69
|
+
|
|
70
|
+
Single sheet (array of objects):
|
|
71
|
+
```json
|
|
72
|
+
[{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Multi-sheet (object of arrays):
|
|
76
|
+
```json
|
|
77
|
+
{
|
|
78
|
+
"users": [{"id": 1, "name": "Alice"}],
|
|
79
|
+
"admins": [{"id": 99, "name": "Root"}]
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Interactive Docs
|
|
84
|
+
|
|
85
|
+
Custom Swagger UI with chained dropdowns (File → Sheet → Column) available at `http://localhost:3000/docs`.
|
|
86
|
+
|
|
87
|
+
## Deployment (VPS)
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
pip install flatapicli
|
|
91
|
+
flatapicli serve --dir /data/files --host 0.0.0.0 --port 3000
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Run behind nginx or use a systemd service for production stability.
|
|
95
|
+
|
|
96
|
+
## Contributing
|
|
97
|
+
|
|
98
|
+
TOON (Token-Oriented Object Notation) parser contributions welcome. Add a parser in `flatapicli/parsers/` following the existing pattern and register it in `flatapicli/parsers/__init__.py`.
|
|
99
|
+
|
|
100
|
+
## License
|
|
101
|
+
|
|
102
|
+
MIT
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import threading
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(
|
|
11
|
+
name="flatapi",
|
|
12
|
+
help="Serve flat files (CSV, XLSX, JSON) as a local REST API.",
|
|
13
|
+
add_completion=False,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
console = Console()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _keyboard_listener(dir_path: Path, stop_event: threading.Event):
|
|
20
|
+
"""Runs in a background thread. Handles r=reload, q=quit."""
|
|
21
|
+
while not stop_event.is_set():
|
|
22
|
+
try:
|
|
23
|
+
cmd = input().strip().lower()
|
|
24
|
+
except EOFError:
|
|
25
|
+
break
|
|
26
|
+
if cmd == "r":
|
|
27
|
+
console.print("\n[cyan]🔄 Manual reload...[/cyan]")
|
|
28
|
+
from flatapi import store
|
|
29
|
+
loaded = store.load_directory(dir_path)
|
|
30
|
+
console.print(f"[green]✅ Reloaded: {', '.join(loaded)}[/green]\n")
|
|
31
|
+
elif cmd in ("q", "exit", "quit"):
|
|
32
|
+
console.print("\n[yellow]🛑 Shutting down...[/yellow]")
|
|
33
|
+
stop_event.set()
|
|
34
|
+
# Signal uvicorn to stop
|
|
35
|
+
import os, signal
|
|
36
|
+
os.kill(os.getpid(), signal.SIGINT)
|
|
37
|
+
break
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@app.command()
|
|
41
|
+
def serve(
|
|
42
|
+
dir: Path = typer.Option(
|
|
43
|
+
..., "--dir", "-d",
|
|
44
|
+
help="Directory containing your data files.",
|
|
45
|
+
exists=True, file_okay=False, dir_okay=True, resolve_path=True,
|
|
46
|
+
),
|
|
47
|
+
port: int = typer.Option(3000, "--port", "-p", help="Port to run the server on."),
|
|
48
|
+
host: str = typer.Option("127.0.0.1", "--host", "-h", help="Host to bind to. Use 0.0.0.0 for network/VPS access."),
|
|
49
|
+
watch: bool = typer.Option(False, "--watch", "-w", help="Auto-reload store when files change."),
|
|
50
|
+
reload: bool = typer.Option(False, "--reload", help="Enable uvicorn auto-reload (dev mode, restarts server)."),
|
|
51
|
+
):
|
|
52
|
+
"""
|
|
53
|
+
Start the flatapi server pointing at a directory of data files.
|
|
54
|
+
|
|
55
|
+
\b
|
|
56
|
+
While running:
|
|
57
|
+
r + Enter → reload data files without restarting server
|
|
58
|
+
q + Enter → clean shutdown
|
|
59
|
+
|
|
60
|
+
\b
|
|
61
|
+
Examples:
|
|
62
|
+
flatapi serve --dir ./data
|
|
63
|
+
flatapi serve --dir ./data --port 8080 --watch
|
|
64
|
+
flatapi serve --dir ./data --host 0.0.0.0
|
|
65
|
+
"""
|
|
66
|
+
from flatapi import store
|
|
67
|
+
|
|
68
|
+
console.print(f"\n[bold cyan]flatapi[/bold cyan] scanning [green]{dir}[/green]...\n")
|
|
69
|
+
loaded = store.load_directory(dir)
|
|
70
|
+
|
|
71
|
+
if not loaded:
|
|
72
|
+
console.print("[bold red]No supported files found.[/bold red] (Supported: .csv, .xlsx, .xls, .json)")
|
|
73
|
+
raise typer.Exit(1)
|
|
74
|
+
|
|
75
|
+
# Print loaded files table
|
|
76
|
+
table = Table(title="Loaded Files", show_header=True, header_style="bold magenta")
|
|
77
|
+
table.add_column("File", style="cyan")
|
|
78
|
+
table.add_column("Sheets", style="green")
|
|
79
|
+
table.add_column("Endpoint")
|
|
80
|
+
|
|
81
|
+
for fname in loaded:
|
|
82
|
+
stem = Path(fname).stem.lower()
|
|
83
|
+
sheets = store.list_sheets(stem)
|
|
84
|
+
sheet_str = ", ".join(sheets) if sheets else "-"
|
|
85
|
+
table.add_row(fname, sheet_str, f"http://{host}:{port}/{stem}/api")
|
|
86
|
+
|
|
87
|
+
console.print(table)
|
|
88
|
+
console.print(f"\n[bold]Docs:[/bold] http://{host}:{port}/docs")
|
|
89
|
+
console.print(f"[bold]Root:[/bold] http://{host}:{port}/\n")
|
|
90
|
+
console.print("[dim]Commands: [r] reload [q] quit[/dim]\n")
|
|
91
|
+
|
|
92
|
+
# Start file watcher if --watch
|
|
93
|
+
observer = None
|
|
94
|
+
if watch:
|
|
95
|
+
from flatapicli.watcher import start_watcher
|
|
96
|
+
observer = start_watcher(dir, on_reload=lambda files: console.print(f"[green]✅ Reloaded: {', '.join(files)}[/green]"))
|
|
97
|
+
console.print(f"[cyan]👁 Watching {dir} for changes...[/cyan]\n")
|
|
98
|
+
|
|
99
|
+
# Start keyboard listener thread
|
|
100
|
+
stop_event = threading.Event()
|
|
101
|
+
kb_thread = threading.Thread(
|
|
102
|
+
target=_keyboard_listener,
|
|
103
|
+
args=(dir, stop_event),
|
|
104
|
+
daemon=True,
|
|
105
|
+
)
|
|
106
|
+
kb_thread.start()
|
|
107
|
+
|
|
108
|
+
# Start uvicorn
|
|
109
|
+
import uvicorn
|
|
110
|
+
try:
|
|
111
|
+
uvicorn.run(
|
|
112
|
+
"flatapi.server:app",
|
|
113
|
+
host=host,
|
|
114
|
+
port=port,
|
|
115
|
+
reload=reload,
|
|
116
|
+
log_level="info",
|
|
117
|
+
)
|
|
118
|
+
finally:
|
|
119
|
+
stop_event.set()
|
|
120
|
+
if observer:
|
|
121
|
+
observer.stop()
|
|
122
|
+
observer.join()
|
|
123
|
+
console.print("[green]✅ flatapi stopped.[/green]")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@app.command(name="list-files")
|
|
127
|
+
def list_files(
|
|
128
|
+
dir: Path = typer.Option(
|
|
129
|
+
..., "--dir", "-d",
|
|
130
|
+
help="Directory to inspect.",
|
|
131
|
+
exists=True, file_okay=False, dir_okay=True, resolve_path=True,
|
|
132
|
+
),
|
|
133
|
+
):
|
|
134
|
+
"""List all supported files in a directory without starting the server."""
|
|
135
|
+
from flatapi import store
|
|
136
|
+
|
|
137
|
+
console.print(f"\nScanning [green]{dir}[/green]...\n")
|
|
138
|
+
loaded = store.load_directory(dir)
|
|
139
|
+
|
|
140
|
+
if not loaded:
|
|
141
|
+
console.print("[yellow]No supported files found.[/yellow]")
|
|
142
|
+
raise typer.Exit()
|
|
143
|
+
|
|
144
|
+
table = Table(show_header=True, header_style="bold magenta")
|
|
145
|
+
table.add_column("File")
|
|
146
|
+
table.add_column("Sheet")
|
|
147
|
+
table.add_column("Columns")
|
|
148
|
+
table.add_column("Rows")
|
|
149
|
+
|
|
150
|
+
for fname in loaded:
|
|
151
|
+
stem = Path(fname).stem.lower()
|
|
152
|
+
sheets = store.list_sheets(stem)
|
|
153
|
+
for i, sheet in enumerate(sheets):
|
|
154
|
+
df = store.get_sheet(stem, sheet)
|
|
155
|
+
table.add_row(
|
|
156
|
+
fname if i == 0 else "",
|
|
157
|
+
sheet,
|
|
158
|
+
str(len(df.columns)),
|
|
159
|
+
str(len(df)),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
console.print(table)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def main():
|
|
166
|
+
app()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
if __name__ == "__main__":
|
|
170
|
+
main()
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import pandas as pd
|
|
3
|
+
|
|
4
|
+
from .csv_parser import load as load_csv
|
|
5
|
+
from .xlsx_parser import load as load_xlsx
|
|
6
|
+
from .json_parser import load as load_json
|
|
7
|
+
|
|
8
|
+
SUPPORTED_EXTENSIONS = {".csv", ".xlsx", ".xls", ".json"}
|
|
9
|
+
|
|
10
|
+
_LOADERS = {
|
|
11
|
+
".csv": load_csv,
|
|
12
|
+
".xlsx": load_xlsx,
|
|
13
|
+
".xls": load_xlsx,
|
|
14
|
+
".json": load_json,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_file(path: Path) -> dict[str, pd.DataFrame]:
|
|
19
|
+
"""
|
|
20
|
+
Returns a dict of {sheet_name: DataFrame}.
|
|
21
|
+
Single-sheet formats use 'default' as the sheet name.
|
|
22
|
+
"""
|
|
23
|
+
ext = path.suffix.lower()
|
|
24
|
+
loader = _LOADERS.get(ext)
|
|
25
|
+
if loader is None:
|
|
26
|
+
raise ValueError(
|
|
27
|
+
f"Unsupported file type '{ext}'. "
|
|
28
|
+
f"Supported: {', '.join(SUPPORTED_EXTENSIONS)}"
|
|
29
|
+
)
|
|
30
|
+
return loader(path)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import pandas as pd
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def load(path: Path) -> dict[str, pd.DataFrame]:
|
|
7
|
+
"""
|
|
8
|
+
Supports:
|
|
9
|
+
- Array of objects: [{...}, {...}] → single 'default' sheet
|
|
10
|
+
- Object of arrays: {"sheet1": [{...}], "sheet2": [{...}]} → multi-sheet
|
|
11
|
+
"""
|
|
12
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
13
|
+
data = json.load(f)
|
|
14
|
+
|
|
15
|
+
if isinstance(data, list):
|
|
16
|
+
return {"default": pd.DataFrame(data)}
|
|
17
|
+
|
|
18
|
+
if isinstance(data, dict):
|
|
19
|
+
# Check if values are lists of objects → treat as named sheets
|
|
20
|
+
if all(isinstance(v, list) for v in data.values()):
|
|
21
|
+
return {k: pd.DataFrame(v) for k, v in data.items()}
|
|
22
|
+
# Single flat object → wrap as one-row DataFrame
|
|
23
|
+
return {"default": pd.DataFrame([data])}
|
|
24
|
+
|
|
25
|
+
raise ValueError(f"Unsupported JSON structure in {path.name}")
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def load(path: Path) -> dict[str, pd.DataFrame]:
|
|
6
|
+
"""Returns {sheet_name: DataFrame} for every sheet in the workbook."""
|
|
7
|
+
xl = pd.ExcelFile(path, engine="openpyxl")
|
|
8
|
+
return {sheet: xl.parse(sheet) for sheet in xl.sheet_names}
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Route structure:
|
|
3
|
+
GET / → list all loaded files
|
|
4
|
+
GET /meta → full schema for Swagger UI dropdowns
|
|
5
|
+
GET /meta/{filename} → sheets + columns for a file
|
|
6
|
+
GET /meta/{filename}/{sheet} → columns for a sheet
|
|
7
|
+
GET /{filename}/api → file health + schema
|
|
8
|
+
GET /{filename}/api/rows → all rows (default sheet)
|
|
9
|
+
GET /{filename}/api/{column} → column values (default sheet)
|
|
10
|
+
GET /{filename}/api/{sheet}/rows → all rows in named sheet
|
|
11
|
+
GET /{filename}/api/{sheet}/{column} → column values from named sheet
|
|
12
|
+
GET /{filename}/api/{sheet}/{column}/{row_id}→ single cell
|
|
13
|
+
"""
|
|
14
|
+
import math
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from fastapi import FastAPI, HTTPException, Query
|
|
18
|
+
from fastapi.responses import JSONResponse, HTMLResponse
|
|
19
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
20
|
+
|
|
21
|
+
from . import store
|
|
22
|
+
|
|
23
|
+
app = FastAPI(
|
|
24
|
+
title="flatapi",
|
|
25
|
+
description="Serve flat files (CSV, XLSX, JSON) as a local REST API.",
|
|
26
|
+
version="0.1.0",
|
|
27
|
+
docs_url=None, # We override /docs with custom Swagger UI
|
|
28
|
+
redoc_url="/redoc",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
app.add_middleware(
|
|
32
|
+
CORSMiddleware,
|
|
33
|
+
allow_origins=["*"],
|
|
34
|
+
allow_methods=["*"],
|
|
35
|
+
allow_headers=["*"],
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
def _sanitize(obj):
|
|
42
|
+
import numpy as np
|
|
43
|
+
if isinstance(obj, np.integer): return int(obj)
|
|
44
|
+
if isinstance(obj, np.floating): return None if (np.isnan(obj) or np.isinf(obj)) else float(obj)
|
|
45
|
+
if isinstance(obj, np.bool_): return bool(obj)
|
|
46
|
+
if isinstance(obj, float) and (obj != obj): return None
|
|
47
|
+
return obj
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _df_to_records(df) -> list[dict]:
|
|
51
|
+
records = df.to_dict(orient="records")
|
|
52
|
+
return [{k: _sanitize(v) for k, v in row.items()} for row in records]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _build_endpoint_hints(filename: str, sheets: list, schema: dict) -> list[str]:
|
|
56
|
+
hints = []
|
|
57
|
+
for sheet in sheets:
|
|
58
|
+
prefix = (
|
|
59
|
+
f"/{filename}/api"
|
|
60
|
+
if sheet == "default"
|
|
61
|
+
else f"/{filename}/api/{sheet}"
|
|
62
|
+
)
|
|
63
|
+
hints.append(f"GET {prefix}/rows")
|
|
64
|
+
for col in schema[sheet]["columns"]:
|
|
65
|
+
hints.append(f"GET {prefix}/{col}")
|
|
66
|
+
return hints
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ── Meta endpoints (for Swagger UI dynamic dropdowns) ─────────────────────────
|
|
70
|
+
|
|
71
|
+
@app.get("/meta", tags=["meta"])
|
|
72
|
+
def get_meta():
|
|
73
|
+
"""Returns full schema of all loaded files. Used by Swagger UI dropdowns."""
|
|
74
|
+
result = {}
|
|
75
|
+
for filename in store.list_files():
|
|
76
|
+
sheets = store.list_sheets(filename)
|
|
77
|
+
result[filename] = {}
|
|
78
|
+
for sheet in sheets:
|
|
79
|
+
df = store.get_sheet(filename, sheet)
|
|
80
|
+
result[filename][sheet] = list(df.columns)
|
|
81
|
+
return result
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@app.get("/meta/{filename}", tags=["meta"])
|
|
85
|
+
def get_file_meta(filename: str):
|
|
86
|
+
sheets = store.list_sheets(filename)
|
|
87
|
+
if sheets is None:
|
|
88
|
+
raise HTTPException(status_code=404, detail=f"File '{filename}' not found.")
|
|
89
|
+
result = {}
|
|
90
|
+
for sheet in sheets:
|
|
91
|
+
df = store.get_sheet(filename, sheet)
|
|
92
|
+
result[sheet] = list(df.columns)
|
|
93
|
+
return result
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@app.get("/meta/{filename}/{sheet}", tags=["meta"])
|
|
97
|
+
def get_sheet_meta(filename: str, sheet: str):
|
|
98
|
+
df = store.get_sheet(filename, sheet)
|
|
99
|
+
if df is None:
|
|
100
|
+
raise HTTPException(status_code=404, detail=f"Sheet '{sheet}' not found in '{filename}'.")
|
|
101
|
+
return {"columns": list(df.columns)}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ── Custom Swagger UI with chained dropdowns ───────────────────────────────────
|
|
105
|
+
|
|
106
|
+
@app.get("/docs", include_in_schema=False)
|
|
107
|
+
async def custom_swagger():
|
|
108
|
+
html = """
|
|
109
|
+
<!DOCTYPE html>
|
|
110
|
+
<html>
|
|
111
|
+
<head>
|
|
112
|
+
<title>flatapi - Swagger UI</title>
|
|
113
|
+
<meta charset="utf-8"/>
|
|
114
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
115
|
+
<link rel="stylesheet" type="text/css" href="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.11.0/swagger-ui.css">
|
|
116
|
+
<style>
|
|
117
|
+
body { margin: 0; }
|
|
118
|
+
.flatapi-bar {
|
|
119
|
+
background: #1b1b1b;
|
|
120
|
+
color: #fff;
|
|
121
|
+
padding: 10px 20px;
|
|
122
|
+
font-family: monospace;
|
|
123
|
+
font-size: 14px;
|
|
124
|
+
display: flex;
|
|
125
|
+
align-items: center;
|
|
126
|
+
gap: 12px;
|
|
127
|
+
flex-wrap: wrap;
|
|
128
|
+
}
|
|
129
|
+
.flatapi-bar label { color: #aaa; font-size: 12px; }
|
|
130
|
+
.flatapi-bar select {
|
|
131
|
+
background: #2d2d2d;
|
|
132
|
+
color: #fff;
|
|
133
|
+
border: 1px solid #444;
|
|
134
|
+
padding: 4px 8px;
|
|
135
|
+
border-radius: 4px;
|
|
136
|
+
font-size: 13px;
|
|
137
|
+
cursor: pointer;
|
|
138
|
+
}
|
|
139
|
+
.flatapi-bar select:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
140
|
+
.flatapi-bar .url-preview {
|
|
141
|
+
margin-left: auto;
|
|
142
|
+
color: #4CAF50;
|
|
143
|
+
font-size: 13px;
|
|
144
|
+
word-break: break-all;
|
|
145
|
+
}
|
|
146
|
+
.flatapi-bar button {
|
|
147
|
+
background: #4CAF50;
|
|
148
|
+
color: #fff;
|
|
149
|
+
border: none;
|
|
150
|
+
padding: 5px 14px;
|
|
151
|
+
border-radius: 4px;
|
|
152
|
+
cursor: pointer;
|
|
153
|
+
font-size: 13px;
|
|
154
|
+
}
|
|
155
|
+
.flatapi-bar button:hover { background: #45a049; }
|
|
156
|
+
#swagger-ui { padding-top: 4px; }
|
|
157
|
+
</style>
|
|
158
|
+
</head>
|
|
159
|
+
<body>
|
|
160
|
+
|
|
161
|
+
<div class="flatapi-bar">
|
|
162
|
+
<span style="color:#4CAF50;font-weight:bold;">⚡ flatapi</span>
|
|
163
|
+
|
|
164
|
+
<div>
|
|
165
|
+
<label>File</label><br>
|
|
166
|
+
<select id="sel-file" onchange="onFileChange()">
|
|
167
|
+
<option value="">— select file —</option>
|
|
168
|
+
</select>
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
<div>
|
|
172
|
+
<label>Sheet</label><br>
|
|
173
|
+
<select id="sel-sheet" onchange="onSheetChange()" disabled>
|
|
174
|
+
<option value="">— select sheet —</option>
|
|
175
|
+
</select>
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
<div>
|
|
179
|
+
<label>Column</label><br>
|
|
180
|
+
<select id="sel-col" onchange="onColChange()" disabled>
|
|
181
|
+
<option value="">— select column —</option>
|
|
182
|
+
</select>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
<div>
|
|
186
|
+
<label>Row ID (optional)</label><br>
|
|
187
|
+
<input id="inp-row" type="number" min="0" placeholder="e.g. 0"
|
|
188
|
+
style="background:#2d2d2d;color:#fff;border:1px solid #444;padding:4px 8px;border-radius:4px;font-size:13px;width:80px;"
|
|
189
|
+
oninput="buildUrl()">
|
|
190
|
+
</div>
|
|
191
|
+
|
|
192
|
+
<button onclick="openUrl()">Open ↗</button>
|
|
193
|
+
|
|
194
|
+
<div class="url-preview" id="url-preview"></div>
|
|
195
|
+
</div>
|
|
196
|
+
|
|
197
|
+
<div id="swagger-ui"></div>
|
|
198
|
+
|
|
199
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.11.0/swagger-ui-bundle.js"></script>
|
|
200
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.11.0/swagger-ui-standalone-preset.js"></script>
|
|
201
|
+
<script>
|
|
202
|
+
let meta = {};
|
|
203
|
+
|
|
204
|
+
// Init Swagger UI
|
|
205
|
+
SwaggerUIBundle({
|
|
206
|
+
url: "/openapi.json",
|
|
207
|
+
dom_id: '#swagger-ui',
|
|
208
|
+
presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
|
|
209
|
+
layout: "StandaloneLayout",
|
|
210
|
+
deepLinking: true,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// Load meta and populate file dropdown
|
|
214
|
+
async function loadMeta() {
|
|
215
|
+
const res = await fetch('/meta');
|
|
216
|
+
meta = await res.json();
|
|
217
|
+
const sel = document.getElementById('sel-file');
|
|
218
|
+
sel.innerHTML = '<option value="">— select file —</option>';
|
|
219
|
+
for (const fname of Object.keys(meta)) {
|
|
220
|
+
const opt = document.createElement('option');
|
|
221
|
+
opt.value = fname;
|
|
222
|
+
opt.textContent = fname;
|
|
223
|
+
sel.appendChild(opt);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function onFileChange() {
|
|
228
|
+
const file = document.getElementById('sel-file').value;
|
|
229
|
+
const selSheet = document.getElementById('sel-sheet');
|
|
230
|
+
const selCol = document.getElementById('sel-col');
|
|
231
|
+
|
|
232
|
+
selSheet.innerHTML = '<option value="">— select sheet —</option>';
|
|
233
|
+
selCol.innerHTML = '<option value="">— select column —</option>';
|
|
234
|
+
selCol.disabled = true;
|
|
235
|
+
|
|
236
|
+
if (!file) { selSheet.disabled = true; buildUrl(); return; }
|
|
237
|
+
|
|
238
|
+
const sheets = Object.keys(meta[file] || {});
|
|
239
|
+
selSheet.disabled = false;
|
|
240
|
+
|
|
241
|
+
if (sheets.length === 1 && sheets[0] === 'default') {
|
|
242
|
+
// Single sheet — skip sheet selector, populate columns directly
|
|
243
|
+
selSheet.innerHTML = '<option value="default">default</option>';
|
|
244
|
+
selSheet.disabled = true;
|
|
245
|
+
populateColumns(file, 'default');
|
|
246
|
+
} else {
|
|
247
|
+
for (const s of sheets) {
|
|
248
|
+
const opt = document.createElement('option');
|
|
249
|
+
opt.value = s; opt.textContent = s;
|
|
250
|
+
selSheet.appendChild(opt);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
buildUrl();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function onSheetChange() {
|
|
257
|
+
const file = document.getElementById('sel-file').value;
|
|
258
|
+
const sheet = document.getElementById('sel-sheet').value;
|
|
259
|
+
if (file && sheet) populateColumns(file, sheet);
|
|
260
|
+
buildUrl();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function populateColumns(file, sheet) {
|
|
264
|
+
const cols = meta[file]?.[sheet] || [];
|
|
265
|
+
const selCol = document.getElementById('sel-col');
|
|
266
|
+
selCol.innerHTML = '<option value="">— all rows —</option>';
|
|
267
|
+
for (const c of cols) {
|
|
268
|
+
const opt = document.createElement('option');
|
|
269
|
+
opt.value = c; opt.textContent = c;
|
|
270
|
+
selCol.appendChild(opt);
|
|
271
|
+
}
|
|
272
|
+
selCol.disabled = false;
|
|
273
|
+
buildUrl();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function onColChange() { buildUrl(); }
|
|
277
|
+
|
|
278
|
+
function buildUrl() {
|
|
279
|
+
const file = document.getElementById('sel-file').value;
|
|
280
|
+
const sheet = document.getElementById('sel-sheet').value;
|
|
281
|
+
const col = document.getElementById('sel-col').value;
|
|
282
|
+
const row = document.getElementById('inp-row').value;
|
|
283
|
+
|
|
284
|
+
if (!file) { document.getElementById('url-preview').textContent = ''; return; }
|
|
285
|
+
|
|
286
|
+
const isDefault = !sheet || sheet === 'default';
|
|
287
|
+
let url = `/${file}/api`;
|
|
288
|
+
|
|
289
|
+
if (!isDefault) url += `/${sheet}`;
|
|
290
|
+
|
|
291
|
+
if (col) {
|
|
292
|
+
url += `/${col}`;
|
|
293
|
+
if (row !== '') url += `/${row}`;
|
|
294
|
+
} else {
|
|
295
|
+
url += '/rows';
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
document.getElementById('url-preview').textContent = url;
|
|
299
|
+
return url;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function openUrl() {
|
|
303
|
+
const url = buildUrl();
|
|
304
|
+
if (url) window.open(url, '_blank');
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
loadMeta();
|
|
308
|
+
</script>
|
|
309
|
+
</body>
|
|
310
|
+
</html>
|
|
311
|
+
"""
|
|
312
|
+
return HTMLResponse(html)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# ── Root ──────────────────────────────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
@app.get("/", tags=["root"])
|
|
318
|
+
def root():
|
|
319
|
+
files = store.list_files()
|
|
320
|
+
return {
|
|
321
|
+
"status": "running",
|
|
322
|
+
"loaded_files": files,
|
|
323
|
+
"count": len(files),
|
|
324
|
+
"hint": "Access a file at /{filename}/api | Explorer: /docs",
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# ── File-level ────────────────────────────────────────────────────────────────
|
|
329
|
+
|
|
330
|
+
@app.get("/{filename}/api", tags=["data"])
|
|
331
|
+
def file_info(filename: str):
|
|
332
|
+
sheets = store.list_sheets(filename)
|
|
333
|
+
if sheets is None:
|
|
334
|
+
raise HTTPException(status_code=404, detail=f"File '{filename}' not found.")
|
|
335
|
+
|
|
336
|
+
schema = {}
|
|
337
|
+
for sheet_name in sheets:
|
|
338
|
+
df = store.get_sheet(filename, sheet_name)
|
|
339
|
+
schema[sheet_name] = {
|
|
340
|
+
"columns": list(df.columns),
|
|
341
|
+
"row_count": len(df),
|
|
342
|
+
"dtypes": {col: str(dtype) for col, dtype in df.dtypes.items()},
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
is_multi = len(sheets) > 1 or sheets[0] != "default"
|
|
346
|
+
return {
|
|
347
|
+
"status": "ok",
|
|
348
|
+
"file": filename,
|
|
349
|
+
"sheets": sheets,
|
|
350
|
+
"multi_sheet": is_multi,
|
|
351
|
+
"schema": schema,
|
|
352
|
+
"endpoints": _build_endpoint_hints(filename, sheets, schema),
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
# ── Default-sheet routes ───────────────────────────────────────────────────────
|
|
357
|
+
|
|
358
|
+
@app.get("/{filename}/api/rows", tags=["data"])
|
|
359
|
+
def get_all_rows(
|
|
360
|
+
filename: str,
|
|
361
|
+
limit: Optional[int] = Query(None, ge=1),
|
|
362
|
+
offset: int = Query(0, ge=0),
|
|
363
|
+
):
|
|
364
|
+
df = store.get_sheet(filename, "default")
|
|
365
|
+
if df is None:
|
|
366
|
+
sheets = store.list_sheets(filename)
|
|
367
|
+
if sheets:
|
|
368
|
+
raise HTTPException(
|
|
369
|
+
status_code=400,
|
|
370
|
+
detail=f"'{filename}' has multiple sheets: {sheets}. Use /{filename}/api/{{sheetname}}/rows",
|
|
371
|
+
)
|
|
372
|
+
raise HTTPException(status_code=404, detail=f"File '{filename}' not found.")
|
|
373
|
+
|
|
374
|
+
sliced = df.iloc[offset: offset + limit] if limit else df.iloc[offset:]
|
|
375
|
+
return {
|
|
376
|
+
"file": filename, "sheet": "default",
|
|
377
|
+
"total": len(df), "offset": offset, "limit": limit,
|
|
378
|
+
"rows": _df_to_records(sliced),
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
@app.get("/{filename}/api/{column_or_sheet}", tags=["data"])
|
|
383
|
+
def get_column_or_sheet_info(filename: str, column_or_sheet: str):
|
|
384
|
+
df = store.get_sheet(filename, "default")
|
|
385
|
+
if df is not None and column_or_sheet in df.columns:
|
|
386
|
+
return {
|
|
387
|
+
"file": filename, "sheet": "default",
|
|
388
|
+
"column": column_or_sheet,
|
|
389
|
+
"values": [_sanitize(v) for v in df[column_or_sheet].tolist()],
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
sheet_df = store.get_sheet(filename, column_or_sheet)
|
|
393
|
+
if sheet_df is not None:
|
|
394
|
+
return {
|
|
395
|
+
"file": filename, "sheet": column_or_sheet,
|
|
396
|
+
"columns": list(sheet_df.columns),
|
|
397
|
+
"row_count": len(sheet_df),
|
|
398
|
+
"hint": f"Use /{filename}/api/{column_or_sheet}/rows for all rows",
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
sheets = store.list_sheets(filename)
|
|
402
|
+
if sheets is None:
|
|
403
|
+
raise HTTPException(status_code=404, detail=f"File '{filename}' not found.")
|
|
404
|
+
raise HTTPException(
|
|
405
|
+
status_code=404,
|
|
406
|
+
detail=f"'{column_or_sheet}' is not a column or sheet in '{filename}'.",
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
# ── Named-sheet routes ────────────────────────────────────────────────────────
|
|
411
|
+
|
|
412
|
+
@app.get("/{filename}/api/{sheet}/rows", tags=["data"])
|
|
413
|
+
def get_sheet_rows(
|
|
414
|
+
filename: str, sheet: str,
|
|
415
|
+
limit: Optional[int] = Query(None, ge=1),
|
|
416
|
+
offset: int = Query(0, ge=0),
|
|
417
|
+
):
|
|
418
|
+
df = store.get_sheet(filename, sheet)
|
|
419
|
+
if df is None:
|
|
420
|
+
raise HTTPException(status_code=404, detail=f"Sheet '{sheet}' not found in '{filename}'.")
|
|
421
|
+
sliced = df.iloc[offset: offset + limit] if limit else df.iloc[offset:]
|
|
422
|
+
return {
|
|
423
|
+
"file": filename, "sheet": sheet,
|
|
424
|
+
"total": len(df), "offset": offset, "limit": limit,
|
|
425
|
+
"rows": _df_to_records(sliced),
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
@app.get("/{filename}/api/{sheet}/{column}", tags=["data"])
|
|
430
|
+
def get_sheet_column(filename: str, sheet: str, column: str):
|
|
431
|
+
df = store.get_sheet(filename, sheet)
|
|
432
|
+
if df is None:
|
|
433
|
+
raise HTTPException(status_code=404, detail=f"Sheet '{sheet}' not found in '{filename}'.")
|
|
434
|
+
if column not in df.columns:
|
|
435
|
+
raise HTTPException(status_code=404, detail=f"Column '{column}' not found. Available: {list(df.columns)}")
|
|
436
|
+
return {
|
|
437
|
+
"file": filename, "sheet": sheet, "column": column,
|
|
438
|
+
"values": [_sanitize(v) for v in df[column].tolist()],
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
@app.get("/{filename}/api/{sheet}/{column}/{row_id}", tags=["data"])
|
|
443
|
+
def get_cell(filename: str, sheet: str, column: str, row_id: int):
|
|
444
|
+
df = store.get_sheet(filename, sheet)
|
|
445
|
+
if df is None:
|
|
446
|
+
raise HTTPException(status_code=404, detail=f"Sheet '{sheet}' not found in '{filename}'.")
|
|
447
|
+
if column not in df.columns:
|
|
448
|
+
raise HTTPException(status_code=404, detail=f"Column '{column}' not found. Available: {list(df.columns)}")
|
|
449
|
+
if not (0 <= row_id < len(df)):
|
|
450
|
+
raise HTTPException(status_code=404, detail=f"Row {row_id} out of range. File has {len(df)} rows (0-indexed).")
|
|
451
|
+
return {
|
|
452
|
+
"file": filename, "sheet": sheet, "column": column,
|
|
453
|
+
"row_id": row_id, "value": _sanitize(df.iloc[row_id][column]),
|
|
454
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""
|
|
2
|
+
In-memory store with thread-safe read/write via RLock.
|
|
3
|
+
Structure: _store[filename_stem][sheet_name] = pd.DataFrame
|
|
4
|
+
"""
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Dict
|
|
7
|
+
import threading
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
from .parsers import load_file, SUPPORTED_EXTENSIONS
|
|
11
|
+
|
|
12
|
+
_store: Dict[str, Dict[str, pd.DataFrame]] = {}
|
|
13
|
+
_lock = threading.RLock()
|
|
14
|
+
_watch_dir: Path | None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_directory(dir_path: Path) -> list[str]:
|
|
18
|
+
global _store, _watch_dir
|
|
19
|
+
_watch_dir = dir_path
|
|
20
|
+
new_store = {}
|
|
21
|
+
loaded = []
|
|
22
|
+
|
|
23
|
+
for file in sorted(dir_path.iterdir()):
|
|
24
|
+
if file.suffix.lower() not in SUPPORTED_EXTENSIONS:
|
|
25
|
+
continue
|
|
26
|
+
try:
|
|
27
|
+
sheets = load_file(file)
|
|
28
|
+
new_store[file.stem.lower()] = sheets
|
|
29
|
+
loaded.append(file.name)
|
|
30
|
+
except Exception as e:
|
|
31
|
+
print(f" ⚠ Skipped '{file.name}': {e}")
|
|
32
|
+
|
|
33
|
+
with _lock:
|
|
34
|
+
_store = new_store
|
|
35
|
+
|
|
36
|
+
return loaded
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_store() -> Dict[str, Dict[str, pd.DataFrame]]:
|
|
40
|
+
with _lock:
|
|
41
|
+
return dict(_store)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_file(filename: str) -> Dict[str, pd.DataFrame] | None:
|
|
45
|
+
with _lock:
|
|
46
|
+
return _store.get(filename.lower())
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_sheet(filename: str, sheet: str) -> pd.DataFrame | None:
|
|
50
|
+
with _lock:
|
|
51
|
+
file_data = _store.get(filename.lower())
|
|
52
|
+
if file_data is None:
|
|
53
|
+
return None
|
|
54
|
+
result = file_data.get(sheet.lower())
|
|
55
|
+
if result is None:
|
|
56
|
+
result = file_data.get(sheet)
|
|
57
|
+
return result
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def list_files() -> list[str]:
|
|
61
|
+
with _lock:
|
|
62
|
+
return list(_store.keys())
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def list_sheets(filename: str) -> list[str] | None:
|
|
66
|
+
with _lock:
|
|
67
|
+
file_data = _store.get(filename.lower())
|
|
68
|
+
if file_data is None:
|
|
69
|
+
return None
|
|
70
|
+
return list(file_data.keys())
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def get_watch_dir() -> Path | None:
|
|
74
|
+
return _watch_dir
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Background file watcher using watchdog.
|
|
3
|
+
Reloads the store when any supported file in the watched directory changes.
|
|
4
|
+
"""
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from watchdog.observers import Observer
|
|
10
|
+
from watchdog.events import FileSystemEventHandler
|
|
11
|
+
|
|
12
|
+
from . import store
|
|
13
|
+
from .parsers import SUPPORTED_EXTENSIONS
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _ReloadHandler(FileSystemEventHandler):
|
|
17
|
+
def __init__(self, dir_path: Path, on_reload=None):
|
|
18
|
+
self._dir = dir_path
|
|
19
|
+
self._on_reload = on_reload
|
|
20
|
+
self._debounce_timer: threading.Timer | None = None
|
|
21
|
+
self._lock = threading.Lock()
|
|
22
|
+
|
|
23
|
+
def _debounced_reload(self):
|
|
24
|
+
"""Debounce: wait 500ms after last event before reloading."""
|
|
25
|
+
with self._lock:
|
|
26
|
+
if self._debounce_timer:
|
|
27
|
+
self._debounce_timer.cancel()
|
|
28
|
+
self._debounce_timer = threading.Timer(0.5, self._do_reload)
|
|
29
|
+
self._debounce_timer.start()
|
|
30
|
+
|
|
31
|
+
def _do_reload(self):
|
|
32
|
+
print("\n🔄 File change detected — reloading store...")
|
|
33
|
+
loaded = store.load_directory(self._dir)
|
|
34
|
+
print(f"✅ Reloaded: {', '.join(loaded)}")
|
|
35
|
+
if self._on_reload:
|
|
36
|
+
self._on_reload(loaded)
|
|
37
|
+
|
|
38
|
+
def on_modified(self, event):
|
|
39
|
+
if not event.is_directory:
|
|
40
|
+
if Path(event.src_path).suffix.lower() in SUPPORTED_EXTENSIONS:
|
|
41
|
+
self._debounced_reload()
|
|
42
|
+
|
|
43
|
+
def on_created(self, event):
|
|
44
|
+
if not event.is_directory:
|
|
45
|
+
if Path(event.src_path).suffix.lower() in SUPPORTED_EXTENSIONS:
|
|
46
|
+
self._debounced_reload()
|
|
47
|
+
|
|
48
|
+
def on_deleted(self, event):
|
|
49
|
+
if not event.is_directory:
|
|
50
|
+
if Path(event.src_path).suffix.lower() in SUPPORTED_EXTENSIONS:
|
|
51
|
+
self._debounced_reload()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def start_watcher(dir_path: Path, on_reload=None) -> Observer:
|
|
55
|
+
handler = _ReloadHandler(dir_path, on_reload=on_reload)
|
|
56
|
+
observer = Observer()
|
|
57
|
+
observer.schedule(handler, str(dir_path), recursive=False)
|
|
58
|
+
observer.start()
|
|
59
|
+
return observer
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flatapicli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Serve flat files (CSV, XLSX, JSON) as a local REST API instantly.
|
|
5
|
+
Author: Avinash Prajapati
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/avinash/flatapicli
|
|
8
|
+
Project-URL: Issues, https://github.com/avinash/flatapicli/issues
|
|
9
|
+
Keywords: csv,xlsx,json,api,fastapi,local,prototyping
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Topic :: Utilities
|
|
13
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: fastapi>=0.100.0
|
|
18
|
+
Requires-Dist: uvicorn[standard]>=0.23.0
|
|
19
|
+
Requires-Dist: pandas>=2.0.0
|
|
20
|
+
Requires-Dist: openpyxl>=3.1.0
|
|
21
|
+
Requires-Dist: typer>=0.9.0
|
|
22
|
+
Requires-Dist: rich>=13.0.0
|
|
23
|
+
Requires-Dist: watchdog>=4.0.0
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# ⚡ flatapicli
|
|
27
|
+
|
|
28
|
+
> Serve flat files (CSV, XLSX, JSON) as a local REST API instantly.
|
|
29
|
+
|
|
30
|
+

|
|
31
|
+

|
|
32
|
+

|
|
33
|
+

|
|
34
|
+

|
|
35
|
+

|
|
36
|
+

|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install flatapicli
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
# Start the server pointing at a directory
|
|
50
|
+
flatapicli serve --dir ./data
|
|
51
|
+
|
|
52
|
+
# Custom port
|
|
53
|
+
flatapicli serve --dir ./data --port 8080
|
|
54
|
+
|
|
55
|
+
# Expose to network (VPS / LAN)
|
|
56
|
+
flatapicli serve --dir ./data --host 0.0.0.0
|
|
57
|
+
|
|
58
|
+
# Auto-reload store on file change (no server restart)
|
|
59
|
+
flatapicli serve --dir ./data --watch
|
|
60
|
+
|
|
61
|
+
# Inspect files without starting server
|
|
62
|
+
flatapicli list-files --dir ./data
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**While running:**
|
|
66
|
+
- `r` + Enter → reload data files without restarting server
|
|
67
|
+
- `q` + Enter → clean shutdown
|
|
68
|
+
|
|
69
|
+
## URL Structure
|
|
70
|
+
|
|
71
|
+
Given a directory with `products.csv`, `orders.xlsx` (sheets: `jan`, `feb`), and `users.json`:
|
|
72
|
+
|
|
73
|
+
| URL | Response |
|
|
74
|
+
|-----|----------|
|
|
75
|
+
| `GET /` | All loaded files |
|
|
76
|
+
| `GET /products/api` | Schema, columns, row count |
|
|
77
|
+
| `GET /products/api/rows` | All rows |
|
|
78
|
+
| `GET /products/api/rows?limit=10&offset=0` | Paginated rows |
|
|
79
|
+
| `GET /products/api/name` | All values in column `name` |
|
|
80
|
+
| `GET /orders/api` | Schema for all sheets |
|
|
81
|
+
| `GET /orders/api/jan/rows` | All rows in sheet `jan` |
|
|
82
|
+
| `GET /orders/api/jan/amount` | Column `amount` from sheet `jan` |
|
|
83
|
+
| `GET /orders/api/jan/amount/0` | Single cell: row 0, column `amount`, sheet `jan` |
|
|
84
|
+
|
|
85
|
+
## Supported Formats
|
|
86
|
+
|
|
87
|
+
| Format | Extension | Multi-sheet |
|
|
88
|
+
|--------|-----------|-------------|
|
|
89
|
+
| CSV | `.csv` | No |
|
|
90
|
+
| Excel | `.xlsx`, `.xls` | Yes |
|
|
91
|
+
| JSON | `.json` | Yes (object of arrays) |
|
|
92
|
+
|
|
93
|
+
### JSON format
|
|
94
|
+
|
|
95
|
+
Single sheet (array of objects):
|
|
96
|
+
```json
|
|
97
|
+
[{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Multi-sheet (object of arrays):
|
|
101
|
+
```json
|
|
102
|
+
{
|
|
103
|
+
"users": [{"id": 1, "name": "Alice"}],
|
|
104
|
+
"admins": [{"id": 99, "name": "Root"}]
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Interactive Docs
|
|
109
|
+
|
|
110
|
+
Custom Swagger UI with chained dropdowns (File → Sheet → Column) available at `http://localhost:3000/docs`.
|
|
111
|
+
|
|
112
|
+
## Deployment (VPS)
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
pip install flatapicli
|
|
116
|
+
flatapicli serve --dir /data/files --host 0.0.0.0 --port 3000
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Run behind nginx or use a systemd service for production stability.
|
|
120
|
+
|
|
121
|
+
## Contributing
|
|
122
|
+
|
|
123
|
+
TOON (Token-Oriented Object Notation) parser contributions welcome. Add a parser in `flatapicli/parsers/` following the existing pattern and register it in `flatapicli/parsers/__init__.py`.
|
|
124
|
+
|
|
125
|
+
## License
|
|
126
|
+
|
|
127
|
+
MIT
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
MANIFEST.in
|
|
3
|
+
README.md
|
|
4
|
+
pyproject.toml
|
|
5
|
+
flatapicli/__init__.py
|
|
6
|
+
flatapicli/cli.py
|
|
7
|
+
flatapicli/server.py
|
|
8
|
+
flatapicli/store.py
|
|
9
|
+
flatapicli/watcher.py
|
|
10
|
+
flatapicli.egg-info/PKG-INFO
|
|
11
|
+
flatapicli.egg-info/SOURCES.txt
|
|
12
|
+
flatapicli.egg-info/dependency_links.txt
|
|
13
|
+
flatapicli.egg-info/entry_points.txt
|
|
14
|
+
flatapicli.egg-info/requires.txt
|
|
15
|
+
flatapicli.egg-info/top_level.txt
|
|
16
|
+
flatapicli/parsers/__init__.py
|
|
17
|
+
flatapicli/parsers/csv_parser.py
|
|
18
|
+
flatapicli/parsers/json_parser.py
|
|
19
|
+
flatapicli/parsers/xlsx_parser.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
flatapicli
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "flatapicli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Serve flat files (CSV, XLSX, JSON) as a local REST API instantly."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "Avinash Prajapati" }]
|
|
13
|
+
keywords = ["csv", "xlsx", "json", "api", "fastapi", "local", "prototyping"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
"Topic :: Utilities",
|
|
18
|
+
"Topic :: Internet :: WWW/HTTP :: HTTP Servers",
|
|
19
|
+
]
|
|
20
|
+
dependencies = [
|
|
21
|
+
"fastapi>=0.100.0",
|
|
22
|
+
"uvicorn[standard]>=0.23.0",
|
|
23
|
+
"pandas>=2.0.0",
|
|
24
|
+
"openpyxl>=3.1.0",
|
|
25
|
+
"typer>=0.9.0",
|
|
26
|
+
"rich>=13.0.0",
|
|
27
|
+
"watchdog>=4.0.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
flatapi = "flatapicli.cli:app"
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/avinash/flatapicli"
|
|
35
|
+
Issues = "https://github.com/avinash/flatapicli/issues"
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.packages.find]
|
|
38
|
+
where = ["."]
|
|
39
|
+
include = ["flatapicli*"]
|