-
Notifications
You must be signed in to change notification settings - Fork 0
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add 'final' and 'strip' to hooks, rework 'ensure_lab' and github on demand #11
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1 @@ | ||
{"openapi": "3.1.0", "info": {"title": "Ghostwriter", "description": "URL shortener/personalizer for Phalanx\n\n[Return to Ghostwriter documentation](.)", "version": "0.1.2.dev6+ge335638.d20241017"}, "paths": {"/ghostwriter/": {"get": {"summary": "Application metadata", "description": "Document the top-level API here. By default it only returns metadata about the application.", "operationId": "get_index_ghostwriter__get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Index"}}}}}}}, "/ghostwriter/rewrite/{full_path}": {"get": {"summary": "Rewrite", "operationId": "rewrite_ghostwriter_rewrite__full_path__get", "parameters": [{"name": "full_path", "in": "path", "required": true, "schema": {"type": "string", "title": "The URL path to rewrite"}}], "responses": {"307": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}}, "components": {"schemas": {"HTTPValidationError": {"properties": {"detail": {"items": {"$ref": "#/components/schemas/ValidationError"}, "type": "array", "title": "Detail"}}, "type": "object", "title": "HTTPValidationError"}, "Index": {"properties": {"metadata": {"$ref": "#/components/schemas/Metadata", "title": "Package metadata"}}, "type": "object", "required": ["metadata"], "title": "Index", "description": "Metadata returned by the external root URL of the application.\n\nNotes\n-----\nAs written, this is not very useful. Add additional metadata that will be\nhelpful for a user exploring the application, or replace this model with\nsome other model that makes more sense to return from the application API\nroot."}, "Metadata": {"properties": {"name": {"type": "string", "title": "Application name", "examples": ["myapp"]}, "version": {"type": "string", "title": "Version", "examples": ["1.0.0"]}, "description": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Description", "examples": ["Some package description"]}, "repository_url": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Repository URL", "examples": ["https://example.com/"]}, "documentation_url": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Documentation URL", "examples": ["https://example.com/"]}}, "type": "object", "required": ["name", "version"], "title": "Metadata", "description": "Metadata about a package."}, "ValidationError": {"properties": {"loc": {"items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, "type": "array", "title": "Location"}, "msg": {"type": "string", "title": "Message"}, "type": {"type": "string", "title": "Error Type"}}, "type": "object", "required": ["loc", "msg", "type"], "title": "ValidationError"}}}} | ||
{"openapi": "3.1.0", "info": {"title": "Ghostwriter", "description": "URL shortener/personalizer for Phalanx\n\n[Return to Ghostwriter documentation](.)", "version": "0.1.2.dev6+ge335638.d20241015"}, "paths": {"/ghostwriter/": {"get": {"summary": "Application metadata", "description": "Document the top-level API here. By default it only returns metadata about the application.", "operationId": "get_index_ghostwriter__get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Index"}}}}}}}, "/ghostwriter/rewrite/{full_path}": {"get": {"summary": "Rewrite", "operationId": "rewrite_ghostwriter_rewrite__full_path__get", "parameters": [{"name": "full_path", "in": "path", "required": true, "schema": {"type": "string", "title": "The URL path to rewrite"}}], "responses": {"307": {"description": "Successful Response"}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}}, "components": {"schemas": {"HTTPValidationError": {"properties": {"detail": {"items": {"$ref": "#/components/schemas/ValidationError"}, "type": "array", "title": "Detail"}}, "type": "object", "title": "HTTPValidationError"}, "Index": {"properties": {"metadata": {"$ref": "#/components/schemas/Metadata", "title": "Package metadata"}}, "type": "object", "required": ["metadata"], "title": "Index", "description": "Metadata returned by the external root URL of the application.\n\nNotes\n-----\nAs written, this is not very useful. Add additional metadata that will be\nhelpful for a user exploring the application, or replace this model with\nsome other model that makes more sense to return from the application API\nroot."}, "Metadata": {"properties": {"name": {"type": "string", "title": "Application name", "examples": ["myapp"]}, "version": {"type": "string", "title": "Version", "examples": ["1.0.0"]}, "description": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Description", "examples": ["Some package description"]}, "repository_url": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Repository URL", "examples": ["https://example.com/"]}, "documentation_url": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Documentation URL", "examples": ["https://example.com/"]}}, "type": "object", "required": ["name", "version"], "title": "Metadata", "description": "Metadata about a package."}, "ValidationError": {"properties": {"loc": {"items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, "type": "array", "title": "Location"}, "msg": {"type": "string", "title": "Message"}, "type": {"type": "string", "title": "Error Type"}}, "type": "object", "required": ["loc", "msg", "type"], "title": "ValidationError"}}}} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
"""Logger for hooks.""" | ||
|
||
import structlog | ||
|
||
LOGGER = structlog.getLogger("ghostwriter") |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,65 @@ | ||
"""Ensure that the user has a running lab. If they don't, they will be | ||
given `recommended` in `medium` size. | ||
""" | ||
|
||
import asyncio | ||
|
||
from rubin.nublado.client import NubladoClient | ||
from rubin.nublado.client.models import ( | ||
NubladoImage, | ||
NubladoImageByClass, | ||
NubladoImageClass, | ||
NubladoImageSize, | ||
) | ||
|
||
from ..models.substitution import Parameters | ||
from ._logger import LOGGER | ||
|
||
LAB_SPAWN_TIMEOUT = 90 | ||
|
||
|
||
async def ensure_autostart_lab(params: Parameters) -> None: | ||
athornton marked this conversation as resolved.
Show resolved
Hide resolved
|
||
"""Start a Lab if one is not present.""" | ||
LOGGER.debug(f"Checking for running Lab for {params}") | ||
client = params.client | ||
LOGGER.debug("Logging in to Hub") | ||
await client.auth_to_hub() | ||
stopped = await client.is_lab_stopped() | ||
if stopped: | ||
LOGGER.debug(f"Starting new 'recommended' Lab for {params.user}") | ||
await _spawn_lab(client) | ||
else: | ||
LOGGER.debug(f"{params.user} already has a running Lab") | ||
LOGGER.debug("Lab spawned; proceeding with redirection") | ||
|
||
|
||
async def _spawn_lab(client: NubladoClient) -> None: | ||
image = _choose_image() | ||
await client.spawn_lab(image) | ||
await _follow_progress(client) | ||
|
||
|
||
def _choose_image() -> NubladoImage: | ||
"""Because the primary use case of this redirection service is | ||
to launch tutorial notebooks, our "start a new lab" parameters | ||
are going to be the currently recommended lab, and Medium size, | ||
because that's how the tutorial notebooks are generally set up | ||
to run. Maybe we will do something more sophisticated later. | ||
""" | ||
return NubladoImageByClass( | ||
image_class=NubladoImageClass.RECOMMENDED, size=NubladoImageSize.Medium | ||
) | ||
|
||
|
||
async def _follow_progress(client: NubladoClient) -> None: | ||
LOGGER.debug("Waiting for lab to spawn") | ||
progress = client.watch_spawn_progress() | ||
try: | ||
async with asyncio.timeout(LAB_SPAWN_TIMEOUT): | ||
async for message in progress: | ||
LOGGER.debug(f"Lab spawn message: {message.message}") | ||
if message.ready: | ||
break | ||
except TimeoutError: | ||
LOGGER.exception(f"Lab did not spawn in {LAB_SPAWN_TIMEOUT} seconds") | ||
raise |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,64 +1,35 @@ | ||
"""Ensure that the user has a running lab.""" | ||
|
||
import asyncio | ||
|
||
import structlog | ||
from rubin.nublado.client import NubladoClient | ||
from rubin.nublado.client.models import ( | ||
NubladoImage, | ||
NubladoImageByClass, | ||
NubladoImageClass, | ||
NubladoImageSize, | ||
) | ||
"""Ensure that the user has a running lab by dropping them in the spawner form | ||
if they don't, and then slingshotting them through the lab ghostwriter endpoint | ||
to restart the redirect process. Requires d_2024_10_19 (corresponds to | ||
w_2024_10_43) or later. | ||
""" | ||
|
||
from ..models.substitution import Parameters | ||
|
||
LAB_SPAWN_TIMEOUT = 90 | ||
LOGGER = structlog.get_logger("ghostwriter") | ||
from ._logger import LOGGER | ||
|
||
|
||
async def ensure_running_lab(params: Parameters) -> None: | ||
async def ensure_running_lab(params: Parameters) -> Parameters | None: | ||
"""Start a Lab if one is not present.""" | ||
LOGGER.debug(f"Checking for running Lab for {params}") | ||
client = params.client | ||
LOGGER.debug("Logging in to Hub") | ||
await client.auth_to_hub() | ||
stopped = await client.is_lab_stopped() | ||
if stopped: | ||
LOGGER.debug(f"Starting new 'recommended' Lab for {params.user}") | ||
await _spawn_lab(client) | ||
else: | ||
if not stopped: | ||
LOGGER.debug(f"{params.user} already has a running Lab") | ||
LOGGER.debug("Lab spawned; proceeding with redirection") | ||
|
||
|
||
async def _spawn_lab(client: NubladoClient) -> None: | ||
image = _choose_image() | ||
await client.spawn_lab(image) | ||
await _follow_progress(client) | ||
|
||
|
||
def _choose_image() -> NubladoImage: | ||
"""Because the primary use case of this redirection service is | ||
to launch tutorial notebooks, our "start a new lab" parameters | ||
are going to be the currently recommended lab, and Medium size, | ||
because that's how the tutorial notebooks are generally set up | ||
to run. Maybe we will do something more sophisticated later. | ||
""" | ||
return NubladoImageByClass( | ||
image_class=NubladoImageClass.RECOMMENDED, size=NubladoImageSize.Medium | ||
return None | ||
LOGGER.debug(f"Sending {params.user} to spawner") | ||
LOGGER.debug(f"Input parameters {params}") | ||
new_p = Parameters( | ||
user=params.user, | ||
base_url=params.base_url, | ||
path=params.path, | ||
token=params.token, | ||
client=params.client, | ||
target="${base_url}/nb/user/${user}/rubin/ghostwriter/${path}", | ||
unique_id=params.unique_id, | ||
strip=False, | ||
final=True, | ||
) | ||
|
||
|
||
async def _follow_progress(client: NubladoClient) -> None: | ||
LOGGER.debug("Waiting for lab to spawn") | ||
progress = client.watch_spawn_progress() | ||
try: | ||
async with asyncio.timeout(LAB_SPAWN_TIMEOUT): | ||
async for message in progress: | ||
LOGGER.debug(f"Lab spawn message: {message.message}") | ||
if message.ready: | ||
break | ||
except TimeoutError: | ||
LOGGER.exception(f"Lab did not spawn in {LAB_SPAWN_TIMEOUT} seconds") | ||
raise | ||
LOGGER.debug(f"Output parameters {new_p}") | ||
return new_p |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -9,14 +9,11 @@ | |
from string import Template | ||
from urllib.parse import urljoin | ||
|
||
import structlog | ||
|
||
from ..models.substitution import Parameters | ||
|
||
LOGGER = structlog.get_logger("ghostwriter") | ||
from ._logger import LOGGER | ||
|
||
|
||
async def github_notebook(params: Parameters) -> Parameters | None: | ||
async def github_notebook(params: Parameters) -> Parameters: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Perhaps a suggestion for when you have more development effort on this would be splitting the path processing here to its own method and writing some tests for that to make sure it can handle all unusual cases of paths |
||
"""Check out a particular notebook from GitHub.""" | ||
client = params.client | ||
LOGGER.debug("Logging in to hub", user=params.user) | ||
|
@@ -29,32 +26,53 @@ async def github_notebook(params: Parameters) -> Parameters | None: | |
# We know the stream output should be the serial number and | ||
# a newline. | ||
serial = (await lab_session.run_python(code)).strip() | ||
if serial and serial != "0" and params.target is not None: | ||
# | ||
# params.target is never None, because it's injected by run_hooks(), | ||
# but mypy doesn't know that. | ||
# | ||
# We had to change the filename and filename schema. | ||
# Return a modified Parameters object. | ||
# | ||
targetcomp = params.target.split("/") | ||
targetcomp[-1] = "${path}-${unique_id}.ipynb" | ||
target = "/".join(targetcomp) | ||
unique_id = str(serial) | ||
LOGGER.debug( | ||
"Continuing to redirect", target=target, unique_id=unique_id | ||
) | ||
return Parameters( | ||
user=params.user, | ||
base_url=params.base_url, | ||
token=params.token, | ||
client=params.client, | ||
path=params.path, | ||
target=target, | ||
unique_id=unique_id, | ||
) | ||
LOGGER.debug("Continuing to redirect; params unchanged") | ||
return None | ||
|
||
# Honestly it's easier to just unconditionally rewrite the target | ||
# than to figure out whether it needs rewriting. | ||
|
||
return _get_new_params(serial, params) | ||
|
||
|
||
def _get_new_params(serial: str, params: Parameters) -> Parameters: | ||
# Start with the common fragment | ||
target = ( | ||
athornton marked this conversation as resolved.
Show resolved
Hide resolved
|
||
f"{params.base_url}nb/user/{params.user}/lab/tree/notebooks" | ||
"/on-demand/github.com/" | ||
) | ||
|
||
# Remove branch information, if any (the checkout will have handled | ||
# it in the code we ran to get the serial). | ||
path = params.path.split("@")[0] | ||
|
||
# Canonicalize path. | ||
prefix = "notebooks/github.com/" | ||
if path.startswith(prefix): | ||
# Needs stripping | ||
path = path[(len(prefix)) :] | ||
if path.endswith(".ipynb"): | ||
# Also needs stripping | ||
path = path[: -(len(".ipynb"))] | ||
athornton marked this conversation as resolved.
Show resolved
Hide resolved
Comment on lines
+48
to
+54
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wondering if this would be simpler: path = path.removeprefix("notebooks/github.com/").removesuffix(".ipynb") |
||
|
||
# Add discriminator if needed | ||
unique_id: str | None = None | ||
if serial and serial != "0": | ||
# Glue in serial if it is nonzero | ||
unique_id = serial | ||
path += f"-{unique_id}" | ||
path += ".ipynb" # And add the extension | ||
target += path | ||
|
||
new_param = Parameters( | ||
user=params.user, | ||
base_url=params.base_url, | ||
token=params.token, | ||
client=params.client, | ||
path=params.path, | ||
target=target, | ||
unique_id=unique_id, | ||
) | ||
LOGGER.debug("Continuing to redirect", param=new_param) | ||
return new_param | ||
|
||
|
||
def _get_user_endpoint(base_url: str, user: str) -> str: | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,11 +1,9 @@ | ||
"""Does nothing, but if it loads, the hook machinery is working.""" | ||
|
||
import structlog | ||
|
||
from ..models.substitution import Parameters | ||
from ._logger import LOGGER | ||
|
||
|
||
async def vacuous_hook(params: Parameters) -> None: | ||
"""Load and execute a no-op.""" | ||
logger = structlog.get_logger("ghostwriter") | ||
logger.debug(f"Vacuous hook called with {params}") | ||
LOGGER.debug(f"Vacuous hook called with {params}") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's a typo (not in your recent changes) just above:
modififactions -> modifications