Skip to content

Commit 3f07deb

Browse files
ENH: portable .rpy flight import/export and notebook generation (#70)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent 9067812 commit 3f07deb

12 files changed

Lines changed: 846 additions & 60 deletions

File tree

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/dependencies.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@
88
from src.controllers.environment import EnvironmentController
99
from src.controllers.flight import FlightController
1010

11+
1112
@cache
1213
def get_rocket_controller() -> RocketController:
1314
"""
1415
Provides a singleton RocketController instance.
15-
16+
1617
The controller is stateless and can be safely reused across requests.
1718
Using functools.cache memoizes this function so a single instance is reused per process; it does not by itself guarantee thread-safe initialization in multi-threaded setups.
18-
19+
1920
Returns:
2021
RocketController: Shared controller instance for rocket operations.
2122
"""
@@ -26,7 +27,7 @@ def get_rocket_controller() -> RocketController:
2627
def get_motor_controller() -> MotorController:
2728
"""
2829
Provides a singleton MotorController instance.
29-
30+
3031
Returns:
3132
MotorController: Shared controller instance for motor operations.
3233
"""
@@ -37,7 +38,7 @@ def get_motor_controller() -> MotorController:
3738
def get_environment_controller() -> EnvironmentController:
3839
"""
3940
Provides a singleton EnvironmentController instance.
40-
41+
4142
Returns:
4243
EnvironmentController: Shared controller instance for environment operations.
4344
"""
@@ -48,15 +49,20 @@ def get_environment_controller() -> EnvironmentController:
4849
def get_flight_controller() -> FlightController:
4950
"""
5051
Provides a singleton FlightController instance.
51-
52+
5253
Returns:
5354
FlightController: Shared controller instance for flight operations.
5455
"""
5556
return FlightController()
5657

57-
RocketControllerDep = Annotated[RocketController, Depends(get_rocket_controller)]
58+
59+
RocketControllerDep = Annotated[
60+
RocketController, Depends(get_rocket_controller)
61+
]
5862
MotorControllerDep = Annotated[MotorController, Depends(get_motor_controller)]
5963
EnvironmentControllerDep = Annotated[
6064
EnvironmentController, Depends(get_environment_controller)
6165
]
62-
FlightControllerDep = Annotated[FlightController, Depends(get_flight_controller)]
66+
FlightControllerDep = Annotated[
67+
FlightController, Depends(get_flight_controller)
68+
]

src/routes/flight.py

Lines changed: 103 additions & 12 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(
@@ -76,6 +88,7 @@ async def read_flight(
7688
with tracer.start_as_current_span("read_flight"):
7789
return await controller.get_flight_by_id(flight_id)
7890

91+
7992
@router.put("/{flight_id}", status_code=204)
8093
async def update_flight(
8194
flight_id: str,
@@ -117,6 +130,7 @@ async def update_flight_from_references(
117130
flight_id, payload
118131
)
119132

133+
120134
@router.delete("/{flight_id}", status_code=204)
121135
async def delete_flight(
122136
flight_id: str,
@@ -136,34 +150,110 @@ async def delete_flight(
136150
"/{flight_id}/rocketpy",
137151
responses={
138152
200: {
139-
"description": "Binary file download",
140-
"content": {"application/octet-stream": {}},
153+
"description": "Portable .rpy JSON file download",
154+
"content": {"application/json": {}},
141155
}
142156
},
143157
status_code=200,
144158
response_class=Response,
145159
)
160+
async def get_rocketpy_flight_rpy(
161+
flight_id: str,
162+
controller: FlightControllerDep,
163+
):
164+
"""
165+
Export a rocketpy Flight as a portable ``.rpy`` JSON file.
166+
167+
The ``.rpy`` format is architecture-, OS-, and
168+
Python-version-agnostic.
169+
170+
## Args
171+
``` flight_id: str ```
172+
"""
173+
with tracer.start_as_current_span("get_rocketpy_flight_rpy"):
174+
headers = {
175+
'Content-Disposition': (
176+
'attachment; filename=' f'"rocketpy_flight_{flight_id}.rpy"'
177+
),
178+
}
179+
rpy = await controller.get_rocketpy_flight_rpy(flight_id)
180+
return Response(
181+
content=rpy,
182+
headers=headers,
183+
media_type="application/json",
184+
status_code=200,
185+
)
186+
187+
188+
@router.post(
189+
"/upload",
190+
status_code=201,
191+
responses={
192+
201: {"description": "Flight imported from .rpy file"},
193+
413: {"description": "Uploaded .rpy file exceeds size limit"},
194+
422: {"description": "Invalid .rpy file"},
195+
},
196+
)
197+
async def import_flight_from_rpy(
198+
file: UploadFile = File(...),
199+
controller: FlightControllerDep = None, # noqa: B008
200+
) -> FlightImported:
201+
"""
202+
Upload a ``.rpy`` JSON file containing a RocketPy Flight.
203+
204+
The file is deserialized and decomposed into its
205+
constituent objects (Environment, Motor, Rocket, Flight).
206+
Each object is persisted as a normal JSON model and the
207+
corresponding IDs are returned. Maximum upload size is 10 MB.
208+
209+
## Args
210+
``` file: .rpy JSON upload ```
211+
"""
212+
with tracer.start_as_current_span("import_flight_from_rpy"):
213+
content = await file.read(MAX_RPY_UPLOAD_BYTES + 1)
214+
if len(content) > MAX_RPY_UPLOAD_BYTES:
215+
raise HTTPException(
216+
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
217+
detail="Uploaded .rpy file exceeds 10 MB limit.",
218+
)
219+
return await controller.import_flight_from_rpy(content)
220+
146221

147-
async def get_rocketpy_flight_binary(
222+
@router.get(
223+
"/{flight_id}/notebook",
224+
responses={
225+
200: {
226+
"description": "Jupyter notebook file download",
227+
"content": {"application/x-ipynb+json": {}},
228+
}
229+
},
230+
status_code=200,
231+
response_class=Response,
232+
)
233+
async def get_flight_notebook(
148234
flight_id: str,
149235
controller: FlightControllerDep,
150236
):
151237
"""
152-
Loads rocketpy.flight as a dill binary.
153-
Currently only amd64 architecture is supported.
238+
Export a flight as a Jupyter notebook (.ipynb).
239+
240+
The notebook loads the flight's ``.rpy`` file and calls
241+
``flight.all_info()`` for interactive exploration.
154242
155243
## Args
156244
``` flight_id: str ```
157245
"""
158-
with tracer.start_as_current_span("get_rocketpy_flight_binary"):
246+
with tracer.start_as_current_span("get_flight_notebook"):
247+
notebook = await controller.get_flight_notebook(flight_id)
248+
content = json.dumps(notebook, indent=1).encode()
249+
filename = f"flight_{flight_id}.ipynb"
159250
headers = {
160-
'Content-Disposition': f'attachment; filename="rocketpy_flight_{flight_id}.dill"'
251+
"Content-Disposition": (f'attachment; filename="{filename}"'),
161252
}
162-
binary = await controller.get_rocketpy_flight_binary(flight_id)
163253
return Response(
164-
content=binary,
254+
content=content,
165255
headers=headers,
166-
media_type="application/octet-stream",
256+
media_type="application/x-ipynb+json",
167257
status_code=200,
168258
)
169259

@@ -210,6 +300,7 @@ async def update_flight_rocket(
210300
rocket=rocket,
211301
)
212302

303+
213304
@router.get("/{flight_id}/simulate")
214305
async def get_flight_simulation(
215306
flight_id: str,
@@ -222,4 +313,4 @@ async def get_flight_simulation(
222313
``` flight_id: Flight ID ```
223314
"""
224315
with tracer.start_as_current_span("get_flight_simulation"):
225-
return await controller.get_flight_simulation(flight_id)
316+
return await controller.get_flight_simulation(flight_id)

src/routes/rocket.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ async def create_rocket(
4242
"""
4343
with tracer.start_as_current_span("create_rocket"):
4444
return await controller.post_rocket(rocket)
45+
46+
4547
@router.post("/from-motor-reference", status_code=201)
4648
async def create_rocket_from_motor_reference(
4749
payload: RocketWithMotorReferenceRequest,
@@ -114,6 +116,8 @@ async def update_rocket_from_motor_reference(
114116
return await controller.update_rocket_from_motor_reference(
115117
rocket_id, payload
116118
)
119+
120+
117121
@router.delete("/{rocket_id}", status_code=204)
118122
async def delete_rocket(
119123
rocket_id: str,

0 commit comments

Comments
 (0)