Skip to content

Commit 50be27f

Browse files
ENH: portable .rpy flight import/export and notebook generation (#56, #57)
Replace dill-based binary serialization with RocketPy's native JSON-based .rpy format (RocketPyEncoder/RocketPyDecoder), making flight export and import architecture-, OS-, and Python-version- agnostic. Add POST /flights/upload to import .rpy files by decomposing them into Environment, Motor, Rocket and Flight models persisted through the standard CRUD pipeline. Add GET /flights/{id}/notebook to export a flight as a Jupyter notebook that loads the .rpy file via load_from_rpy. Closes #56, closes #57. Made-with: Cursor
1 parent 9067812 commit 50be27f

12 files changed

Lines changed: 721 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: 88 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@
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 APIRouter, Response, UploadFile, File
68
from opentelemetry import trace
79

810
from src.views.flight import (
911
FlightSimulation,
1012
FlightCreated,
1113
FlightRetrieved,
14+
FlightImported,
1215
)
1316
from src.models.environment import EnvironmentModel
1417
from src.models.flight import FlightModel, FlightWithReferencesRequest
@@ -76,6 +79,7 @@ async def read_flight(
7679
with tracer.start_as_current_span("read_flight"):
7780
return await controller.get_flight_by_id(flight_id)
7881

82+
7983
@router.put("/{flight_id}", status_code=204)
8084
async def update_flight(
8185
flight_id: str,
@@ -117,6 +121,7 @@ async def update_flight_from_references(
117121
flight_id, payload
118122
)
119123

124+
120125
@router.delete("/{flight_id}", status_code=204)
121126
async def delete_flight(
122127
flight_id: str,
@@ -136,34 +141,104 @@ async def delete_flight(
136141
"/{flight_id}/rocketpy",
137142
responses={
138143
200: {
139-
"description": "Binary file download",
140-
"content": {"application/octet-stream": {}},
144+
"description": "Portable .rpy JSON file download",
145+
"content": {"application/json": {}},
141146
}
142147
},
143148
status_code=200,
144149
response_class=Response,
145150
)
151+
async def get_rocketpy_flight_rpy(
152+
flight_id: str,
153+
controller: FlightControllerDep,
154+
):
155+
"""
156+
Export a rocketpy Flight as a portable ``.rpy`` JSON file.
157+
158+
The ``.rpy`` format is architecture-, OS-, and
159+
Python-version-agnostic.
160+
161+
## Args
162+
``` flight_id: str ```
163+
"""
164+
with tracer.start_as_current_span("get_rocketpy_flight_rpy"):
165+
headers = {
166+
'Content-Disposition': (
167+
'attachment; filename=' f'"rocketpy_flight_{flight_id}.rpy"'
168+
),
169+
}
170+
rpy = await controller.get_rocketpy_flight_rpy(flight_id)
171+
return Response(
172+
content=rpy,
173+
headers=headers,
174+
media_type="application/json",
175+
status_code=200,
176+
)
146177

147-
async def get_rocketpy_flight_binary(
178+
179+
@router.post(
180+
"/upload",
181+
status_code=201,
182+
responses={
183+
201: {"description": "Flight imported from .rpy file"},
184+
422: {"description": "Invalid .rpy file"},
185+
},
186+
)
187+
async def import_flight_from_rpy(
188+
file: UploadFile = File(...),
189+
controller: FlightControllerDep = None,
190+
) -> FlightImported:
191+
"""
192+
Upload a ``.rpy`` JSON file containing a RocketPy Flight.
193+
194+
The file is deserialized and decomposed into its
195+
constituent objects (Environment, Motor, Rocket, Flight).
196+
Each object is persisted as a normal JSON model and the
197+
corresponding IDs are returned.
198+
199+
## Args
200+
``` file: .rpy JSON upload ```
201+
"""
202+
with tracer.start_as_current_span("import_flight_from_rpy"):
203+
content = await file.read()
204+
return await controller.import_flight_from_rpy(content)
205+
206+
207+
@router.get(
208+
"/{flight_id}/notebook",
209+
responses={
210+
200: {
211+
"description": "Jupyter notebook file download",
212+
"content": {"application/x-ipynb+json": {}},
213+
}
214+
},
215+
status_code=200,
216+
response_class=Response,
217+
)
218+
async def get_flight_notebook(
148219
flight_id: str,
149220
controller: FlightControllerDep,
150221
):
151222
"""
152-
Loads rocketpy.flight as a dill binary.
153-
Currently only amd64 architecture is supported.
223+
Export a flight as a Jupyter notebook (.ipynb).
224+
225+
The notebook loads the flight's ``.rpy`` file and calls
226+
``flight.all_info()`` for interactive exploration.
154227
155228
## Args
156229
``` flight_id: str ```
157230
"""
158-
with tracer.start_as_current_span("get_rocketpy_flight_binary"):
231+
with tracer.start_as_current_span("get_flight_notebook"):
232+
notebook = await controller.get_flight_notebook(flight_id)
233+
content = json.dumps(notebook, indent=1).encode()
234+
filename = f"flight_{flight_id}.ipynb"
159235
headers = {
160-
'Content-Disposition': f'attachment; filename="rocketpy_flight_{flight_id}.dill"'
236+
"Content-Disposition": (f'attachment; filename="{filename}"'),
161237
}
162-
binary = await controller.get_rocketpy_flight_binary(flight_id)
163238
return Response(
164-
content=binary,
239+
content=content,
165240
headers=headers,
166-
media_type="application/octet-stream",
241+
media_type="application/x-ipynb+json",
167242
status_code=200,
168243
)
169244

@@ -210,6 +285,7 @@ async def update_flight_rocket(
210285
rocket=rocket,
211286
)
212287

288+
213289
@router.get("/{flight_id}/simulate")
214290
async def get_flight_simulation(
215291
flight_id: str,
@@ -222,4 +298,4 @@ async def get_flight_simulation(
222298
``` flight_id: Flight ID ```
223299
"""
224300
with tracer.start_as_current_span("get_flight_simulation"):
225-
return await controller.get_flight_simulation(flight_id)
301+
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)