Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ jobs:
path: dist/*

publish:
if: github.ref_type == 'tag' && github.event.base_ref == 'refs/heads/main'
if: startsWith(github.ref, 'refs/tags/v')
needs: build
runs-on: ubuntu-latest
permissions:
Expand Down
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.3.0] - 2026-04-05

### Added
- **9 new REPL commands** (PRD-004: REPL to Production)
- `bench <action>` — benchmark action performance (req/sec, min/avg/max latency)
- `cache keys/clear` — cache management (list keys, clear by pattern)
- `cls` — clear terminal screen
- `destroy <service>` — destroy a local service at runtime
- `env` — display environment variables
- `listener add/remove/list` — subscribe to events and print live
- `load <path>` — load service from .py file at runtime
- `metrics [-f pattern]` — display collected metrics
- `quit` / `exit` — graceful broker stop and exit
- Total commands: 18 (was 10) — 95%+ parity with Node.js moleculer-repl (19 commands)
- Python-unique commands: `dcall` (direct call), `loglevel` (runtime log level)

## [0.14.1] - 2026-02-11

### Added
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "moleculerpy-repl"
version = "0.2.0"
version = "0.3.0"
description = "Interactive CLI shell for MoleculerPy microservices framework"
readme = "README.md"
license = "MIT"
Expand Down
27 changes: 27 additions & 0 deletions src/moleculerpy_repl/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,22 @@
# Import all command classes
from .actions import ActionsCommand
from .base import BaseCommand, CommandRegistry, CommandResult
from .bench import BenchCommand
from .cache import CacheCommand
from .call import CallCommand
from .cls import ClsCommand
from .dcall import DirectCallCommand
from .destroy import DestroyCommand
from .emit import BroadcastCommand, EmitCommand
from .env import EnvCommand
from .events import EventsCommand
from .info import InfoCommand
from .listener import ListenerCommand
from .load import LoadCommand
from .loglevel import LogLevelCommand
from .metrics import MetricsCommand
from .nodes import NodesCommand
from .quit import QuitCommand
from .services import ServicesCommand


Expand All @@ -24,14 +33,23 @@ def get_builtin_commands() -> list[type[BaseCommand]]:
"""
return [
ActionsCommand,
BenchCommand,
BroadcastCommand,
CacheCommand,
CallCommand,
ClsCommand,
DestroyCommand,
DirectCallCommand,
EmitCommand,
EnvCommand,
EventsCommand,
InfoCommand,
ListenerCommand,
LoadCommand,
LogLevelCommand,
MetricsCommand,
NodesCommand,
QuitCommand,
ServicesCommand,
]

Expand All @@ -56,13 +74,22 @@ def create_default_registry() -> CommandRegistry:
"create_default_registry",
# Individual commands
"ActionsCommand",
"BenchCommand",
"BroadcastCommand",
"CacheCommand",
"CallCommand",
"ClsCommand",
"DestroyCommand",
"DirectCallCommand",
"EmitCommand",
"EnvCommand",
"EventsCommand",
"InfoCommand",
"ListenerCommand",
"LoadCommand",
"LogLevelCommand",
"MetricsCommand",
"NodesCommand",
"QuitCommand",
"ServicesCommand",
]
139 changes: 139 additions & 0 deletions src/moleculerpy_repl/commands/bench.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
"""Bench command — Benchmark a service action.

Node.js equivalent: moleculer-repl/src/commands/bench.js
"""

from __future__ import annotations

import time
from typing import Any

from ..parser import ParsedArgs
from .base import BaseCommand, CommandResult

_DEFAULT_NUM = 1000
# Node.js: if neither --num nor --time given, defaults to 5 seconds
_DEFAULT_TIME = 5.0


class BenchCommand(BaseCommand):
"""Benchmark a service action with timing statistics."""

name = "bench"
description = "Benchmark a service action"
usage = "bench <action> [jsonParams] [--num N] [--time T] [--nodeID id]"

async def execute(self, broker: Any, args: ParsedArgs) -> CommandResult:
"""Execute the bench command."""
if not args.positional:
return CommandResult(
success=False,
error="Action name required. Usage: bench <action> [params] [--num N] [--time T]",
)

action_name = args.positional[0]

# Parse flags
has_num = "num" in args.flags
has_time = "time" in args.flags
num = int(args.flags.get("num", _DEFAULT_NUM))
duration = float(args.flags.get("time", _DEFAULT_TIME))

# Node.js: if --num given explicitly, use count-based mode
# If --time given, use time-based mode
# If neither, default to time-based (5s)
use_time = not has_num or has_time

# Build params
params = dict(args.payload) if args.payload else {}
if len(args.positional) > 1:
import json # noqa: PLC0415

try:
extra = json.loads(args.positional[1])
if isinstance(extra, dict):
params.update(extra)
except (json.JSONDecodeError, ValueError):
pass

try:
output = await _run_benchmark(
broker, action_name, params, num if not use_time else 0, duration if use_time else 0
)
return CommandResult(success=True, output=output)
except Exception as e:
return CommandResult(success=False, error=f"Benchmark failed: {e}")


async def _run_benchmark(
broker: Any,
action: str,
params: dict[str, Any],
num: int,
duration: float,
) -> str:
"""Run the benchmark loop and return formatted statistics."""
timings: list[float] = []
errors = 0

print(f"Benchmarking '{action}'...")

# Track total wall-clock time for real RPS calculation
wall_start = time.perf_counter()

if duration > 0:
# Time-based: run for `duration` seconds
end_time = wall_start + duration
while time.perf_counter() < end_time:
t0 = time.perf_counter()
try:
await broker.call(action, params)
except Exception:
errors += 1
# Record time for ALL calls including errors (audit fix H3/M3)
timings.append((time.perf_counter() - t0) * 1000)
else:
# Count-based: run `num` iterations
for _ in range(num):
t0 = time.perf_counter()
try:
await broker.call(action, params)
except Exception:
errors += 1
timings.append((time.perf_counter() - t0) * 1000)

wall_total = time.perf_counter() - wall_start
return _format_stats(action, timings, errors, wall_total)


def _format_stats(action: str, timings: list[float], errors: int, wall_seconds: float) -> str:
"""Format benchmark statistics."""
total = len(timings)

if not timings:
return f"Benchmark '{action}': 0 calls, {errors} errors"

total_time_ms = sum(timings)
avg_ms = total_time_ms / total
min_ms = min(timings)
max_ms = max(timings)

# Real throughput RPS (Node.js: resCount / duration * 1000)
rps = total / wall_seconds if wall_seconds > 0 else 0.0

lines = [
f"Benchmark: {action}",
"-" * 40,
f" Total calls: {total}",
f" Successful: {total - errors}",
f" Errors: {errors}",
f" Duration: {wall_seconds:.3f}s",
f" Req/sec: {rps:.1f}",
"",
"Latency:",
f" min: {min_ms:.3f}ms",
f" avg: {avg_ms:.3f}ms",
f" max: {max_ms:.3f}ms",
]

return "\n".join(lines)
80 changes: 80 additions & 0 deletions src/moleculerpy_repl/commands/cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""Cache command — Manage broker cache."""

from __future__ import annotations

from typing import Any

from ..parser import ParsedArgs
from .base import BaseCommand, CommandResult


class CacheCommand(BaseCommand):
"""Manage broker cache keys and entries."""

name = "cache"
description = "Manage cache (keys/clear)"
usage = "cache keys [-f pattern] | cache clear [pattern]"

async def execute(self, broker: Any, args: ParsedArgs) -> CommandResult:
"""Execute the cache command."""
cacher = getattr(broker, "cacher", None)
if not cacher:
return CommandResult(success=False, error="Cacher is not configured")

subcommand = args.positional[0] if args.positional else "keys"

if subcommand == "keys":
return await self._list_keys(cacher, args)
elif subcommand == "clear":
return await self._clear_keys(cacher, args)
else:
return CommandResult(
success=False,
error=f"Unknown subcommand '{subcommand}'. Use: keys, clear",
)

async def _list_keys(self, cacher: Any, args: ParsedArgs) -> CommandResult:
"""List cache keys, optionally filtered by pattern."""
pattern = args.flags.get("f") or (args.positional[1] if len(args.positional) > 1 else None)

try:
if hasattr(cacher, "keys"):
keys = await cacher.keys(pattern) if pattern else await cacher.keys()
else:
return CommandResult(
success=False,
error="Cacher does not support listing keys",
)

if not keys:
output = "No cache keys found"
if pattern:
output += f" matching '{pattern}'"
else:
lines = [f"Cache keys ({len(keys)}):"]
lines.extend(f" {key}" for key in sorted(keys))
output = "\n".join(lines)

return CommandResult(success=True, output=output)

except Exception as e:
return CommandResult(success=False, error=f"Failed to list keys: {e}")

async def _clear_keys(self, cacher: Any, args: ParsedArgs) -> CommandResult:
"""Clear cache entries, optionally matching pattern."""
pattern = args.positional[1] if len(args.positional) > 1 else None

try:
if hasattr(cacher, "clean"):
await cacher.clean(pattern) if pattern else await cacher.clean()
else:
return CommandResult(
success=False,
error="Cacher does not support clean operation",
)

msg = f"Cache cleared (pattern: '{pattern}')" if pattern else "Cache cleared"
return CommandResult(success=True, output=msg)

except Exception as e:
return CommandResult(success=False, error=f"Failed to clear cache: {e}")
24 changes: 24 additions & 0 deletions src/moleculerpy_repl/commands/cls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""Cls command — Clear terminal screen."""

from __future__ import annotations

import sys
from typing import Any

from ..parser import ParsedArgs
from .base import BaseCommand, CommandResult


class ClsCommand(BaseCommand):
"""Clear the terminal screen."""

name = "cls"
description = "Clear terminal screen"
usage = "cls"
aliases = ["clear"]

async def execute(self, broker: Any, args: ParsedArgs) -> CommandResult:
"""Execute the cls command."""
sys.stdout.write("\x1bc")
sys.stdout.flush()
return CommandResult(success=True)
Loading
Loading