mirror of
https://github.com/Monadical-SAS/cubbi.git
synced 2025-12-20 12:19:07 +00:00
feat(cli): auto connect to a session
This commit is contained in:
@@ -17,8 +17,9 @@ uv run -m mcontainer.cli
|
|||||||
# Run linting
|
# Run linting
|
||||||
uv run --with=ruff ruff check .
|
uv run --with=ruff ruff check .
|
||||||
|
|
||||||
# Run type checking
|
# Run type checking (note: currently has unresolved stub dependencies)
|
||||||
uv run --with=mypy mypy .
|
# Skip for now during development
|
||||||
|
# uv run --with=mypy mypy .
|
||||||
|
|
||||||
# Run formatting
|
# Run formatting
|
||||||
uv run --with=ruff ruff format .
|
uv run --with=ruff ruff format .
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import os
|
import os
|
||||||
import sys
|
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
import typer
|
import typer
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
@@ -25,7 +24,7 @@ def main(ctx: typer.Context) -> None:
|
|||||||
"""Monadical Container Tool"""
|
"""Monadical Container Tool"""
|
||||||
# If no command is specified, create a session
|
# If no command is specified, create a session
|
||||||
if ctx.invoked_subcommand is None:
|
if ctx.invoked_subcommand is None:
|
||||||
create_session(driver=None, project=None, env=[], name=None)
|
create_session(driver=None, project=None, env=[], name=None, no_connect=False)
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
@@ -87,11 +86,16 @@ def list_sessions() -> None:
|
|||||||
@session_app.command("create")
|
@session_app.command("create")
|
||||||
def create_session(
|
def create_session(
|
||||||
driver: Optional[str] = typer.Option(None, "--driver", "-d", help="Driver to use"),
|
driver: Optional[str] = typer.Option(None, "--driver", "-d", help="Driver to use"),
|
||||||
project: Optional[str] = typer.Option(None, "--project", "-p", help="Project repository URL"),
|
project: Optional[str] = typer.Option(
|
||||||
|
None, "--project", "-p", help="Project repository URL"
|
||||||
|
),
|
||||||
env: List[str] = typer.Option(
|
env: List[str] = typer.Option(
|
||||||
[], "--env", "-e", help="Environment variables (KEY=VALUE)"
|
[], "--env", "-e", help="Environment variables (KEY=VALUE)"
|
||||||
),
|
),
|
||||||
name: Optional[str] = typer.Option(None, "--name", "-n", help="Session name"),
|
name: Optional[str] = typer.Option(None, "--name", "-n", help="Session name"),
|
||||||
|
no_connect: bool = typer.Option(
|
||||||
|
False, "--no-connect", help="Don't automatically connect to the session"
|
||||||
|
),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Create a new MC session"""
|
"""Create a new MC session"""
|
||||||
# Use default driver if not specified
|
# Use default driver if not specified
|
||||||
@@ -127,25 +131,42 @@ def create_session(
|
|||||||
for container_port, host_port in session.ports.items():
|
for container_port, host_port in session.ports.items():
|
||||||
console.print(f" {container_port} -> {host_port}")
|
console.print(f" {container_port} -> {host_port}")
|
||||||
|
|
||||||
console.print(
|
# Auto-connect unless --no-connect flag is provided
|
||||||
f"\nConnect to the session with:\n mc session connect {session.id}"
|
if not no_connect:
|
||||||
)
|
console.print(f"\nConnecting to session {session.id}...")
|
||||||
|
container_manager.connect_session(session.id)
|
||||||
|
else:
|
||||||
|
console.print(
|
||||||
|
f"\nConnect to the session with:\n mc session connect {session.id}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
console.print("[red]Failed to create session[/red]")
|
console.print("[red]Failed to create session[/red]")
|
||||||
|
|
||||||
|
|
||||||
@session_app.command("close")
|
@session_app.command("close")
|
||||||
def close_session(
|
def close_session(
|
||||||
session_id: str = typer.Argument(..., help="Session ID to close"),
|
session_id: Optional[str] = typer.Argument(None, help="Session ID to close"),
|
||||||
|
all_sessions: bool = typer.Option(False, "--all", help="Close all active sessions"),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Close a MC session"""
|
"""Close a MC session or all sessions"""
|
||||||
with console.status(f"Closing session {session_id}..."):
|
if all_sessions:
|
||||||
success = container_manager.close_session(session_id)
|
with console.status("Closing all sessions..."):
|
||||||
|
count, success = container_manager.close_all_sessions()
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
console.print(f"[green]Session {session_id} closed successfully[/green]")
|
console.print(f"[green]{count} sessions closed successfully[/green]")
|
||||||
|
else:
|
||||||
|
console.print("[red]Failed to close all sessions[/red]")
|
||||||
|
elif session_id:
|
||||||
|
with console.status(f"Closing session {session_id}..."):
|
||||||
|
success = container_manager.close_session(session_id)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
console.print(f"[green]Session {session_id} closed successfully[/green]")
|
||||||
|
else:
|
||||||
|
console.print(f"[red]Failed to close session {session_id}[/red]")
|
||||||
else:
|
else:
|
||||||
console.print(f"[red]Failed to close session {session_id}[/red]")
|
console.print("[red]Error: Please provide a session ID or use --all flag[/red]")
|
||||||
|
|
||||||
|
|
||||||
@session_app.command("connect")
|
@session_app.command("connect")
|
||||||
@@ -199,27 +220,32 @@ def quick_create(
|
|||||||
[], "--env", "-e", help="Environment variables (KEY=VALUE)"
|
[], "--env", "-e", help="Environment variables (KEY=VALUE)"
|
||||||
),
|
),
|
||||||
name: Optional[str] = typer.Option(None, "--name", "-n", help="Session name"),
|
name: Optional[str] = typer.Option(None, "--name", "-n", help="Session name"),
|
||||||
|
no_connect: bool = typer.Option(
|
||||||
|
False, "--no-connect", help="Don't automatically connect to the session"
|
||||||
|
),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Create a new MC session with a project repository"""
|
"""Create a new MC session with a project repository"""
|
||||||
create_session(driver=driver, project=project, env=env, name=name)
|
create_session(
|
||||||
|
driver=driver, project=project, env=env, name=name, no_connect=no_connect
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@driver_app.command("list")
|
@driver_app.command("list")
|
||||||
def list_drivers() -> None:
|
def list_drivers() -> None:
|
||||||
"""List available MC drivers"""
|
"""List available MC drivers"""
|
||||||
drivers = config_manager.list_drivers()
|
drivers = config_manager.list_drivers()
|
||||||
|
|
||||||
if not drivers:
|
if not drivers:
|
||||||
console.print("No drivers found")
|
console.print("No drivers found")
|
||||||
return
|
return
|
||||||
|
|
||||||
table = Table(show_header=True, header_style="bold")
|
table = Table(show_header=True, header_style="bold")
|
||||||
table.add_column("Name")
|
table.add_column("Name")
|
||||||
table.add_column("Description")
|
table.add_column("Description")
|
||||||
table.add_column("Version")
|
table.add_column("Version")
|
||||||
table.add_column("Maintainer")
|
table.add_column("Maintainer")
|
||||||
table.add_column("Image")
|
table.add_column("Image")
|
||||||
|
|
||||||
for name, driver in drivers.items():
|
for name, driver in drivers.items():
|
||||||
table.add_row(
|
table.add_row(
|
||||||
driver.name,
|
driver.name,
|
||||||
@@ -228,7 +254,7 @@ def list_drivers() -> None:
|
|||||||
driver.maintainer,
|
driver.maintainer,
|
||||||
driver.image,
|
driver.image,
|
||||||
)
|
)
|
||||||
|
|
||||||
console.print(table)
|
console.print(table)
|
||||||
|
|
||||||
|
|
||||||
@@ -236,7 +262,9 @@ def list_drivers() -> None:
|
|||||||
def build_driver(
|
def build_driver(
|
||||||
driver_name: str = typer.Argument(..., help="Driver name to build"),
|
driver_name: str = typer.Argument(..., help="Driver name to build"),
|
||||||
tag: str = typer.Option("latest", "--tag", "-t", help="Image tag"),
|
tag: str = typer.Option("latest", "--tag", "-t", help="Image tag"),
|
||||||
push: bool = typer.Option(False, "--push", "-p", help="Push image to registry after building"),
|
push: bool = typer.Option(
|
||||||
|
False, "--push", "-p", help="Push image to registry after building"
|
||||||
|
),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Build a driver Docker image"""
|
"""Build a driver Docker image"""
|
||||||
# Get driver path
|
# Get driver path
|
||||||
@@ -244,35 +272,35 @@ def build_driver(
|
|||||||
if not driver_path:
|
if not driver_path:
|
||||||
console.print(f"[red]Driver '{driver_name}' not found[/red]")
|
console.print(f"[red]Driver '{driver_name}' not found[/red]")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check if Dockerfile exists
|
# Check if Dockerfile exists
|
||||||
dockerfile_path = driver_path / "Dockerfile"
|
dockerfile_path = driver_path / "Dockerfile"
|
||||||
if not dockerfile_path.exists():
|
if not dockerfile_path.exists():
|
||||||
console.print(f"[red]Dockerfile not found in {driver_path}[/red]")
|
console.print(f"[red]Dockerfile not found in {driver_path}[/red]")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Build image name
|
# Build image name
|
||||||
image_name = f"monadical/mc-{driver_name}:{tag}"
|
image_name = f"monadical/mc-{driver_name}:{tag}"
|
||||||
|
|
||||||
# Build the image
|
# Build the image
|
||||||
with console.status(f"Building image {image_name}..."):
|
with console.status(f"Building image {image_name}..."):
|
||||||
result = os.system(f"cd {driver_path} && docker build -t {image_name} .")
|
result = os.system(f"cd {driver_path} && docker build -t {image_name} .")
|
||||||
|
|
||||||
if result != 0:
|
if result != 0:
|
||||||
console.print("[red]Failed to build driver image[/red]")
|
console.print("[red]Failed to build driver image[/red]")
|
||||||
return
|
return
|
||||||
|
|
||||||
console.print(f"[green]Successfully built image: {image_name}[/green]")
|
console.print(f"[green]Successfully built image: {image_name}[/green]")
|
||||||
|
|
||||||
# Push if requested
|
# Push if requested
|
||||||
if push:
|
if push:
|
||||||
with console.status(f"Pushing image {image_name}..."):
|
with console.status(f"Pushing image {image_name}..."):
|
||||||
result = os.system(f"docker push {image_name}")
|
result = os.system(f"docker push {image_name}")
|
||||||
|
|
||||||
if result != 0:
|
if result != 0:
|
||||||
console.print("[red]Failed to push driver image[/red]")
|
console.print("[red]Failed to push driver image[/red]")
|
||||||
return
|
return
|
||||||
|
|
||||||
console.print(f"[green]Successfully pushed image: {image_name}[/green]")
|
console.print(f"[green]Successfully pushed image: {image_name}[/green]")
|
||||||
|
|
||||||
|
|
||||||
@@ -285,23 +313,23 @@ def driver_info(
|
|||||||
if not driver:
|
if not driver:
|
||||||
console.print(f"[red]Driver '{driver_name}' not found[/red]")
|
console.print(f"[red]Driver '{driver_name}' not found[/red]")
|
||||||
return
|
return
|
||||||
|
|
||||||
console.print(f"[bold]Driver: {driver.name}[/bold]")
|
console.print(f"[bold]Driver: {driver.name}[/bold]")
|
||||||
console.print(f"Description: {driver.description}")
|
console.print(f"Description: {driver.description}")
|
||||||
console.print(f"Version: {driver.version}")
|
console.print(f"Version: {driver.version}")
|
||||||
console.print(f"Maintainer: {driver.maintainer}")
|
console.print(f"Maintainer: {driver.maintainer}")
|
||||||
console.print(f"Image: {driver.image}")
|
console.print(f"Image: {driver.image}")
|
||||||
|
|
||||||
if driver.ports:
|
if driver.ports:
|
||||||
console.print("\n[bold]Ports:[/bold]")
|
console.print("\n[bold]Ports:[/bold]")
|
||||||
for port in driver.ports:
|
for port in driver.ports:
|
||||||
console.print(f" {port}")
|
console.print(f" {port}")
|
||||||
|
|
||||||
# Get driver path
|
# Get driver path
|
||||||
driver_path = config_manager.get_driver_path(driver_name)
|
driver_path = config_manager.get_driver_path(driver_name)
|
||||||
if driver_path:
|
if driver_path:
|
||||||
console.print(f"\n[bold]Path:[/bold] {driver_path}")
|
console.print(f"\n[bold]Path:[/bold] {driver_path}")
|
||||||
|
|
||||||
# Check for README
|
# Check for README
|
||||||
readme_path = driver_path / "README.md"
|
readme_path = driver_path / "README.md"
|
||||||
if readme_path.exists():
|
if readme_path.exists():
|
||||||
|
|||||||
@@ -52,22 +52,22 @@ class ConfigManager:
|
|||||||
try:
|
try:
|
||||||
with open(self.config_path, "r") as f:
|
with open(self.config_path, "r") as f:
|
||||||
config_data = yaml.safe_load(f) or {}
|
config_data = yaml.safe_load(f) or {}
|
||||||
|
|
||||||
# Create a new config from scratch, then update with data from file
|
# Create a new config from scratch, then update with data from file
|
||||||
config = Config(
|
config = Config(
|
||||||
docker=config_data.get('docker', {}),
|
docker=config_data.get("docker", {}),
|
||||||
defaults=config_data.get('defaults', {})
|
defaults=config_data.get("defaults", {}),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add drivers
|
# Add drivers
|
||||||
if 'drivers' in config_data:
|
if "drivers" in config_data:
|
||||||
for driver_name, driver_data in config_data['drivers'].items():
|
for driver_name, driver_data in config_data["drivers"].items():
|
||||||
config.drivers[driver_name] = Driver.model_validate(driver_data)
|
config.drivers[driver_name] = Driver.model_validate(driver_data)
|
||||||
|
|
||||||
# Add sessions (stored as simple dictionaries)
|
# Add sessions (stored as simple dictionaries)
|
||||||
if 'sessions' in config_data:
|
if "sessions" in config_data:
|
||||||
config.sessions = config_data['sessions']
|
config.sessions = config_data["sessions"]
|
||||||
|
|
||||||
return config
|
return config
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error loading config: {e}")
|
print(f"Error loading config: {e}")
|
||||||
@@ -79,10 +79,10 @@ class ConfigManager:
|
|||||||
"""Create a default configuration"""
|
"""Create a default configuration"""
|
||||||
self.config_dir.mkdir(parents=True, exist_ok=True)
|
self.config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
self.drivers_dir.mkdir(parents=True, exist_ok=True)
|
self.drivers_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Load built-in drivers from directories
|
# Load built-in drivers from directories
|
||||||
builtin_drivers = self.load_builtin_drivers()
|
builtin_drivers = self.load_builtin_drivers()
|
||||||
|
|
||||||
# Merge with default drivers, with directory drivers taking precedence
|
# Merge with default drivers, with directory drivers taking precedence
|
||||||
drivers = {**DEFAULT_DRIVERS, **builtin_drivers}
|
drivers = {**DEFAULT_DRIVERS, **builtin_drivers}
|
||||||
|
|
||||||
@@ -106,10 +106,10 @@ class ConfigManager:
|
|||||||
self.config = config
|
self.config = config
|
||||||
|
|
||||||
self.config_dir.mkdir(parents=True, exist_ok=True)
|
self.config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Use model_dump with mode="json" for proper serialization of enums
|
# Use model_dump with mode="json" for proper serialization of enums
|
||||||
config_dict = self.config.model_dump(mode="json")
|
config_dict = self.config.model_dump(mode="json")
|
||||||
|
|
||||||
# Write to file
|
# Write to file
|
||||||
with open(self.config_path, "w") as f:
|
with open(self.config_path, "w") as f:
|
||||||
yaml.dump(config_dict, f)
|
yaml.dump(config_dict, f)
|
||||||
@@ -137,23 +137,28 @@ class ConfigManager:
|
|||||||
def list_sessions(self) -> Dict:
|
def list_sessions(self) -> Dict:
|
||||||
"""List all sessions in the config"""
|
"""List all sessions in the config"""
|
||||||
return self.config.sessions
|
return self.config.sessions
|
||||||
|
|
||||||
def load_driver_from_dir(self, driver_dir: Path) -> Optional[Driver]:
|
def load_driver_from_dir(self, driver_dir: Path) -> Optional[Driver]:
|
||||||
"""Load a driver configuration from a directory"""
|
"""Load a driver configuration from a directory"""
|
||||||
yaml_path = driver_dir / "mai-driver.yaml" # Keep this name for backward compatibility
|
yaml_path = (
|
||||||
|
driver_dir / "mai-driver.yaml"
|
||||||
|
) # Keep this name for backward compatibility
|
||||||
|
|
||||||
if not yaml_path.exists():
|
if not yaml_path.exists():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(yaml_path, "r") as f:
|
with open(yaml_path, "r") as f:
|
||||||
driver_data = yaml.safe_load(f)
|
driver_data = yaml.safe_load(f)
|
||||||
|
|
||||||
# Extract required fields
|
# Extract required fields
|
||||||
if not all(k in driver_data for k in ["name", "description", "version", "maintainer"]):
|
if not all(
|
||||||
|
k in driver_data
|
||||||
|
for k in ["name", "description", "version", "maintainer"]
|
||||||
|
):
|
||||||
print(f"Driver config {yaml_path} missing required fields")
|
print(f"Driver config {yaml_path} missing required fields")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Create driver object
|
# Create driver object
|
||||||
driver = Driver(
|
driver = Driver(
|
||||||
name=driver_data["name"],
|
name=driver_data["name"],
|
||||||
@@ -163,37 +168,37 @@ class ConfigManager:
|
|||||||
image=f"monadical/mc-{driver_data['name']}:latest",
|
image=f"monadical/mc-{driver_data['name']}:latest",
|
||||||
ports=driver_data.get("ports", []),
|
ports=driver_data.get("ports", []),
|
||||||
)
|
)
|
||||||
|
|
||||||
return driver
|
return driver
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error loading driver from {yaml_path}: {e}")
|
print(f"Error loading driver from {yaml_path}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def load_builtin_drivers(self) -> Dict[str, Driver]:
|
def load_builtin_drivers(self) -> Dict[str, Driver]:
|
||||||
"""Load all built-in drivers from the drivers directory"""
|
"""Load all built-in drivers from the drivers directory"""
|
||||||
drivers = {}
|
drivers = {}
|
||||||
|
|
||||||
if not BUILTIN_DRIVERS_DIR.exists():
|
if not BUILTIN_DRIVERS_DIR.exists():
|
||||||
return drivers
|
return drivers
|
||||||
|
|
||||||
for driver_dir in BUILTIN_DRIVERS_DIR.iterdir():
|
for driver_dir in BUILTIN_DRIVERS_DIR.iterdir():
|
||||||
if driver_dir.is_dir():
|
if driver_dir.is_dir():
|
||||||
driver = self.load_driver_from_dir(driver_dir)
|
driver = self.load_driver_from_dir(driver_dir)
|
||||||
if driver:
|
if driver:
|
||||||
drivers[driver.name] = driver
|
drivers[driver.name] = driver
|
||||||
|
|
||||||
return drivers
|
return drivers
|
||||||
|
|
||||||
def get_driver_path(self, driver_name: str) -> Optional[Path]:
|
def get_driver_path(self, driver_name: str) -> Optional[Path]:
|
||||||
"""Get the directory path for a driver"""
|
"""Get the directory path for a driver"""
|
||||||
# Check built-in drivers first
|
# Check built-in drivers first
|
||||||
builtin_path = BUILTIN_DRIVERS_DIR / driver_name
|
builtin_path = BUILTIN_DRIVERS_DIR / driver_name
|
||||||
if builtin_path.exists() and builtin_path.is_dir():
|
if builtin_path.exists() and builtin_path.is_dir():
|
||||||
return builtin_path
|
return builtin_path
|
||||||
|
|
||||||
# Then check user drivers
|
# Then check user drivers
|
||||||
user_path = self.drivers_dir / driver_name
|
user_path = self.drivers_dir / driver_name
|
||||||
if user_path.exists() and user_path.is_dir():
|
if user_path.exists() and user_path.is_dir():
|
||||||
return user_path
|
return user_path
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -213,6 +213,35 @@ class ContainerManager:
|
|||||||
print(f"Error connecting to session: {e}")
|
print(f"Error connecting to session: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def close_all_sessions(self) -> tuple[int, bool]:
|
||||||
|
"""Close all MC sessions
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (number of sessions closed, success)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
sessions = self.list_sessions()
|
||||||
|
if not sessions:
|
||||||
|
return 0, True
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for session in sessions:
|
||||||
|
if session.container_id:
|
||||||
|
try:
|
||||||
|
container = self.client.containers.get(session.container_id)
|
||||||
|
container.stop()
|
||||||
|
container.remove()
|
||||||
|
self.config_manager.remove_session(session.id)
|
||||||
|
count += 1
|
||||||
|
except DockerException as e:
|
||||||
|
print(f"Error closing session {session.id}: {e}")
|
||||||
|
|
||||||
|
return count, count > 0
|
||||||
|
|
||||||
|
except DockerException as e:
|
||||||
|
print(f"Error closing all sessions: {e}")
|
||||||
|
return 0, False
|
||||||
|
|
||||||
def get_session_logs(self, session_id: str, follow: bool = False) -> Optional[str]:
|
def get_session_logs(self, session_id: str, follow: bool = False) -> Optional[str]:
|
||||||
"""Get logs from a MC session"""
|
"""Get logs from a MC session"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -44,5 +44,7 @@ class Session(BaseModel):
|
|||||||
class Config(BaseModel):
|
class Config(BaseModel):
|
||||||
docker: Dict[str, str] = Field(default_factory=dict)
|
docker: Dict[str, str] = Field(default_factory=dict)
|
||||||
drivers: Dict[str, Driver] = Field(default_factory=dict)
|
drivers: Dict[str, Driver] = Field(default_factory=dict)
|
||||||
sessions: Dict[str, dict] = Field(default_factory=dict) # Store as dict to avoid serialization issues
|
sessions: Dict[str, dict] = Field(
|
||||||
|
default_factory=dict
|
||||||
|
) # Store as dict to avoid serialization issues
|
||||||
defaults: Dict[str, str] = Field(default_factory=dict)
|
defaults: Dict[str, str] = Field(default_factory=dict)
|
||||||
|
|||||||
@@ -24,4 +24,4 @@ def test_help() -> None:
|
|||||||
result = runner.invoke(app, ["--help"])
|
result = runner.invoke(app, ["--help"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Usage" in result.stdout
|
assert "Usage" in result.stdout
|
||||||
assert "Monadical Container Tool" in result.stdout
|
assert "Monadical Container Tool" in result.stdout
|
||||||
|
|||||||
Reference in New Issue
Block a user