From bf2075d5ff8b4688e614869e32aa0b2616dff362 Mon Sep 17 00:00:00 2001 From: takitsu21 Date: Sun, 11 Jan 2026 17:17:27 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20adaptive=20size=20and=20timeo?= =?UTF-8?q?ut=20capability=20to=20gain=20time?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 20 +++ docs/faq.md | 81 ++++++++++ docs/features.md | 59 +++++++ docs/usage.md | 44 ++++++ speedtest_cloudflare_cli/core/speedtest.py | 175 +++++++++++++++++++-- speedtest_cloudflare_cli/main.py | 34 +++- 6 files changed, 393 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index c9cd92b..6abc3b9 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,16 @@ uv tool install speedtest-cloudflare-cli pip install speedtest-cloudflare-cli ``` +## Features + +- ⏱️ **Time-Based Testing** - 10 second default timeout per test for consistent, fast results +- 🚀 **Adaptive Test Sizing** - Automatically adjusts test size based on your connection speed +- 📊 **Comprehensive Metrics** - Download/upload speed, ping, jitter, and HTTP latency +- 🌍 **Cloudflare Infrastructure** - Tests using Cloudflare's global network +- 🎨 **Beautiful Output** - Rich terminal interface with progress bars and tables +- 📄 **Multiple Output Formats** - Console, JSON, and interactive web dashboard +- 🔒 **Privacy Focused** - No tracking, no accounts, open source + ## Usage Run the following command to test your internet speed. @@ -50,6 +60,16 @@ speedtest-cli ![Speedtest output](docs/assets/speedtest_output.png) +By default, each test (download/upload) runs for **10 seconds** and calculates speed based on data transferred. You can customize the timeout: + +```bash +# 5 second timeout per test +speedtest-cli --timeout 5 + +# 20 second timeout for more accuracy +speedtest-cli --timeout 20 +``` + For more information, run the --help command. ```bash speedtest-cli --help diff --git a/docs/faq.md b/docs/faq.md index 0aad24e..341e190 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -262,6 +262,87 @@ This is **normal** and expected because: 3. Use same connection type (Wi-Fi vs Ethernet) 4. Close background applications +### Why Does My Test Size Change Each Time? + +**Problem:** Test size varies between runs + +**Explanation:** + +This is **adaptive mode** (enabled by default). It automatically adjusts test size based on your connection speed + +**How It Works:** + +1. Runs a quick 5MB probe test +2. Estimates your connection speed +3. Calculates optimal test size for ~7.5 second duration +4. Uses size between 1MB and 200MB + +**To Disable:** + +```bash +# Use fixed 30MB size +speedtest-cli --no-adaptive + +# Or specify manual size +speedtest-cli --download_size 50 +``` + +### How Do I Disable Adaptive Mode? + +**Problem:** Want to use fixed test sizes + +**Solutions:** + +1. **Disable adaptive mode:** + + ```bash + speedtest-cli --no-adaptive + ``` + +2. **Specify manual sizes** (automatically disables adaptive): + + ```bash + speedtest-cli --download_size 50 --upload_size 25 + ``` + +3. **For scripts** expecting fixed sizes: + + ```bash + speedtest-cli --no-adaptive --json-output results.json + ``` + +**Use Cases for Disabling:** + +- Comparing results with specific test sizes +- Meeting exact test requirements +- Legacy scripts expecting fixed data volumes +- Benchmarking with consistent parameters + +### What If the Probe Test Gives Wrong Results? + +**Problem:** Probe test estimates incorrect speed + +**Explanation:** + +The probe test is intentionally short (1-2 seconds) and may not always be perfectly accurate. This is okay because: + +- It's designed for a quick estimate, not precision +- Falls back to default 30MB if probe fails +- Main test still provides accurate final results +- Uses min/max boundaries (1MB - 200MB) to prevent extremes + +**If Consistently Inaccurate:** + +```bash +# Disable adaptive mode +speedtest-cli --no-adaptive + +# Or specify exact sizes +speedtest-cli -ds 100 -us 50 +``` + +**Note:** The probe's purpose is to optimize test duration, not to measure your exact speed. The main test provides the accurate measurement. + ### Web Dashboard Won't Open **Problem:** `--web_view` doesn't open browser diff --git a/docs/features.md b/docs/features.md index bea5cd0..f1eb7e6 100644 --- a/docs/features.md +++ b/docs/features.md @@ -466,6 +466,65 @@ speedtest-cli -ds 100 -us 50 | 100-500 Mbps | 100-200 MB | 50-100 MB | | > 500 Mbps | 200+ MB | 100+ MB | +### Adaptive Test Sizing + +**NEW:** Automatically adjusts test sizes based on your connection speed. + +**How It Works:** + +1. Runs a quick 5MB probe test (takes 1-2 seconds) +2. Estimates your connection speed from probe results +3. Calculates optimal test size for ~7.5 second test duration +4. Applies min/max boundaries (1MB - 200MB) +5. Runs main test with adaptive size + +**Benefits:** + +- **Fast for slow connections**: 1 Mbps connection uses 1MB test (not 30MB!) +- **Accurate for fast connections**: 500 Mbps connection uses 200MB test +- **Saves time**: No more waiting for large downloads on slow connections +- **Balanced accuracy**: Tests run long enough for accurate measurements + +**Enabled by Default:** + +```bash +# Adaptive mode is enabled automatically +speedtest-cli + +# Example output: +# Running probe test to detect connection speed... +# ✓ Detected speed: ~56.1 Mbps +# Adaptive mode: Using 53MB for download test +``` + +**Disable Adaptive Mode:** + +```bash +# Use fixed 30MB size (legacy behavior) +speedtest-cli --no-adaptive + +# Manual sizes always disable adaptive +speedtest-cli --download_size 50 # Uses exactly 50MB +``` + +**Example Scenarios:** + +| Connection Speed | Probe Detects | Adaptive Size | Test Duration | +|-----------------|---------------|---------------|---------------| +| 1 Mbps | ~1 Mbps | 1 MB | ~8 seconds | +| 10 Mbps | ~10 Mbps | 9 MB | ~7 seconds | +| 50 Mbps | ~50 Mbps | 47 MB | ~7.5 seconds | +| 100 Mbps | ~100 Mbps | 94 MB | ~7.5 seconds | +| 500 Mbps | ~480 Mbps | 200 MB (max) | ~3.2 seconds | +| 1000 Mbps | ~950 Mbps | 200 MB (max) | ~1.6 seconds | + +**When to Disable:** + +- Comparing results with specific test sizes +- Meeting exact test requirements for diagnostics +- Using scripts that expect specific data volumes +- Benchmarking with consistent parameters + --- ## IPv6 Support diff --git a/docs/usage.md b/docs/usage.md index 2dee573..75c3a5c 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -157,6 +157,50 @@ speedtest-cli -a 3 speedtest-cli -a 5 ``` +#### `--adaptive` / `--no-adaptive` + +Enable or disable adaptive test sizing based on connection speed. + +```bash +# Adaptive mode is enabled by default +speedtest-cli + +# Explicitly disable adaptive mode +speedtest-cli --no-adaptive +``` + +**Default:** Enabled +**Use Case:** +- **Enabled (default)**: Automatically adjusts test size for optimal duration +- **Disabled**: Uses fixed 30MB test size (legacy behavior) + +**How It Works:** +1. Runs a quick 5MB probe test to estimate your speed +2. Calculates optimal test size for ~7.5 second duration +3. Uses adaptive size (between 1MB and 200MB) + +**Example:** +```bash +# Let adaptive mode optimize test size (default) +speedtest-cli + +# Output: +# Running probe test to detect connection speed... +# ✓ Detected speed: ~56.1 Mbps +# Adaptive mode: Using 53MB for download test + +# Disable adaptive mode to use fixed 30MB +speedtest-cli --no-adaptive + +# Note: Manual size specification automatically disables adaptive +speedtest-cli --download_size 50 # Uses exactly 50MB, adaptive disabled +``` + +**Benefits:** +- **Faster tests** on slow connections (no more waiting for 30MB on 1 Mbps!) +- **More accurate** on fast connections (uses larger test sizes) +- **Consistent duration** (~7-10 seconds regardless of connection speed) + --- ### Output Options diff --git a/speedtest_cloudflare_cli/core/speedtest.py b/speedtest_cloudflare_cli/core/speedtest.py index 8a3aacc..07c41ce 100644 --- a/speedtest_cloudflare_cli/core/speedtest.py +++ b/speedtest_cloudflare_cli/core/speedtest.py @@ -11,11 +11,9 @@ import ping3 from rich.progress import ( BarColumn, - DownloadColumn, Progress, TaskID, TextColumn, - TimeRemainingColumn, TransferSpeedColumn, ) @@ -27,6 +25,15 @@ PING_COUNT = 3 PING_TIMEOUT = 3 +# Adaptive mode constants +PROBE_SIZE_MB = 5 # Size for preliminary probe test +PROBE_TIMEOUT_SECONDS = 2.0 # Max seconds for probe test +TARGET_TEST_DURATION = 7.5 # Target duration for main test in seconds +MIN_TEST_SIZE_MB = 1 # Minimum test size +MAX_TEST_SIZE_MB = 200 # Maximum test size +MIN_REALISTIC_SPEED = 0.1 # Minimum realistic speed in Mbps +MAX_REALISTIC_SPEED = 10000 # Maximum realistic speed in Mbps + @functools.cache def client() -> httpx.Client: @@ -39,14 +46,25 @@ def track_progress(silent: bool = False) -> Generator[Progress]: with Progress( TextColumn("{task.description}"), BarColumn(bar_width=None), - DownloadColumn(), TransferSpeedColumn(), - TimeRemainingColumn(), disable=silent, ) as progress: yield progress +@contextlib.contextmanager +def track_progress_transient(silent: bool = False) -> Generator[Progress]: + """Progress bar that disappears when done (for probe tests).""" + with Progress( + TextColumn("{task.description}"), + BarColumn(bar_width=None), + TransferSpeedColumn(), + disable=silent, + transient=True, # Progress bar disappears when done + ) as progress: + yield progress + + def _fallback_ping() -> float | str: # Try system ping try: @@ -71,11 +89,12 @@ def _fallback_ping() -> float | str: class SpeedTest: - def __init__(self, url: str, download_size: int, upload_size: int, attempts: int): + def __init__(self, url: str, download_size: int, upload_size: int, attempts: int, timeout: float | None = None): self.url = url self.download_size = download_size self.upload_size = upload_size self.attempts = attempts + self.timeout = timeout # Timeout per test in seconds (None = no timeout) self._ping_thread = threading.Thread(target=self.ping, daemon=True) self._ping_thread.start() @@ -89,11 +108,15 @@ def _init_connection(self) -> None: """Opens a connection to the server and keeps it alive for subsequent requests.""" client().get(f"{self.url}/__down", params={"bytes": 0}) - def _download(self, progress: Progress | None = None, task: TaskID | None = None) -> None: + def _download( + self, progress: Progress | None = None, task: TaskID | None = None, deadline: float | None = None + ) -> None: """Download data in streaming chunks to keep the HTTP connection alive.""" with client().stream("GET", f"{self.url}/__down", params={"bytes": self.download_size}) as response: # Consume the body in chunks so the server keeps feeding data. for chunk in response.iter_bytes(chunk_size=CHUNK_SIZE): + if deadline is not None and time.perf_counter() > deadline: + break # Timeout reached, stop downloading if progress and task is not None: progress.update(task, description="Downloading... 🚀", advance=len(chunk)) @@ -116,12 +139,15 @@ def _upload( self, progress: Progress | None = None, task: TaskID | None = None, + deadline: float | None = None, ) -> None: """Upload data in streaming chunks to keep the HTTP connection alive and update progress.""" def data_stream(): offset = 0 while offset < self.upload_size: + if deadline is not None and time.perf_counter() > deadline: + break # Timeout reached, stop uploading chunk = self.upload_data_blocks[offset : offset + CHUNK_SIZE] offset += len(chunk) if progress and task is not None: @@ -141,11 +167,20 @@ def _compute_network_speed(self, progress: Progress, size_to_process: int, func: self._init_connection() - if progress: - task = progress.add_task("", total=size_to_process * self.attempts) + # Calculate deadline if timeout is set + deadline = time.perf_counter() + self.timeout if self.timeout else None + + # Use indeterminate progress (total=None) when timeout is set since we don't know final size + total = None if self.timeout else size_to_process * self.attempts + task = progress.add_task("", total=total) + for _ in range(self.attempts): + # Check if we've exceeded the deadline before starting a new attempt + if deadline is not None and time.perf_counter() > deadline: + break + start = time.perf_counter() - func(progress, task) # perform full transfer for this attempt + func(progress, task, deadline) # perform transfer with deadline elapsed_time = time.perf_counter() - start times_to_process.append(elapsed_time) @@ -153,9 +188,13 @@ def _compute_network_speed(self, progress: Progress, size_to_process: int, func: jitter = abs(times_to_process[-1] - times_to_process[-2]) jitters.append(jitter) - jitter = sum(jitters) / len(jitters) if jitters else times_to_process[-1] + # Check if we've exceeded the deadline after the attempt + if deadline is not None and time.perf_counter() > deadline: + break + + jitter = sum(jitters) / len(jitters) if jitters else (times_to_process[-1] if times_to_process else 0) http_latency = self._http_latency() - speed = progress.tasks[task].speed * 8 / 1_000_000 + speed = progress.tasks[task].speed * 8 / 1_000_000 if progress.tasks[task].speed else 0 # wait for ping to finish if not self.latency: @@ -163,7 +202,13 @@ def _compute_network_speed(self, progress: Progress, size_to_process: int, func: return result.Result(speed=speed, jitter=jitter, latency=self.latency, http_latency=http_latency) - def download_speed(self, silent: bool) -> result.Result: + def download_speed(self, silent: bool, adaptive: bool = False, default_size_mb: int = 30) -> result.Result: + # Run adaptive sizing if enabled + if adaptive: + probe_speed = self._run_probe_test("download", silent=silent) + adaptive_size = self._calculate_adaptive_size(probe_speed, "download", default_size_mb) + self.download_size = adaptive_size + with track_progress(silent=silent) as progress: download_result = self._compute_network_speed( progress=progress, size_to_process=self.download_size, func=self._download @@ -171,7 +216,17 @@ def download_speed(self, silent: bool) -> result.Result: return download_result - def upload_speed(self, silent: bool) -> result.Result: + def upload_speed(self, silent: bool, adaptive: bool = False, default_size_mb: int = 30) -> result.Result: + # Run adaptive sizing if enabled + if adaptive: + probe_speed = self._run_probe_test("upload", silent=silent) + adaptive_size = self._calculate_adaptive_size(probe_speed, "upload", default_size_mb) + self.upload_size = adaptive_size + + # Clear cached upload_data_blocks since upload_size changed + if "upload_data_blocks" in self.__dict__: + del self.__dict__["upload_data_blocks"] + with track_progress(silent=silent) as progress: upload_result = self._compute_network_speed( progress=progress, size_to_process=self.upload_size, func=self._upload @@ -182,3 +237,97 @@ def upload_speed(self, silent: bool) -> result.Result: @property def metadata(self) -> metadata.Metadata: return metadata.Metadata.model_validate(client().get(f"{self.url}/meta").json()) + + def _run_probe_test(self, test_type: str, silent: bool = True) -> float | None: + """ + Run a quick probe test to estimate bandwidth. + + Args: + test_type: "download" or "upload" + silent: Whether to suppress progress output + + Returns: + Estimated speed in Mbps, or None if probe fails + """ + probe_size = PROBE_SIZE_MB * CHUNK_SIZE + original_size = self.download_size if test_type == "download" else self.upload_size + + try: + # Temporarily set size to probe size + if test_type == "download": + self.download_size = probe_size + else: + self.upload_size = probe_size + # Clear cached upload_data_blocks since upload_size changed + if "upload_data_blocks" in self.__dict__: + del self.__dict__["upload_data_blocks"] + + start_time = time.perf_counter() + + # Run single probe attempt with transient progress bar (disappears when done) + with track_progress_transient(silent=silent) as progress: + task = progress.add_task("🔍 Probing connection speed...", total=probe_size) + + if test_type == "download": + self._init_connection() + self._download(progress, task) + else: + self._init_connection() + self._upload(progress, task) + + elapsed_time = time.perf_counter() - start_time + + # Check if probe timed out + if elapsed_time > PROBE_TIMEOUT_SECONDS: + return None + + # Calculate speed in Mbps + speed_mbps = (probe_size * 8) / (elapsed_time * 1_000_000) + return speed_mbps + + except Exception: + return None + finally: + # Restore original size + if test_type == "download": + self.download_size = original_size + else: + self.upload_size = original_size + # Clear cached upload_data_blocks again since upload_size changed + if "upload_data_blocks" in self.__dict__: + del self.__dict__["upload_data_blocks"] + + def _calculate_adaptive_size(self, probe_speed: float | None, test_type: str, default_size_mb: int) -> int: + """ + Calculate optimal test size based on probe results. + + Args: + probe_speed: Estimated speed in Mbps from probe test + test_type: "download" or "upload" for logging + default_size_mb: Default size in MB if probe fails + + Returns: + Test size in bytes PER ATTEMPT (accounts for self.attempts) + """ + # If probe failed or returned invalid speed, use default + if probe_speed is None: + return default_size_mb * CHUNK_SIZE + + # Check for unrealistic speeds + if probe_speed < MIN_REALISTIC_SPEED or probe_speed > MAX_REALISTIC_SPEED: + return default_size_mb * CHUNK_SIZE + + # Convert Mbps to MBps (megabytes per second) + speed_MBps = probe_speed / 8 + + # Calculate ideal size PER ATTEMPT + # Use longer duration per attempt for better accuracy (3 seconds per attempt) + # This ensures each attempt is substantial enough for accurate measurement + duration_per_attempt = 3.0 # seconds per attempt + ideal_size_mb = speed_MBps * duration_per_attempt + + # Apply boundaries (minimum 10MB per attempt for accuracy) + final_size_mb = max(10, min(ideal_size_mb, MAX_TEST_SIZE_MB)) + + # Round to nearest integer and convert to bytes + return int(round(final_size_mb)) * CHUNK_SIZE diff --git a/speedtest_cloudflare_cli/main.py b/speedtest_cloudflare_cli/main.py index 7129830..9dfdb2a 100644 --- a/speedtest_cloudflare_cli/main.py +++ b/speedtest_cloudflare_cli/main.py @@ -6,6 +6,7 @@ from importlib import metadata as pkg_metadata from pathlib import Path +import rich import rich.json import rich.table import rich_click as click @@ -75,10 +76,16 @@ def safe_value(result: result.Result | None, attr: str) -> str: @click.option("--download_size", "-ds", type=int, default=DOWNLOAD_SIZE, help="Download size in MB") @click.option("--upload_size", "-us", type=int, default=UPLOAD_SIZE, help="Upload size in MB") @click.option("--attempts", "-a", type=int, default=5, help="Number of attempts") +@click.option("--timeout", "-t", type=float, default=10.0, help="Timeout per test in seconds (default: 10)") @click.option("--json", is_flag=True, help="Output results in JSON format") @click.option("--silent", is_flag=True, help="Run in silent mode") @click.option("--json-output", type=click.Path(writable=True), default=None, help="Save JSON results to file") @click.option("--web_view", is_flag=True, help="Open results in web browser") +@click.option( + "--adaptive/--no-adaptive", + default=True, + help="Enable adaptive test sizing based on connection speed (default: enabled)", +) def main( *, download: bool, @@ -86,25 +93,38 @@ def main( download_size: int, upload_size: int, attempts: int, + timeout: float | None, json: bool, silent: bool, json_output: str, web_view: bool, + adaptive: bool, ) -> None: - download_size = download_size * speedtest.CHUNK_SIZE - upload_size = upload_size * speedtest.CHUNK_SIZE + # If user specifies manual size, disable adaptive mode + user_specified_size = download_size != DOWNLOAD_SIZE or upload_size != UPLOAD_SIZE + if user_specified_size and adaptive: + adaptive = False + if not silent: + rich.print("[yellow]Note: Adaptive mode disabled due to manual size specification[/yellow]") + + download_size_bytes = download_size * speedtest.CHUNK_SIZE + upload_size_bytes = upload_size * speedtest.CHUNK_SIZE speedtester = speedtest.SpeedTest( - url=SPEEDTEST_URL, download_size=download_size, upload_size=upload_size, attempts=attempts + url=SPEEDTEST_URL, + download_size=download_size_bytes, + upload_size=upload_size_bytes, + attempts=attempts, + timeout=timeout, ) download_result = None upload_result = None if download: - download_result = speedtester.download_speed(silent=silent) + download_result = speedtester.download_speed(silent=silent, adaptive=adaptive, default_size_mb=download_size) if upload: - upload_result = speedtester.upload_speed(silent=silent) + upload_result = speedtester.upload_speed(silent=silent, adaptive=adaptive, default_size_mb=upload_size) if not download and not upload: - download_result = speedtester.download_speed(silent=silent) - upload_result = speedtester.upload_speed(silent=silent) + download_result = speedtester.download_speed(silent=silent, adaptive=adaptive, default_size_mb=download_size) + upload_result = speedtester.upload_speed(silent=silent, adaptive=adaptive, default_size_mb=upload_size) results = { "download": download_result.__dict__ if download_result else None,