fix: restore runtime workspace provisioning

This commit is contained in:
2026-06-09 02:43:37 +00:00
committed by didericis
parent f24e2857ab
commit dfd2d5f620
17 changed files with 201 additions and 108 deletions
+3 -17
View File
@@ -226,24 +226,10 @@ class AgentProvider(ABC):
def provision_git(self, bottle: "Bottle", plan: "BottlePlan") -> None: def provision_git(self, bottle: "Bottle", plan: "BottlePlan") -> None:
"""Configure git inside the agent container. """Configure git inside the agent container.
Default: Debian/node — copies .git when --cwd is set, writes the Default: Debian/node — writes the git-gate insteadOf gitconfig
git-gate insteadOf gitconfig, sets user.name/email as node. and sets user.name/email as node. Workspace copy runs through
Override for images that run as a different user or use a BottleBackend.provision_workspace against the running bottle."""
non-standard home directory."""
from .log import info from .log import info
# FIXME: re-enable workspace planning
# workspace = plan.workspace_plan
# if workspace.enabled and workspace.copy_git and workspace.has_host_git_dir:
# guest_workspace_git = f"{workspace.guest_path}/.git"
# host_git = str(workspace.host_path / ".git")
# info(f"copying {host_git} -> {bottle.name}:{guest_workspace_git}")
# bottle.exec(f"mkdir -p {shlex.quote(workspace.guest_path)}", user="root")
# bottle.cp_in(host_git, guest_workspace_git)
# bottle.exec(
# f"chown -R {shlex.quote(workspace.owner)} "
# f"{shlex.quote(guest_workspace_git)}",
# user="root",
# )
manifest_bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name) manifest_bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
if manifest_bottle.git: if manifest_bottle.git:
+33 -7
View File
@@ -32,6 +32,7 @@ manifest does not carry a backend field; the host picks.
from __future__ import annotations from __future__ import annotations
import os import os
import shlex
import sys import sys
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from contextlib import AbstractContextManager from contextlib import AbstractContextManager
@@ -47,7 +48,7 @@ from ..manifest import ManifestGitEntry, Manifest
from ..supervise import SupervisePlan from ..supervise import SupervisePlan
from ..util import expand_tilde from ..util import expand_tilde
from ..env import resolve_env, ResolvedEnv from ..env import resolve_env, ResolvedEnv
# from ..workspace import WorkspacePlan from ..workspace import WorkspacePlan, workspace_plan
from .print_util import print_multi, visible_agent_env_names from .print_util import print_multi, visible_agent_env_names
from .util import host_skill_dir from .util import host_skill_dir
@@ -101,7 +102,10 @@ class BottlePlan(ABC):
egress_plan: EgressPlan egress_plan: EgressPlan
supervise_plan: SupervisePlan | None supervise_plan: SupervisePlan | None
agent_provision: AgentProvisionPlan agent_provision: AgentProvisionPlan
# workspace_plan: WorkspacePlan
@property
def workspace_plan(self) -> WorkspacePlan:
return workspace_plan(self.spec, guest_home=self.guest_home)
def print(self, *, remote_control: bool) -> None: def print(self, *, remote_control: bool) -> None:
"""Render the y/N preflight summary to stderr.""" """Render the y/N preflight summary to stderr."""
@@ -293,6 +297,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
manifest_agent_provider = manifest_bottle.agent_provider manifest_agent_provider = manifest_bottle.agent_provider
agent_provider = get_provider(manifest_agent_provider.template) agent_provider = get_provider(manifest_agent_provider.template)
resolved_env = resolve_env(manifest, spec.agent_name) resolved_env = resolve_env(manifest, spec.agent_name)
workspace = workspace_plan(spec, guest_home=agent_provider.guest_home)
slug = mint_slug(spec) slug = mint_slug(spec)
write_launch_metadata(slug, spec, compose_project="", backend=self.name) write_launch_metadata(slug, spec, compose_project="", backend=self.name)
@@ -319,7 +324,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
forward_host_credentials=manifest_agent_provider.forward_host_credentials, forward_host_credentials=manifest_agent_provider.forward_host_credentials,
auth_token=manifest_agent_provider.auth_token, auth_token=manifest_agent_provider.auth_token,
host_env=dict(os.environ), host_env=dict(os.environ),
# trusted_project_path=workspace_plan.workdir, trusted_project_path=workspace.workdir,
label=spec.label, label=spec.label,
color=spec.color, color=spec.color,
) )
@@ -448,7 +453,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
prompt_path = provider.provision_prompt(plan, bottle) prompt_path = provider.provision_prompt(plan, bottle)
provider.provision(plan, bottle) provider.provision(plan, bottle)
provider.provision_skills(plan, bottle) provider.provision_skills(plan, bottle)
# self.provision_workspace(plan, bottle) self.provision_workspace(plan, bottle)
provider.provision_git(bottle, plan) provider.provision_git(bottle, plan)
provider.provision_supervise_mcp( provider.provision_supervise_mcp(
plan, bottle, self.supervise_mcp_url(plan), plan, bottle, self.supervise_mcp_url(plan),
@@ -456,9 +461,30 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
return prompt_path return prompt_path
def provision_workspace(self, plan: PlanT, bottle: "Bottle") -> None: def provision_workspace(self, plan: PlanT, bottle: "Bottle") -> None:
"""Copy the operator workspace into the running bottle when """Copy the operator workspace into the running bottle.
the backend cannot bake it into the agent image. Default is
no-op for backends like Docker that handle this before launch.""" This is the only supported workspace-provisioning path: Docker
does not build a derived image containing the current
workspace."""
workspace = plan.workspace_plan
if not (workspace.enabled and workspace.copy_contents):
return
guest_parent = workspace.guest_path.rsplit("/", 1)[0] or "/"
guest_path = shlex.quote(workspace.guest_path)
guest_parent = shlex.quote(guest_parent)
owner = shlex.quote(workspace.owner)
mode = shlex.quote(workspace.mode)
info(f"copying {workspace.host_path} -> {bottle.name}:{workspace.guest_path}")
bottle.exec(
f"rm -rf {guest_path} && mkdir -p {guest_parent}",
user="root",
)
bottle.cp_in(str(workspace.host_path), workspace.guest_path)
bottle.exec(
f"chown -R {owner} {guest_path} && chmod {mode} {guest_path}",
user="root",
)
def supervise_mcp_url(self, plan: PlanT) -> str: def supervise_mcp_url(self, plan: PlanT) -> str:
"""Return the agent-side URL of the per-bottle supervise """Return the agent-side URL of the per-bottle supervise
+4 -4
View File
@@ -4,8 +4,8 @@ PRD 0018 chunk 3: each instance is one `docker compose` project.
The flow is: The flow is:
1. Build the agent's base + derived image (compose builds the 1. Build the agent image from the provider Dockerfile (compose
sidecar images via the `build:` directive on first up). builds the sidecar images via the `build:` directive on first up).
2. Mint the per-bottle egress CA (chunk 2 writes it under 2. Mint the per-bottle egress CA (chunk 2 writes it under
state/<slug>/egress/). state/<slug>/egress/).
3. Populate the inner plans with launch-time fields so the 3. Populate the inner plans with launch-time fields so the
@@ -15,8 +15,8 @@ The flow is:
7. `docker compose up -d` (token + OAuth values flow into the 7. `docker compose up -d` (token + OAuth values flow into the
compose subprocess env so `environment: [NAME]` bare-name compose subprocess env so `environment: [NAME]` bare-name
entries inherit without rendering values into the file). entries inherit without rendering values into the file).
8. Provision (CA install, prompt copy, skills, git, supervise 8. Provision (CA install, prompt copy, skills, workspace, git,
config) — unchanged, uses `docker exec`. supervise config) — unchanged, uses `docker exec` / `docker cp`.
9. Yield a DockerBottle handle. `exec_agent` runs claude via 9. Yield a DockerBottle handle. `exec_agent` runs claude via
`docker exec -it` exactly like the pre-compose world. `docker exec -it` exactly like the pre-compose world.
@@ -56,5 +56,4 @@ def resolve_plan(
supervise_plan=supervise_plan, supervise_plan=supervise_plan,
use_runsc=use_runsc, use_runsc=use_runsc,
agent_provision=agent_provision_plan, agent_provision=agent_provision_plan,
# workspace_plan=workspace_plan,
) )
@@ -27,7 +27,6 @@ from . import smolvm as _smolvm
from .bottle import SmolmachinesBottle from .bottle import SmolmachinesBottle
from .bottle_cleanup_plan import SmolmachinesBottleCleanupPlan from .bottle_cleanup_plan import SmolmachinesBottleCleanupPlan
from .bottle_plan import SmolmachinesBottlePlan from .bottle_plan import SmolmachinesBottlePlan
# from .provision import workspace as _workspace
class SmolmachinesBottleBackend( class SmolmachinesBottleBackend(
@@ -82,11 +81,6 @@ class SmolmachinesBottleBackend(
with _launch.launch(plan, provision=self.provision) as bottle: with _launch.launch(plan, provision=self.provision) as bottle:
yield bottle yield bottle
# def provision_workspace(
# self, plan: SmolmachinesBottlePlan, bottle: Bottle
# ) -> None:
# _workspace.provision_workspace(plan, bottle)
def supervise_mcp_url(self, plan: SmolmachinesBottlePlan) -> str: def supervise_mcp_url(self, plan: SmolmachinesBottlePlan) -> str:
"""The smolmachines guest reaches the supervise sidecar via a """The smolmachines guest reaches the supervise sidecar via a
host-published random port the launch step pinned earlier host-published random port the launch step pinned earlier
@@ -6,9 +6,7 @@ the `AgentProvider` plugin under `bot_bottle/contrib/`. CA and git
provisioning also moved to the AgentProvider ABC (with Debian/node provisioning also moved to the AgentProvider ABC (with Debian/node
defaults); user plugins override them for non-standard images. defaults); user plugins override them for non-standard images.
The module left in this subpackage handles the remaining backend- No modules remain in this subpackage. Workspace copying now runs
specific step: through `BottleBackend.provision_workspace` against the running
bottle for every backend.
- workspace.py — copy the operator workspace into the guest
(currently commented out — workspace planning is disabled)
""" """
@@ -1,37 +0,0 @@
"""Copy the operator workspace into a smolmachines guest.
DISABLED — workspace planning is currently commented out at the
BottlePlan level. This module is kept as a placeholder for when
workspace support is re-enabled.
"""
# from __future__ import annotations
#
# import shlex
#
# from ....log import info
# from ... import Bottle
# from ..bottle_plan import SmolmachinesBottlePlan
#
#
# def provision_workspace(plan: SmolmachinesBottlePlan, bottle: Bottle) -> None:
# """Copy host cwd contents to the planned guest workspace."""
# workspace = plan.workspace_plan
# if not (workspace.enabled and workspace.copy_contents):
# return
#
# guest_parent = workspace.guest_path.rsplit("/", 1)[0] or "/"
# guest_path_q = shlex.quote(workspace.guest_path)
# guest_parent_q = shlex.quote(guest_parent)
# owner_q = shlex.quote(workspace.owner)
# mode_q = shlex.quote(workspace.mode)
# info(f"copying {workspace.host_path} -> {bottle.name}:{workspace.guest_path}")
# bottle.exec(
# f"rm -rf {guest_path_q} && mkdir -p {guest_parent_q}",
# user="root",
# )
# bottle.cp_in(str(workspace.host_path), workspace.guest_path)
# bottle.exec(
# f"chown -R {owner_q} {guest_path_q} && chmod {mode_q} {guest_path_q}",
# user="root",
# )
@@ -18,8 +18,6 @@ from ...agent_provider import AgentProvisionPlan
from ...egress import EgressPlan from ...egress import EgressPlan
from ...supervise import SupervisePlan from ...supervise import SupervisePlan
from ...git_gate import GitGatePlan from ...git_gate import GitGatePlan
# from ...workspace import workspace_plan as resolve_workspace_plan
from .bottle_plan import SmolmachinesBottlePlan from .bottle_plan import SmolmachinesBottlePlan
from .util import smolmachines_bundle_subnet, smolmachines_preflight from .util import smolmachines_bundle_subnet, smolmachines_preflight
@@ -79,5 +77,4 @@ def resolve_plan(
egress_plan=egress_plan, egress_plan=egress_plan,
supervise_plan=supervise_plan, supervise_plan=supervise_plan,
agent_provision=agent_provision_plan, agent_provision=agent_provision_plan,
# workspace_plan=workspace_plan,
) )
+1 -1
View File
@@ -39,7 +39,7 @@ from . import tui
def cmd_start(argv: list[str]) -> int: def cmd_start(argv: list[str]) -> int:
parser = argparse.ArgumentParser(prog=f"{PROG} start", add_help=True) parser = argparse.ArgumentParser(prog=f"{PROG} start", add_help=True)
parser.add_argument("--dry-run", action="store_true") parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--cwd", action="store_true", help="copy host cwd into a derived image") parser.add_argument("--cwd", action="store_true", help="copy host cwd into the running bottle")
parser.add_argument("--remote-control", action="store_true") parser.add_argument("--remote-control", action="store_true")
parser.add_argument( parser.add_argument(
"--backend", "--backend",
+156
View File
@@ -0,0 +1,156 @@
"""Unit: runtime workspace provisioning.
Workspace copy is intentionally handled through
`BottleBackend.provision_workspace` against a running bottle. The
Docker derived-image workspace path stays disabled.
"""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock, call, patch
from bot_bottle import bottle_state
from bot_bottle import supervise
from bot_bottle.backend import Bottle, BottleSpec, ExecResult
from bot_bottle.backend.docker import DockerBottleBackend
from bot_bottle.backend.smolmachines import SmolmachinesBottleBackend
from bot_bottle.manifest import Manifest
def _manifest() -> Manifest:
return Manifest.from_json_obj({
"bottles": {"dev": {}},
"agents": {
"demo": {
"bottle": "dev",
"skills": [],
"prompt": "",
},
},
})
def _spec(tmp: Path, *, copy_cwd: bool = True, identity: str = "demo-work") -> BottleSpec:
return BottleSpec(
manifest=_manifest(),
agent_name="demo",
copy_cwd=copy_cwd,
user_cwd=str(tmp),
identity=identity,
)
def _bottle() -> MagicMock:
bottle = MagicMock(spec=Bottle)
bottle.name = "bot-bottle-demo-work"
bottle.exec.return_value = ExecResult(returncode=0, stdout="", stderr="")
return bottle
class _FakeStateMixin:
def setUp(self) -> None:
self.tmp_dir = tempfile.TemporaryDirectory(prefix="backend-workspace.")
self.tmp = Path(self.tmp_dir.name)
self.root = self.tmp / ".bot-bottle"
self.original_root = supervise.bot_bottle_root
supervise.bot_bottle_root = lambda: self.root # type: ignore[assignment]
def tearDown(self) -> None:
supervise.bot_bottle_root = self.original_root # type: ignore[assignment]
self.tmp_dir.cleanup()
class TestRuntimeWorkspaceProvisioning(_FakeStateMixin, unittest.TestCase):
def test_default_backend_method_copies_workspace_to_running_bottle(self) -> None:
(self.tmp / "src.txt").write_text("hello\n")
(self.tmp / ".git").mkdir()
backend = DockerBottleBackend()
with (
patch("bot_bottle.backend.docker.resolve_plan.docker_mod.require_docker"),
patch(
"bot_bottle.backend.docker.resolve_plan.docker_mod.runsc_available",
return_value=False,
),
):
plan = backend.prepare(_spec(self.tmp), self.tmp / "stage")
bottle = _bottle()
backend.provision_workspace(plan, bottle)
self.assertEqual(
[
call(
"rm -rf /home/node/workspace && mkdir -p /home/node",
user="root",
),
call(
"chown -R node:node /home/node/workspace && "
"chmod 755 /home/node/workspace",
user="root",
),
],
bottle.exec.call_args_list,
)
bottle.cp_in.assert_called_once_with(str(self.tmp), "/home/node/workspace")
def test_default_backend_method_noops_without_copy_cwd(self) -> None:
backend = DockerBottleBackend()
with (
patch("bot_bottle.backend.docker.resolve_plan.docker_mod.require_docker"),
patch(
"bot_bottle.backend.docker.resolve_plan.docker_mod.runsc_available",
return_value=False,
),
):
plan = backend.prepare(_spec(self.tmp, copy_cwd=False), self.tmp / "stage")
bottle = _bottle()
backend.provision_workspace(plan, bottle)
bottle.exec.assert_not_called()
bottle.cp_in.assert_not_called()
def test_smolmachines_uses_same_running_bottle_method(self) -> None:
backend = SmolmachinesBottleBackend()
with patch(
"bot_bottle.backend.smolmachines.resolve_plan.smolmachines_preflight",
):
plan = backend.prepare(
_spec(self.tmp, identity="demo-smol-work"),
self.tmp / "stage",
)
bottle = _bottle()
backend.provision_workspace(plan, bottle)
bottle.cp_in.assert_called_once_with(str(self.tmp), "/home/node/workspace")
metadata = bottle_state.read_metadata("demo-smol-work")
self.assertIsNotNone(metadata)
self.assertEqual("smolmachines", metadata.backend)
class TestWorkspaceTrustPath(_FakeStateMixin, unittest.TestCase):
def test_prepare_trusts_workspace_path_when_copying_cwd(self) -> None:
backend = DockerBottleBackend()
with (
patch("bot_bottle.backend.docker.resolve_plan.docker_mod.require_docker"),
patch(
"bot_bottle.backend.docker.resolve_plan.docker_mod.runsc_available",
return_value=False,
),
):
plan = backend.prepare(_spec(self.tmp), self.tmp / "stage")
claude_config = self.root / "state" / "demo-work" / "agent" / "claude.json"
config = claude_config.read_text()
self.assertIn('"/home/node/workspace"', config)
self.assertEqual("/home/node/workspace", plan.workspace_plan.workdir)
if __name__ == "__main__":
unittest.main()
-1
View File
@@ -33,7 +33,6 @@ from bot_bottle.egress import (
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
from bot_bottle.manifest import Manifest from bot_bottle.manifest import Manifest
from bot_bottle.supervise import SupervisePlan from bot_bottle.supervise import SupervisePlan
# from bot_bottle.workspace import workspace_plan
SLUG = "demo-abc12" SLUG = "demo-abc12"
@@ -24,7 +24,6 @@ from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import Manifest from bot_bottle.manifest import Manifest
from bot_bottle.supervise import SupervisePlan from bot_bottle.supervise import SupervisePlan
# from bot_bottle.workspace import workspace_plan
_URL = "http://supervise:9100/" _URL = "http://supervise:9100/"
@@ -25,7 +25,6 @@ from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import Manifest from bot_bottle.manifest import Manifest
from bot_bottle.supervise import SupervisePlan from bot_bottle.supervise import SupervisePlan
# from bot_bottle.workspace import workspace_plan
_URL = "http://supervise:9100/" _URL = "http://supervise:9100/"
@@ -22,7 +22,6 @@ from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.egress import EgressPlan from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import Manifest from bot_bottle.manifest import Manifest
# from bot_bottle.workspace import workspace_plan
def _manifest() -> Manifest: def _manifest() -> Manifest:
@@ -22,7 +22,6 @@ from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.egress import EgressPlan from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import Manifest from bot_bottle.manifest import Manifest
# from bot_bottle.workspace import workspace_plan
class _Provider(AgentProvider): class _Provider(AgentProvider):
@@ -124,10 +123,6 @@ class TestProvisionGitUser(unittest.TestCase):
_PROVIDER.provision_git(bottle, _plan(stage_dir=self.stage)) _PROVIDER.provision_git(bottle, _plan(stage_dir=self.stage))
self.assertEqual([], _git_config_exec_calls(bottle)) self.assertEqual([], _git_config_exec_calls(bottle))
# def test_copies_cwd_git_to_workspace_plan_path(self):
# # DISABLED — workspace planning is currently commented out.
# pass
def test_sets_name_and_email(self): def test_sets_name_and_email(self):
plan = _plan( plan = _plan(
git_user={"name": "Eric Bauerfeld", "email": "eric@dideric.is"}, git_user={"name": "Eric Bauerfeld", "email": "eric@dideric.is"},
-1
View File
@@ -20,7 +20,6 @@ from bot_bottle.backend.smolmachines.bottle_plan import SmolmachinesBottlePlan
from bot_bottle.egress import EgressPlan, EgressRoute from bot_bottle.egress import EgressPlan, EgressRoute
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
from bot_bottle.manifest import Manifest from bot_bottle.manifest import Manifest
# from bot_bottle.workspace import workspace_plan
def _manifest() -> Manifest: def _manifest() -> Manifest:
+1 -17
View File
@@ -35,7 +35,6 @@ from bot_bottle.egress import EgressPlan, EgressRoute
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
from bot_bottle.manifest import ManifestGitEntry, Manifest from bot_bottle.manifest import ManifestGitEntry, Manifest
from bot_bottle.supervise import SupervisePlan from bot_bottle.supervise import SupervisePlan
# from bot_bottle.workspace import workspace_plan
class _Provider(AgentProvider): class _Provider(AgentProvider):
@@ -337,9 +336,7 @@ class TestSmolmachinesBottleExec(unittest.TestCase):
class TestProvisionGit(unittest.TestCase): class TestProvisionGit(unittest.TestCase):
"""provision_git dispatches two independent passes (cwd .git """provision_git writes gitconfig insteadOf rules when configured."""
copy + gitconfig insteadOf write); each no-ops on its own
when its condition doesn't hold."""
def setUp(self): def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="cb-prov-git.") # pylint: disable=consider-using-with self._tmp = tempfile.TemporaryDirectory(prefix="cb-prov-git.") # pylint: disable=consider-using-with
@@ -354,14 +351,6 @@ class TestProvisionGit(unittest.TestCase):
bottle.cp_in.assert_not_called() bottle.cp_in.assert_not_called()
bottle.exec.assert_not_called() bottle.exec.assert_not_called()
# def test_copies_cwd_git_when_copy_cwd_and_git_present(self):
# # DISABLED — workspace planning is currently commented out.
# pass
# def test_skips_cwd_when_copy_cwd_false(self):
# # DISABLED — workspace planning is currently commented out.
# pass
def test_writes_gitconfig_with_ip_port_form_for_smolmachines(self): def test_writes_gitconfig_with_ip_port_form_for_smolmachines(self):
# Smolmachines's TSI-allowlisted guest dials git-gate via # Smolmachines's TSI-allowlisted guest dials git-gate via
# smart HTTP at `127.0.0.1:<host port>` — the bundle's # smart HTTP at `127.0.0.1:<host port>` — the bundle's
@@ -481,10 +470,5 @@ class TestProvisionGitUser(unittest.TestCase):
self.assertIn("bot@example.com", calls[0][0]) self.assertIn("bot@example.com", calls[0][0])
# class TestProvisionWorkspace(unittest.TestCase):
# # DISABLED — workspace planning / provision_workspace are commented out.
# pass
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()