Skip to content

Commit 07996f8

Browse files
authored
Support engine-registered development runs in the client (#512)
- Fix response unpacking bug in local_server -- It should have been unwrapping the Service response like the Steamship Proxy - Fix the HttpREPL, which depended upon the improperly unwrapped response (see above) - Upon `ship run local`, register the local running NGROK URL with the Engine so that it can make async callbacks to it.
1 parent d215998 commit 07996f8

5 files changed

Lines changed: 205 additions & 44 deletions

File tree

src/steamship/base/mime_types.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,29 @@ class MimeTypes(str, Enum):
3434
def has_value(cls, value):
3535
return value in cls._value2member_map_
3636

37+
@classmethod
38+
def is_binary(cls, value):
39+
"""Returns whether the mime type is likely a binary file."""
40+
return value in [
41+
cls.UNKNOWN,
42+
cls.PDF,
43+
cls.JPG,
44+
cls.PNG,
45+
cls.TIFF,
46+
cls.GIF,
47+
cls.DOC,
48+
cls.PPT,
49+
cls.BINARY,
50+
cls.WAV,
51+
cls.MP3,
52+
cls.OGG_VIDEO,
53+
cls.OGG_AUDIO,
54+
cls.MP4_AUDIO,
55+
cls.MP4_VIDEO,
56+
cls.WEBM_AUDIO,
57+
cls.WEBM_VIDEO,
58+
]
59+
3760

3861
class ContentEncodings:
3962
BASE64 = "base64"

src/steamship/cli/cli.py

Lines changed: 97 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import click
1010

1111
import steamship
12-
from steamship import PackageInstance, Steamship, SteamshipError
12+
from steamship import PackageInstance, Steamship, SteamshipError, Workspace
1313
from steamship.base.configuration import DEFAULT_WEB_BASE, Configuration
1414
from steamship.cli.create_instance import (
1515
config_str_to_dict,
@@ -46,6 +46,30 @@ def initialize(suppress_message: bool = False):
4646
click.echo(f"Steamship Python CLI version {steamship.__version__}")
4747

4848

49+
def initialize_and_get_client_and_prep_project():
50+
initialize()
51+
client = None
52+
try:
53+
client = Steamship()
54+
except SteamshipError as e:
55+
click.secho(e.message, fg="red")
56+
click.get_current_context().abort()
57+
58+
user = User.current(client)
59+
if path.exists("steamship.json"):
60+
manifest = Manifest.load_manifest()
61+
else:
62+
manifest = manifest_init_wizard(client)
63+
manifest.save()
64+
65+
if not path.exists("requirements.txt"):
66+
requirements_init_wizard()
67+
68+
update_config_template(manifest)
69+
70+
return client, user, manifest
71+
72+
4973
@click.command()
5074
def login():
5175
"""Log in to Steamship, creating ~/.steamship.json"""
@@ -208,9 +232,29 @@ def _run_web_interface(base_url: str) -> str:
208232
return web_url
209233

210234

211-
def serve_local(
235+
def register_locally_running_package_with_engine(
236+
client: Steamship,
237+
ngrok_api_url: str,
238+
package_handle: str,
239+
manifest: Manifest,
240+
config: Optional[str] = None,
241+
) -> PackageInstance:
242+
"""Registers the locally running package with the Steamship Engine."""
243+
244+
# Register the Instance in the Engine
245+
invocable_config, is_file = config_str_to_dict(config)
246+
set_unset_params(config, invocable_config, is_file, manifest)
247+
package_instance = PackageInstance.create_local_development_instance(
248+
client,
249+
local_development_url=ngrok_api_url,
250+
package_handle=package_handle,
251+
config=invocable_config,
252+
)
253+
return package_instance
254+
255+
256+
def serve_local( # noqa: C901
212257
port: int = 8443,
213-
instance_handle: Optional[str] = None,
214258
no_ngrok: Optional[bool] = False,
215259
no_repl: Optional[bool] = False,
216260
no_ui: Optional[bool] = False,
@@ -220,32 +264,74 @@ def serve_local(
220264
"""Serve the invocable on localhost. Useful for debugging locally."""
221265
dev_logging_handler = DevelopmentLoggingHandler.init_and_take_root()
222266

223-
initialize()
224267
click.secho("Running your project...\n")
225268

226-
# Report the logs
269+
client, user, manifest = initialize_and_get_client_and_prep_project()
270+
271+
if workspace:
272+
workspace_obj = Workspace.get(client, handle=workspace)
273+
else:
274+
workspace_obj = Workspace.get(client)
275+
workspace = workspace_obj.handle
276+
277+
# Make sure we're running a package.
278+
if manifest.type != DeployableType.PACKAGE:
279+
click.secho(
280+
f"⚠️ Must run `ship serve local` in a folder with a Steamship Package. Found: {manifest.type}"
281+
)
282+
exit(-1)
283+
284+
# Make sure we have a package name -- this allows us to register the running copy with the engine.
285+
deployer = PackageDeployer()
286+
deployable = deployer.create_or_fetch_deployable(client, user, manifest)
287+
288+
# Report the logs output file.
227289
click.secho(f"📝 Log file: {dev_logging_handler.log_filename}")
228290

229291
# Start the NGROK connection
230292
ngrok_api_url = None
293+
public_api_url = None
294+
231295
if not no_ngrok:
232296
ngrok_api_url = _run_ngrok(port)
233-
click.secho(f"🌎 Public API: {ngrok_api_url}")
297+
298+
# It requires a trailing slash
299+
if ngrok_api_url[-1] != "/":
300+
ngrok_api_url = ngrok_api_url + "/"
301+
302+
registered_instance = register_locally_running_package_with_engine(
303+
client=client,
304+
ngrok_api_url=ngrok_api_url,
305+
package_handle=deployable.handle,
306+
manifest=manifest,
307+
config=config,
308+
)
309+
310+
# Notes:
311+
# 1. registered_instance.invocation_url is the NGROK URL, not the Steamship Proxy URL.
312+
# 2. The public_api_url should still be NGROK, not the Proxy. The local server emulates the Proxy and
313+
# the Proxy blocks this kind of development traffic.
314+
315+
public_api_url = ngrok_api_url
316+
click.secho(f"🌎 Public API: {public_api_url}")
234317

235318
# Start the local API Server. This has to happen after NGROK because the port & url need to be plummed.
236319
try:
237320
local_api_url = _run_local_server(
238321
local_port=port,
239-
instance_handle=instance_handle,
322+
instance_handle=registered_instance.handle,
240323
config=config,
241324
workspace=workspace,
242-
base_url=ngrok_api_url,
325+
base_url=public_api_url,
243326
)
244327
except BaseException as e:
245328
click.secho("⚠️ Local API: Unable to start local server.")
246329
click.secho(e)
247330
exit(-1)
248331

332+
if local_api_url[-1] != "/":
333+
local_api_url = local_api_url + "/"
334+
249335
if local_api_url:
250336
click.secho(f"🌎 Local API: {local_api_url}")
251337
else:
@@ -254,7 +340,7 @@ def serve_local(
254340

255341
# Start the web UI
256342
if not no_ui:
257-
web_url = _run_web_interface(ngrok_api_url or local_api_url)
343+
web_url = _run_web_interface(public_api_url or local_api_url)
258344
if web_url:
259345
click.secho(f"🌎 Web UI: {web_url}")
260346

@@ -264,7 +350,7 @@ def serve_local(
264350
time.sleep(1)
265351
else:
266352
click.secho("\n💬 Interactive REPL below. Type to interact.\n")
267-
prompt_url = f"{local_api_url or ngrok_api_url}/prompt"
353+
prompt_url = f"{local_api_url or public_api_url}prompt"
268354
repl = HttpREPL(prompt_url=prompt_url, dev_logging_handler=dev_logging_handler)
269355
repl.run()
270356

@@ -337,7 +423,6 @@ def run(
337423
if environment == "local":
338424
serve_local(
339425
port=port,
340-
instance_handle=instance_handle,
341426
no_ngrok=no_ngrok,
342427
no_repl=no_repl,
343428
no_ui=no_ui,
@@ -355,28 +440,10 @@ def run(
355440
@click.command()
356441
def deploy():
357442
"""Deploy the package or plugin in this directory"""
358-
initialize()
359-
client = None
360-
try:
361-
client = Steamship()
362-
except SteamshipError as e:
363-
click.secho(e.message, fg="red")
364-
click.get_current_context().abort()
365-
366-
user = User.current(client)
367-
if path.exists("steamship.json"):
368-
manifest = Manifest.load_manifest()
369-
else:
370-
manifest = manifest_init_wizard(client)
371-
manifest.save()
372-
373-
if not path.exists("requirements.txt"):
374-
requirements_init_wizard()
443+
client, user, manifest = initialize_and_get_client_and_prep_project()
375444

376445
deployable_type = manifest.type
377446

378-
update_config_template(manifest)
379-
380447
deployer = None
381448
if deployable_type == DeployableType.PACKAGE:
382449
deployer = PackageDeployer()

src/steamship/cli/local_server/handler.py

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import base64
12
import json
23
import logging
34
import re
@@ -128,8 +129,16 @@ def _do_request(self, payload: dict, http_verb: str):
128129
client = self._get_client()
129130
context = self._get_invocation_context(client)
130131

132+
# Fix for GET parameters -- in production the Proxy would have done this.
133+
thepath = self.path
134+
if "?" in thepath:
135+
path_parts = thepath.split("?")
136+
thepath = path_parts[0]
137+
queryargs = parse_qs(path_parts[1])
138+
payload.update(queryargs)
139+
131140
invocation = Invocation(
132-
http_verb=http_verb, invocation_path=self.path, arguments=payload, config=config
141+
http_verb=http_verb, invocation_path=thepath, arguments=payload, config=config
133142
)
134143
event = InvocableRequest(
135144
client_config=client.config,
@@ -140,9 +149,31 @@ def _do_request(self, payload: dict, http_verb: str):
140149

141150
handler = create_safe_handler(invocable_class)
142151
resp = handler(event.dict(by_alias=True), context)
143-
res_str = json.dumps(resp)
144-
res_bytes = bytes(res_str, "utf-8")
145-
self._send_response(res_bytes, MimeTypes.JSON)
152+
153+
# Since the local-server handler is simulating the Steamship Proxy's behavior, we now have to unwrap the
154+
# `resp.data` object if it exists. This is what the proxy would do before returning the raw values
155+
# to the HTTP User. Note that this is the behavior for a Package but not a Plugin.
156+
157+
res_mime = MimeTypes.JSON
158+
if isinstance(resp, dict):
159+
res_mime = (
160+
resp.get("http", {}).get("headers", {}).get("Content-Type", MimeTypes.JSON)
161+
)
162+
163+
if "data" in resp and resp.get("data") is not None:
164+
if res_mime in [MimeTypes.JSON, MimeTypes.FILE_JSON]:
165+
res_str = json.dumps(resp.get("data"))
166+
res_bytes = bytes(res_str, "utf-8")
167+
elif MimeTypes.is_binary(res_mime):
168+
res_bytes = base64.b64decode(resp.get("data"))
169+
else:
170+
res_str = resp.get("data")
171+
res_bytes = bytes(res_str, "utf-8")
172+
else:
173+
res_str = json.dumps(resp)
174+
res_bytes = bytes(res_str, "utf-8")
175+
176+
self._send_response(res_bytes, res_mime)
146177
except Exception as e:
147178
self._send_response(bytes(f"{e}", "utf-8"), MimeTypes.TXT)
148179

src/steamship/data/package/package_instance.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414
from steamship.data.workspace import Workspace
1515
from steamship.utils.url import Verb
1616

17+
LOCAL_DEVELOPMENT_VERSION_HANDLE = (
18+
"local-development!" # Special handle for a locally-running development instances.
19+
)
20+
1721

1822
class CreatePackageInstanceRequest(Request):
1923
id: str = None
@@ -26,6 +30,12 @@ class CreatePackageInstanceRequest(Request):
2630
config: Dict[str, Any] = None
2731
workspace_id: str = None
2832

33+
local_development_url: Optional[str] = None
34+
"""Special argument only intended for creating an PackageInstance bound to a local development server.
35+
36+
If used, the package_version_handle should be set to LOCAL_DEVELOPMENT_VERSION_HANDLE above.
37+
"""
38+
2939

3040
class PackageInstance(CamelModel):
3141
client: Client = Field(None, exclude=True)
@@ -72,6 +82,28 @@ def create(
7282

7383
return client.post("package/instance/create", payload=req, expect=PackageInstance)
7484

85+
@staticmethod
86+
def create_local_development_instance(
87+
client: Client,
88+
local_development_url: str,
89+
package_id: str = None,
90+
package_handle: str = None,
91+
handle: str = None,
92+
fetch_if_exists: bool = True,
93+
config: Dict[str, Any] = None,
94+
) -> PackageInstance:
95+
req = CreatePackageInstanceRequest(
96+
handle=handle,
97+
package_id=package_id,
98+
package_handle=package_handle,
99+
package_version_handle=LOCAL_DEVELOPMENT_VERSION_HANDLE,
100+
fetch_if_exists=fetch_if_exists,
101+
config=config,
102+
local_development_url=local_development_url,
103+
)
104+
"""Create a PackageInstance bound to a local development server."""
105+
return client.post("package/instance/create", payload=req, expect=PackageInstance)
106+
75107
def delete(self) -> PackageInstance:
76108
req = DeleteRequest(id=self.id)
77109
return self.client.post("package/instance/delete", payload=req, expect=PackageInstance)

src/steamship/utils/repl.py

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -246,17 +246,25 @@ def colored(text: str, color: str):
246246
logging.exception(ex)
247247

248248
if result:
249-
if result.get("status", {}).get("state", None) == TaskState.failed:
250-
message = result.get("status", {}).get("status_message", None)
251-
logging.error(f"Response failed with remote error: {message or 'No message'}")
252-
if suggestion := result.get("status", {}).get("status_suggestion", None):
253-
logging.error(f"Suggestion: {suggestion}")
254-
elif data := result.get("data", None):
255-
self.print_object_or_objects(data)
249+
if isinstance(result, dict):
250+
if result.get("status", {}).get("state", None) == TaskState.failed:
251+
message = result.get("status", {}).get("status_message", None)
252+
logging.error(
253+
f"Response failed with remote error: {message or 'No message'}"
254+
)
255+
if suggestion := result.get("status", {}).get("status_suggestion", None):
256+
logging.error(f"Suggestion: {suggestion}")
257+
elif data := result.get("data", None):
258+
self.print_object_or_objects(data)
259+
else:
260+
logging.warning(
261+
"REPL interaction completed with empty data field in InvocableResponse."
262+
)
263+
if isinstance(result, list):
264+
self.print_object_or_objects(result)
256265
else:
257-
logging.warning(
258-
"REPL interaction completed with empty data field in InvocableResponse."
259-
)
266+
logging.warning("Unsure how to display result:")
267+
logging.warning(result)
260268
else:
261269
logging.warning("REPL interaction completed with no result to print.")
262270

0 commit comments

Comments
 (0)