diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fa3d24..930f9de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,41 @@ # cmping changelog +## 0.16.1.dev0 + +(in development) + +## 0.16.0 + +### Features + +- Add timing statistics at the end of each run showing: + - Account setup time + - Group join time + - Message send/receive time + - Send and receive message rates (msg/s) + +### Improvements + +- Streamlined code with extracted helper functions: + - `print_progress()` for consistent progress display + - `format_duration()` for human-readable time formatting + - `wait_profiles_online()` for profile online waiting logic + - `SPINNER_CHARS` constant to avoid duplication + +- Improved verbosity handling for receiver addresses: + - Normal mode: shows only receiver count in statistics + - `-v` mode: shows receiver count after joining + - `-vv` mode: shows full list of receiver addresses + +- Added comprehensive documentation: + - Module-level docstring explaining 4-phase message flow + - Detailed docstrings for Pinger class and methods + +### Bug Fixes + +- Fixed `loss` property to return `0.0` instead of `1` when no messages expected + ## 0.15.0 ### Improvements diff --git a/README.md b/README.md index b2c1109..e8e249f 100644 --- a/README.md +++ b/README.md @@ -67,10 +67,17 @@ Example output for two-domain ping: 3. edit cmping.py and test, finally commit your changes -4. set a new git-tag +4. update CHANGELOG.md with the new version number and changes 5. install build/release tools: `pip install build twine` -6. run the following command: +6. run the release script: - rm -rf dist && python -m build && twine upload -r pypi dist/cmping* + python release.py + + The release script will: + - Validate the version in CHANGELOG.md is a proper version jump + - Create and push a git tag for the version + - Build the package and upload to PyPI + - Add a dev changelog entry and commit it + - Print which tag was uploaded to PyPI diff --git a/cmping.py b/cmping.py index 593e6ff..eda58a2 100644 --- a/cmping.py +++ b/cmping.py @@ -1,5 +1,24 @@ """ chatmail ping aka "cmping" transmits messages between relays. + +Message Flow: +============= +1. ACCOUNT SETUP: Create sender and receiver accounts on specified relay domains + - Each account connects to its relay's IMAP/SMTP servers + - Accounts wait for IMAP_INBOX_IDLE state indicating readiness + +2. GROUP CREATION: Sender creates a group chat and adds all receivers + - An initialization message is sent to promote the group + - All receivers must accept the group invitation before ping begins + +3. PING SEND: Sender transmits messages to the group at specified intervals + - Messages contain: unique-id timestamp sequence-number + - Messages flow: Sender -> relay1 SMTP -> relay2 IMAP -> Receivers + +4. PING RECEIVE: Each receiver waits for incoming messages + - On receipt, round-trip time is calculated from embedded timestamp + - Progress is tracked per-sequence across all receivers + - Stats are accumulated for final report """ import argparse @@ -19,6 +38,9 @@ from deltachat_rpc_client import DeltaChat, EventType, Rpc from xdg_base_dirs import xdg_cache_home +# Spinner characters for progress display +SPINNER_CHARS = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + def log_event_verbose(event, addr, verbose_level=3): """Helper function to log events at specified verbose level.""" @@ -77,6 +99,41 @@ def create_qr_url(domain_or_ip): return f"dcaccount:{domain_or_ip}" +def print_progress(message, current=None, total=None, spinner_idx=0, done=False): + """Print progress with optional spinner and counter. + + Args: + message: The progress message to display + current: Current count (optional) + total: Total count (optional) + spinner_idx: Index into SPINNER_CHARS for spinner animation + done: If True, print 'Done!' and newline + """ + if done: + print(f"\r# {message}... Done!".ljust(60)) + elif current is not None and total is not None: + spinner = SPINNER_CHARS[spinner_idx % len(SPINNER_CHARS)] + print(f"\r# {message} {spinner} {current}/{total}", end="", flush=True) + else: + spinner = SPINNER_CHARS[spinner_idx % len(SPINNER_CHARS)] + print(f"\r# {message} {spinner}", end="", flush=True) + + +def format_duration(seconds): + """Format a duration in seconds to a human-readable string. + + Args: + seconds: Duration in seconds + + Returns: + str: Formatted duration (e.g., "1.23s" or "45.67ms") + """ + if seconds >= 1: + return f"{seconds:.2f}s" + else: + return f"{seconds * 1000:.2f}ms" + + def main(): """Ping between addresses of specified chatmail relay domains or IP addresses.""" @@ -204,6 +261,8 @@ def get_relay_account(self, domain): def setup_accounts(args, maker): """Set up sender and receiver accounts with progress display. + Timing: This function's duration is tracked as 'account_setup_time'. + Returns: tuple: (sender_account, list_of_receiver_accounts) """ @@ -212,20 +271,12 @@ def setup_accounts(args, maker): profiles_created = 0 # Create sender and receiver accounts with spinner - spinner_chars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] - print( - f"# Setting up profiles {spinner_chars[0]} {profiles_created}/{total_profiles}", - end="", - flush=True, - ) + print_progress("Setting up profiles", profiles_created, total_profiles, 0) + try: sender = maker.get_relay_account(args.relay1) profiles_created += 1 - print( - f"\r# Setting up profiles {spinner_chars[profiles_created % len(spinner_chars)]} {profiles_created}/{total_profiles}", - end="", - flush=True, - ) + print_progress("Setting up profiles", profiles_created, total_profiles, profiles_created) except Exception as e: print(f"\r✗ Failed to setup sender profile on {args.relay1}: {e}") sys.exit(1) @@ -237,17 +288,13 @@ def setup_accounts(args, maker): receiver = maker.get_relay_account(args.relay2) receivers.append(receiver) profiles_created += 1 - print( - f"\r# Setting up profiles {spinner_chars[profiles_created % len(spinner_chars)]} {profiles_created}/{total_profiles}", - end="", - flush=True, - ) + print_progress("Setting up profiles", profiles_created, total_profiles, profiles_created) except Exception as e: print(f"\r✗ Failed to setup receiver profile {i+1} on {args.relay2}: {e}") sys.exit(1) # Profile setup complete - print("\r# Setting up profiles... Done!") + print_progress("Setting up profiles", done=True) return sender, receivers @@ -282,6 +329,8 @@ def create_and_promote_group(sender, receivers, verbose=0): def wait_for_receivers_to_join(args, sender, receivers, timeout_seconds=30): """Wait concurrently for all receivers to join the group with progress display. + Timing: This function's duration is tracked as 'group_join_time'. + Args: args: Command line arguments (for verbose flag) sender: Sender account @@ -408,9 +457,14 @@ def wait_for_receiver_join(idx, receiver, deadline): f"\r# Waiting for receivers to come online {len(joined_receivers)}/{total_receivers} - Complete!" ) - # In verbose mode, print all receiver addresses - if args.verbose >= 1 and joined_addrs: - print(f"# Receivers online: {', '.join(joined_addrs)}") + # Print receiver info based on verbosity level + # -vv or higher: print full list of addresses + # -v or normal: just print count + if joined_addrs: + if args.verbose >= 2: + print(f"# Receivers online: {', '.join(joined_addrs)}") + elif args.verbose >= 1: + print(f"# Receivers online: {len(joined_addrs)}") # Check if all receivers joined if len(joined_receivers) < total_receivers: @@ -421,7 +475,59 @@ def wait_for_receiver_join(idx, receiver, deadline): return len(joined_receivers) +def wait_profiles_online(maker): + """Wait for all profiles to be online with spinner progress. + + Args: + maker: AccountMaker instance with accounts to wait for + + Raises: + SystemExit: If waiting for profiles fails + """ + # Flag to indicate when wait_all_online is complete + online_complete = threading.Event() + online_error = None + + def wait_online_thread(): + nonlocal online_error + try: + maker.wait_all_online() + except Exception as e: + online_error = e + finally: + online_complete.set() + + # Start the wait in a separate thread + wait_thread = threading.Thread(target=wait_online_thread) + wait_thread.start() + + # Show spinner while waiting + spinner_idx = 0 + while not online_complete.is_set(): + print_progress("Waiting for profiles to be online", spinner_idx=spinner_idx) + spinner_idx += 1 + online_complete.wait(timeout=0.1) + + wait_thread.join() + + if online_error: + print(f"\n✗ Timeout or error waiting for profiles to be online: {online_error}") + sys.exit(1) + + print_progress("Waiting for profiles to be online", done=True) + + def perform_ping(args): + """Main ping execution function with timing measurements. + + Timing Phases: + 1. account_setup_time: Time to create and configure all accounts + 2. group_join_time: Time for all receivers to join the group + 3. message_time: Time to send and receive all ping messages + + Returns: + Pinger: The pinger object with results + """ accounts_dir = xdg_cache_home().joinpath("cmping") print(f"# using accounts_dir at: {accounts_dir}") if accounts_dir.exists() and not accounts_dir.joinpath("accounts.toml").exists(): @@ -431,49 +537,19 @@ def perform_ping(args): dc = DeltaChat(rpc) maker = AccountMaker(dc, verbose=args.verbose) + # Phase 1: Account Setup (timed) + account_setup_start = time.time() + # Set up sender and receiver accounts sender, receivers = setup_accounts(args, maker) # Wait for all accounts to be online with timeout feedback - spinner_chars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] - - # Flag to indicate when wait_all_online is complete - online_complete = threading.Event() - online_error = None - - def wait_online_thread(): - nonlocal online_error - try: - maker.wait_all_online() - except Exception as e: - online_error = e - finally: - online_complete.set() - - # Start the wait in a separate thread - wait_thread = threading.Thread(target=wait_online_thread) - wait_thread.start() - - # Show spinner while waiting - spinner_idx = 0 - while not online_complete.is_set(): - print( - f"\r# Waiting for profiles to be online {spinner_chars[spinner_idx % len(spinner_chars)]}", - end="", - flush=True, - ) - spinner_idx += 1 - online_complete.wait(timeout=0.1) - - wait_thread.join() + wait_profiles_online(maker) - if online_error: - print( - f"\n✗ Timeout or error waiting for profiles to be online: {online_error}" - ) - sys.exit(1) + account_setup_time = time.time() - account_setup_start - print("\r# Waiting for profiles to be online... Done! ") + # Phase 2: Group Join (timed) + group_join_start = time.time() # Create group and promote it group = create_and_promote_group(sender, receivers, verbose=args.verbose) @@ -481,6 +557,11 @@ def wait_online_thread(): # Wait for all receivers to join the group wait_for_receivers_to_join(args, sender, receivers) + group_join_time = time.time() - group_join_start + + # Phase 3: Message Ping/Pong (timed) + message_start = time.time() + pinger = Pinger(args, sender, group, receivers) received = {} # Track current sequence for output formatting @@ -536,9 +617,18 @@ def wait_online_thread(): except KeyboardInterrupt: pass + + message_time = time.time() - message_start + if current_seq is not None: print() # End last line - print(f"--- {pinger.addr1} -> {pinger.receivers_addrs_str} statistics ---") + + # Print statistics - show full addresses only in verbose >= 2 + if args.verbose >= 2: + receivers_info = pinger.receivers_addrs_str + else: + receivers_info = f"{len(pinger.receivers_addrs)} receivers" + print(f"--- {pinger.addr1} -> {receivers_info} statistics ---") print( f"{pinger.sent} transmitted, {pinger.received} received, {pinger.loss:.2f}% loss" ) @@ -551,11 +641,49 @@ def wait_online_thread(): print( f"rtt min/avg/max/mdev = {rmin:.3f}/{ravg:.3f}/{rmax:.3f}/{rmdev:.3f} ms" ) + + # Print timing and rate statistics + print("--- timing statistics ---") + print(f"account setup: {format_duration(account_setup_time)}") + print(f"group join: {format_duration(group_join_time)}") + print(f"message send/recv: {format_duration(message_time)}") + + # Calculate message rates + if message_time > 0 and pinger.sent > 0: + send_rate = pinger.sent / message_time + print(f"send rate: {send_rate:.2f} msg/s") + if message_time > 0 and pinger.received > 0: + recv_rate = pinger.received / message_time + print(f"recv rate: {recv_rate:.2f} msg/s") + return pinger class Pinger: + """Handles sending ping messages and receiving responses. + + Message Flow: + 1. send_pings() runs in a background thread, sending messages at intervals + 2. Each message contains: unique_id timestamp sequence_number + 3. Messages are sent to a group chat (single send, multiple receivers) + 4. receive() yields (seq, duration, size, receiver_idx) for each received message + 5. Multiple receivers may receive each sequence number + + Attributes: + sent: Number of messages sent + received: Number of messages received (across all receivers) + loss: Percentage of expected messages not received + """ + def __init__(self, args, sender, group, receivers): + """Initialize Pinger and start sending messages. + + Args: + args: Command line arguments + sender: Sender account object + group: Group chat object + receivers: List of receiver account objects + """ self.args = args self.sender = sender self.group = group @@ -579,10 +707,14 @@ def __init__(self, args, sender, group, receivers): @property def loss(self): expected_total = self.sent * len(self.receivers) - return 1 if expected_total == 0 else (1 - self.received / expected_total) * 100 + return 0.0 if expected_total == 0 else (1 - self.received / expected_total) * 100 def send_pings(self): - # Send to the group chat (single message to all recipients) + """Send ping messages to the group at regular intervals. + + Each message contains: unique_id timestamp sequence_number + Flow: Sender -> SMTP relay1 -> IMAP relay2 -> All receivers + """ for seq in range(self.args.count): text = f"{self.tx} {time.time():.4f} {seq:17}" self.group.send_text(text) @@ -593,6 +725,15 @@ def send_pings(self): os.kill(os.getpid(), signal.SIGINT) def receive(self): + """Receive ping messages from all receivers. + + Yields: + tuple: (seq, ms_duration, size, receiver_idx) for each received message + - seq: Sequence number of the message + - ms_duration: Round-trip time in milliseconds + - size: Size of the message in bytes + - receiver_idx: Index of the receiver that received the message + """ num_pending = self.args.count * len(self.receivers) start_clock = time.time() # Track which sequence numbers have been received by which receiver @@ -602,7 +743,7 @@ def receive(self): event_queue = queue.Queue() def receiver_thread(receiver_idx, receiver): - """Thread function to listen to events from a single receiver""" + """Thread function to listen to events from a single receiver.""" while True: try: event = receiver.wait_for_event() diff --git a/release.py b/release.py new file mode 100755 index 0000000..34d3450 --- /dev/null +++ b/release.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +""" +Release script for cmping. + +This script: +1. Parses the CHANGELOG.md to get the latest version +2. Validates it's a proper version jump from the current git tag +3. Creates a git tag for the version +4. Builds and uploads to PyPI +5. On success, adds a "dev" changelog entry and commits it +""" + +import re +import subprocess +import sys +from pathlib import Path + + +def run(cmd, check=True, capture=True): + """Run a shell command and return output.""" + print(f"$ {cmd}") + result = subprocess.run( + cmd, shell=True, check=check, capture_output=capture, text=True + ) + if capture and result.stdout: + print(result.stdout.strip()) + return result + + +def get_changelog_version(): + """Parse CHANGELOG.md to get the latest version.""" + changelog = Path("CHANGELOG.md").read_text() + # Match version patterns like "## 0.16.0" or "## 1.0.0" + match = re.search(r"^## (\d+\.\d+\.\d+)", changelog, re.MULTILINE) + if not match: + print("ERROR: Could not find version in CHANGELOG.md") + print("Expected format: '## X.Y.Z' at the start of a line") + sys.exit(1) + return match.group(1) + + +def get_latest_git_tag(): + """Get the latest git tag version.""" + result = run("git tag --sort=-v:refname", check=False) + if result.returncode != 0 or not result.stdout.strip(): + return None + # Get the first tag (latest by version) + tags = result.stdout.strip().split("\n") + for tag in tags: + # Match version tags like "0.15.0" or "v0.15.0" + if re.match(r"^v?\d+\.\d+\.\d+$", tag): + return tag.lstrip("v") + return None + + +def parse_version(version_str): + """Parse version string into tuple of ints.""" + return tuple(int(x) for x in version_str.split(".")) + + +def validate_version_jump(new_version, old_version): + """Validate that new_version is a proper increment from old_version.""" + if old_version is None: + print(f"No previous version found, {new_version} will be the first release") + return True + + new = parse_version(new_version) + old = parse_version(old_version) + + # Check for proper version jump (major, minor, or patch increment) + if new <= old: + print(f"ERROR: New version {new_version} is not greater than {old_version}") + return False + + # Check that it's a single increment (not skipping versions) + major_diff = new[0] - old[0] + minor_diff = new[1] - old[1] + patch_diff = new[2] - old[2] + + valid = False + if major_diff == 1 and new[1] == 0 and new[2] == 0: + # Major version bump: X.0.0 + valid = True + elif major_diff == 0 and minor_diff == 1 and new[2] == 0: + # Minor version bump: X.Y.0 + valid = True + elif major_diff == 0 and minor_diff == 0 and patch_diff == 1: + # Patch version bump: X.Y.Z + valid = True + + if not valid: + print(f"ERROR: Invalid version jump from {old_version} to {new_version}") + print("Expected one of:") + print(f" - Major: {old[0]+1}.0.0") + print(f" - Minor: {old[0]}.{old[1]+1}.0") + print(f" - Patch: {old[0]}.{old[1]}.{old[2]+1}") + return False + + return True + + +def create_git_tag(version): + """Create and push a git tag for the version.""" + tag = version + print(f"\nCreating git tag: {tag}") + + # Check if tag already exists + result = run(f"git tag -l {tag}", check=False) + if result.stdout.strip() == tag: + print(f"ERROR: Tag {tag} already exists") + sys.exit(1) + + # Create the tag + run(f"git tag {tag}") + + # Push the tag + print(f"Pushing tag {tag} to origin...") + run(f"git push origin {tag}") + + return tag + + +def build_and_upload(): + """Build the package and upload to PyPI.""" + print("\nCleaning previous builds...") + run("rm -rf dist build *.egg-info") + + print("\nBuilding package...") + run("python -m build") + + print("\nUploading to PyPI...") + # Upload all distribution files (wheel and sdist) + run("twine upload dist/*") + + +def add_dev_changelog_entry(released_version): + """Add a dev changelog entry after successful release.""" + changelog_path = Path("CHANGELOG.md") + changelog = changelog_path.read_text() + + # Parse the version to create the dev version + parts = parse_version(released_version) + # Bump patch version for dev + dev_version = f"{parts[0]}.{parts[1]}.{parts[2] + 1}.dev0" + + # Insert new dev entry after the header + new_entry = f""" +# cmping changelog + +## {dev_version} + +(in development) + +## {released_version}""" + + # Replace the header and current version + changelog = re.sub( + r"^\s*# cmping changelog\s*\n\s*## " + re.escape(released_version), + new_entry, + changelog, + flags=re.MULTILINE, + ) + + changelog_path.write_text(changelog) + print(f"\nAdded dev changelog entry: {dev_version}") + + # Commit the change + run("git add CHANGELOG.md") + run(f'git commit -m "post release: start {dev_version}"') + run("git push") + + +def main(): + """Main release workflow.""" + print("=" * 60) + print("cmping Release Script") + print("=" * 60) + + # Step 1: Get version from changelog + print("\n[1/5] Reading version from CHANGELOG.md...") + new_version = get_changelog_version() + print(f"Found version: {new_version}") + + # Step 2: Get current git tag + print("\n[2/5] Checking current git tags...") + old_version = get_latest_git_tag() + if old_version: + print(f"Latest git tag: {old_version}") + else: + print("No previous version tags found") + + # Step 3: Validate version jump + print("\n[3/5] Validating version jump...") + if not validate_version_jump(new_version, old_version): + sys.exit(1) + print(f"Version jump {old_version or 'none'} -> {new_version} is valid") + + # Step 4: Create git tag and build/upload + print("\n[4/5] Creating tag and uploading to PyPI...") + tag = create_git_tag(new_version) + build_and_upload() + + # Step 5: Add dev changelog entry + print("\n[5/5] Adding dev changelog entry...") + add_dev_changelog_entry(new_version) + + # Final summary + print("\n" + "=" * 60) + print("Release completed successfully!") + print("=" * 60) + print(f"\nUploaded to PyPI: cmping {tag}") + print(f"Tag: {tag}") + print("\nNext steps:") + print(" - Verify the release on PyPI: https://pypi.org/project/cmping/") + print(f" - Check the tag on GitHub: https://github.com/chatmail/cmping/releases/tag/{tag}") + + +if __name__ == "__main__": + main()