From 53b1ed7c83e4b1b92b1cf0efed78685fb6cfcb5b Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Wed, 11 Feb 2026 13:53:24 -0500 Subject: [PATCH 1/3] fix: implemented a stats script and updated telemetry tests --- capy_discord/exts/core/telemetry.py | 112 +++++++++++++++-- pyproject.toml | 2 +- scripts/demo_stats.py | 140 ++++++++++++++++++++++ tests/capy_discord/exts/test_telemetry.py | 122 +++++++++++++++++++ 4 files changed, 368 insertions(+), 8 deletions(-) create mode 100644 scripts/demo_stats.py diff --git a/capy_discord/exts/core/telemetry.py b/capy_discord/exts/core/telemetry.py index 47b294a..57bc4a4 100644 --- a/capy_discord/exts/core/telemetry.py +++ b/capy_discord/exts/core/telemetry.py @@ -1,10 +1,11 @@ """Telemetry extension for tracking Discord bot interactions. -PHASE 2a: Queue Buffering and Error Categorization -Builds on Phase 1 event capture by adding: -- asyncio.Queue to decouple event listeners from I/O (fire-and-forget enqueue) -- Background consumer task that drains the queue and logs events -- Error categorization: "user_error" (UserFriendlyError) vs "internal_error" (real bugs) +PHASE 2b: In-Memory Analytics +Builds on Phase 2a queue buffering by adding: +- In-memory metrics dataclasses (TelemetryMetrics, CommandLatencyStats) +- Real-time counters for interactions, commands, users, guilds, errors +- Running latency stats (min/max/avg) per command with O(1) memory +- Public get_metrics() accessor for the /stats command Key Design Decisions: - We capture on_interaction (ALL interactions: commands, buttons, dropdowns, modals) @@ -14,6 +15,7 @@ - Telemetry failures are caught and logged, never crashing the bot - Each interaction gets a UUID correlation_id linking interaction and completion logs - Command failures are tracked via log_command_failure called from bot error handlers +- Metrics are in-memory only — reset on bot restart (validated before Phase 3 DB storage) Future Phases: - Phase 3: Add database storage (SQLite or PostgreSQL) @@ -24,7 +26,9 @@ import logging import time import uuid -from dataclasses import dataclass +from collections import defaultdict +from dataclasses import dataclass, field +from datetime import UTC, datetime from typing import Any import discord @@ -53,6 +57,54 @@ class TelemetryEvent: data: dict[str, Any] +@dataclass(slots=True) +class CommandLatencyStats: + """O(1) memory running latency stats for a single command.""" + + count: int = 0 + total_ms: float = 0.0 + min_ms: float = float("inf") + max_ms: float = 0.0 + + def record(self, duration_ms: float) -> None: + """Record a new latency observation.""" + self.count += 1 + self.total_ms += duration_ms + self.min_ms = min(self.min_ms, duration_ms) + self.max_ms = max(self.max_ms, duration_ms) + + @property + def avg_ms(self) -> float: + """Return the average latency, or 0.0 if no observations.""" + return self.total_ms / self.count if self.count else 0.0 + + +@dataclass +class TelemetryMetrics: + """All in-memory counters, one instance per bot lifetime.""" + + boot_time: datetime = field(default_factory=lambda: datetime.now(UTC)) + + # Volume + total_interactions: int = 0 + interactions_by_type: defaultdict[str, int] = field(default_factory=lambda: defaultdict(int)) + command_invocations: defaultdict[str, int] = field(default_factory=lambda: defaultdict(int)) + unique_user_ids: set[int] = field(default_factory=set) + guild_interactions: defaultdict[int, int] = field(default_factory=lambda: defaultdict(int)) + + # Health + completions_by_status: defaultdict[str, int] = field(default_factory=lambda: defaultdict(int)) + command_failures: defaultdict[str, defaultdict[str, int]] = field( + default_factory=lambda: defaultdict(lambda: defaultdict(int)) + ) + error_types: defaultdict[str, int] = field(default_factory=lambda: defaultdict(int)) + + # Performance + command_latency: defaultdict[str, CommandLatencyStats] = field( + default_factory=lambda: defaultdict(CommandLatencyStats) + ) + + class Telemetry(commands.Cog): """Telemetry Cog for capturing and logging Discord bot interactions. @@ -79,7 +131,8 @@ def __init__(self, bot: commands.Bot) -> None: # Maps interaction.id -> (correlation_id, start_time_monotonic) self._pending: dict[int, tuple[str, float]] = {} self._queue: asyncio.Queue[TelemetryEvent] = asyncio.Queue(maxsize=_QUEUE_MAX_SIZE) - self.log.info("Telemetry cog initialized - Phase 2a: Queue buffering and error categorization") + self._metrics = TelemetryMetrics() + self.log.info("Telemetry cog initialized - Phase 2b: In-memory analytics") # ======================================================================================== # LIFECYCLE @@ -140,8 +193,10 @@ def _dispatch_event(self, event: TelemetryEvent) -> None: try: if event.event_type == "interaction": self._log_interaction(event.data) + self._record_interaction_metrics(event.data) elif event.event_type == "completion": self._log_completion(**event.data) + self._record_completion_metrics(event.data) else: self.log.warning("Unknown telemetry event type: %s", event.event_type) except Exception: @@ -274,6 +329,49 @@ def log_command_failure( except Exception: self.log.exception("Failed to capture command failure event") + # ======================================================================================== + # ANALYTICS + # ======================================================================================== + + def get_metrics(self) -> TelemetryMetrics: + """Return the current in-memory metrics snapshot.""" + return self._metrics + + def _record_interaction_metrics(self, data: dict[str, Any]) -> None: + """Update in-memory counters from an interaction event.""" + m = self._metrics + m.total_interactions += 1 + m.interactions_by_type[data.get("interaction_type", "unknown")] += 1 + + command_name = data.get("command_name") + if command_name: + m.command_invocations[command_name] += 1 + + user_id = data.get("user_id") + if user_id is not None: + m.unique_user_ids.add(user_id) + + guild_id = data.get("guild_id") + if guild_id is not None: + m.guild_interactions[guild_id] += 1 + + def _record_completion_metrics(self, data: dict[str, Any]) -> None: + """Update in-memory counters from a completion event.""" + m = self._metrics + status = data.get("status", "unknown") + command_name = data.get("command_name", "unknown") + duration_ms = data.get("duration_ms", 0.0) + + m.completions_by_status[status] += 1 + m.command_latency[command_name].record(duration_ms) + + if status != "success": + m.command_failures[command_name][status] += 1 + + error_type = data.get("error_type") + if error_type: + m.error_types[error_type] += 1 + # ======================================================================================== # DATA EXTRACTION METHODS # ======================================================================================== diff --git a/pyproject.toml b/pyproject.toml index b1d7680..b44e839 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -102,7 +102,7 @@ ignore = [ ] [tool.ruff.lint.per-file-ignores] -"tests/*" = ["ANN", "D", "S101"] +"tests/*" = ["ANN", "D", "PLR2004", "S101"] "__init__.py" = ["F401"] [tool.ruff.lint.isort] diff --git a/scripts/demo_stats.py b/scripts/demo_stats.py new file mode 100644 index 0000000..dfc658c --- /dev/null +++ b/scripts/demo_stats.py @@ -0,0 +1,140 @@ +"""Demo script to exercise in-memory telemetry metrics and print stats. + +Run with: uv run python -c "import sys; sys.path.insert(0, '.'); exec(open('scripts/demo_stats.py').read())" +""" + +import sys +from datetime import UTC, datetime, timedelta + +sys.path.insert(0, ".") + +from capy_discord.exts.core.telemetry import TelemetryMetrics + + +def populate_metrics() -> TelemetryMetrics: + """Simulate a bot session with realistic telemetry data.""" + m = TelemetryMetrics() + m.boot_time = datetime.now(UTC) - timedelta(hours=2, minutes=15, seconds=42) + + interactions = [ + ("slash_command", "ping", 101, 9000), + ("slash_command", "ping", 102, 9000), + ("slash_command", "ping", 101, 9000), + ("slash_command", "help", 103, 9000), + ("slash_command", "help", 101, 9001), + ("slash_command", "feedback", 104, 9000), + ("slash_command", "stats", 101, 9000), + ("button", "confirm_btn", 102, 9000), + ("button", "cancel_btn", 103, 9000), + ("modal", "feedback_form", 104, 9000), + ("slash_command", "ping", 105, None), + ] + + for itype, cmd, user_id, guild_id in interactions: + m.total_interactions += 1 + m.interactions_by_type[itype] += 1 + if cmd: + m.command_invocations[cmd] += 1 + m.unique_user_ids.add(user_id) + if guild_id is not None: + m.guild_interactions[guild_id] += 1 + + completions = [ + ("ping", "success", 12.3, None), + ("ping", "success", 8.7, None), + ("ping", "success", 15.1, None), + ("ping", "success", 9.4, None), + ("help", "success", 22.0, None), + ("help", "user_error", 5.2, "UserFriendlyError"), + ("feedback", "success", 45.6, None), + ("stats", "success", 3.1, None), + ("ping", "internal_error", 2.0, "RuntimeError"), + ("feedback", "internal_error", 100.5, "ValueError"), + ] + + for cmd, status, duration, error_type in completions: + m.completions_by_status[status] += 1 + m.command_latency[cmd].record(duration) + if status != "success": + m.command_failures[cmd][status] += 1 + if error_type: + m.error_types[error_type] += 1 + + return m + + +def _print_header(m: TelemetryMetrics) -> None: + delta = datetime.now(UTC) - m.boot_time + total_seconds = int(delta.total_seconds()) + hours, remainder = divmod(total_seconds, 3600) + minutes, seconds = divmod(remainder, 60) + print("=" * 50) # noqa: T201 + print(" Bot Statistics") # noqa: T201 + print(f" Stats since last restart ({hours}h {minutes}m {seconds}s ago)") # noqa: T201 + print("=" * 50) # noqa: T201 + + +def _print_overview(m: TelemetryMetrics) -> None: + total_completions = sum(m.completions_by_status.values()) + successes = m.completions_by_status.get("success", 0) + rate = (successes / total_completions * 100) if total_completions else 0.0 + print("\n--- Overview ---") # noqa: T201 + print(f" Total Interactions: {m.total_interactions}") # noqa: T201 + print(f" Unique Users: {len(m.unique_user_ids)}") # noqa: T201 + print(f" Active Guilds: {len(m.guild_interactions)}") # noqa: T201 + print(f" Success Rate: {rate:.1f}%") # noqa: T201 + + +def _print_commands_and_types(m: TelemetryMetrics) -> None: + if m.command_invocations: + print("\n--- Top Commands ---") # noqa: T201 + top = sorted(m.command_invocations.items(), key=lambda x: x[1], reverse=True)[:5] + for cmd, count in top: + latency = m.command_latency.get(cmd) + avg = f" ({latency.avg_ms:.1f}ms avg)" if latency and latency.count else "" + print(f" /{cmd}: {count}{avg}") # noqa: T201 + + if m.interactions_by_type: + print("\n--- Interaction Types ---") # noqa: T201 + for itype, count in sorted(m.interactions_by_type.items()): + print(f" {itype}: {count}") # noqa: T201 + + if m.command_latency: + print("\n--- Latency Details ---") # noqa: T201 + for cmd in sorted(m.command_latency): + s = m.command_latency[cmd] + print(f" /{cmd}: min={s.min_ms:.1f}ms avg={s.avg_ms:.1f}ms max={s.max_ms:.1f}ms (n={s.count})") # noqa: T201 + + +def _print_errors(m: TelemetryMetrics) -> None: + total_errors = sum(c for s, c in m.completions_by_status.items() if s != "success") + if total_errors > 0: + print("\n--- Errors ---") # noqa: T201 + print(f" User Errors: {m.completions_by_status.get('user_error', 0)}") # noqa: T201 + print(f" Internal Errors: {m.completions_by_status.get('internal_error', 0)}") # noqa: T201 + if m.error_types: + print(" Top error types:") # noqa: T201 + for etype, ecount in sorted(m.error_types.items(), key=lambda x: x[1], reverse=True): + print(f" {etype}: {ecount}") # noqa: T201 + + if m.command_failures: + print("\n--- Failures by Command ---") # noqa: T201 + for cmd, statuses in sorted(m.command_failures.items()): + parts = [f"{s}={c}" for s, c in statuses.items()] + print(f" /{cmd}: {', '.join(parts)}") # noqa: T201 + + +def print_stats(m: TelemetryMetrics) -> None: + """Print stats in a readable format.""" + _print_header(m) + _print_overview(m) + _print_commands_and_types(m) + _print_errors(m) + print("\n" + "=" * 50) # noqa: T201 + print(" In-memory stats \u2014 resets on bot restart") # noqa: T201 + print("=" * 50) # noqa: T201 + + +if __name__ == "__main__": + metrics = populate_metrics() + print_stats(metrics) diff --git a/tests/capy_discord/exts/test_telemetry.py b/tests/capy_discord/exts/test_telemetry.py index b339f1e..5dba7bb 100644 --- a/tests/capy_discord/exts/test_telemetry.py +++ b/tests/capy_discord/exts/test_telemetry.py @@ -183,3 +183,125 @@ def test_dispatch_unknown_event_type(cog): cog.log.warning.assert_called_once() assert "Unknown telemetry event type" in cog.log.warning.call_args[0][0] + + +# ======================================================================================== +# Phase 2b: In-memory analytics tests +# ======================================================================================== + + +def test_record_interaction_metrics_increments_counters(cog): + data = { + "interaction_type": "slash_command", + "command_name": "ping", + "user_id": 42, + "guild_id": 100, + } + + cog._record_interaction_metrics(data) + + m = cog.get_metrics() + assert m.total_interactions == 1 + assert m.interactions_by_type["slash_command"] == 1 + assert m.command_invocations["ping"] == 1 + assert 42 in m.unique_user_ids + assert m.guild_interactions[100] == 1 + + +def test_record_interaction_metrics_multiple_events(cog): + events = [ + {"interaction_type": "slash_command", "command_name": "ping", "user_id": 1, "guild_id": 10}, + {"interaction_type": "slash_command", "command_name": "help", "user_id": 1, "guild_id": 10}, + {"interaction_type": "button", "command_name": "ping", "user_id": 2, "guild_id": 20}, + ] + for data in events: + cog._record_interaction_metrics(data) + + m = cog.get_metrics() + assert m.total_interactions == 3 + assert m.command_invocations["ping"] == 2 + assert m.command_invocations["help"] == 1 + assert len(m.unique_user_ids) == 2 + assert len(m.guild_interactions) == 2 + + +def test_record_interaction_metrics_dm_no_guild(cog): + data = { + "interaction_type": "slash_command", + "command_name": "ping", + "user_id": 42, + "guild_id": None, + } + + cog._record_interaction_metrics(data) + + m = cog.get_metrics() + assert m.total_interactions == 1 + assert None not in m.guild_interactions + + +def test_record_completion_metrics_success(cog): + data = { + "command_name": "ping", + "status": "success", + "duration_ms": 15.0, + } + + cog._record_completion_metrics(data) + + m = cog.get_metrics() + assert m.completions_by_status["success"] == 1 + assert m.command_latency["ping"].count == 1 + assert m.command_latency["ping"].avg_ms == 15.0 + assert "ping" not in m.command_failures + + +def test_record_completion_metrics_failure(cog): + data = { + "command_name": "broken", + "status": "user_error", + "duration_ms": 5.0, + "error_type": "UserFriendlyError", + } + + cog._record_completion_metrics(data) + + m = cog.get_metrics() + assert m.completions_by_status["user_error"] == 1 + assert m.command_failures["broken"]["user_error"] == 1 + assert m.error_types["UserFriendlyError"] == 1 + + +def test_record_completion_metrics_latency_stats(cog): + cog._record_completion_metrics({"command_name": "ping", "status": "success", "duration_ms": 10.0}) + cog._record_completion_metrics({"command_name": "ping", "status": "success", "duration_ms": 30.0}) + + stats = cog.get_metrics().command_latency["ping"] + assert stats.count == 2 + assert stats.avg_ms == 20.0 + assert stats.min_ms == 10.0 + assert stats.max_ms == 30.0 + + +def test_dispatch_event_feeds_metrics(cog): + interaction_event = TelemetryEvent( + "interaction", + { + "interaction_type": "slash_command", + "command_name": "ping", + "user_id": 42, + "guild_id": 100, + "correlation_id": "abc123", + "timestamp": MagicMock(strftime=MagicMock(return_value="2025-01-01 00:00:00 UTC")), + "username": "TestUser", + }, + ) + + cog._enqueue(interaction_event) + cog._process_pending_events() + + m = cog.get_metrics() + assert m.total_interactions == 1 + assert m.command_invocations["ping"] == 1 + # Verify logging also happened + cog.log.debug.assert_called() From 3fecf53457a8b39efdd866fee1041a51d5a6ea4a Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Wed, 11 Feb 2026 17:37:51 -0500 Subject: [PATCH 2/3] fix: get_metrics returns deep copy and added tests --- capy_discord/exts/core/telemetry.py | 14 ++++++++++---- tests/capy_discord/exts/test_telemetry.py | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/capy_discord/exts/core/telemetry.py b/capy_discord/exts/core/telemetry.py index 57bc4a4..d117c50 100644 --- a/capy_discord/exts/core/telemetry.py +++ b/capy_discord/exts/core/telemetry.py @@ -23,6 +23,7 @@ """ import asyncio +import copy import logging import time import uuid @@ -334,8 +335,12 @@ def log_command_failure( # ======================================================================================== def get_metrics(self) -> TelemetryMetrics: - """Return the current in-memory metrics snapshot.""" - return self._metrics + """Return a snapshot copy of the current in-memory metrics. + + Returns a deep copy so callers cannot accidentally mutate + the live internal state. + """ + return copy.deepcopy(self._metrics) def _record_interaction_metrics(self, data: dict[str, Any]) -> None: """Update in-memory counters from an interaction event.""" @@ -360,10 +365,11 @@ def _record_completion_metrics(self, data: dict[str, Any]) -> None: m = self._metrics status = data.get("status", "unknown") command_name = data.get("command_name", "unknown") - duration_ms = data.get("duration_ms", 0.0) + duration_ms = data.get("duration_ms") m.completions_by_status[status] += 1 - m.command_latency[command_name].record(duration_ms) + if duration_ms is not None: + m.command_latency[command_name].record(duration_ms) if status != "success": m.command_failures[command_name][status] += 1 diff --git a/tests/capy_discord/exts/test_telemetry.py b/tests/capy_discord/exts/test_telemetry.py index 5dba7bb..2b2876a 100644 --- a/tests/capy_discord/exts/test_telemetry.py +++ b/tests/capy_discord/exts/test_telemetry.py @@ -8,6 +8,7 @@ from capy_discord.errors import UserFriendlyError from capy_discord.exts.core.telemetry import ( + CommandLatencyStats, Telemetry, TelemetryEvent, _QUEUE_MAX_SIZE, @@ -283,6 +284,22 @@ def test_record_completion_metrics_latency_stats(cog): assert stats.max_ms == 30.0 +def test_command_latency_stats_zero_observations(): + stats = CommandLatencyStats() + assert stats.count == 0 + assert stats.min_ms == float("inf") + assert stats.max_ms == 0.0 + assert stats.avg_ms == 0.0 + + +def test_record_completion_metrics_missing_duration(cog): + cog._record_completion_metrics({"command_name": "ping", "status": "success"}) + + m = cog.get_metrics() + assert m.completions_by_status["success"] == 1 + assert "ping" not in m.command_latency + + def test_dispatch_event_feeds_metrics(cog): interaction_event = TelemetryEvent( "interaction", From e3fa10d72f31c95892811329cf0cf3c1e7258ee1 Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Wed, 18 Feb 2026 20:58:29 -0500 Subject: [PATCH 3/3] fix: fixed warnings in test cases --- CLAUDE.md | 105 ++++++++++++++++++++++ tests/capy_discord/exts/test_telemetry.py | 6 +- 2 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f4df568 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,105 @@ +# Claude Code Guide — capy-discord + +## Instructions for Claude +At the end of every conversation, update this file with any new knowledge gained: +- New patterns, conventions, or decisions made during the session +- Bugs found and how they were resolved +- New files, modules, or features added +- Any preferences or workflow notes from the user + +Keep additions concise and placed in the relevant section. If no relevant section exists, create one. + +## Project Overview +A Discord bot built with `discord.py`. Extensions live in `capy_discord/exts/` and follow a modular cog-based architecture. + +## Commands +- **Start**: `uv run task start` +- **Lint**: `uv run task lint` — run before every commit +- **Test**: `uv run task test` + +Always use `uv` to run commands. + +## Directory Structure +``` +capy_discord/ +├── exts/ +│ ├── guild.py # Simple Cog +│ ├── tools/ # Grouping directory +│ ├── profile/ # Complex feature directory +│ │ ├── profile.py # Main cog (matches directory name) +│ │ ├── _schemas.py # Helper — underscore prefix required +│ │ └── _views.py # Helper — underscore prefix required +│ └── __init__.py +├── ui/ +│ ├── forms.py # ModelModal +│ ├── views.py # BaseView +│ └── modal.py # Low-level base classes +└── bot.py +``` + +Helper files inside feature folders **must be prefixed with `_`** to prevent the extension loader from treating them as cogs. + +## UI Patterns + +### Forms — `ModelModal` +Use for data collection. Auto-generates forms from Pydantic models with built-in validation and retry. +```python +from capy_discord.ui.forms import ModelModal +modal = ModelModal(MyModel, callback=self.handler, title="Title") +await interaction.response.send_modal(modal) +``` + +### Interactive Views — `BaseView` +Always inherit from `BaseView` instead of `discord.ui.View`. +```python +from capy_discord.ui.views import BaseView +class MyView(BaseView): + @discord.ui.button(label="Click") + async def on_click(self, interaction, button): ... +``` + +### Simple Inputs — `CallbackModal` +For one-off inputs where a full Pydantic model is overkill. +```python +from capy_discord.ui.modal import CallbackModal +modal = CallbackModal(callback=my_handler, title="Quick Input") +``` + +## Command Patterns +- **Single resource (CRUD)**: Use one command with `app_commands.choices`. +- **Complex features**: Use `commands.GroupCog`. + +## Cog Standards +All Cogs **must** accept `bot` in `__init__`. Do not use `capy_discord.instance` (deprecated). +```python +class MyCog(commands.Cog): + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(MyCog(bot)) +``` + +## Error Handling +A global `on_tree_error` handler in `bot.py` covers most cases. Do not wrap every command in `try/except` — only catch specific business logic errors. + +## Logging +```python +import logging +self.log = logging.getLogger(__name__) +``` +Format: `[{asctime}] [{levelname:<8}] {name}: {message}` — always use `__name__`. + +## Time & Timezones +Always use `zoneinfo.ZoneInfo`. Store in UTC. +```python +from zoneinfo import ZoneInfo +from datetime import datetime +datetime.now(ZoneInfo("UTC")) +``` + +## Git Workflow +- **Branches**: `feature/CAPY-123-description`, `fix/CAPY-123-description`, `refactor/`, `docs/`, `test/` +- **Commits**: Conventional Commits — `feat(scope): subject`, `fix(scope): subject` + - Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore` +- **PRs**: Merge into `develop`. Reviewers: Shamik and Jason. All CI checks must pass. diff --git a/tests/capy_discord/exts/test_telemetry.py b/tests/capy_discord/exts/test_telemetry.py index 2b2876a..1dd9306 100644 --- a/tests/capy_discord/exts/test_telemetry.py +++ b/tests/capy_discord/exts/test_telemetry.py @@ -1,5 +1,4 @@ -import asyncio -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import discord import pytest @@ -20,8 +19,7 @@ def bot(): intents = discord.Intents.default() b = MagicMock(spec=commands.Bot) b.intents = intents - b.wait_until_ready = MagicMock(return_value=asyncio.Future()) - b.wait_until_ready.return_value.set_result(None) + b.wait_until_ready = AsyncMock(return_value=None) return b