Skip to content

Commit e56e6d4

Browse files
committed
Merge branch 'master' of github.com:RocketPy-Team/Infinity-API into enh/rocket-drawing-geometry
2 parents edf06e5 + eff3b9d commit e56e6d4

8 files changed

Lines changed: 812 additions & 34 deletions

File tree

Makefile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,4 +73,7 @@ clean:
7373
build:
7474
docker build -t infinity-api . --no-cache
7575

76+
buildx:
77+
docker buildx build --platform linux/amd64 -t infinity-api . --no-cache
78+
7679
.PHONY: black flake8 pylint test dev clean build ruff format

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
dill
22
python-dotenv
3+
python-multipart
34
fastapi
45
uvloop
56
pydantic

src/controllers/flight.py

Lines changed: 77 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@
44
ControllerBase,
55
controller_exception_handler,
66
)
7-
from src.views.flight import FlightSimulation, FlightCreated
7+
from src.views.flight import FlightSimulation, FlightCreated, FlightImported
88
from src.models.flight import (
99
FlightModel,
1010
FlightWithReferencesRequest,
1111
)
1212
from src.models.environment import EnvironmentModel
13+
from src.models.motor import MotorModel
1314
from src.models.rocket import RocketModel
1415
from src.repositories.interface import RepositoryInterface
1516
from src.services.flight import FlightService
@@ -22,6 +23,7 @@ class FlightController(ControllerBase):
2223
Enables:
2324
- Simulation of a RocketPy Flight.
2425
- CRUD for Flight BaseApiModel.
26+
- Import/export as portable .rpy files and Jupyter notebooks.
2527
"""
2628

2729
def __init__(self):
@@ -122,25 +124,26 @@ async def update_rocket_by_flight_id(
122124
return
123125

124126
@controller_exception_handler
125-
async def get_rocketpy_flight_binary(
127+
async def get_rocketpy_flight_rpy(
126128
self,
127129
flight_id: str,
128130
) -> bytes:
129131
"""
130-
Get rocketpy.flight as dill binary.
132+
Get rocketpy.flight as a portable ``.rpy`` JSON file.
131133
132134
Args:
133135
flight_id: str
134136
135137
Returns:
136-
bytes
138+
bytes (UTF-8 encoded JSON)
137139
138140
Raises:
139-
HTTP 404 Not Found: If the flight is not found in the database.
141+
HTTP 404 Not Found: If the flight is not found
142+
in the database.
140143
"""
141144
flight = await self.get_flight_by_id(flight_id)
142145
flight_service = FlightService.from_flight_model(flight.flight)
143-
return flight_service.get_flight_binary()
146+
return flight_service.get_flight_rpy()
144147

145148
@controller_exception_handler
146149
async def get_flight_simulation(
@@ -162,3 +165,71 @@ async def get_flight_simulation(
162165
flight = await self.get_flight_by_id(flight_id)
163166
flight_service = FlightService.from_flight_model(flight.flight)
164167
return flight_service.get_flight_simulation()
168+
169+
async def _persist_model(self, model_cls, model_instance) -> str:
170+
repo_cls = RepositoryInterface.get_model_repo(model_cls)
171+
async with repo_cls() as repo:
172+
creator = getattr(repo, f"create_{model_cls.NAME}")
173+
return await creator(model_instance)
174+
175+
@controller_exception_handler
176+
async def import_flight_from_rpy(
177+
self,
178+
content: bytes,
179+
) -> FlightImported:
180+
"""
181+
Import a ``.rpy`` JSON file: decompose the RocketPy Flight
182+
into Environment, Motor, Rocket and Flight models, persist
183+
each one via the normal CRUD pipeline, and return all IDs.
184+
185+
Args:
186+
content: raw bytes of a ``.rpy`` JSON file.
187+
188+
Returns:
189+
FlightImported with environment_id, motor_id,
190+
rocket_id, and flight_id.
191+
192+
Raises:
193+
HTTP 422: If the file is not a valid ``.rpy`` Flight.
194+
"""
195+
try:
196+
flight_service = FlightService.from_rpy(content)
197+
except Exception as exc:
198+
raise HTTPException(
199+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
200+
detail=f"Invalid .rpy file: {exc}",
201+
) from exc
202+
203+
env, motor, rocket, flight = flight_service.extract_models()
204+
205+
env_id = await self._persist_model(EnvironmentModel, env)
206+
motor_id = await self._persist_model(MotorModel, motor)
207+
rocket_id = await self._persist_model(RocketModel, rocket)
208+
flight_id = await self._persist_model(FlightModel, flight)
209+
210+
return FlightImported(
211+
flight_id=flight_id,
212+
rocket_id=rocket_id,
213+
motor_id=motor_id,
214+
environment_id=env_id,
215+
)
216+
217+
@controller_exception_handler
218+
async def get_flight_notebook(
219+
self,
220+
flight_id: str,
221+
) -> dict:
222+
"""
223+
Generate a Jupyter notebook for a persisted flight.
224+
225+
Args:
226+
flight_id: str
227+
228+
Returns:
229+
dict representing a valid .ipynb.
230+
231+
Raises:
232+
HTTP 404 Not Found: If the flight does not exist.
233+
"""
234+
await self.get_flight_by_id(flight_id)
235+
return FlightService.generate_notebook(flight_id)

src/routes/flight.py

Lines changed: 101 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,23 @@
22
Flight routes with dependency injection for improved performance.
33
"""
44

