Compare commits
1 Commits
step35/132
...
step35/91-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1a728f5f4 |
@@ -1,288 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Codebase Genome Diff — Detect structural changes between two versions.
|
||||
|
||||
Compares two git refs (commits, branches, tags) and produces a human-readable
|
||||
report of structural changes:
|
||||
• Added/removed/renamed files
|
||||
• Changed functions/classes (signature modifications)
|
||||
• New dependencies (imports, requirements, etc.)
|
||||
|
||||
Usage:
|
||||
python3 scripts/genome_diff.py --ref1 <commit1> --ref2 <commit2>
|
||||
python3 scripts/genome_diff.py --ref1 main --ref2 feature-branch
|
||||
python3 scripts/genome_diff.py --ref1 v1.0 --ref2 v2.0 --output report.txt
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, SCRIPT_DIR)
|
||||
from diff_analyzer import DiffAnalyzer, ChangeCategory
|
||||
|
||||
|
||||
@dataclass
|
||||
class FunctionChange:
|
||||
file: str
|
||||
name: str
|
||||
kind: str # 'function' or 'class'
|
||||
change_type: str # 'added' or 'removed' (simplified)
|
||||
old_line: Optional[int] = None
|
||||
new_line: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DependencyChange:
|
||||
file: str
|
||||
module: str
|
||||
change_type: str # 'added' or 'removed' or 'modified'
|
||||
line: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class GenomeDiffReport:
|
||||
ref1: str
|
||||
ref2: str
|
||||
file_changes: List[Dict[str, Any]] = field(default_factory=list)
|
||||
function_changes: List[FunctionChange] = field(default_factory=list)
|
||||
dependency_changes: List[DependencyChange] = field(default_factory=list)
|
||||
total_files_changed: int = 0
|
||||
total_functions_changed: int = 0
|
||||
total_dependencies_changed: int = 0
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"ref1": self.ref1,
|
||||
"ref2": self.ref2,
|
||||
"summary": {
|
||||
"files": self.total_files_changed,
|
||||
"functions": self.total_functions_changed,
|
||||
"dependencies": self.total_dependencies_changed,
|
||||
},
|
||||
"file_changes": self.file_changes,
|
||||
"function_changes": [fc.__dict__ for fc in self.function_changes],
|
||||
"dependency_changes": [dc.__dict__ for dc in self.dependency_changes],
|
||||
}
|
||||
|
||||
def human_report(self) -> str:
|
||||
lines = []
|
||||
lines.append(f"Codebase Genome Diff: {self.ref1} → {self.ref2}")
|
||||
lines.append("=" * 60)
|
||||
lines.append(f" Files changed: {self.total_files_changed}")
|
||||
lines.append(f" Functions changed: {self.total_functions_changed}")
|
||||
lines.append(f" Dependencies changed: {self.total_dependencies_changed}")
|
||||
lines.append("")
|
||||
|
||||
for fc in self.file_changes:
|
||||
kind = []
|
||||
if fc.get('is_new'):
|
||||
kind.append("NEW")
|
||||
if fc.get('is_deleted'):
|
||||
kind.append("DELETED")
|
||||
if fc.get('is_renamed'):
|
||||
kind.append("RENAMED")
|
||||
if fc.get('is_binary'):
|
||||
kind.append("BINARY")
|
||||
kind_str = f" [{', '.join(kind)}]" if kind else ""
|
||||
lines.append(f" {fc['path']}{kind_str} (+{fc['added_lines']}/-{fc['deleted_lines']})")
|
||||
lines.append("")
|
||||
|
||||
for fc in self.function_changes:
|
||||
op = {'added': '+', 'removed': '-', 'modified': '~'}.get(fc.change_type, '?')
|
||||
lines.append(f" [{op}] {fc.file}: {fc.kind} '{fc.name}'")
|
||||
lines.append("")
|
||||
|
||||
for dc in self.dependency_changes:
|
||||
op = '+' if dc.change_type == 'added' else '-'
|
||||
lines.append(f" [{op}] {dc.file}: {dc.module}")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def run_git_diff(ref1: str, ref2: str) -> str:
|
||||
result = subprocess.run(
|
||||
['git', 'diff', '--unified=0', f'{ref1}...{ref2}'],
|
||||
capture_output=True, text=True, cwd=SCRIPT_DIR
|
||||
)
|
||||
if result.returncode not in (0, 1):
|
||||
print(f"git diff failed: {result.stderr}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def extract_function_changes(diff_text: str) -> List[FunctionChange]:
|
||||
changes: List[FunctionChange] = []
|
||||
pattern = re.compile(r'^([+\-])\s*(def|class)\s+(\w+)', re.MULTILINE)
|
||||
hunk_header_re = re.compile(r'^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@')
|
||||
current_old_line: Optional[int] = None
|
||||
current_new_line: Optional[int] = None
|
||||
|
||||
for line in diff_text.split('\n'):
|
||||
hdr = hunk_header_re.match(line)
|
||||
if hdr:
|
||||
current_old_line = int(hdr.group(1))
|
||||
current_new_line = int(hdr.group(3))
|
||||
continue
|
||||
m = pattern.match(line)
|
||||
if m:
|
||||
op = m.group(1)
|
||||
kind = m.group(2)
|
||||
name = m.group(3)
|
||||
change_type = "added" if op == '+' else "removed"
|
||||
line_num = current_new_line if change_type == "added" else current_old_line
|
||||
changes.append(FunctionChange(
|
||||
file="<unknown>",
|
||||
name=name,
|
||||
kind=kind,
|
||||
change_type=change_type,
|
||||
new_line=line_num if change_type == "added" else None,
|
||||
old_line=line_num if change_type == "removed" else None,
|
||||
))
|
||||
# Advance line counters heuristically
|
||||
if op == '-':
|
||||
if current_old_line is not None:
|
||||
current_old_line += 1
|
||||
elif op == '+':
|
||||
if current_new_line is not None:
|
||||
current_new_line += 1
|
||||
elif line.startswith(' '):
|
||||
if current_old_line is not None:
|
||||
current_old_line += 1
|
||||
if current_new_line is not None:
|
||||
current_new_line += 1
|
||||
# lines starting with other prefixes (like \\ No newline) ignored
|
||||
return changes
|
||||
|
||||
|
||||
def extract_dependency_changes(diff_text: str, analyzer: DiffAnalyzer) -> List[DependencyChange]:
|
||||
changes: List[DependencyChange] = []
|
||||
import_pattern = re.compile(
|
||||
r'^([+\-])\s*(?:import\s+([\w\.]+)|from\s+([\w\.]+)\s+import)',
|
||||
re.MULTILINE
|
||||
)
|
||||
file_diffs = analyzer._split_files(diff_text)
|
||||
for file_diff in file_diffs:
|
||||
file_match = re.search(r'^diff --git a/.*? b/(.*?)$', file_diff, re.MULTILINE)
|
||||
if not file_match:
|
||||
continue
|
||||
filepath = file_match.group(1)
|
||||
|
||||
# Scan each line for import changes
|
||||
for line in file_diff.split('\n'):
|
||||
m = import_pattern.match(line)
|
||||
if m:
|
||||
change_type = "added" if m.group(1) == '+' else "removed"
|
||||
module = m.group(2) or m.group(3)
|
||||
changes.append(DependencyChange(
|
||||
file=filepath,
|
||||
module=module,
|
||||
change_type=change_type,
|
||||
line=0
|
||||
))
|
||||
|
||||
# Detect if this file is a dependency manifest
|
||||
req_file_pattern = re.compile(
|
||||
r'^[\+\-].*?(requirements(.*?)\.txt|pyproject\.toml|setup\.py|Pipfile)'
|
||||
)
|
||||
if any(req_file_pattern.match(line) for line in file_diff.split('\n')):
|
||||
if not any(c.file == filepath and c.module == "<file>" for c in changes):
|
||||
changes.append(DependencyChange(
|
||||
file=filepath,
|
||||
module="<file>",
|
||||
change_type="modified",
|
||||
line=0
|
||||
))
|
||||
return changes
|
||||
|
||||
|
||||
def correlate_function_changes_with_files(diff_text: str, functions: List[FunctionChange]) -> List[FunctionChange]:
|
||||
result: List[FunctionChange] = []
|
||||
# Split diff into per-file sections
|
||||
file_sections: List[tuple[str, str]] = []
|
||||
current_file: Optional[str] = None
|
||||
current_lines: List[str] = []
|
||||
for line in diff_text.split('\n'):
|
||||
if line.startswith('diff --git'):
|
||||
if current_file is not None:
|
||||
file_sections.append((current_file, '\n'.join(current_lines)))
|
||||
m = re.match(r'^diff --git a/.*? b/(.*?)$', line)
|
||||
current_file = m.group(1) if m else "unknown"
|
||||
current_lines = [line]
|
||||
else:
|
||||
current_lines.append(line)
|
||||
if current_file is not None:
|
||||
file_sections.append((current_file, '\n'.join(current_lines)))
|
||||
|
||||
pattern = re.compile(r'^([+\-])\s*(def|class)\s+(\w+)', re.MULTILINE)
|
||||
for filepath, section in file_sections:
|
||||
for m in pattern.finditer(section):
|
||||
op = m.group(1)
|
||||
kind = m.group(2)
|
||||
name = m.group(3)
|
||||
change_type = "added" if op == '+' else "removed"
|
||||
result.append(FunctionChange(
|
||||
file=filepath,
|
||||
name=name,
|
||||
kind=kind,
|
||||
change_type=change_type
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Codebase Genome Diff — structural changes between versions")
|
||||
parser.add_argument("--ref1", required=True, help="First git ref (commit, branch, tag)")
|
||||
parser.add_argument("--ref2", required=True, help="Second git ref")
|
||||
parser.add_argument("--output", help="Write report to file")
|
||||
parser.add_argument("--json", action="store_true", help="Output JSON instead of human report")
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
diff_text = run_git_diff(args.ref1, args.ref2)
|
||||
except Exception as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if not diff_text.strip():
|
||||
print(f"No differences between {args.ref1} and {args.ref2}.")
|
||||
sys.exit(0)
|
||||
|
||||
analyzer = DiffAnalyzer()
|
||||
summary = analyzer.analyze(diff_text)
|
||||
|
||||
file_changes = [fc.to_dict() for fc in summary.files]
|
||||
func_changes = extract_function_changes(diff_text)
|
||||
func_changes = correlate_function_changes_with_files(diff_text, func_changes)
|
||||
dep_changes = extract_dependency_changes(diff_text, analyzer)
|
||||
|
||||
report = GenomeDiffReport(
|
||||
ref1=args.ref1,
|
||||
ref2=args.ref2,
|
||||
file_changes=file_changes,
|
||||
function_changes=func_changes,
|
||||
dependency_changes=dep_changes,
|
||||
total_files_changed=len(file_changes),
|
||||
total_functions_changed=len(func_changes),
|
||||
total_dependencies_changed=len(dep_changes),
|
||||
)
|
||||
|
||||
output = json.dumps(report.to_dict(), indent=2) if args.json else report.human_report()
|
||||
|
||||
if args.output:
|
||||
with open(args.output, 'w') as f:
|
||||
f.write(output + '\n')
|
||||
print(f"Report written to {args.output}")
|
||||
else:
|
||||
print(output)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -22,114 +22,95 @@ import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from session_reader import extract_conversation, read_session
|
||||
|
||||
|
||||
def compute_hash(text: str) -> str:
|
||||
"""Content hash for deduplication."""
|
||||
return hashlib.sha256(text.encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
def extract_pairs_from_session(session_data: dict, min_ratio: float = 1.5,
|
||||
def extract_pairs_from_conversation(conversation: list, session_id: str, model: str,
|
||||
min_ratio: float = 1.5,
|
||||
min_response_words: int = 20) -> list:
|
||||
"""Extract terse→rich pairs from a single session object."""
|
||||
"""Extract terse→rich pairs from a normalized conversation."""
|
||||
pairs = []
|
||||
conversations = session_data.get("conversations", [])
|
||||
session_id = session_data.get("id", "unknown")
|
||||
model = session_data.get("model", "unknown")
|
||||
|
||||
seen_hashes = set()
|
||||
|
||||
for i, msg in enumerate(conversations):
|
||||
# Look for assistant/gpt responses
|
||||
if msg.get("from") not in ("gpt", "assistant"):
|
||||
for i, msg in enumerate(conversation):
|
||||
# Look for assistant responses
|
||||
if msg.get('role') != 'assistant':
|
||||
continue
|
||||
|
||||
response_text = msg.get("value", "")
|
||||
response_text = msg.get('content', '')
|
||||
if not response_text or len(response_text.split()) < min_response_words:
|
||||
continue
|
||||
|
||||
# Find the preceding human message
|
||||
# Find the preceding user message
|
||||
prompt_text = ""
|
||||
for j in range(i - 1, -1, -1):
|
||||
if conversations[j].get("from") == "human":
|
||||
prompt_text = conversations[j].get("value", "")
|
||||
if conversation[j].get('role') == 'user':
|
||||
prompt_text = conversation[j].get('content', '')
|
||||
break
|
||||
|
||||
if not prompt_text:
|
||||
continue
|
||||
|
||||
# Filter: skip tool results, system messages embedded as human
|
||||
if prompt_text.startswith("{") and "output" in prompt_text[:100]:
|
||||
continue # likely a tool result
|
||||
if prompt_text.startswith("# SOUL.md") or prompt_text.startswith("You are"):
|
||||
continue # system prompt leak
|
||||
if prompt_text.startswith('{') and 'output' in prompt_text[:100]:
|
||||
continue
|
||||
if prompt_text.startswith('# SOUL.md') or prompt_text.startswith('You are'):
|
||||
continue
|
||||
|
||||
# Quality filters
|
||||
prompt_words = len(prompt_text.split())
|
||||
response_words = len(response_text.split())
|
||||
|
||||
# Must have meaningful length ratio
|
||||
if prompt_words == 0 or response_words == 0:
|
||||
continue
|
||||
ratio = response_words / prompt_words
|
||||
if ratio < min_ratio:
|
||||
continue
|
||||
|
||||
# Skip responses that are mostly code
|
||||
code_blocks = response_text.count("```")
|
||||
if code_blocks >= 4 and len(response_text.replace("```", "").strip()) < 50:
|
||||
code_blocks = response_text.count('```')
|
||||
if code_blocks >= 4 and len(response_text.replace('```', '').strip()) < 50:
|
||||
continue
|
||||
|
||||
# Skip responses with tool call artifacts
|
||||
if "tool_call" in response_text[:100] or "function_call" in response_text[:100]:
|
||||
if 'tool_call' in response_text[:100] or 'function_call' in response_text[:100]:
|
||||
continue
|
||||
|
||||
# Deduplicate by content hash
|
||||
content_hash = compute_hash(prompt_text + response_text[:200])
|
||||
if content_hash in seen_hashes:
|
||||
continue
|
||||
seen_hashes.add(content_hash)
|
||||
|
||||
# Clean up response: remove markdown headers if too many
|
||||
clean_response = response_text
|
||||
|
||||
pairs.append({
|
||||
"terse": prompt_text.strip(),
|
||||
"rich": clean_response.strip(),
|
||||
"source": session_id,
|
||||
"model": model,
|
||||
"prompt_words": prompt_words,
|
||||
"response_words": response_words,
|
||||
"ratio": round(ratio, 2),
|
||||
'terse': prompt_text.strip(),
|
||||
'rich': clean_response.strip(),
|
||||
'source': session_id,
|
||||
'model': model,
|
||||
'prompt_words': prompt_words,
|
||||
'response_words': response_words,
|
||||
'ratio': round(ratio, 2),
|
||||
})
|
||||
|
||||
return pairs
|
||||
|
||||
|
||||
def extract_from_jsonl_file(filepath: str, **kwargs) -> list:
|
||||
"""Extract pairs from a session JSONL file."""
|
||||
pairs = []
|
||||
path = Path(filepath)
|
||||
|
||||
if not path.exists():
|
||||
print(f"Warning: {filepath} not found", file=sys.stderr)
|
||||
return pairs
|
||||
|
||||
content = path.read_text()
|
||||
lines = content.strip().split("\n")
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
session = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
session_pairs = extract_pairs_from_session(session, **kwargs)
|
||||
pairs.extend(session_pairs)
|
||||
|
||||
return pairs
|
||||
def extract_from_jsonl_file(path: str, **kwargs) -> list:
|
||||
"""Read a session file and extract training pairs using normalized conversation."""
|
||||
session_messages = read_session(path)
|
||||
if not session_messages:
|
||||
return []
|
||||
conversation = extract_conversation(session_messages)
|
||||
# Derive session_id and model from first real message metadata
|
||||
first_msg = next((m for m in session_messages if m.get('role') or m.get('from')), {})
|
||||
session_id = first_msg.get('meta_session_id', Path(path).name)
|
||||
model = first_msg.get('model', 'unknown')
|
||||
return extract_pairs_from_conversation(conversation, session_id, model, **kwargs)
|
||||
|
||||
|
||||
def deduplicate_pairs(pairs: list) -> list:
|
||||
|
||||
118
tests/test_session_pair_harvester.py
Normal file
118
tests/test_session_pair_harvester.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
Tests for session_pair_harvester — training pair extraction from sessions.
|
||||
"""
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
|
||||
from session_pair_harvester import (
|
||||
extract_pairs_from_conversation,
|
||||
extract_from_jsonl_file,
|
||||
deduplicate_pairs,
|
||||
compute_hash,
|
||||
)
|
||||
|
||||
|
||||
class TestSessionPairHarvester(unittest.TestCase):
|
||||
def test_compute_hash_consistent(self):
|
||||
h1 = compute_hash("hello world")
|
||||
h2 = compute_hash("hello world")
|
||||
self.assertEqual(h1, h2)
|
||||
self.assertEqual(len(h1), 16)
|
||||
|
||||
def test_extract_simple_qa_pair(self):
|
||||
"""A simple user→assistant exchange produces one pair."""
|
||||
conversation = [
|
||||
{"role": "user", "content": "What is the capital of France?"},
|
||||
{"role": "assistant", "content": "The capital of France is Paris. It is a major European city renowned for its art, fashion, gastronomy, cultural heritage, and historical significance. The city attracts millions of tourists annually."},
|
||||
]
|
||||
pairs = extract_pairs_from_conversation(conversation, "test_session", "test-model")
|
||||
self.assertEqual(len(pairs), 1)
|
||||
self.assertEqual(pairs[0]["terse"], "What is the capital of France?")
|
||||
self.assertIn("Paris", pairs[0]["rich"])
|
||||
self.assertEqual(pairs[0]["source"], "test_session")
|
||||
|
||||
def test_min_ratio_filter(self):
|
||||
"""Very short responses are filtered out."""
|
||||
conversation = [
|
||||
{"role": "user", "content": "Yes"},
|
||||
{"role": "assistant", "content": "No."},
|
||||
]
|
||||
# Default min_ratio = 1.5, min_words = 20 for response
|
||||
pairs = extract_pairs_from_conversation(conversation, "s", "m", min_response_words=3)
|
||||
self.assertEqual(len(pairs), 0)
|
||||
|
||||
def test_min_words_filter(self):
|
||||
"""Assistant responses below min word count are skipped."""
|
||||
conversation = [
|
||||
{"role": "user", "content": "Explain the project architecture in detail"},
|
||||
{"role": "assistant", "content": "OK."},
|
||||
]
|
||||
pairs = extract_pairs_from_conversation(conversation, "s", "m", min_response_words=5)
|
||||
self.assertEqual(len(pairs), 0)
|
||||
|
||||
def test_skip_non_assistant_messages(self):
|
||||
"""System and tool messages are ignored."""
|
||||
conversation = [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi there! How can I help you today?"},
|
||||
]
|
||||
pairs = extract_pairs_from_conversation(conversation, "s", "m", min_response_words=3)
|
||||
self.assertEqual(len(pairs), 1)
|
||||
self.assertEqual(pairs[0]["terse"], "Hello")
|
||||
|
||||
def test_multiple_pairs_from_one_session(self):
|
||||
"""A conversation with several Q&A turns yields multiple pairs."""
|
||||
conversation = [
|
||||
{"role": "user", "content": "First question?"},
|
||||
{"role": "assistant", "content": "Here is a detailed and comprehensive answer that thoroughly explores multiple aspects of the subject. It provides background context and practical implications for the reader."},
|
||||
{"role": "user", "content": "Second?"},
|
||||
{"role": "assistant", "content": "Another comprehensive response with detailed examples. This includes practical code blocks and thorough explanations to ensure deep understanding of the topic at hand."},
|
||||
]
|
||||
pairs = extract_pairs_from_conversation(conversation, "s", "m", min_ratio=1.0)
|
||||
self.assertEqual(len(pairs), 2)
|
||||
|
||||
def test_deduplication_removes_duplicates(self):
|
||||
"""Identical pairs across sessions are deduplicated."""
|
||||
pairs = [
|
||||
{"terse": "q1", "rich": "a1", "source": "s1", "model": "m"},
|
||||
{"terse": "q1", "rich": "a1", "source": "s2", "model": "m"},
|
||||
{"terse": "q2", "rich": "a2", "source": "s1", "model": "m"},
|
||||
]
|
||||
unique = deduplicate_pairs(pairs)
|
||||
self.assertEqual(len(unique), 2)
|
||||
sources = {p["source"] for p in unique}
|
||||
# First unique pair can be from either s1 or s2
|
||||
self.assertIn("s1", sources)
|
||||
|
||||
def test_integration_with_test_sessions(self):
|
||||
"""Harvester finds pairs in real test session files."""
|
||||
repo_root = Path(__file__).parent.parent
|
||||
test_sessions_dir = repo_root / "test_sessions"
|
||||
if not test_sessions_dir.exists():
|
||||
self.skipTest("test_sessions not found")
|
||||
|
||||
pairs = []
|
||||
for jsonl_file in sorted(test_sessions_dir.glob("*.jsonl")):
|
||||
pairs.extend(extract_from_jsonl_file(str(jsonl_file)))
|
||||
|
||||
self.assertGreater(len(pairs), 0, "Should extract at least one pair from test_sessions")
|
||||
for p in pairs:
|
||||
self.assertIn("terse", p)
|
||||
self.assertIn("rich", p)
|
||||
self.assertIn("source", p)
|
||||
self.assertIn("model", p)
|
||||
# Verify content exists
|
||||
self.assertGreater(len(p["terse"]), 0)
|
||||
self.assertGreater(len(p["rich"]), 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user