From 005ecefdba15f90eb753413ca1319af9db2a530e Mon Sep 17 00:00:00 2001 From: Nicole Brewer Date: Thu, 26 Jun 2025 17:45:04 -0700 Subject: [PATCH 1/6] added get_project_name to context module --- chi/context.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/chi/context.py b/chi/context.py index 0a94001..2833e68 100644 --- a/chi/context.py +++ b/chi/context.py @@ -72,6 +72,7 @@ def default_key_name(): _auth_plugin = None _session = None _sites = {} +_lease = None version = "1.1" @@ -441,6 +442,40 @@ def on_change(change): print("Choose site feature is only available in an ipynb environment.") +def get_project_name(project_id: Optional[str] = None) -> str: + """ + Returns the name of a project by ID, or the current project name if no ID is given. + + Args: + project_id (str, optional): The ID of the project. If None, uses the current session project. + + Returns: + str: The name of the project. + + Raises: + ResourceError: If the project cannot be found or the request fails. + """ + keystone_session = session() + keystone_client = KeystoneClient( + session=keystone_session, + interface=getattr(keystone_session, "interface", None), + region_name=getattr(keystone_session, "region_name", None), + ) + + try: + if project_id: + project = keystone_client.projects.get(project_id) + else: + current_id = keystone_session.get_project_id() + project = keystone_client.projects.get(current_id) + except keystone_exceptions.NotFound: + raise ResourceError("Project not found.") + except keystone_exceptions.Unauthorized: + raise ResourceError("Failed to retrieve project. Check your credentials.") + + return project.name + + def list_projects(show: str = None) -> List[str]: """ Retrieves a list of projects associated with the current user. From 5178257dad78c8cd71d25c9b54a066c8d24ce79f Mon Sep 17 00:00:00 2001 From: Nicole Brewer Date: Thu, 26 Jun 2025 18:05:58 -0700 Subject: [PATCH 2/6] add _ipython_display_ method to Lease class --- chi/lease.py | 73 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/chi/lease.py b/chi/lease.py index d663c2f..c235017 100644 --- a/chi/lease.py +++ b/chi/lease.py @@ -3,8 +3,11 @@ import numbers import re import time +import os from datetime import datetime, timedelta from typing import TYPE_CHECKING, List, Optional, Union +from ipywidgets import Box, HTML, Layout + from blazarclient.exception import BlazarClientException from IPython.display import display @@ -259,6 +262,76 @@ def _populate_from_json(self, lease_json): # self.events = lease_json.get('events', []) + def _ipython_display_(self): + """ + Displays a styled summary of the lease when run in a Jupyter notebook. + + This method is called automatically by the Jupyter display system when + an instance of the Lease object is the final expression in a cell. + It presents key lease attributes using ipywidgets for readability. + """ + layout = Layout(padding="4px 10px") + style = { + 'description_width': 'initial', + 'background': '#d3d3d3', + 'white_space': 'nowrap' + } + + status_style = style.copy() + status_colors = { + "ACTIVE": "#a2d9fe", + "PENDING": "#ffe599", + "TERMINATED": "#f69084" + } + if self.status: + status_style['background'] = status_colors.get(self.status, "#d3d3d3") + + children = [ + #HTML(f"Lease ID: {self.id}", style=style, layout=layout), + HTML(f"Status: {self.status}", style=status_style, layout=layout), + HTML(f"Name: {self.name}", style=style, layout=layout), + ] + + if self.start_date: + children.append(HTML(f"Start: {self.start_date.strftime('%Y-%m-%d %H:%M')}", style=style, layout=layout)) + if self.end_date: + children.append(HTML(f"End: {self.end_date.strftime('%Y-%m-%d %H:%M')}", style=style, layout=layout)) + + remaining = None + if self.end_date and datetime.now() < self.end_date: + remaining = self.end_date - datetime.now() + + if remaining: + days = remaining.days + hours = remaining.seconds // 3600 + minutes = (remaining.seconds % 3600) // 60 + children.append(HTML(f"Remaining: {days:02d}d {hours:02d}h {minutes:02d}m", style=style, layout=layout)) + + # Reservations + children.append(HTML(f"Node Reservations: {len(self.node_reservations)}", style=style, layout=layout)) + children.append(HTML(f"FIP Reservations: {len(self.fip_reservations)}", style=style, layout=layout)) + children.append(HTML(f"Device Reservations: {len(self.device_reservations)}", style=style, layout=layout)) + + if self.project_id: + try: + project_name = context.get_project_name(self.project_id) + children.append(HTML(f"Project Name: {project_name}", style=style, layout=layout)) + except: + children.append(HTML(f"Project ID: {self.project_id}", style=style, layout=layout)) + if self.user_id: + user_id = os.getenv("OS_USER_ID") # or "OS_USERNAME" if set + if self.user_id == user_id: + label = os.getenv('OS_USERNAME') + else: + label = self.user_id #[:8] # or just show a truncated ID + children.append(HTML(f"User ID: {self.user_id}", style=style, layout=layout)) + if self.created_at: + children.append(HTML(f"Created At: {self.created_at.strftime('%Y-%m-%d %H:%M')}", style=style, layout=layout)) + + box = Box(children=children) + box.layout = Layout(flex_flow='row wrap') + display(box) + def add_device_reservation( self, amount: int = None, From 0e1b449134060e2f71e65886c0cdc89ffbe97a2b Mon Sep 17 00:00:00 2001 From: Nicole Brewer Date: Mon, 30 Jun 2025 14:06:01 -0700 Subject: [PATCH 3/6] Address linter errors --- chi/lease.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/chi/lease.py b/chi/lease.py index c235017..33ede22 100644 --- a/chi/lease.py +++ b/chi/lease.py @@ -1,17 +1,15 @@ import json import logging import numbers +import os import re import time -import os from datetime import datetime, timedelta from typing import TYPE_CHECKING, List, Optional, Union -from ipywidgets import Box, HTML, Layout - from blazarclient.exception import BlazarClientException from IPython.display import display -from ipywidgets import HTML +from ipywidgets import HTML, Box, Layout from packaging.version import Version from chi import context, server, util @@ -316,15 +314,16 @@ def _ipython_display_(self): try: project_name = context.get_project_name(self.project_id) children.append(HTML(f"Project Name: {project_name}", style=style, layout=layout)) - except: + except ResourceError: children.append(HTML(f"Project ID: {self.project_id}", style=style, layout=layout)) if self.user_id: user_id = os.getenv("OS_USER_ID") # or "OS_USERNAME" if set if self.user_id == user_id: label = os.getenv('OS_USERNAME') + children.append(HTML(f"User Name: {label}", style=style, layout=layout)) else: label = self.user_id #[:8] # or just show a truncated ID - children.append(HTML(f"User ID: {self.user_id}", style=style, layout=layout)) + children.append(HTML(f"User ID: {label}", style=style, layout=layout)) if self.created_at: children.append(HTML(f"Created At: {self.created_at.strftime('%Y-%m-%d %H:%M')}", style=style, layout=layout)) From 86b372f3d64ad2daba30f9ce990cb0ee6bd2e8db Mon Sep 17 00:00:00 2001 From: Nicole Brewer Date: Mon, 30 Jun 2025 14:40:37 -0700 Subject: [PATCH 4/6] Reformatted with ruff --- chi/lease.py | 116 ++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 87 insertions(+), 29 deletions(-) diff --git a/chi/lease.py b/chi/lease.py index 33ede22..58ed93f 100644 --- a/chi/lease.py +++ b/chi/lease.py @@ -263,72 +263,130 @@ def _populate_from_json(self, lease_json): def _ipython_display_(self): """ Displays a styled summary of the lease when run in a Jupyter notebook. - + This method is called automatically by the Jupyter display system when an instance of the Lease object is the final expression in a cell. It presents key lease attributes using ipywidgets for readability. """ layout = Layout(padding="4px 10px") style = { - 'description_width': 'initial', - 'background': '#d3d3d3', - 'white_space': 'nowrap' + "description_width": "initial", + "background": "#d3d3d3", + "white_space": "nowrap", } - + status_style = style.copy() status_colors = { "ACTIVE": "#a2d9fe", "PENDING": "#ffe599", - "TERMINATED": "#f69084" + "TERMINATED": "#f69084", } if self.status: - status_style['background'] = status_colors.get(self.status, "#d3d3d3") - + status_style["background"] = status_colors.get(self.status, "#d3d3d3") + children = [ - #HTML(f"Lease ID: {self.id}", style=style, layout=layout), + # HTML(f"Lease ID: {self.id}", style=style, layout=layout), HTML(f"Status: {self.status}", style=status_style, layout=layout), HTML(f"Name: {self.name}", style=style, layout=layout), ] - + if self.start_date: - children.append(HTML(f"Start: {self.start_date.strftime('%Y-%m-%d %H:%M')}", style=style, layout=layout)) + children.append( + HTML( + f"Start: {self.start_date.strftime('%Y-%m-%d %H:%M')}", + style=style, + layout=layout, + ) + ) if self.end_date: - children.append(HTML(f"End: {self.end_date.strftime('%Y-%m-%d %H:%M')}", style=style, layout=layout)) - + children.append( + HTML( + f"End: {self.end_date.strftime('%Y-%m-%d %H:%M')}", + style=style, + layout=layout, + ) + ) + remaining = None if self.end_date and datetime.now() < self.end_date: remaining = self.end_date - datetime.now() - + if remaining: days = remaining.days hours = remaining.seconds // 3600 minutes = (remaining.seconds % 3600) // 60 - children.append(HTML(f"Remaining: {days:02d}d {hours:02d}h {minutes:02d}m", style=style, layout=layout)) - + children.append( + HTML( + f"Remaining: {days:02d}d {hours:02d}h {minutes:02d}m", + style=style, + layout=layout, + ) + ) + # Reservations - children.append(HTML(f"Node Reservations: {len(self.node_reservations)}", style=style, layout=layout)) - children.append(HTML(f"FIP Reservations: {len(self.fip_reservations)}", style=style, layout=layout)) - children.append(HTML(f"Device Reservations: {len(self.device_reservations)}", style=style, layout=layout)) - + children.append( + HTML( + f"Node Reservations: {len(self.node_reservations)}", + style=style, + layout=layout, + ) + ) + children.append( + HTML( + f"FIP Reservations: {len(self.fip_reservations)}", + style=style, + layout=layout, + ) + ) + children.append( + HTML( + f"Device Reservations: {len(self.device_reservations)}", + style=style, + layout=layout, + ) + ) + if self.project_id: try: project_name = context.get_project_name(self.project_id) - children.append(HTML(f"Project Name: {project_name}", style=style, layout=layout)) + children.append( + HTML( + f"Project Name: {project_name}", + style=style, + layout=layout, + ) + ) except ResourceError: - children.append(HTML(f"Project ID: {self.project_id}", style=style, layout=layout)) + children.append( + HTML( + f"Project ID: {self.project_id}", + style=style, + layout=layout, + ) + ) if self.user_id: user_id = os.getenv("OS_USER_ID") # or "OS_USERNAME" if set if self.user_id == user_id: - label = os.getenv('OS_USERNAME') - children.append(HTML(f"User Name: {label}", style=style, layout=layout)) + label = os.getenv("OS_USERNAME") + children.append( + HTML(f"User Name: {label}", style=style, layout=layout) + ) else: - label = self.user_id #[:8] # or just show a truncated ID - children.append(HTML(f"User ID: {label}", style=style, layout=layout)) + label = self.user_id # [:8] # or just show a truncated ID + children.append( + HTML(f"User ID: {label}", style=style, layout=layout) + ) if self.created_at: - children.append(HTML(f"Created At: {self.created_at.strftime('%Y-%m-%d %H:%M')}", style=style, layout=layout)) - + children.append( + HTML( + f"Created At: {self.created_at.strftime('%Y-%m-%d %H:%M')}", + style=style, + layout=layout, + ) + ) + box = Box(children=children) - box.layout = Layout(flex_flow='row wrap') + box.layout = Layout(flex_flow="row wrap") display(box) def add_device_reservation( From dc587787cda7d6767e9923dc19d893a3658b2d1c Mon Sep 17 00:00:00 2001 From: Nicole Brewer Date: Wed, 2 Jul 2025 15:21:59 -0700 Subject: [PATCH 5/6] Added getters and setters for global _lease_id --- chi/context.py | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/chi/context.py b/chi/context.py index 2833e68..fd1a741 100644 --- a/chi/context.py +++ b/chi/context.py @@ -1,5 +1,6 @@ import logging import os +import re import sys import time from typing import List, Optional @@ -72,7 +73,7 @@ def default_key_name(): _auth_plugin = None _session = None _sites = {} -_lease = None +_lease_id = None version = "1.1" @@ -441,6 +442,36 @@ def on_change(change): else: print("Choose site feature is only available in an ipynb environment.") +def use_lease_id(lease_id: str) -> None: + """ + Sets the current lease ID to use in the global context. + + This configures the lease so it can be stored for ease + of restoring suspended sessions. Further lease validation, + visualizations, and selectors are available in the lease module. + + Args: + lease_id (str): The ID of the lease to use. + """ + global _lease_id + + if not re.fullmatch(r"[A-Za-z0-9\-]+", lease_id): + raise CHIValueError(f'Lease ID "{lease_id}" is invalid. It must contain only letters, numbers, and hyphens with no spaces or special characters.') + + _lease_id = lease_id + + print(f"Now using lease with ID {lease_id}.") + +def get_lease_id(): + """ + Returns the currently active lease ID, if one has been set. + + Returns: + str or None: The lease ID currently in use, or None if no lease has been selected. + """ + if _lease_id is None: + print("No lease ID has been set. Use `use_lease_id()` to select one.") + return _lease_id def get_project_name(project_id: Optional[str] = None) -> str: """ From bf906212fbda114115c01406c54994a73b2e8a2b Mon Sep 17 00:00:00 2001 From: Nicole Brewer Date: Thu, 3 Jul 2025 17:53:57 -0700 Subject: [PATCH 6/6] add show_leases function --- chi/lease.py | 121 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 118 insertions(+), 3 deletions(-) diff --git a/chi/lease.py b/chi/lease.py index 58ed93f..50a7fa5 100644 --- a/chi/lease.py +++ b/chi/lease.py @@ -7,15 +7,17 @@ from datetime import datetime, timedelta from typing import TYPE_CHECKING, List, Optional, Union +import pandas from blazarclient.exception import BlazarClientException +from ipydatagrid import DataGrid, Expr, TextRenderer from IPython.display import display from ipywidgets import HTML, Box, Layout from packaging.version import Version from chi import context, server, util -from .clients import blazar -from .context import _is_ipynb +from .clients import blazar, connection +from .context import _is_ipynb, get_project_name from .exception import CHIValueError, ResourceError, ServiceError from .hardware import Device, Node from .network import PUBLIC_NETWORK, get_network_id, list_floating_ips @@ -365,7 +367,7 @@ def _ipython_display_(self): ) ) if self.user_id: - user_id = os.getenv("OS_USER_ID") # or "OS_USERNAME" if set + user_id = connection().get_user_id() if self.user_id == user_id: label = os.getenv("OS_USERNAME") children.append( @@ -1208,6 +1210,119 @@ def list_leases() -> List[Lease]: return leases +def _status_color(cell): + return "#a2d9fe" if cell.value == "2-ACTIVE" else ( + "#ffe599" if cell.value == "1-PENDING" else ( + "#f69084" if cell.value == "3-TERMINATED" else "#e0e0e0")) + +def show_leases() -> DataGrid: + """ + Displays a table of the user's leases in an interactive, sortable format. + + Uses an ipydatagrid to present key lease attributes such as ID, name, status, + duration, and reservation counts. The grid supports sorting, filtering, and + scrolling for easy exploration of lease state. + + Returns: + DataGrid: An ipydatagrid widget displaying the leases. + """ + + def estimate_column_width(df, column, char_px=7, padding=0): + if column not in df.columns: + raise ValueError(f"Column '{column}' not found in DataFrame.") + max_chars = df[column].astype(str).map(len).max() + return max(max_chars * char_px + padding, 80) + + + leases = list_leases() + + rows = [] + for lease in leases: + + try: + project_name = get_project_name(lease.project_id) + except ResourceError: + project_name = lease.project_id[:8] if lease.project_id else "Unknown" + + if lease.user_id == connection().current_user_id: + user_label = os.getenv("OS_USERNAME") + else: + user_label = lease.user_id if lease.user_id else "Unknown" + + if lease.start_date and lease.end_date: + duration_hrs = round((lease.end_date - lease.start_date).total_seconds() / 3600, 1) + else: + duration_hrs = "N/A" + + # Inside your row-building loop: + if lease.end_date and lease.end_date > datetime.now(): + remaining_td = lease.end_date - datetime.now() + remaining_str = f"{remaining_td.days:02d}d {(remaining_td.seconds // 3600):02d}h" + elif lease.end_date and lease.end_date <= datetime.now(): + remaining_str = "Expired" + else: + remaining_str = "N/A" + + + # prepending status with numeric makes it possible to character sort + # since ipydatagrid does not allow custom sort functions + status_order = { + "PENDING": "1-PENDING", + "ACTIVE": "2-ACTIVE", + "TERMINATED": "3-TERMINATED" + } + + rows.append({ + "Name": lease.name, + "Status": status_order.get(lease.status, f"4-{lease.status}"), + "User": user_label, + "Project": project_name, + "Start": lease.start_date.strftime("%Y-%m-%d %H:%M") if lease.start_date else "", + "End": lease.end_date.strftime("%Y-%m-%d %H:%M") if lease.end_date else "", + "Remaining": remaining_str, + "Total Hours": duration_hrs, + "# Nodes": len(lease.node_reservations), + "# FIPs": len(lease.fip_reservations), + "Created": lease.created_at.strftime("%Y-%m-%d %H:%M") if lease.created_at else "", + "Lease ID": lease.id, + "_is_user_lease": 0 if lease.user_id == connection().current_user_id else 1 + }) + + df = pandas.DataFrame(rows) + df = pandas.DataFrame(rows) + df = df.sort_values(by=["_is_user_lease", "Status", "Created"]) + df = df.drop(columns=["_is_user_lease"]) + + renderers = { + "Status": TextRenderer( + background_color=Expr(_status_color), + text_color="black", + ), + + } + + display(DataGrid( + df, + layout={"height": "400px", "width": "100%"}, + column_widths={ + "key": 30, + "Name": int(estimate_column_width(df, "Name")), + "Status": 120, + "Remaining": 80, + "Total Hours": 50, + "# Nodes": 30, + "# FIPs": 30, + "Project": 100, + "User": 75, + "Start": 95, + "End": 95, + "Created": 95, + "Lease ID": 30, + + }, + renderers=renderers + )) + def _get_lease_from_blazar(ref: str): blazar_client = blazar()