5-
from fastapi import APIRouter, Response
5+
import json
6+
7+
from fastapi import (
8+
APIRouter,
9+
File,
10+
HTTPException,
11+
Response,
12+
UploadFile,
13+
status,
14+
)
615
from opentelemetry import trace
716

817
from src.views.flight import (
918
FlightSimulation,
1019
FlightCreated,
1120
FlightRetrieved,
21+
FlightImported,
1222
)
1323
from src.models.environment import EnvironmentModel
1424
from src.models.flight import FlightModel, FlightWithReferencesRequest
@@ -27,6 +37,8 @@
2737

2838
tracer = trace.get_tracer(__name__)
2939

40+
MAX_RPY_UPLOAD_BYTES = 10 * 1024 * 1024 # 10 MB
41+
3042

3143
@router.post("/", status_code=201)
3244
async def create_flight(
@@ -77,6 +89,7 @@ async def read_flight(
7789
return await controller.get_flight_by_id(flight_id)
7890

7991

92+
8093
@router.put("/{flight_id}", status_code=204)
8194
async def update_flight(
8295
flight_id: str,
@@ -138,33 +151,110 @@ async def delete_flight(
138151
"/{flight_id}/rocketpy",
139152
responses={
140153
200: {
141-
"description": "Binary file download",
142-
"content": {"application/octet-stream": {}},
154+
"description": "Portable .rpy JSON file download",
155+
"content": {"application/json": {}},
143156
}
144157
},
145158
status_code=200,
146159
response_class=Response,
147160
)
148-
async def get_rocketpy_flight_binary(
161+
async def get_rocketpy_flight_rpy(
149162
flight_id: str,
150163
controller: FlightControllerDep,
151164
):
152165
"""
153-
Loads rocketpy.flight as a dill binary.
154-
Currently only amd64 architecture is supported.
166+
Export a rocketpy Flight as a portable ``.rpy`` JSON file.
167+
168+
The ``.rpy`` format is architecture-, OS-, and
169+
Python-version-agnostic.
170+
171+
## Args
172+
``` flight_id: str ```
173+
"""
174+
with tracer.start_as_current_span("get_rocketpy_flight_rpy"):
175+
headers = {
176+
'Content-Disposition': (
177+
'attachment; filename=' f'"rocketpy_flight_{flight_id}.rpy"'
178+
),
179+
}
180+
rpy = await controller.get_rocketpy_flight_rpy(flight_id)
181+
return Response(
182+
content=rpy,
183+
headers=headers,
184+
media_type="application/json",
185+
status_code=200,
186+
)
187+
188+
189+
@router.post(
190+
"/upload",
191+
status_code=201,
192+
responses={
193+
201: {"description": "Flight imported from .rpy file"},
194+
413: {"description": "Uploaded .rpy file exceeds size limit"},
195+
422: {"description": "Invalid .rpy file"},
196+
},
197+
)
198+
async def import_flight_from_rpy(
199+
file: UploadFile = File(...),
200+
controller: FlightControllerDep = None, # noqa: B008
201+
) -> FlightImported:
202+
"""
203+
Upload a ``.rpy`` JSON file containing a RocketPy Flight.
204+
205+
The file is deserialized and decomposed into its
206+
constituent objects (Environment, Motor, Rocket, Flight).
207+
Each object is persisted as a normal JSON model and the
208+
corresponding IDs are returned. Maximum upload size is 10 MB.
209+
210+
## Args
211+
``` file: .rpy JSON upload ```
212+
"""
213+
with tracer.start_as_current_span("import_flight_from_rpy"):
214+
content = await file.read(MAX_RPY_UPLOAD_BYTES + 1)
215+
if len(content) > MAX_RPY_UPLOAD_BYTES:
216+
raise HTTPException(
217+
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
218+
detail="Uploaded .rpy file exceeds 10 MB limit.",
219+
)
220+
return await controller.import_flight_from_rpy(content)
221+
222+
223+
@router.get(
224+
"/{flight_id}/notebook",
225+
responses={
226+
200: {
227+
"description": "Jupyter notebook file download",
228+
"content": {"application/x-ipynb+json": {}},
229+
}
230+
},
231+
status_code=200,
232+
response_class=Response,
233+
)
234+
async def get_flight_notebook(
235+
flight_id: str,
236+
controller: FlightControllerDep,
237+
):
238+
"""
239+
Export a flight as a Jupyter notebook (.ipynb).
240+
241+
The notebook loads the flight's ``.rpy`` file and calls
242+
``flight.all_info()`` for interactive exploration.
155243
156244
## Args
157245
``` flight_id: str ```
158246
"""
159-
with tracer.start_as_current_span("get_rocketpy_flight_binary"):
247+
with tracer.start_as_current_span("get_flight_notebook"):
248+
notebook = await controller.get_flight_notebook(flight_id)
249+
content = json.dumps(notebook, indent=1).encode()
250+
filename = f"flight_{flight_id}.ipynb"
160251
headers = {
161-
'Content-Disposition': f'attachment; filename="rocketpy_flight_{flight_id}.dill"'
252+
"Content-Disposition": (f'attachment; filename="{filename}"'),
162253
}
163-
binary = await controller.get_rocketpy_flight_binary(flight_id)
164254
return Response(
165-
content=binary,
255+
content=content,
166256
headers=headers,
167-
media_type="application/octet-stream",
257+
media_type="application/x-ipynb+json",
168258
status_code=200,
169259
)
170260

0 commit comments

Comments
 (0)