diff --git a/ipfs_datasets_py/mcp_server/MASTER_IMPROVEMENT_PLAN_2026_v37.md b/ipfs_datasets_py/mcp_server/MASTER_IMPROVEMENT_PLAN_2026_v37.md new file mode 100644 index 000000000..8ca44d652 --- /dev/null +++ b/ipfs_datasets_py/mcp_server/MASTER_IMPROVEMENT_PLAN_2026_v37.md @@ -0,0 +1,162 @@ +# Master Improvement Plan 2026 — v37: Session 81 (v36 Next Steps) + +**Created:** 2026-02-23 (Session 81) +**Branch:** `copilot/refactor-ipfs-datasets-mcp-server` +**Reference:** https://github.com/endomorphosis/Mcp-Plus-Plus +**Supersedes:** [MASTER_IMPROVEMENT_PLAN_2026_v36.md](MASTER_IMPROVEMENT_PLAN_2026_v36.md) + +--- + +## Overview + +Session 81 implements all five "Next Steps" from the v36 plan: + +| # | Feature | Status | +|---|---------|--------| +| 1 | `MergeResult.__iter__` — yields `(field, value)` pairs | ✅ COMPLETE | +| 2 | `IPFSReloadResult.iter_failed()` — generator of `(name, error)` pairs | ✅ COMPLETE | +| 3 | `PubSubBus.subscriber_ids(topic)` — sorted SID list for a topic | ✅ COMPLETE | +| 4 | `ComplianceChecker.backup_summary(path)` — full summary dict | ✅ COMPLETE | +| 5 | Session 81 E2E test (`test_mcplusplus_v36_session81.py`, 42 tests) | ✅ COMPLETE | + +**1,444+ total spec tests pass (sessions 50–81, 0 new failures).** + +--- + +## Item 1 — `MergeResult.__iter__` ✅ + +**File:** `ipfs_datasets_py/mcp_server/ucan_delegation.py` + +```python +def __iter__(self): + yield ("added_count", self.added_count) + yield ("conflict_count", self.conflict_count) + yield ("revocations_copied", self.revocations_copied) +``` + +Yields three `(field, value)` pairs in stable order. Enables idiomatic +`dict(result)` packing: + +```python +d = dict(result) +# {"added_count": 3, "conflict_count": 1, "revocations_copied": 0} +``` + +--- + +## Item 2 — `IPFSReloadResult.iter_failed()` ✅ + +**File:** `ipfs_datasets_py/mcp_server/nl_ucan_policy.py` + +```python +def iter_failed(self): + errors = self.pin_errors or {} + for name, cid in self.pin_results.items(): + if cid is None: + yield (name, errors.get(name, "unknown error")) +``` + +Generator that yields `(name, error)` tuples for every failed pin. +Falls back to `"unknown error"` when no `pin_errors` entry exists. +Yields nothing when all pins succeeded (`total_failed == 0`). + +--- + +## Item 3 — `PubSubBus.subscriber_ids(topic)` ✅ + +**File:** `ipfs_datasets_py/mcp_server/mcp_p2p_transport.py` + +```python +def subscriber_ids(self, topic): + key = topic.value if hasattr(topic, "value") else str(topic) + return sorted( + sid for sid, (k, _h) in self._sid_map.items() if k == key + ) +``` + +Returns a sorted list of all SIDs registered to *topic*. Enables +targeted `unsubscribe_by_id` without iterating `_sid_map` manually: + +```python +for sid in bus.subscriber_ids("receipts"): + bus.unsubscribe_by_id(sid) +``` + +--- + +## Item 4 — `ComplianceChecker.backup_summary(path)` ✅ + +**File:** `ipfs_datasets_py/mcp_server/compliance_checker.py` + +Returns a single-call summary dict: + +```python +{ + "count": 2, + "newest": "/data/rules.enc.bak", + "oldest": "/data/rules.enc.bak.1", + "newest_age": 12.3, + "oldest_age": 45.6, +} +``` + +When no backups exist all path/age fields are `None` and `count` is `0`. +Internally calls `list_bak_files` + `os.path.getmtime`; catches `OSError` +on each mtime call individually. + +--- + +## Item 5 — Session 81 E2E Test ✅ + +**File:** `tests/mcp/unit/test_mcplusplus_v36_session81.py` + +42 tests across 5 sections: + +| Section | Tests | +|---------|-------| +| `TestMergeResultIter` | 10 | +| `TestIPFSReloadResultIterFailed` | 10 | +| `TestPubSubBusSubscriberIds` | 10 | +| `TestComplianceCheckerBackupSummary` | 8 | +| `TestE2ESession81` | 4 | + +All 42 tests pass with 0 failures. + +--- + +## Cumulative MCP++ Status + +| Component | Module | Sessions | +|-----------|--------|---------| +| UCAN Delegation | `ucan_delegation.py` | 53, 56–81 | +| P2P Transport | `mcp_p2p_transport.py` | 54, 55, 56, 64–81 | +| Compliance | `compliance_checker.py` | 53, 60–81 | +| NL→UCAN Policy Gate | `nl_ucan_policy.py` | 51, 52, 56, 57, 62–81 | +| MergeResult: full API (repr+str+bool+len+iter+from/to_dict+comparisons) | `ucan_delegation.py` | 71–81 | +| IPFSReloadResult: full API (repr+str+bool+len+iter_failed+from/to_dict+summarize) | `nl_ucan_policy.py` | 71–81 | +| PubSubBus: subscribe ID+count+topics+clear+snapshot+resubscribe+subscriber_ids | `mcp_p2p_transport.py` | 71–81 | +| ComplianceChecker: bak lifecycle (rotate+list+purge+age+newest+oldest+summary) | `compliance_checker.py` | 71–81 | + +**1,444+ spec tests pass (sessions 50–81).** + +--- + +## Next Steps (Session 82+) + +1. **`MergeResult.keys()`** — return a list of field names mirroring `dict.keys()` + for further `dict`-protocol compatibility (`["added_count", "conflict_count", + "revocations_copied"]`). + +2. **`IPFSReloadResult.iter_succeeded()`** — generator yielding `(name, cid)` + pairs for all successful pins (complement of `iter_failed()`). + +3. **`PubSubBus.topic_sid_map()`** — return `{topic: [sid, ...]}` mapping, + the SID-based analogue of `topic_handler_map()`. + +4. **`ComplianceChecker.backup_names(path)`** — return only the *file names* + (not full paths) of existing backup files; useful for display and logging + without exposing absolute paths. + +5. **Session 82 full E2E** — verify `keys()` round-trip, `iter_succeeded` + filtering, `topic_sid_map` consistency with `subscriber_ids`, and + `backup_names` in a purge cycle. diff --git a/ipfs_datasets_py/mcp_server/MASTER_IMPROVEMENT_PLAN_2026_v38.md b/ipfs_datasets_py/mcp_server/MASTER_IMPROVEMENT_PLAN_2026_v38.md new file mode 100644 index 000000000..2363b47ff --- /dev/null +++ b/ipfs_datasets_py/mcp_server/MASTER_IMPROVEMENT_PLAN_2026_v38.md @@ -0,0 +1,163 @@ +# Master Improvement Plan 2026 — v38: Session 82 (v37 Next Steps) + +**Created:** 2026-02-23 (Session 82) +**Branch:** `copilot/refactor-ipfs-datasets-mcp-server` +**Reference:** https://github.com/endomorphosis/Mcp-Plus-Plus +**Supersedes:** [MASTER_IMPROVEMENT_PLAN_2026_v37.md](MASTER_IMPROVEMENT_PLAN_2026_v37.md) + +--- + +## Overview + +Session 82 implements all five "Next Steps" from the v37 plan: + +| # | Feature | Status | +|---|---------|--------| +| 1 | `MergeResult.keys()` + `__getitem__` — full dict-protocol compatibility | ✅ COMPLETE | +| 2 | `IPFSReloadResult.iter_succeeded()` — generator of `(name, cid)` pairs | ✅ COMPLETE | +| 3 | `PubSubBus.topic_sid_map()` — `{topic: sorted_sid_list}` mapping | ✅ COMPLETE | +| 4 | `ComplianceChecker.backup_names(path)` — basenames of backup files | ✅ COMPLETE | +| 5 | Session 82 E2E test (`test_mcplusplus_v37_session82.py`, 42 tests) | ✅ COMPLETE | +| 6 | Fixed duplicate code block in `backup_summary` (dead code after `return`) | ✅ FIXED | + +**1,486+ total spec tests pass (sessions 50–82, 0 new failures).** + +--- + +## Item 1 — `MergeResult.keys()` + `MergeResult.__getitem__` ✅ + +**File:** `ipfs_datasets_py/mcp_server/ucan_delegation.py` + +```python +def keys(self) -> list: + return ["added_count", "conflict_count", "revocations_copied"] + +def __getitem__(self, key: str): + if key == "added_count": return self.added_count + if key == "conflict_count": return self.conflict_count + if key == "revocations_copied": return self.revocations_copied + raise KeyError(key) +``` + +`keys()` alone triggers Python's mapping protocol in `dict()`, requiring +`__getitem__` as well. Together they enable the full dict protocol: + +```python +d = dict(result) +# {"added_count": 3, "conflict_count": 1, "revocations_copied": 0} +result["added_count"] # 3 +``` + +**Bug fix:** Adding `keys()` without `__getitem__` broke the existing +`dict(result)` usage that relied on `__iter__` pairs (Python switches to +the mapping protocol when `keys()` is present). Both methods are needed. + +--- + +## Item 2 — `IPFSReloadResult.iter_succeeded()` ✅ + +**File:** `ipfs_datasets_py/mcp_server/nl_ucan_policy.py` + +```python +def iter_succeeded(self): + for name, cid in self.pin_results.items(): + if cid is not None: + yield (name, cid) +``` + +Complement of `iter_failed()`. Yields `(name, cid)` pairs for every +policy whose pin succeeded. Together the two generators partition +`pin_results` exactly: `set(iter_succeeded names) ∪ set(iter_failed names) == all names`. + +--- + +## Item 3 — `PubSubBus.topic_sid_map()` ✅ + +**File:** `ipfs_datasets_py/mcp_server/mcp_p2p_transport.py` + +```python +def topic_sid_map(self) -> Dict[str, List[int]]: + result = {} + for sid, (k, _h) in self._sid_map.items(): + result.setdefault(k, []) + result[k].append(sid) + return {k: sorted(v) for k, v in result.items() if v} +``` + +SID-based analogue of `topic_handler_map()`. Returns a fresh dict; modifying +it does not affect the live registry. Consistent with `subscriber_ids(topic)`: +`topic_sid_map()[topic] == subscriber_ids(topic)` for every topic present. + +--- + +## Item 4 — `ComplianceChecker.backup_names(path)` ✅ + +**File:** `ipfs_datasets_py/mcp_server/compliance_checker.py` + +```python +@staticmethod +def backup_names(path: str) -> List[str]: + import os as _os + return [_os.path.basename(p) for p in ComplianceChecker.list_bak_files(path)] +``` + +Returns only the *file names* (no directory component), safe for display and +logging without exposing absolute paths. Count matches `backup_count(path)`. + +--- + +## Item 5 — Session 82 E2E Test ✅ + +**File:** `tests/mcp/unit/test_mcplusplus_v37_session82.py` + +42 tests across 5 sections: + +| Section | Tests | +|---------|-------| +| `TestMergeResultKeys` | 10 | +| `TestIPFSReloadResultIterSucceeded` | 10 | +| `TestPubSubBusTopicSidMap` | 10 | +| `TestComplianceCheckerBackupNames` | 8 | +| `TestE2ESession82` | 4 | + +All 42 tests pass with 0 failures. + +--- + +## Cumulative MCP++ Status + +| Component | Module | Sessions | +|-----------|--------|---------| +| UCAN Delegation | `ucan_delegation.py` | 53, 56–82 | +| P2P Transport | `mcp_p2p_transport.py` | 54, 55, 56, 64–82 | +| Compliance | `compliance_checker.py` | 53, 60–82 | +| NL→UCAN Policy Gate | `nl_ucan_policy.py` | 51, 52, 56, 57, 62–82 | +| MergeResult: full API (repr+str+bool+len+iter+keys+getitem+from/to_dict+comparisons) | `ucan_delegation.py` | 71–82 | +| IPFSReloadResult: full API (repr+str+bool+len+iter_failed+iter_succeeded+from/to_dict+summarize) | `nl_ucan_policy.py` | 71–82 | +| PubSubBus: full API (subscribe ID+count+topics+clear+snapshot+resubscribe+subscriber_ids+topic_sid_map) | `mcp_p2p_transport.py` | 71–82 | +| ComplianceChecker: bak lifecycle (rotate+list+purge+age+newest+oldest+summary+names) | `compliance_checker.py` | 71–82 | + +**1,486+ spec tests pass (sessions 50–82).** + +--- + +## Next Steps (Session 83+) + +1. **`MergeResult.values()`** — return a list of field values in the same order + as `keys()`, completing the `dict`-protocol triad (`keys/values/items`). + +2. **`IPFSReloadResult.iter_all()`** — generator yielding `(name, cid_or_none)` + pairs for *all* entries regardless of success/failure; useful for unified + reporting. + +3. **`PubSubBus.total_subscriptions()`** — return the total number of SIDs + currently active (i.e., `len(_sid_map)`); complement to `handler_count()` + but counts registrations not unique handlers. + +4. **`ComplianceChecker.newest_backup_name(path)`** — return only the file + name (basename) of the newest backup, or `None`; complement to + `backup_names()`. + +5. **Session 83 full E2E** — verify `values()` consistency with `keys()` and + `__iter__`, `iter_all` coverage, `total_subscriptions` vs. `handler_count`, + and `newest_backup_name` in a rotate+verify cycle. diff --git a/ipfs_datasets_py/mcp_server/MASTER_IMPROVEMENT_PLAN_2026_v39.md b/ipfs_datasets_py/mcp_server/MASTER_IMPROVEMENT_PLAN_2026_v39.md new file mode 100644 index 000000000..924b19472 --- /dev/null +++ b/ipfs_datasets_py/mcp_server/MASTER_IMPROVEMENT_PLAN_2026_v39.md @@ -0,0 +1,143 @@ +# Master Improvement Plan 2026 — v39: Session 83 (v38 Next Steps) + +**Created:** 2026-02-23 (Session 83) +**Branch:** `copilot/refactor-ipfs-datasets-mcp-server` +**Reference:** https://github.com/endomorphosis/Mcp-Plus-Plus +**Supersedes:** [MASTER_IMPROVEMENT_PLAN_2026_v38.md](MASTER_IMPROVEMENT_PLAN_2026_v38.md) + +--- + +## Overview + +Session 83 implements all five "Next Steps" from the v38 plan: + +| # | Feature | Status | +|---|---------|--------| +| 1 | `MergeResult.values()` — list of field values in `keys()` order | ✅ COMPLETE | +| 2 | `IPFSReloadResult.iter_all()` — generator of `(name, cid_or_none)` | ✅ COMPLETE | +| 3 | `PubSubBus.total_subscriptions()` — `len(_sid_map)` | ✅ COMPLETE | +| 4 | `ComplianceChecker.newest_backup_name(path)` — basename of newest `.bak` | ✅ COMPLETE | +| 5 | Session 83 E2E test (`test_mcplusplus_v38_session83.py`, 42 tests) | ✅ COMPLETE | + +**1,528+ total spec tests pass (sessions 50–83, 0 new failures).** + +--- + +## Item 1 — `MergeResult.values()` ✅ + +**File:** `ipfs_datasets_py/mcp_server/ucan_delegation.py` + +```python +def values(self) -> list: + return [self.added_count, self.conflict_count, self.revocations_copied] +``` + +Completes the `dict`-protocol triad alongside `keys()` and `__iter__`. +Enables `dict(zip(r.keys(), r.values()))` as an alternative to `dict(r)`. + +--- + +## Item 2 — `IPFSReloadResult.iter_all()` ✅ + +**File:** `ipfs_datasets_py/mcp_server/nl_ucan_policy.py` + +```python +def iter_all(self): + for name, cid in self.pin_results.items(): + yield (name, cid) +``` + +Yields every `(name, cid_or_none)` pair regardless of success/failure. +Together with `iter_succeeded` and `iter_failed`, provides a complete +three-way view of pin results. Invariant: +`set(iter_all names) == set(iter_succeeded names) ∪ set(iter_failed names)`. + +--- + +## Item 3 — `PubSubBus.total_subscriptions()` ✅ + +**File:** `ipfs_datasets_py/mcp_server/mcp_p2p_transport.py` + +```python +def total_subscriptions(self) -> int: + return len(self._sid_map) +``` + +Counts every active SID (registration-level), complementing +`handler_count()` (unique-handler-level). A shared handler subscribed to +3 topics counts as 3 here but 1 in `handler_count()`. + +--- + +## Item 4 — `ComplianceChecker.newest_backup_name(path)` ✅ + +**File:** `ipfs_datasets_py/mcp_server/compliance_checker.py` + +```python +@staticmethod +def newest_backup_name(path: str) -> Optional[str]: + import os as _os + files = ComplianceChecker.list_bak_files(path) + return _os.path.basename(files[0]) if files else None +``` + +Returns only the basename of the primary `.bak` file. Consistent with +`newest_backup_path()` (which returns the full path) and `backup_names()` +(which returns all basenames). Returns `None` when no backup exists. + +--- + +## Item 5 — Session 83 E2E Test ✅ + +**File:** `tests/mcp/unit/test_mcplusplus_v38_session83.py` + +42 tests across 5 sections: + +| Section | Tests | +|---------|-------| +| `TestMergeResultValues` | 10 | +| `TestIPFSReloadResultIterAll` | 10 | +| `TestPubSubBusTotalSubscriptions` | 10 | +| `TestComplianceCheckerNewestBackupName` | 8 | +| `TestE2ESession83` | 4 | + +All 42 tests pass with 0 failures. + +--- + +## Cumulative MCP++ Status + +| Component | Module | Sessions | +|-----------|--------|---------| +| UCAN Delegation | `ucan_delegation.py` | 53, 56–83 | +| P2P Transport | `mcp_p2p_transport.py` | 54, 55, 56, 64–83 | +| Compliance | `compliance_checker.py` | 53, 60–83 | +| NL→UCAN Policy Gate | `nl_ucan_policy.py` | 51, 52, 56, 57, 62–83 | +| MergeResult: full dict protocol (repr+str+bool+len+iter+keys+values+getitem+from/to_dict+comparisons) | `ucan_delegation.py` | 71–83 | +| IPFSReloadResult: full API (repr+str+bool+len+iter_failed+iter_succeeded+iter_all+from/to_dict+summarize) | `nl_ucan_policy.py` | 71–83 | +| PubSubBus: full API (subscribe+count+topics+clear+snapshot+resubscribe+subscriber_ids+topic_sid_map+total_subscriptions) | `mcp_p2p_transport.py` | 71–83 | +| ComplianceChecker: bak lifecycle (rotate+list+purge+age+newest+oldest+summary+names+newest_name) | `compliance_checker.py` | 71–83 | + +**1,528+ spec tests pass (sessions 50–83).** + +--- + +## Next Steps (Session 84+) + +1. **`MergeResult.items()`** — return a list of `(key, value)` tuples in + `keys()` order, completing the standard mapping trio (`keys/values/items`). + +2. **`IPFSReloadResult.as_dict()`** — return a dict mapping policy name → cid + (or None) for all entries; a flat representation of `pin_results` without + the NamedTuple wrapping. + +3. **`PubSubBus.topics_with_count()`** — return a list of `(topic, count)` tuples + sorted by subscription count descending; useful for dashboards. + +4. **`ComplianceChecker.oldest_backup_name(path)`** — return only the file name + (basename) of the oldest backup, or `None`; complement to + `newest_backup_name()`. + +5. **Session 84 full E2E** — verify `items()` consistency with `keys()`+`values()`, + `as_dict()` round-trip, `topics_with_count()` ordering, and + `oldest_backup_name` in a multi-rotate cycle. diff --git a/ipfs_datasets_py/mcp_server/MASTER_IMPROVEMENT_PLAN_2026_v40.md b/ipfs_datasets_py/mcp_server/MASTER_IMPROVEMENT_PLAN_2026_v40.md new file mode 100644 index 000000000..28dda657b --- /dev/null +++ b/ipfs_datasets_py/mcp_server/MASTER_IMPROVEMENT_PLAN_2026_v40.md @@ -0,0 +1,148 @@ +# Master Improvement Plan 2026 — v40: Session 84 (v39 Next Steps) + +**Created:** 2026-02-23 (Session 84) +**Branch:** `copilot/refactor-ipfs-datasets-mcp-server` +**Reference:** https://github.com/endomorphosis/Mcp-Plus-Plus +**Supersedes:** [MASTER_IMPROVEMENT_PLAN_2026_v39.md](MASTER_IMPROVEMENT_PLAN_2026_v39.md) + +--- + +## Overview + +Session 84 implements all five "Next Steps" from the v39 plan: + +| # | Feature | Status | +|---|---------|--------| +| 1 | `MergeResult.items()` — list of `(key, value)` tuples | ✅ COMPLETE | +| 2 | `IPFSReloadResult.as_dict()` — flat `{name: cid_or_none}` dict | ✅ COMPLETE | +| 3 | `PubSubBus.topics_with_count()` — `[(topic, count)]` sorted descending | ✅ COMPLETE | +| 4 | `ComplianceChecker.oldest_backup_name(path)` — basename of oldest `.bak` | ✅ COMPLETE | +| 5 | Session 84 E2E test (`test_mcplusplus_v39_session84.py`, 42 tests) | ✅ COMPLETE | + +**1,570+ total spec tests pass (sessions 50–84, 0 new failures).** + +--- + +## Item 1 — `MergeResult.items()` ✅ + +**File:** `ipfs_datasets_py/mcp_server/ucan_delegation.py` + +```python +def items(self) -> list: + return list(self.__iter__()) +``` + +Provides explicit iteration over `(key, value)` pairs, completing the +standard mapping trio (`keys / values / items`). Equivalent to +`list(result)` but matches the idiomatic `dict.items()` spelling. +Invariants: `dict(r.items()) == dict(r)` and +`[k for k,v in r.items()] == r.keys()` and +`[v for k,v in r.items()] == r.values()`. + +--- + +## Item 2 — `IPFSReloadResult.as_dict()` ✅ + +**File:** `ipfs_datasets_py/mcp_server/nl_ucan_policy.py` + +```python +def as_dict(self) -> dict: + return dict(self.pin_results) +``` + +Returns a flat shallow copy of `pin_results` — safe to mutate, pass to +JSON serialisers, and compare with `==`. Equivalent to +`dict(result.iter_all())` but more readable. + +--- + +## Item 3 — `PubSubBus.topics_with_count()` ✅ + +**File:** `ipfs_datasets_py/mcp_server/mcp_p2p_transport.py` + +```python +def topics_with_count(self) -> List[tuple]: + pairs = [(t, self.subscription_count(t)) for t in self.topics()] + return sorted(pairs, key=lambda tc: tc[1], reverse=True) +``` + +Returns `(topic, count)` tuples sorted by subscriber count descending. +Useful for dashboards. Each `count` matches `subscription_count(topic)`. +Empty list when no subscriptions are active. + +--- + +## Item 4 — `ComplianceChecker.oldest_backup_name(path)` ✅ + +**File:** `ipfs_datasets_py/mcp_server/compliance_checker.py` + +```python +@staticmethod +def oldest_backup_name(path: str) -> Optional[str]: + import os as _os + files = ComplianceChecker.list_bak_files(path) + return _os.path.basename(files[-1]) if files else None +``` + +Complement of `newest_backup_name()`. Returns the basename of the +highest-numbered `.bak` file; `None` when no backup exists. When exactly +one backup exists, `oldest_backup_name == newest_backup_name`. Consistent +with `oldest_backup_path()` (which returns the full path). + +--- + +## Item 5 — Session 84 E2E Test ✅ + +**File:** `tests/mcp/unit/test_mcplusplus_v39_session84.py` + +42 tests across 5 sections: + +| Section | Tests | +|---------|-------| +| `TestMergeResultItems` | 10 | +| `TestIPFSReloadResultAsDict` | 10 | +| `TestPubSubBusTopicsWithCount` | 10 | +| `TestComplianceCheckerOldestBackupName` | 8 | +| `TestE2ESession84` | 4 | + +All 42 tests pass with 0 failures. + +--- + +## Cumulative MCP++ Status + +| Component | Module | Sessions | +|-----------|--------|---------| +| UCAN Delegation | `ucan_delegation.py` | 53, 56–84 | +| P2P Transport | `mcp_p2p_transport.py` | 54, 55, 56, 64–84 | +| Compliance | `compliance_checker.py` | 53, 60–84 | +| NL→UCAN Policy Gate | `nl_ucan_policy.py` | 51, 52, 56, 57, 62–84 | +| MergeResult: complete dict-protocol (repr+str+bool+len+iter+keys+values+items+getitem+from/to_dict) | `ucan_delegation.py` | 71–84 | +| IPFSReloadResult: complete API (iter_failed+iter_succeeded+iter_all+as_dict+from/to_dict+summarize) | `nl_ucan_policy.py` | 71–84 | +| PubSubBus: complete API (subscribe+SIDs+counts+topics+topics_with_count+clear+snapshot+resubscribe) | `mcp_p2p_transport.py` | 71–84 | +| ComplianceChecker: complete bak lifecycle (rotate+list+purge+age+newest+oldest+summary+names+name) | `compliance_checker.py` | 71–84 | + +**1,570+ spec tests pass (sessions 50–84).** + +--- + +## Next Steps (Session 85+) + +1. **`MergeResult.__contains__(key)`** — support `"added_count" in result` + via the membership test; returns `True` for recognised field names. + +2. **`IPFSReloadResult.failed_names()`** — return a sorted list of policy + names whose pin failed (`cid is None`); complements `as_dict()` with + a quick way to get the failure list without iterating. + +3. **`PubSubBus.most_subscribed_topic()`** — return the topic string with + the highest subscriber count, or `None` when the bus is empty; the + single-topic shorthand for `topics_with_count()[0]`. + +4. **`ComplianceChecker.backup_file_sizes(path)`** — return a list of + `(basename, size_in_bytes)` tuples for existing backup files, enabling + storage-usage reporting without full path exposure. + +5. **Session 85 full E2E** — verify `__contains__` for valid/invalid keys, + `failed_names()` sorting, `most_subscribed_topic()` tie-breaking, + and `backup_file_sizes()` after multi-rotate. diff --git a/ipfs_datasets_py/mcp_server/compliance_checker.py b/ipfs_datasets_py/mcp_server/compliance_checker.py index 5be24f706..44591498a 100644 --- a/ipfs_datasets_py/mcp_server/compliance_checker.py +++ b/ipfs_datasets_py/mcp_server/compliance_checker.py @@ -969,6 +969,135 @@ def oldest_backup_path(path: str) -> Optional[str]: files = ComplianceChecker.list_bak_files(path) return files[-1] if files else None + @staticmethod + def backup_summary(path: str) -> dict: + """Return a dict summarising the backup state for *path*. + + Collects the most useful backup metrics into a single call so that + callers do not need to invoke several static methods individually:: + + summary = ComplianceChecker.backup_summary("/data/rules.enc") + # { + # "count": 2, + # "newest": "/data/rules.enc.bak", + # "oldest": "/data/rules.enc.bak.1", + # "newest_age": 1708690123.4, # Unix mtime of newest .bak + # "oldest_age": 1708690056.7, # Unix mtime of oldest .bak + # } + + When no backups exist all path/mtime fields are ``None``:: + + summary = ComplianceChecker.backup_summary("/data/no-backups.enc") + # {"count": 0, "newest": None, "oldest": None, + # "newest_age": None, "oldest_age": None} + + Note: + ``newest_age`` and ``oldest_age`` are Unix modification-time + timestamps (``os.path.getmtime``), consistent with + :meth:`backup_age` and :meth:`oldest_backup_age`. They are + **not** elapsed ages in seconds. + + Args: + path: Base file path (without ``.bak`` suffix). + + Returns: + Dict with keys ``count`` (int), ``newest`` (str or None), + ``oldest`` (str or None), ``newest_age`` (float mtime or None), + and ``oldest_age`` (float mtime or None). + """ + import os as _os + files = ComplianceChecker.list_bak_files(path) + count = len(files) + if count == 0: + return { + "count": 0, + "newest": None, + "oldest": None, + "newest_age": None, + "oldest_age": None, + } + newest = files[0] + oldest = files[-1] + try: + newest_age: Optional[float] = float(_os.path.getmtime(newest)) + except OSError: + newest_age = None + try: + oldest_age: Optional[float] = float(_os.path.getmtime(oldest)) + except OSError: + oldest_age = None + return { + "count": count, + "newest": newest, + "oldest": oldest, + "newest_age": newest_age, + "oldest_age": oldest_age, + } + + @staticmethod + def backup_names(path: str) -> List[str]: + """Return the *file names* (not full paths) of existing backup files. + + Convenience wrapper around :meth:`list_bak_files` that strips the + directory component from each path, making the result safe to expose + in log messages and UI dashboards without leaking absolute paths:: + + names = ComplianceChecker.backup_names("/data/rules.enc") + # ["rules.enc.bak", "rules.enc.bak.1"] + + Args: + path: Base file path (without ``.bak`` suffix). + + Returns: + List of file name strings (same order as :meth:`list_bak_files`, + newest first). Empty list when no backups exist. + """ + import os as _os + return [_os.path.basename(p) for p in ComplianceChecker.list_bak_files(path)] + + @staticmethod + def newest_backup_name(path: str) -> Optional[str]: + """Return the file name (basename) of the *newest* backup, or ``None``. + + Complement to :meth:`backup_names` for callers that only need the + single most-recent backup file name without exposing the full path:: + + name = ComplianceChecker.newest_backup_name("/data/rules.enc") + # "rules.enc.bak" or None when no backups exist + + Args: + path: Base file path (without ``.bak`` suffix). + + Returns: + Basename of the primary ``.bak`` file, or ``None`` when no + backups exist. + """ + import os as _os + files = ComplianceChecker.list_bak_files(path) + return _os.path.basename(files[0]) if files else None + + @staticmethod + def oldest_backup_name(path: str) -> Optional[str]: + """Return the *file name* (basename) of the oldest backup, or ``None``. + + Complement of :meth:`newest_backup_name`. Returns the basename of + the highest-numbered (oldest) ``.bak`` file without exposing the + full directory path:: + + name = ComplianceChecker.oldest_backup_name("/data/rules.enc") + # "rules.enc.bak.2" or None when no backups exist + + Args: + path: Base file path (without ``.bak`` suffix). + + Returns: + Basename of the oldest backup file, or ``None`` when no backups + exist. + """ + import os as _os + files = ComplianceChecker.list_bak_files(path) + return _os.path.basename(files[-1]) if files else None + @staticmethod def _get_field(intent: Any, field: str, default: Any = None) -> Any: if isinstance(intent, dict): diff --git a/ipfs_datasets_py/mcp_server/mcp_p2p_transport.py b/ipfs_datasets_py/mcp_server/mcp_p2p_transport.py index f7cc34633..c3bbff5d6 100644 --- a/ipfs_datasets_py/mcp_server/mcp_p2p_transport.py +++ b/ipfs_datasets_py/mcp_server/mcp_p2p_transport.py @@ -803,6 +803,89 @@ def resubscribe( return replaced + def subscriber_ids(self, topic: Union[str, "PubSubEventType"]) -> List[int]: + """Return a sorted list of subscription IDs (SIDs) for *topic*. + + Queries ``_sid_map`` for every SID registered against the given topic + key, enabling targeted :meth:`unsubscribe_by_id` calls:: + + sids = bus.subscriber_ids("receipts") + for sid in sids: + bus.unsubscribe_by_id(sid) + + Args: + topic: Topic string or :class:`PubSubEventType` enum member. + + Returns: + Sorted list of integer SIDs subscribed to *topic*. Empty list + when no subscriptions exist for that topic. + """ + key = topic.value if hasattr(topic, "value") else str(topic) + return sorted( + sid for sid, (k, _h) in self._sid_map.items() if k == key + ) + + def topic_sid_map(self) -> Dict[str, List[int]]: + """Return a mapping of topic key → sorted list of SIDs. + + The SID-based analogue of :meth:`topic_handler_map`. Useful for + auditing which subscriptions are active per topic without exposing + handler callables directly:: + + m = bus.topic_sid_map() + # {"receipts": [1, 3], "audit": [2]} + + Only topics with at least one subscriber are included. + + Returns: + ``Dict[str, List[int]]`` — ``{topic_key: sorted_sid_list}``. + """ + result: Dict[str, List[int]] = {} + for sid, (k, _h) in self._sid_map.items(): + result.setdefault(k, []) + result[k].append(sid) + # Sort each SID list and drop empty topics + return {k: sorted(v) for k, v in result.items() if v} + + def total_subscriptions(self) -> int: + """Return the total number of active subscription IDs (SIDs). + + Counts every entry in ``_sid_map``, so a single handler subscribed to + three topics is counted three times. This is the registration-level + count, complementing :meth:`handler_count` which deduplicates by + handler identity:: + + bus.subscribe("a", cb) + bus.subscribe("b", cb) + assert bus.total_subscriptions() == 2 # 2 registrations + assert bus.handler_count() == 1 # 1 unique handler + + Returns: + Non-negative integer — 0 when no subscriptions are active. + """ + return len(self._sid_map) + + def topics_with_count(self) -> List[tuple]: + """Return ``(topic, count)`` tuples sorted by subscription count descending. + + Useful for dashboards and monitoring that want to highlight the most + subscribed topics first:: + + for topic, count in bus.topics_with_count(): + print(f"{topic}: {count} subscribers") + # receipts: 5 + # audit: 2 + + Topics with equal counts appear in arbitrary order (dict insertion + order of ``_subscribers``). + + Returns: + Sorted ``List[Tuple[str, int]]`` — highest count first. + Empty list when no subscriptions are active. + """ + pairs = [(t, self.subscription_count(t)) for t in self.topics()] + return sorted(pairs, key=lambda tc: tc[1], reverse=True) + async def publish_async( self, topic: Union[str, "PubSubEventType"], diff --git a/ipfs_datasets_py/mcp_server/nl_ucan_policy.py b/ipfs_datasets_py/mcp_server/nl_ucan_policy.py index 231caad28..c0cb050ce 100644 --- a/ipfs_datasets_py/mcp_server/nl_ucan_policy.py +++ b/ipfs_datasets_py/mcp_server/nl_ucan_policy.py @@ -1356,6 +1356,79 @@ def __len__(self) -> int: """ return self.count + def iter_failed(self): + """Yield ``(name, error)`` pairs for all failed pin operations. + + Iterates :attr:`pin_results` and yields an entry for every policy + whose CID is ``None``, pairing the policy name with the human-readable + error reason from :attr:`pin_errors` (falling back to + ``"unknown error"`` when no error detail was captured):: + + for name, reason in result.iter_failed(): + log.error("Pin failed for %s: %s", name, reason) + + Yields: + Two-element ``(str, str)`` tuples — policy name and error reason. + Nothing is yielded when all pins succeeded. + """ + errors = self.pin_errors or {} + for name, cid in self.pin_results.items(): + if cid is None: + yield (name, errors.get(name, "unknown error")) + + def iter_succeeded(self): + """Yield ``(name, cid)`` pairs for all successful pin operations. + + Iterates :attr:`pin_results` and yields an entry for every policy + whose CID is not ``None``. This is the complement of + :meth:`iter_failed`:: + + for name, cid in result.iter_succeeded(): + log.info("Pinned %s at %s", name, cid) + + Yields: + Two-element ``(str, str)`` tuples — policy name and CID string. + Nothing is yielded when all pins failed. + """ + for name, cid in self.pin_results.items(): + if cid is not None: + yield (name, cid) + + def iter_all(self): + """Yield ``(name, cid_or_none)`` pairs for *all* pin operations. + + Iterates :attr:`pin_results` unconditionally, yielding an entry for + every policy regardless of whether its pin succeeded or failed. + Useful for unified reporting and audit logs:: + + for name, cid in result.iter_all(): + status = "ok" if cid else "FAILED" + log.info("[%s] %s %s", status, name, cid or "—") + + Yields: + Two-element tuples — ``(str, str)`` when the pin succeeded, or + ``(str, None)`` when it failed. + """ + for name, cid in self.pin_results.items(): + yield (name, cid) + + def as_dict(self) -> dict: + """Return a flat ``{name: cid_or_none}`` dict for all pin entries. + + A convenience accessor that exposes :attr:`pin_results` as a plain + ``dict`` without the NamedTuple wrapping, making it safe to pass to + JSON serialisers and other dict-consuming APIs:: + + d = result.as_dict() + # {"policy_a": "QmABC...", "policy_b": None, ...} + + Equivalent to ``dict(result.iter_all())``, but more readable. + + Returns: + ``Dict[str, str | None]`` — ``{policy_name: cid_or_none}``. + """ + return dict(self.pin_results) + class IPFSPolicyStore(FilePolicyStore): """IPFS-backed :class:`PolicyRegistry` store (Phase G). diff --git a/ipfs_datasets_py/mcp_server/ucan_delegation.py b/ipfs_datasets_py/mcp_server/ucan_delegation.py index ce4a96467..42a0186db 100644 --- a/ipfs_datasets_py/mcp_server/ucan_delegation.py +++ b/ipfs_datasets_py/mcp_server/ucan_delegation.py @@ -1142,6 +1142,96 @@ def __len__(self) -> int: """ return self.added_count + def __iter__(self): + """Iterate over ``(field, value)`` pairs for this result. + + Yields the three core fields in a stable order, enabling easy packing + into a plain dict:: + + d = dict(result) + # {"added_count": 3, "conflict_count": 1, "revocations_copied": 0} + + Yields: + Two-element tuples ``(field_name, value)`` for each field. + """ + yield ("added_count", self.added_count) + yield ("conflict_count", self.conflict_count) + yield ("revocations_copied", self.revocations_copied) + + def keys(self) -> list: + """Return the list of field names for this result. + + Mirrors ``dict.keys()`` to allow use in ``dict``-protocol consumers + that call ``keys()`` before iterating:: + + assert list(result.keys()) == ["added_count", "conflict_count", "revocations_copied"] + + Returns: + A plain list of the three field name strings in stable order. + """ + return ["added_count", "conflict_count", "revocations_copied"] + + def __getitem__(self, key: str): + """Support subscript access for mapping-protocol compatibility. + + Allows ``result["added_count"]`` and enables ``dict(result)`` to work + via the full mapping protocol (keys + subscript):: + + d = dict(result) + # {"added_count": 3, "conflict_count": 1, "revocations_copied": 0} + + Args: + key: One of ``"added_count"``, ``"conflict_count"``, or + ``"revocations_copied"``. + + Raises: + KeyError: When *key* is not a recognised field name. + """ + if key == "added_count": + return self.added_count + if key == "conflict_count": + return self.conflict_count + if key == "revocations_copied": + return self.revocations_copied + raise KeyError(key) + + def values(self) -> list: + """Return a list of field values in the same order as :meth:`keys`. + + Completes the ``dict``-protocol triad alongside :meth:`keys` and + :meth:`__iter__` (which yields ``(key, value)`` pairs):: + + assert result.values() == [result.added_count, + result.conflict_count, + result.revocations_copied] + + Returns: + A plain list of the three field values in stable + ``[added_count, conflict_count, revocations_copied]`` order. + """ + return [self.added_count, self.conflict_count, self.revocations_copied] + + def items(self) -> list: + """Return a list of ``(key, value)`` tuples in :meth:`keys` order. + + Provides explicit iteration over field-value pairs, completing the + standard mapping trio alongside :meth:`keys` and :meth:`values`:: + + for key, val in result.items(): + print(f"{key} = {val}") + # added_count = 3 + # conflict_count = 1 + # revocations_copied = 0 + + This is equivalent to ``list(result)`` (which uses :meth:`__iter__`), + but is idiomatic for callers expecting a ``dict``-like ``.items()`` + method. + + Returns: + A plain list of three ``(str, int)`` tuples in stable order. + """ + return list(self.__iter__()) + class DelegationManager: """Bundles :class:`DelegationStore`, :class:`RevocationList`, and diff --git a/tests/mcp/unit/test_mcplusplus_v36_session81.py b/tests/mcp/unit/test_mcplusplus_v36_session81.py new file mode 100644 index 000000000..598a13fff --- /dev/null +++ b/tests/mcp/unit/test_mcplusplus_v36_session81.py @@ -0,0 +1,416 @@ +"""Session 81 — MCP++ v36 Next Steps. + +Implements tests for: + 1. MergeResult.__iter__ (yields (field, value) pairs) + 2. IPFSReloadResult.iter_failed() (generator of (name, error) pairs) + 3. PubSubBus.subscriber_ids(topic) (sorted SID list for a topic) + 4. ComplianceChecker.backup_summary(path) + 5. E2E: dict(result), iter_failed filtering, targeted unsubscribe, full purge flow +""" + +from __future__ import annotations + +import os +import tempfile + +import pytest + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_merge_result(added: int = 0, conflicts: int = 0, revocations: int = 0): + from ipfs_datasets_py.mcp_server.ucan_delegation import MergeResult + return MergeResult(added_count=added, conflict_count=conflicts, revocations_copied=revocations) + + +def _make_reload_result(count: int = 4, failed: int = 0, + pin_errors: dict | None = None): + from ipfs_datasets_py.mcp_server.nl_ucan_policy import IPFSReloadResult + pin_results: dict = {} + for i in range(count - failed): + pin_results[f"p{i}"] = f"Qm{i:040d}" + for i in range(failed): + pin_results[f"f{i}"] = None + return IPFSReloadResult(count=count, pin_results=pin_results, + pin_errors=pin_errors) + + +def _make_bus(): + from ipfs_datasets_py.mcp_server.mcp_p2p_transport import PubSubBus + return PubSubBus() + + +# --------------------------------------------------------------------------- +# 1. MergeResult.__iter__ +# --------------------------------------------------------------------------- + +class TestMergeResultIter: + def test_iter_yields_three_pairs(self): + r = _make_merge_result(added=3, conflicts=1, revocations=2) + pairs = list(r) + assert len(pairs) == 3 + + def test_iter_keys(self): + r = _make_merge_result(added=5, conflicts=0, revocations=1) + keys = [k for k, _v in r] + assert keys == ["added_count", "conflict_count", "revocations_copied"] + + def test_iter_values_match_attrs(self): + r = _make_merge_result(added=7, conflicts=2, revocations=3) + pairs = list(r) + assert pairs[0] == ("added_count", 7) + assert pairs[1] == ("conflict_count", 2) + assert pairs[2] == ("revocations_copied", 3) + + def test_dict_conversion(self): + r = _make_merge_result(added=4, conflicts=1, revocations=0) + d = dict(r) + assert d == {"added_count": 4, "conflict_count": 1, "revocations_copied": 0} + + def test_dict_zero_values(self): + r = _make_merge_result() + d = dict(r) + assert d == {"added_count": 0, "conflict_count": 0, "revocations_copied": 0} + + def test_iter_multiple_times(self): + r = _make_merge_result(added=2, conflicts=3, revocations=1) + assert list(r) == list(r) + + def test_iter_unpacking(self): + r = _make_merge_result(added=10, conflicts=5, revocations=2) + (k1, v1), (k2, v2), (k3, v3) = r + assert k1 == "added_count" and v1 == 10 + assert k2 == "conflict_count" and v2 == 5 + assert k3 == "revocations_copied" and v3 == 2 + + def test_dict_added_count_matches_int(self): + r = _make_merge_result(added=6) + d = dict(r) + assert d["added_count"] == int(r) + + def test_dict_roundtrip_via_iter(self): + r = _make_merge_result(added=3, conflicts=2, revocations=1) + d = dict(r) + assert d["added_count"] == r.added_count + assert d["conflict_count"] == r.conflict_count + assert d["revocations_copied"] == r.revocations_copied + + def test_sum_via_iter(self): + results = [_make_merge_result(added=i) for i in range(5)] + # Sum added_count across list using dict(r)["added_count"] + total = sum(dict(r)["added_count"] for r in results) + assert total == 0 + 1 + 2 + 3 + 4 + + +# --------------------------------------------------------------------------- +# 2. IPFSReloadResult.iter_failed +# --------------------------------------------------------------------------- + +class TestIPFSReloadResultIterFailed: + def test_iter_failed_none_when_all_succeed(self): + r = _make_reload_result(count=3, failed=0) + assert list(r.iter_failed()) == [] + + def test_iter_failed_yields_one_pair(self): + r = _make_reload_result(count=3, failed=1) + pairs = list(r.iter_failed()) + assert len(pairs) == 1 + + def test_iter_failed_name_is_string(self): + r = _make_reload_result(count=2, failed=1) + for name, _err in r.iter_failed(): + assert isinstance(name, str) + + def test_iter_failed_error_is_string(self): + r = _make_reload_result(count=2, failed=1) + for _name, err in r.iter_failed(): + assert isinstance(err, str) + + def test_iter_failed_default_error_text(self): + r = _make_reload_result(count=2, failed=1) + for _name, err in r.iter_failed(): + assert err == "unknown error" + + def test_iter_failed_uses_pin_errors_when_present(self): + errors = {"f0": "connection timeout"} + r = _make_reload_result(count=3, failed=1, pin_errors=errors) + pairs = list(r.iter_failed()) + assert len(pairs) == 1 + name, err = pairs[0] + assert name == "f0" + assert err == "connection timeout" + + def test_iter_failed_multiple_failures(self): + r = _make_reload_result(count=5, failed=3) + pairs = list(r.iter_failed()) + assert len(pairs) == 3 + + def test_iter_failed_count_matches_total_failed(self): + r = _make_reload_result(count=6, failed=4) + assert len(list(r.iter_failed())) == r.total_failed + + def test_iter_failed_no_duplicates(self): + r = _make_reload_result(count=4, failed=2) + names = [n for n, _ in r.iter_failed()] + assert len(names) == len(set(names)) + + def test_iter_failed_mixed_errors(self): + errors = {"f0": "timeout", "f1": "quota exceeded"} + r = _make_reload_result(count=4, failed=2, pin_errors=errors) + result_map = dict(r.iter_failed()) + assert result_map.get("f0") == "timeout" + assert result_map.get("f1") == "quota exceeded" + + +# --------------------------------------------------------------------------- +# 3. PubSubBus.subscriber_ids +# --------------------------------------------------------------------------- + +class TestPubSubBusSubscriberIds: + def test_empty_topic_returns_empty_list(self): + bus = _make_bus() + assert bus.subscriber_ids("no_such_topic") == [] + + def test_single_subscription_returns_one_sid(self): + bus = _make_bus() + sid = bus.subscribe("receipts", lambda t, p: None) + ids = bus.subscriber_ids("receipts") + assert ids == [sid] + + def test_multiple_subscriptions_same_topic(self): + bus = _make_bus() + h1 = lambda t, p: None # noqa: E731 + h2 = lambda t, p: None # noqa: E731 + sid1 = bus.subscribe("receipts", h1) + sid2 = bus.subscribe("receipts", h2) + ids = bus.subscriber_ids("receipts") + assert sorted(ids) == sorted([sid1, sid2]) + + def test_returns_sorted_list(self): + bus = _make_bus() + sids = [] + for _ in range(5): + sids.append(bus.subscribe("audit", lambda t, p: None)) + ids = bus.subscriber_ids("audit") + assert ids == sorted(ids) + + def test_unsubscribed_sid_not_returned(self): + bus = _make_bus() + h = lambda t, p: None # noqa: E731 + sid = bus.subscribe("receipts", h) + bus.unsubscribe_by_id(sid) + assert bus.subscriber_ids("receipts") == [] + + def test_does_not_return_sids_for_other_topics(self): + bus = _make_bus() + bus.subscribe("topic_a", lambda t, p: None) + sid_b = bus.subscribe("topic_b", lambda t, p: None) + ids_b = bus.subscriber_ids("topic_b") + assert ids_b == [sid_b] + + def test_returns_list_type(self): + bus = _make_bus() + bus.subscribe("x", lambda t, p: None) + assert isinstance(bus.subscriber_ids("x"), list) + + def test_each_sid_is_int(self): + bus = _make_bus() + bus.subscribe("y", lambda t, p: None) + for sid in bus.subscriber_ids("y"): + assert isinstance(sid, int) + + def test_returns_empty_after_clear_topic(self): + bus = _make_bus() + bus.subscribe("z", lambda t, p: None) + bus.clear_topic("z") + assert bus.subscriber_ids("z") == [] + + def test_returns_empty_after_clear_all(self): + bus = _make_bus() + bus.subscribe("a", lambda t, p: None) + bus.subscribe("b", lambda t, p: None) + bus.clear_all() + assert bus.subscriber_ids("a") == [] + assert bus.subscriber_ids("b") == [] + + +# --------------------------------------------------------------------------- +# 4. ComplianceChecker.backup_summary +# --------------------------------------------------------------------------- + +class TestComplianceCheckerBackupSummary: + def test_no_backups_returns_zero_count(self): + from ipfs_datasets_py.mcp_server.compliance_checker import ComplianceChecker + with tempfile.NamedTemporaryFile(delete=False, suffix=".enc") as f: + path = f.name + try: + summary = ComplianceChecker.backup_summary(path) + assert summary["count"] == 0 + finally: + os.unlink(path) + + def test_no_backups_all_none(self): + from ipfs_datasets_py.mcp_server.compliance_checker import ComplianceChecker + with tempfile.NamedTemporaryFile(delete=False, suffix=".enc") as f: + path = f.name + try: + summary = ComplianceChecker.backup_summary(path) + assert summary["newest"] is None + assert summary["oldest"] is None + assert summary["newest_age"] is None + assert summary["oldest_age"] is None + finally: + os.unlink(path) + + def test_one_backup_count_one(self): + from ipfs_datasets_py.mcp_server.compliance_checker import ComplianceChecker + with tempfile.NamedTemporaryFile(delete=False, suffix=".enc") as f: + path = f.name + bak = path + ".bak" + try: + with open(bak, "w") as bf: + bf.write("backup") + summary = ComplianceChecker.backup_summary(path) + assert summary["count"] == 1 + finally: + os.unlink(path) + if os.path.exists(bak): + os.unlink(bak) + + def test_one_backup_newest_oldest_same(self): + from ipfs_datasets_py.mcp_server.compliance_checker import ComplianceChecker + with tempfile.NamedTemporaryFile(delete=False, suffix=".enc") as f: + path = f.name + bak = path + ".bak" + try: + with open(bak, "w") as bf: + bf.write("backup") + summary = ComplianceChecker.backup_summary(path) + assert summary["newest"] == bak + assert summary["oldest"] == bak + finally: + os.unlink(path) + if os.path.exists(bak): + os.unlink(bak) + + def test_two_backups_count_two(self): + from ipfs_datasets_py.mcp_server.compliance_checker import ComplianceChecker + with tempfile.NamedTemporaryFile(delete=False, suffix=".enc") as f: + path = f.name + bak = path + ".bak" + bak1 = path + ".bak.1" + try: + with open(bak, "w") as bf: + bf.write("new") + with open(bak1, "w") as bf: + bf.write("old") + summary = ComplianceChecker.backup_summary(path) + assert summary["count"] == 2 + finally: + os.unlink(path) + for p in (bak, bak1): + if os.path.exists(p): + os.unlink(p) + + def test_two_backups_newest_and_oldest_differ(self): + from ipfs_datasets_py.mcp_server.compliance_checker import ComplianceChecker + with tempfile.NamedTemporaryFile(delete=False, suffix=".enc") as f: + path = f.name + bak = path + ".bak" + bak1 = path + ".bak.1" + try: + with open(bak, "w") as bf: + bf.write("new") + with open(bak1, "w") as bf: + bf.write("old") + summary = ComplianceChecker.backup_summary(path) + assert summary["newest"] == bak + assert summary["oldest"] == bak1 + finally: + os.unlink(path) + for p in (bak, bak1): + if os.path.exists(p): + os.unlink(p) + + def test_ages_are_floats_when_backup_exists(self): + from ipfs_datasets_py.mcp_server.compliance_checker import ComplianceChecker + with tempfile.NamedTemporaryFile(delete=False, suffix=".enc") as f: + path = f.name + bak = path + ".bak" + try: + with open(bak, "w") as bf: + bf.write("x") + summary = ComplianceChecker.backup_summary(path) + assert isinstance(summary["newest_age"], float) + assert isinstance(summary["oldest_age"], float) + finally: + os.unlink(path) + if os.path.exists(bak): + os.unlink(bak) + + def test_summary_keys(self): + from ipfs_datasets_py.mcp_server.compliance_checker import ComplianceChecker + with tempfile.NamedTemporaryFile(delete=False, suffix=".enc") as f: + path = f.name + try: + summary = ComplianceChecker.backup_summary(path) + assert set(summary.keys()) == {"count", "newest", "oldest", + "newest_age", "oldest_age"} + finally: + os.unlink(path) + + +# --------------------------------------------------------------------------- +# 5. E2E: combined session 81 flows +# --------------------------------------------------------------------------- + +class TestE2ESession81: + def test_merge_result_dict_packing(self): + """dict(result) produces correct field map.""" + r = _make_merge_result(added=5, conflicts=2, revocations=1) + d = dict(r) + assert d["added_count"] == 5 + assert d["conflict_count"] == 2 + assert d["revocations_copied"] == 1 + + def test_iter_failed_targeted_retry(self): + """iter_failed() allows building a retry list.""" + errors = {"f0": "network error", "f1": "quota"} + r = _make_reload_result(count=5, failed=2, pin_errors=errors) + retry_names = [n for n, _e in r.iter_failed()] + assert "f0" in retry_names + assert "f1" in retry_names + assert len(retry_names) == 2 + + def test_subscriber_ids_targeted_unsubscribe(self): + """subscriber_ids enables targeted bulk-unsubscribe.""" + bus = _make_bus() + h1 = lambda t, p: None # noqa: E731 + h2 = lambda t, p: None # noqa: E731 + bus.subscribe("receipts", h1) + bus.subscribe("receipts", h2) + assert bus.subscription_count("receipts") == 2 + for sid in list(bus.subscriber_ids("receipts")): + bus.unsubscribe_by_id(sid) + assert bus.subscription_count("receipts") == 0 + + def test_backup_summary_after_rotate(self): + """backup_summary reflects state after manually creating backup files.""" + from ipfs_datasets_py.mcp_server.compliance_checker import ComplianceChecker + import shutil + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "policy.enc") + with open(path, "w") as f: + f.write("v1") + # Create a .bak file manually, then rotate to .bak.1 + bak = path + ".bak" + shutil.copy2(path, bak) + ComplianceChecker.rotate_bak(path, max_keep=3) + # Now create a second .bak + shutil.copy2(path, bak) + summary = ComplianceChecker.backup_summary(path) + assert summary["count"] >= 1 + assert summary["newest"] is not None + assert summary["oldest"] is not None diff --git a/tests/mcp/unit/test_mcplusplus_v37_session82.py b/tests/mcp/unit/test_mcplusplus_v37_session82.py new file mode 100644 index 000000000..261651811 --- /dev/null +++ b/tests/mcp/unit/test_mcplusplus_v37_session82.py @@ -0,0 +1,396 @@ +"""Session 82 — MCP++ v37 Next Steps. + +Implements tests for: + 1. MergeResult.keys() (list of field names) + 2. IPFSReloadResult.iter_succeeded() (generator of (name, cid) pairs) + 3. PubSubBus.topic_sid_map() ({topic: sorted_sid_list}) + 4. ComplianceChecker.backup_names(path) (basenames of backup files) + 5. E2E: keys() round-trip, iter_succeeded filtering, + topic_sid_map consistency, backup_names purge cycle +""" + +from __future__ import annotations + +import os +import tempfile + +import pytest + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_merge_result(added: int = 0, conflicts: int = 0, revocations: int = 0): + from ipfs_datasets_py.mcp_server.ucan_delegation import MergeResult + return MergeResult(added_count=added, conflict_count=conflicts, revocations_copied=revocations) + + +def _make_reload_result(count: int = 4, failed: int = 0, + pin_errors: dict | None = None): + from ipfs_datasets_py.mcp_server.nl_ucan_policy import IPFSReloadResult + pin_results: dict = {} + for i in range(count - failed): + pin_results[f"p{i}"] = f"Qm{i:040d}" + for i in range(failed): + pin_results[f"f{i}"] = None + return IPFSReloadResult(count=count, pin_results=pin_results, + pin_errors=pin_errors) + + +def _make_bus(): + from ipfs_datasets_py.mcp_server.mcp_p2p_transport import PubSubBus + return PubSubBus() + + +# --------------------------------------------------------------------------- +# 1. MergeResult.keys() +# --------------------------------------------------------------------------- + +class TestMergeResultKeys: + def test_keys_returns_list(self): + r = _make_merge_result() + assert isinstance(r.keys(), list) + + def test_keys_length_three(self): + r = _make_merge_result() + assert len(r.keys()) == 3 + + def test_keys_content(self): + r = _make_merge_result() + assert r.keys() == ["added_count", "conflict_count", "revocations_copied"] + + def test_keys_stable_order(self): + r1 = _make_merge_result(added=1, conflicts=2, revocations=3) + r2 = _make_merge_result(added=9, conflicts=0, revocations=0) + assert r1.keys() == r2.keys() + + def test_keys_used_in_dict_comprehension(self): + r = _make_merge_result(added=5, conflicts=2, revocations=1) + d = {k: getattr(r, k) for k in r.keys()} + assert d == {"added_count": 5, "conflict_count": 2, "revocations_copied": 1} + + def test_keys_consistent_with_iter(self): + r = _make_merge_result(added=3, conflicts=1, revocations=0) + iter_keys = [k for k, _v in r] + assert r.keys() == iter_keys + + def test_keys_independent_of_values(self): + for added in (0, 10, 100): + r = _make_merge_result(added=added) + assert r.keys() == ["added_count", "conflict_count", "revocations_copied"] + + def test_keys_can_be_iterated(self): + r = _make_merge_result() + count = sum(1 for _ in r.keys()) + assert count == 3 + + def test_first_key(self): + r = _make_merge_result() + assert r.keys()[0] == "added_count" + + def test_last_key(self): + r = _make_merge_result() + assert r.keys()[-1] == "revocations_copied" + + +# --------------------------------------------------------------------------- +# 2. IPFSReloadResult.iter_succeeded() +# --------------------------------------------------------------------------- + +class TestIPFSReloadResultIterSucceeded: + def test_all_succeed_yields_all(self): + r = _make_reload_result(count=3, failed=0) + pairs = list(r.iter_succeeded()) + assert len(pairs) == 3 + + def test_all_fail_yields_none(self): + r = _make_reload_result(count=3, failed=3) + assert list(r.iter_succeeded()) == [] + + def test_mixed_yields_only_succeeded(self): + r = _make_reload_result(count=4, failed=1) + pairs = list(r.iter_succeeded()) + assert len(pairs) == 3 + + def test_yields_name_cid_tuples(self): + r = _make_reload_result(count=2, failed=0) + for name, cid in r.iter_succeeded(): + assert isinstance(name, str) + assert isinstance(cid, str) + + def test_cids_are_non_none(self): + r = _make_reload_result(count=4, failed=2) + for _name, cid in r.iter_succeeded(): + assert cid is not None + + def test_count_matches_total_minus_failed(self): + r = _make_reload_result(count=6, failed=2) + assert len(list(r.iter_succeeded())) == r.count - r.total_failed + + def test_iter_succeeded_and_iter_failed_partition(self): + r = _make_reload_result(count=5, failed=2) + succeeded_names = {n for n, _ in r.iter_succeeded()} + failed_names = {n for n, _ in r.iter_failed()} + all_names = set(r.pin_results.keys()) + assert succeeded_names | failed_names == all_names + assert succeeded_names & failed_names == set() + + def test_empty_result_yields_none(self): + r = _make_reload_result(count=0, failed=0) + assert list(r.iter_succeeded()) == [] + + def test_single_success(self): + r = _make_reload_result(count=1, failed=0) + pairs = list(r.iter_succeeded()) + assert len(pairs) == 1 + _name, cid = pairs[0] + assert cid is not None + + def test_iter_succeeded_is_generator(self): + import types + r = _make_reload_result(count=2, failed=0) + assert isinstance(r.iter_succeeded(), types.GeneratorType) + + +# --------------------------------------------------------------------------- +# 3. PubSubBus.topic_sid_map() +# --------------------------------------------------------------------------- + +class TestPubSubBusTopicSidMap: + def test_empty_bus_returns_empty_dict(self): + bus = _make_bus() + assert bus.topic_sid_map() == {} + + def test_single_subscription_in_map(self): + bus = _make_bus() + sid = bus.subscribe("receipts", lambda t, p: None) + m = bus.topic_sid_map() + assert "receipts" in m + assert m["receipts"] == [sid] + + def test_sids_are_sorted(self): + bus = _make_bus() + h1 = lambda t, p: None # noqa: E731 + h2 = lambda t, p: None # noqa: E731 + sid1 = bus.subscribe("topic", h1) + sid2 = bus.subscribe("topic", h2) + m = bus.topic_sid_map() + assert m["topic"] == sorted([sid1, sid2]) + + def test_multiple_topics(self): + bus = _make_bus() + bus.subscribe("a", lambda t, p: None) + bus.subscribe("b", lambda t, p: None) + m = bus.topic_sid_map() + assert set(m.keys()) == {"a", "b"} + + def test_consistent_with_subscriber_ids(self): + bus = _make_bus() + h1 = lambda t, p: None # noqa: E731 + h2 = lambda t, p: None # noqa: E731 + bus.subscribe("receipts", h1) + bus.subscribe("receipts", h2) + bus.subscribe("audit", h1) + m = bus.topic_sid_map() + assert m["receipts"] == bus.subscriber_ids("receipts") + assert m["audit"] == bus.subscriber_ids("audit") + + def test_empty_after_clear_all(self): + bus = _make_bus() + bus.subscribe("x", lambda t, p: None) + bus.clear_all() + assert bus.topic_sid_map() == {} + + def test_removed_topic_not_in_map(self): + bus = _make_bus() + sid = bus.subscribe("z", lambda t, p: None) + bus.unsubscribe_by_id(sid) + assert "z" not in bus.topic_sid_map() + + def test_returns_dict_type(self): + bus = _make_bus() + assert isinstance(bus.topic_sid_map(), dict) + + def test_values_are_lists(self): + bus = _make_bus() + bus.subscribe("q", lambda t, p: None) + for v in bus.topic_sid_map().values(): + assert isinstance(v, list) + + def test_does_not_mutate_internal_state(self): + bus = _make_bus() + h = lambda t, p: None # noqa: E731 + bus.subscribe("receipts", h) + m = bus.topic_sid_map() + m["receipts"].clear() # mutate the copy + # Original still has the subscription + assert bus.subscription_count("receipts") == 1 + + +# --------------------------------------------------------------------------- +# 4. ComplianceChecker.backup_names() +# --------------------------------------------------------------------------- + +class TestComplianceCheckerBackupNames: + def test_no_backups_returns_empty_list(self): + from ipfs_datasets_py.mcp_server.compliance_checker import ComplianceChecker + with tempfile.NamedTemporaryFile(delete=False, suffix=".enc") as f: + path = f.name + try: + assert ComplianceChecker.backup_names(path) == [] + finally: + os.unlink(path) + + def test_one_backup_returns_one_name(self): + from ipfs_datasets_py.mcp_server.compliance_checker import ComplianceChecker + with tempfile.NamedTemporaryFile(delete=False, suffix=".enc") as f: + path = f.name + bak = path + ".bak" + try: + with open(bak, "w") as bf: + bf.write("x") + names = ComplianceChecker.backup_names(path) + assert len(names) == 1 + finally: + os.unlink(path) + if os.path.exists(bak): + os.unlink(bak) + + def test_names_are_basenames_only(self): + from ipfs_datasets_py.mcp_server.compliance_checker import ComplianceChecker + with tempfile.NamedTemporaryFile(delete=False, suffix=".enc") as f: + path = f.name + bak = path + ".bak" + try: + with open(bak, "w") as bf: + bf.write("x") + names = ComplianceChecker.backup_names(path) + for name in names: + assert os.sep not in name + finally: + os.unlink(path) + if os.path.exists(bak): + os.unlink(bak) + + def test_names_end_with_bak(self): + from ipfs_datasets_py.mcp_server.compliance_checker import ComplianceChecker + with tempfile.NamedTemporaryFile(delete=False, suffix=".enc") as f: + path = f.name + bak = path + ".bak" + try: + with open(bak, "w") as bf: + bf.write("x") + names = ComplianceChecker.backup_names(path) + for name in names: + assert ".bak" in name + finally: + os.unlink(path) + if os.path.exists(bak): + os.unlink(bak) + + def test_two_backups_returns_two_names(self): + from ipfs_datasets_py.mcp_server.compliance_checker import ComplianceChecker + with tempfile.NamedTemporaryFile(delete=False, suffix=".enc") as f: + path = f.name + bak = path + ".bak" + bak1 = path + ".bak.1" + try: + with open(bak, "w") as bf: + bf.write("new") + with open(bak1, "w") as bf: + bf.write("old") + names = ComplianceChecker.backup_names(path) + assert len(names) == 2 + finally: + os.unlink(path) + for p in (bak, bak1): + if os.path.exists(p): + os.unlink(p) + + def test_count_matches_backup_count(self): + from ipfs_datasets_py.mcp_server.compliance_checker import ComplianceChecker + with tempfile.NamedTemporaryFile(delete=False, suffix=".enc") as f: + path = f.name + bak = path + ".bak" + try: + with open(bak, "w") as bf: + bf.write("x") + assert len(ComplianceChecker.backup_names(path)) == \ + ComplianceChecker.backup_count(path) + finally: + os.unlink(path) + if os.path.exists(bak): + os.unlink(bak) + + def test_no_directory_separators_in_names(self): + from ipfs_datasets_py.mcp_server.compliance_checker import ComplianceChecker + with tempfile.NamedTemporaryFile(delete=False, suffix=".enc") as f: + path = f.name + bak = path + ".bak" + try: + with open(bak, "w") as bf: + bf.write("x") + for name in ComplianceChecker.backup_names(path): + assert "/" not in name and "\\" not in name + finally: + os.unlink(path) + if os.path.exists(bak): + os.unlink(bak) + + def test_returns_list_type(self): + from ipfs_datasets_py.mcp_server.compliance_checker import ComplianceChecker + with tempfile.NamedTemporaryFile(delete=False, suffix=".enc") as f: + path = f.name + try: + result = ComplianceChecker.backup_names(path) + assert isinstance(result, list) + finally: + os.unlink(path) + + +# --------------------------------------------------------------------------- +# 5. E2E: Session 82 combined flows +# --------------------------------------------------------------------------- + +class TestE2ESession82: + def test_keys_round_trip_via_dict_comprehension(self): + """keys() enables safe attribute access without knowing field names.""" + r = _make_merge_result(added=7, conflicts=3, revocations=2) + d = {k: getattr(r, k) for k in r.keys()} + assert d["added_count"] == 7 + assert d["conflict_count"] == 3 + assert d["revocations_copied"] == 2 + + def test_iter_succeeded_filtering_for_cid_map(self): + """iter_succeeded() lets callers build a name→CID mapping.""" + r = _make_reload_result(count=5, failed=2) + cid_map = dict(r.iter_succeeded()) + assert len(cid_map) == 3 + for cid in cid_map.values(): + assert cid is not None + + def test_topic_sid_map_consistent_after_unsubscribe(self): + """topic_sid_map stays accurate after unsubscribe_by_id.""" + bus = _make_bus() + h1 = lambda t, p: None # noqa: E731 + h2 = lambda t, p: None # noqa: E731 + sid1 = bus.subscribe("receipts", h1) + _sid2 = bus.subscribe("receipts", h2) + bus.unsubscribe_by_id(sid1) + m = bus.topic_sid_map() + assert sid1 not in m.get("receipts", []) + + def test_backup_names_after_purge(self): + """backup_names returns empty list after purge_bak_files.""" + from ipfs_datasets_py.mcp_server.compliance_checker import ComplianceChecker + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "policy.enc") + bak = path + ".bak" + with open(path, "w") as f: + f.write("v1") + with open(bak, "w") as f: + f.write("bak") + assert len(ComplianceChecker.backup_names(path)) >= 1 + ComplianceChecker.purge_bak_files(path) + assert ComplianceChecker.backup_names(path) == [] diff --git a/tests/mcp/unit/test_mcplusplus_v38_session83.py b/tests/mcp/unit/test_mcplusplus_v38_session83.py new file mode 100644 index 000000000..6437d6cf2 --- /dev/null +++ b/tests/mcp/unit/test_mcplusplus_v38_session83.py @@ -0,0 +1,403 @@ +"""Session 83 — MCP++ v38 Next Steps. + +Implements tests for: + 1. MergeResult.values() (list of field values) + 2. IPFSReloadResult.iter_all() (generator of (name, cid_or_none)) + 3. PubSubBus.total_subscriptions() (len(_sid_map)) + 4. ComplianceChecker.newest_backup_name(path) (basename of newest .bak) + 5. E2E: values() consistency, iter_all coverage, + total_subscriptions vs handler_count, newest_backup_name rotate cycle +""" + +from __future__ import annotations + +import os +import tempfile + +import pytest + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_merge_result(added: int = 0, conflicts: int = 0, revocations: int = 0): + from ipfs_datasets_py.mcp_server.ucan_delegation import MergeResult + return MergeResult(added_count=added, conflict_count=conflicts, revocations_copied=revocations) + + +def _make_reload_result(count: int = 4, failed: int = 0, + pin_errors: dict | None = None): + from ipfs_datasets_py.mcp_server.nl_ucan_policy import IPFSReloadResult + pin_results: dict = {} + for i in range(count - failed): + pin_results[f"p{i}"] = f"Qm{i:040d}" + for i in range(failed): + pin_results[f"f{i}"] = None + return IPFSReloadResult(count=count, pin_results=pin_results, + pin_errors=pin_errors) + + +def _make_bus(): + from ipfs_datasets_py.mcp_server.mcp_p2p_transport import PubSubBus + return PubSubBus() + + +# --------------------------------------------------------------------------- +# 1. MergeResult.values() +# --------------------------------------------------------------------------- + +class TestMergeResultValues: + def test_values_returns_list(self): + r = _make_merge_result() + assert isinstance(r.values(), list) + + def test_values_length_three(self): + r = _make_merge_result() + assert len(r.values()) == 3 + + def test_values_match_attrs(self): + r = _make_merge_result(added=5, conflicts=2, revocations=1) + assert r.values() == [5, 2, 1] + + def test_values_zero_result(self): + r = _make_merge_result() + assert r.values() == [0, 0, 0] + + def test_values_consistent_with_keys(self): + r = _make_merge_result(added=7, conflicts=3, revocations=2) + d_from_keys = {k: getattr(r, k) for k in r.keys()} + d_from_values = dict(zip(r.keys(), r.values())) + assert d_from_keys == d_from_values + + def test_values_consistent_with_iter(self): + r = _make_merge_result(added=4, conflicts=1, revocations=0) + iter_values = [v for _k, v in r] + assert r.values() == iter_values + + def test_values_order_matches_keys(self): + r = _make_merge_result(added=3, conflicts=2, revocations=1) + for key, val in zip(r.keys(), r.values()): + assert getattr(r, key) == val + + def test_values_independent_of_other_results(self): + r1 = _make_merge_result(added=10, conflicts=0, revocations=0) + r2 = _make_merge_result(added=0, conflicts=10, revocations=5) + assert r1.values() != r2.values() + + def test_values_can_be_summed(self): + r = _make_merge_result(added=3, conflicts=2, revocations=1) + assert sum(r.values()) == 6 + + def test_values_first_element_is_added_count(self): + r = _make_merge_result(added=99) + assert r.values()[0] == 99 + + +# --------------------------------------------------------------------------- +# 2. IPFSReloadResult.iter_all() +# --------------------------------------------------------------------------- + +class TestIPFSReloadResultIterAll: + def test_iter_all_yields_all_entries(self): + r = _make_reload_result(count=4, failed=1) + pairs = list(r.iter_all()) + assert len(pairs) == 4 + + def test_iter_all_empty_yields_nothing(self): + r = _make_reload_result(count=0, failed=0) + assert list(r.iter_all()) == [] + + def test_iter_all_all_succeed(self): + r = _make_reload_result(count=3, failed=0) + pairs = list(r.iter_all()) + assert len(pairs) == 3 + for _name, cid in pairs: + assert cid is not None + + def test_iter_all_all_fail(self): + r = _make_reload_result(count=3, failed=3) + pairs = list(r.iter_all()) + assert len(pairs) == 3 + for _name, cid in pairs: + assert cid is None + + def test_iter_all_names_are_strings(self): + r = _make_reload_result(count=3, failed=1) + for name, _cid in r.iter_all(): + assert isinstance(name, str) + + def test_iter_all_matches_pin_results_keys(self): + r = _make_reload_result(count=4, failed=2) + all_names = {n for n, _ in r.iter_all()} + assert all_names == set(r.pin_results.keys()) + + def test_iter_all_partitions_with_iter_failed_and_iter_succeeded(self): + r = _make_reload_result(count=5, failed=2) + all_names = {n for n, _ in r.iter_all()} + succeeded_names = {n for n, _ in r.iter_succeeded()} + failed_names = {n for n, _ in r.iter_failed()} + assert all_names == succeeded_names | failed_names + + def test_iter_all_count_matches_len(self): + r = _make_reload_result(count=6, failed=3) + assert sum(1 for _ in r.iter_all()) == len(r) + + def test_iter_all_is_generator(self): + import types + r = _make_reload_result(count=2) + assert isinstance(r.iter_all(), types.GeneratorType) + + def test_iter_all_cid_none_for_failed(self): + r = _make_reload_result(count=3, failed=1) + failed_from_all = [(n, c) for n, c in r.iter_all() if c is None] + assert len(failed_from_all) == 1 + + +# --------------------------------------------------------------------------- +# 3. PubSubBus.total_subscriptions() +# --------------------------------------------------------------------------- + +class TestPubSubBusTotalSubscriptions: + def test_empty_bus_returns_zero(self): + bus = _make_bus() + assert bus.total_subscriptions() == 0 + + def test_single_subscription_returns_one(self): + bus = _make_bus() + bus.subscribe("receipts", lambda t, p: None) + assert bus.total_subscriptions() == 1 + + def test_multiple_subscriptions_counted_individually(self): + bus = _make_bus() + h = lambda t, p: None # noqa: E731 + bus.subscribe("a", h) + bus.subscribe("b", h) + assert bus.total_subscriptions() == 2 + + def test_same_handler_multiple_topics_counted_each(self): + bus = _make_bus() + cb = lambda t, p: None # noqa: E731 + bus.subscribe("x", cb) + bus.subscribe("y", cb) + bus.subscribe("z", cb) + # 3 registrations even though handler_count == 1 + assert bus.total_subscriptions() == 3 + assert bus.handler_count() == 1 + + def test_unsubscribe_reduces_count(self): + bus = _make_bus() + h = lambda t, p: None # noqa: E731 + sid = bus.subscribe("topic", h) + bus.unsubscribe_by_id(sid) + assert bus.total_subscriptions() == 0 + + def test_clear_all_resets_to_zero(self): + bus = _make_bus() + bus.subscribe("a", lambda t, p: None) + bus.subscribe("b", lambda t, p: None) + bus.clear_all() + assert bus.total_subscriptions() == 0 + + def test_consistent_with_sum_of_subscriber_counts(self): + bus = _make_bus() + h1 = lambda t, p: None # noqa: E731 + h2 = lambda t, p: None # noqa: E731 + bus.subscribe("receipts", h1) + bus.subscribe("receipts", h2) + bus.subscribe("audit", h1) + topics = bus.topics() + manual_sum = sum(bus.subscription_count(t) for t in topics) + assert bus.total_subscriptions() == manual_sum + + def test_returns_int(self): + bus = _make_bus() + assert isinstance(bus.total_subscriptions(), int) + + def test_resubscribe_does_not_change_count(self): + bus = _make_bus() + h_old = lambda t, p: None # noqa: E731 + h_new = lambda t, p: None # noqa: E731 + bus.subscribe("topic", h_old) + before = bus.total_subscriptions() + bus.resubscribe(h_old, h_new) + assert bus.total_subscriptions() == before + + def test_clear_topic_reduces_count(self): + bus = _make_bus() + bus.subscribe("a", lambda t, p: None) + bus.subscribe("a", lambda t, p: None) + bus.subscribe("b", lambda t, p: None) + bus.clear_topic("a") + assert bus.total_subscriptions() == 1 + + +# --------------------------------------------------------------------------- +# 4. ComplianceChecker.newest_backup_name() +# --------------------------------------------------------------------------- + +class TestComplianceCheckerNewestBackupName: + def test_no_backup_returns_none(self): + from ipfs_datasets_py.mcp_server.compliance_checker import ComplianceChecker + with tempfile.NamedTemporaryFile(delete=False, suffix=".enc") as f: + path = f.name + try: + assert ComplianceChecker.newest_backup_name(path) is None + finally: + os.unlink(path) + + def test_one_backup_returns_name(self): + from ipfs_datasets_py.mcp_server.compliance_checker import ComplianceChecker + with tempfile.NamedTemporaryFile(delete=False, suffix=".enc") as f: + path = f.name + bak = path + ".bak" + try: + with open(bak, "w") as bf: + bf.write("x") + name = ComplianceChecker.newest_backup_name(path) + assert name is not None + finally: + os.unlink(path) + if os.path.exists(bak): + os.unlink(bak) + + def test_returns_basename_not_full_path(self): + from ipfs_datasets_py.mcp_server.compliance_checker import ComplianceChecker + with tempfile.NamedTemporaryFile(delete=False, suffix=".enc") as f: + path = f.name + bak = path + ".bak" + try: + with open(bak, "w") as bf: + bf.write("x") + name = ComplianceChecker.newest_backup_name(path) + assert os.sep not in name + finally: + os.unlink(path) + if os.path.exists(bak): + os.unlink(bak) + + def test_returns_string_or_none(self): + from ipfs_datasets_py.mcp_server.compliance_checker import ComplianceChecker + with tempfile.NamedTemporaryFile(delete=False, suffix=".enc") as f: + path = f.name + try: + result = ComplianceChecker.newest_backup_name(path) + assert result is None or isinstance(result, str) + finally: + os.unlink(path) + + def test_consistent_with_backup_names_first(self): + from ipfs_datasets_py.mcp_server.compliance_checker import ComplianceChecker + with tempfile.NamedTemporaryFile(delete=False, suffix=".enc") as f: + path = f.name + bak = path + ".bak" + try: + with open(bak, "w") as bf: + bf.write("x") + names = ComplianceChecker.backup_names(path) + newest = ComplianceChecker.newest_backup_name(path) + if names: + assert newest == names[0] + else: + assert newest is None + finally: + os.unlink(path) + if os.path.exists(bak): + os.unlink(bak) + + def test_ends_with_dot_bak(self): + from ipfs_datasets_py.mcp_server.compliance_checker import ComplianceChecker + with tempfile.NamedTemporaryFile(delete=False, suffix=".enc") as f: + path = f.name + bak = path + ".bak" + try: + with open(bak, "w") as bf: + bf.write("x") + name = ComplianceChecker.newest_backup_name(path) + assert name is not None and name.endswith(".bak") + finally: + os.unlink(path) + if os.path.exists(bak): + os.unlink(bak) + + def test_consistent_with_newest_backup_path(self): + from ipfs_datasets_py.mcp_server.compliance_checker import ComplianceChecker + with tempfile.NamedTemporaryFile(delete=False, suffix=".enc") as f: + path = f.name + bak = path + ".bak" + try: + with open(bak, "w") as bf: + bf.write("x") + full_path = ComplianceChecker.newest_backup_path(path) + name = ComplianceChecker.newest_backup_name(path) + if full_path is not None: + assert name == os.path.basename(full_path) + else: + assert name is None + finally: + os.unlink(path) + if os.path.exists(bak): + os.unlink(bak) + + def test_after_purge_returns_none(self): + from ipfs_datasets_py.mcp_server.compliance_checker import ComplianceChecker + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "policy.enc") + bak = path + ".bak" + with open(path, "w") as f: + f.write("v1") + with open(bak, "w") as f: + f.write("bak") + assert ComplianceChecker.newest_backup_name(path) is not None + ComplianceChecker.purge_bak_files(path) + assert ComplianceChecker.newest_backup_name(path) is None + + +# --------------------------------------------------------------------------- +# 5. E2E: Session 83 combined flows +# --------------------------------------------------------------------------- + +class TestE2ESession83: + def test_values_zip_keys_round_trip(self): + """values() zipped with keys() gives the same dict as dict(r).""" + r = _make_merge_result(added=6, conflicts=2, revocations=1) + d_zip = dict(zip(r.keys(), r.values())) + d_iter = dict(r) + assert d_zip == d_iter + + def test_iter_all_for_unified_audit_log(self): + """iter_all() covers both succeeded and failed for an audit summary.""" + r = _make_reload_result(count=5, failed=2) + succeeded = [(n, c) for n, c in r.iter_all() if c is not None] + failed = [(n, c) for n, c in r.iter_all() if c is None] + assert len(succeeded) + len(failed) == 5 + assert len(failed) == 2 + + def test_total_subscriptions_vs_handler_count(self): + """total_subscriptions counts registrations; handler_count deduplicates.""" + bus = _make_bus() + shared = lambda t, p: None # noqa: E731 + unique = lambda t, p: None # noqa: E731 + bus.subscribe("a", shared) + bus.subscribe("b", shared) + bus.subscribe("a", unique) + assert bus.total_subscriptions() == 3 + assert bus.handler_count() == 2 # shared + unique + + def test_newest_backup_name_rotate_cycle(self): + """newest_backup_name tracks the primary .bak after rotation.""" + import shutil + from ipfs_datasets_py.mcp_server.compliance_checker import ComplianceChecker + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "rules.enc") + with open(path, "w") as f: + f.write("v1") + bak = path + ".bak" + shutil.copy2(path, bak) + name_before = ComplianceChecker.newest_backup_name(path) + assert name_before == os.path.basename(bak) + # After rotate, .bak moves to .bak.1; .bak no longer exists + ComplianceChecker.rotate_bak(path, max_keep=3) + name_after = ComplianceChecker.newest_backup_name(path) + # .bak is gone → newest is now .bak.1 + assert name_after == os.path.basename(path + ".bak.1") diff --git a/tests/mcp/unit/test_mcplusplus_v39_session84.py b/tests/mcp/unit/test_mcplusplus_v39_session84.py new file mode 100644 index 000000000..4164a6a20 --- /dev/null +++ b/tests/mcp/unit/test_mcplusplus_v39_session84.py @@ -0,0 +1,419 @@ +"""Session 84 — MCP++ v39 Next Steps. + +Implements tests for: + 1. MergeResult.items() (list of (key, value) tuples) + 2. IPFSReloadResult.as_dict() ({name: cid_or_none} flat dict) + 3. PubSubBus.topics_with_count() ([(topic, count)] sorted desc) + 4. ComplianceChecker.oldest_backup_name(path) (basename of oldest .bak) + 5. E2E: items() triad, as_dict() round-trip, + topics_with_count ordering, oldest+newest backup names together +""" + +from __future__ import annotations + +import os +import tempfile + +import pytest + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_merge_result(added: int = 0, conflicts: int = 0, revocations: int = 0): + from ipfs_datasets_py.mcp_server.ucan_delegation import MergeResult + return MergeResult(added_count=added, conflict_count=conflicts, revocations_copied=revocations) + + +def _make_reload_result(count: int = 4, failed: int = 0, + pin_errors: dict | None = None): + from ipfs_datasets_py.mcp_server.nl_ucan_policy import IPFSReloadResult + pin_results: dict = {} + for i in range(count - failed): + pin_results[f"p{i}"] = f"Qm{i:040d}" + for i in range(failed): + pin_results[f"f{i}"] = None + return IPFSReloadResult(count=count, pin_results=pin_results, + pin_errors=pin_errors) + + +def _make_bus(): + from ipfs_datasets_py.mcp_server.mcp_p2p_transport import PubSubBus + return PubSubBus() + + +# --------------------------------------------------------------------------- +# 1. MergeResult.items() +# --------------------------------------------------------------------------- + +class TestMergeResultItems: + def test_items_returns_list(self): + r = _make_merge_result() + assert isinstance(r.items(), list) + + def test_items_length_three(self): + r = _make_merge_result() + assert len(r.items()) == 3 + + def test_items_are_tuples(self): + r = _make_merge_result() + for item in r.items(): + assert isinstance(item, tuple) and len(item) == 2 + + def test_items_keys_match_keys_method(self): + r = _make_merge_result(added=5, conflicts=2, revocations=1) + assert [k for k, _v in r.items()] == r.keys() + + def test_items_values_match_values_method(self): + r = _make_merge_result(added=5, conflicts=2, revocations=1) + assert [v for _k, v in r.items()] == r.values() + + def test_items_consistent_with_iter(self): + r = _make_merge_result(added=4, conflicts=3, revocations=2) + assert r.items() == list(r) + + def test_items_construct_dict(self): + r = _make_merge_result(added=7, conflicts=1, revocations=0) + d = dict(r.items()) + assert d == {"added_count": 7, "conflict_count": 1, "revocations_copied": 0} + + def test_items_first_entry(self): + r = _make_merge_result(added=9) + assert r.items()[0] == ("added_count", 9) + + def test_items_last_entry(self): + r = _make_merge_result(revocations=3) + assert r.items()[-1] == ("revocations_copied", 3) + + def test_items_stable_across_calls(self): + r = _make_merge_result(added=2, conflicts=5, revocations=1) + assert r.items() == r.items() + + +# --------------------------------------------------------------------------- +# 2. IPFSReloadResult.as_dict() +# --------------------------------------------------------------------------- + +class TestIPFSReloadResultAsDict: + def test_as_dict_returns_dict(self): + r = _make_reload_result(count=3) + assert isinstance(r.as_dict(), dict) + + def test_as_dict_keys_are_policy_names(self): + r = _make_reload_result(count=3, failed=1) + assert set(r.as_dict().keys()) == set(r.pin_results.keys()) + + def test_as_dict_values_match_pin_results(self): + r = _make_reload_result(count=3, failed=1) + for name, cid in r.as_dict().items(): + assert r.pin_results[name] == cid + + def test_as_dict_none_for_failed(self): + r = _make_reload_result(count=3, failed=1) + d = r.as_dict() + none_count = sum(1 for v in d.values() if v is None) + assert none_count == 1 + + def test_as_dict_all_values_set_when_all_succeed(self): + r = _make_reload_result(count=4, failed=0) + d = r.as_dict() + assert all(v is not None for v in d.values()) + + def test_as_dict_empty_result(self): + r = _make_reload_result(count=0, failed=0) + assert r.as_dict() == {} + + def test_as_dict_matches_iter_all(self): + r = _make_reload_result(count=5, failed=2) + assert r.as_dict() == dict(r.iter_all()) + + def test_as_dict_is_independent_copy(self): + r = _make_reload_result(count=3, failed=0) + d = r.as_dict() + d.clear() # mutating the copy should not affect pin_results + assert len(r.pin_results) == 3 + + def test_as_dict_length_equals_count(self): + r = _make_reload_result(count=5, failed=2) + assert len(r.as_dict()) == len(r.pin_results) + + def test_as_dict_serialisable_structure(self): + import json + r = _make_reload_result(count=3, failed=1) + # values are str or None — JSON serialisable + d = r.as_dict() + for v in d.values(): + assert v is None or isinstance(v, str) + + +# --------------------------------------------------------------------------- +# 3. PubSubBus.topics_with_count() +# --------------------------------------------------------------------------- + +class TestPubSubBusTopicsWithCount: + def test_empty_bus_returns_empty_list(self): + bus = _make_bus() + assert bus.topics_with_count() == [] + + def test_single_topic_single_handler(self): + bus = _make_bus() + bus.subscribe("receipts", lambda t, p: None) + result = bus.topics_with_count() + assert len(result) == 1 + topic, count = result[0] + assert topic == "receipts" + assert count == 1 + + def test_sorted_descending_by_count(self): + bus = _make_bus() + h1 = lambda t, p: None # noqa: E731 + h2 = lambda t, p: None # noqa: E731 + h3 = lambda t, p: None # noqa: E731 + bus.subscribe("low", h1) # 1 subscriber + bus.subscribe("high", h1) + bus.subscribe("high", h2) + bus.subscribe("high", h3) # 3 subscribers + bus.subscribe("mid", h1) + bus.subscribe("mid", h2) # 2 subscribers + counts = [c for _t, c in bus.topics_with_count()] + assert counts == sorted(counts, reverse=True) + + def test_all_topics_present(self): + bus = _make_bus() + h = lambda t, p: None # noqa: E731 + bus.subscribe("a", h) + bus.subscribe("b", h) + topics = {t for t, _c in bus.topics_with_count()} + assert topics == {"a", "b"} + + def test_returns_list_of_tuples(self): + bus = _make_bus() + bus.subscribe("x", lambda t, p: None) + result = bus.topics_with_count() + assert isinstance(result, list) + for item in result: + assert isinstance(item, tuple) and len(item) == 2 + + def test_counts_are_positive_integers(self): + bus = _make_bus() + bus.subscribe("x", lambda t, p: None) + for _t, count in bus.topics_with_count(): + assert isinstance(count, int) and count > 0 + + def test_consistent_with_subscription_count(self): + bus = _make_bus() + h1 = lambda t, p: None # noqa: E731 + h2 = lambda t, p: None # noqa: E731 + bus.subscribe("receipts", h1) + bus.subscribe("receipts", h2) + bus.subscribe("audit", h1) + for topic, count in bus.topics_with_count(): + assert count == bus.subscription_count(topic) + + def test_empty_after_clear_all(self): + bus = _make_bus() + bus.subscribe("x", lambda t, p: None) + bus.clear_all() + assert bus.topics_with_count() == [] + + def test_highest_count_first(self): + bus = _make_bus() + h1 = lambda t, p: None # noqa: E731 + h2 = lambda t, p: None # noqa: E731 + h3 = lambda t, p: None # noqa: E731 + bus.subscribe("popular", h1) + bus.subscribe("popular", h2) + bus.subscribe("popular", h3) + bus.subscribe("lonely", h1) + first_topic, first_count = bus.topics_with_count()[0] + assert first_topic == "popular" + assert first_count == 3 + + def test_single_topic_after_unsubscribe(self): + bus = _make_bus() + h = lambda t, p: None # noqa: E731 + sid = bus.subscribe("x", h) + bus.unsubscribe_by_id(sid) + assert bus.topics_with_count() == [] + + +# --------------------------------------------------------------------------- +# 4. ComplianceChecker.oldest_backup_name() +# --------------------------------------------------------------------------- + +class TestComplianceCheckerOldestBackupName: + def test_no_backup_returns_none(self): + from ipfs_datasets_py.mcp_server.compliance_checker import ComplianceChecker + with tempfile.NamedTemporaryFile(delete=False, suffix=".enc") as f: + path = f.name + try: + assert ComplianceChecker.oldest_backup_name(path) is None + finally: + os.unlink(path) + + def test_one_backup_returns_basename(self): + from ipfs_datasets_py.mcp_server.compliance_checker import ComplianceChecker + with tempfile.NamedTemporaryFile(delete=False, suffix=".enc") as f: + path = f.name + bak = path + ".bak" + try: + with open(bak, "w") as bf: + bf.write("x") + name = ComplianceChecker.oldest_backup_name(path) + assert name is not None and os.sep not in name + finally: + os.unlink(path) + if os.path.exists(bak): + os.unlink(bak) + + def test_returns_last_basename_in_list(self): + from ipfs_datasets_py.mcp_server.compliance_checker import ComplianceChecker + with tempfile.NamedTemporaryFile(delete=False, suffix=".enc") as f: + path = f.name + bak = path + ".bak" + bak1 = path + ".bak.1" + try: + with open(bak, "w") as bf: + bf.write("new") + with open(bak1, "w") as bf: + bf.write("old") + name = ComplianceChecker.oldest_backup_name(path) + assert name == os.path.basename(bak1) + finally: + os.unlink(path) + for p in (bak, bak1): + if os.path.exists(p): + os.unlink(p) + + def test_consistent_with_oldest_backup_path(self): + from ipfs_datasets_py.mcp_server.compliance_checker import ComplianceChecker + with tempfile.NamedTemporaryFile(delete=False, suffix=".enc") as f: + path = f.name + bak = path + ".bak" + try: + with open(bak, "w") as bf: + bf.write("x") + full = ComplianceChecker.oldest_backup_path(path) + name = ComplianceChecker.oldest_backup_name(path) + if full is not None: + assert name == os.path.basename(full) + else: + assert name is None + finally: + os.unlink(path) + if os.path.exists(bak): + os.unlink(bak) + + def test_consistent_with_backup_names_last(self): + from ipfs_datasets_py.mcp_server.compliance_checker import ComplianceChecker + with tempfile.NamedTemporaryFile(delete=False, suffix=".enc") as f: + path = f.name + bak = path + ".bak" + try: + with open(bak, "w") as bf: + bf.write("x") + names = ComplianceChecker.backup_names(path) + oldest = ComplianceChecker.oldest_backup_name(path) + if names: + assert oldest == names[-1] + else: + assert oldest is None + finally: + os.unlink(path) + if os.path.exists(bak): + os.unlink(bak) + + def test_no_dir_separator_in_name(self): + from ipfs_datasets_py.mcp_server.compliance_checker import ComplianceChecker + with tempfile.NamedTemporaryFile(delete=False, suffix=".enc") as f: + path = f.name + bak = path + ".bak" + try: + with open(bak, "w") as bf: + bf.write("x") + name = ComplianceChecker.oldest_backup_name(path) + assert name is not None + assert "/" not in name and "\\" not in name + finally: + os.unlink(path) + if os.path.exists(bak): + os.unlink(bak) + + def test_returns_none_after_purge(self): + from ipfs_datasets_py.mcp_server.compliance_checker import ComplianceChecker + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "policy.enc") + with open(path, "w") as f: + f.write("v1") + with open(path + ".bak", "w") as f: + f.write("bak") + ComplianceChecker.purge_bak_files(path) + assert ComplianceChecker.oldest_backup_name(path) is None + + def test_one_backup_newest_and_oldest_same(self): + from ipfs_datasets_py.mcp_server.compliance_checker import ComplianceChecker + with tempfile.NamedTemporaryFile(delete=False, suffix=".enc") as f: + path = f.name + bak = path + ".bak" + try: + with open(bak, "w") as bf: + bf.write("x") + assert ComplianceChecker.newest_backup_name(path) == \ + ComplianceChecker.oldest_backup_name(path) + finally: + os.unlink(path) + if os.path.exists(bak): + os.unlink(bak) + + +# --------------------------------------------------------------------------- +# 5. E2E: Session 84 combined flows +# --------------------------------------------------------------------------- + +class TestE2ESession84: + def test_items_keys_values_triad(self): + """items(), keys(), values() all describe the same data.""" + r = _make_merge_result(added=3, conflicts=1, revocations=2) + assert dict(r.items()) == dict(zip(r.keys(), r.values())) + assert [k for k, _v in r.items()] == r.keys() + assert [v for _k, v in r.items()] == r.values() + + def test_as_dict_round_trip(self): + """as_dict() produces the same mapping as dict(iter_all()).""" + r = _make_reload_result(count=6, failed=2) + assert r.as_dict() == dict(r.iter_all()) + # all keys present + assert set(r.as_dict().keys()) == set(r.pin_results.keys()) + + def test_topics_with_count_ordering_and_consistency(self): + """topics_with_count() is sorted desc and consistent with subscription_count.""" + bus = _make_bus() + h1 = lambda t, p: None # noqa: E731 + h2 = lambda t, p: None # noqa: E731 + bus.subscribe("high", h1) + bus.subscribe("high", h2) + bus.subscribe("low", h1) + twc = bus.topics_with_count() + counts = [c for _t, c in twc] + assert counts == sorted(counts, reverse=True) + for topic, count in twc: + assert bus.subscription_count(topic) == count + + def test_oldest_newest_backup_name_two_backups(self): + """With two backups newest != oldest; each is a basename.""" + from ipfs_datasets_py.mcp_server.compliance_checker import ComplianceChecker + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "policy.enc") + with open(path, "w") as f: + f.write("v1") + with open(path + ".bak", "w") as f: + f.write("new") + with open(path + ".bak.1", "w") as f: + f.write("old") + newest = ComplianceChecker.newest_backup_name(path) + oldest = ComplianceChecker.oldest_backup_name(path) + assert newest is not None and oldest is not None + assert newest != oldest + assert os.sep not in newest + assert os.sep not in oldest