From 2dd9d8cfba23949da7796dbfbefcad1e980494c9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 26 Sep 2026 16:24:37 +0000 Subject: [PATCH] Repo health: modernize Azure SDK, tests, CI, and docs - Migrate from deprecated BlockBlobService to azure-storage-blob v12 - Fix container/blob iteration bug that double-counted blobs - Add package layout, env-based config, Ruff, pytest suite, and GitHub Actions - Add SECURITY.md, Dependabot, MIT LICENSE, .gitignore, and .editorconfig Co-authored-by: Joel Town Road --- .editorconfig | 15 +++++ .env.example | 6 ++ .github/dependabot.yml | 18 ++++++ .github/workflows/ci.yml | 41 ++++++++++++++ .gitignore | 52 +++++++++++++++++ LICENSE | 21 +++++++ README.md | 104 +++++++++++++++++++++++++++++++++- SECURITY.md | 24 ++++++++ filetype_size/__init__.py | 3 + filetype_size/azure_client.py | 35 ++++++++++++ filetype_size/calculator.py | 73 ++++++++++++++++++++++++ filetype_size/cli.py | 79 ++++++++++++++++++++++++++ main.py | 32 ++--------- pyproject.toml | 43 ++++++++++++++ requirements.lock | 21 +++++++ requirements.txt | 2 + tests/test_azure_client.py | 33 +++++++++++ tests/test_calculator.py | 55 ++++++++++++++++++ tests/test_cli.py | 51 +++++++++++++++++ 19 files changed, 678 insertions(+), 30 deletions(-) create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 SECURITY.md create mode 100644 filetype_size/__init__.py create mode 100644 filetype_size/azure_client.py create mode 100644 filetype_size/calculator.py create mode 100644 filetype_size/cli.py create mode 100644 pyproject.toml create mode 100644 requirements.lock create mode 100644 requirements.txt create mode 100644 tests/test_azure_client.py create mode 100644 tests/test_calculator.py create mode 100644 tests/test_cli.py diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c90b128 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{yml,yaml,json}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1440303 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Azure Blob Storage credentials (never commit real values) +AZURE_STORAGE_ACCOUNT_NAME=your-storage-account-name +AZURE_STORAGE_ACCOUNT_KEY=your-storage-account-key + +# File extension to totalize (e.g. .vhd, .png, or vhd) +FILE_TYPE=.vhd diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..0bd5daf --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,18 @@ +version: 2 +updates: + - package-ecosystem: pip + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 10 + groups: + dev-dependencies: + dependency-type: development + patterns: + - "*" + + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..604f56a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +on: + push: + branches: [master, main] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.12", "3.13"] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + - name: Install dependencies + run: | + python -m pip install --upgrade pip setuptools + pip install -r requirements.lock + pip install -e . --no-deps + + - name: Ruff lint + run: ruff check . + + - name: Ruff format check + run: ruff format --check . + + - name: Run tests + run: pytest --cov=filetype_size --cov-report=term-missing diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c76672c --- /dev/null +++ b/.gitignore @@ -0,0 +1,52 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual environments +.venv/ +venv/ +ENV/ + +# Testing / coverage +.pytest_cache/ +.coverage +htmlcov/ +.mypy_cache/ +.ruff_cache/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Secrets / local config +.env +.env.* +!.env.example + +# Logs +*.log diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f4f7fae --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Joel Shearon + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 3475341..221d919 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,104 @@ # FileTypeSizeAzureStoragePython -For helping with determining total byte size of certain file types in Azure Storage -Ex. after providing your storage account name and key, you could type in .vhd and it would return total bytes of all vhd files in your storage account +[![CI](https://github.com/hippi345/FileTypeSizeAzureStoragePython/actions/workflows/ci.yml/badge.svg)](https://github.com/hippi345/FileTypeSizeAzureStoragePython/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) + +Calculate the **total byte size** of blobs matching a file extension across all containers in an Azure Storage account—for example, sum every `.vhd` or `.png` in the account. + +The tool uses the current [Azure Storage Blob SDK for Python](https://learn.microsoft.com/en-us/python/api/azure-storage-blob/) (`azure-storage-blob` v12+). Credentials are read from the environment or interactive prompts; they are never stored in the repository. + +## Features + +- Lists every container and blob (name and size) in the storage account +- Totals sizes for blobs whose names end with your chosen extension (e.g. `.vhd`) +- Supports non-interactive runs via environment variables +- Offline unit tests with mocked Azure clients + +## Requirements + +- Python **3.12+** +- An Azure Storage account with account name and key (or use a connection string pattern via env vars above) + +## Setup + +```bash +git clone https://github.com/hippi345/FileTypeSizeAzureStoragePython.git +cd FileTypeSizeAzureStoragePython +python3 -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -e ".[dev]" +``` + +Copy `.env.example` to `.env` and fill in values locally (`.env` is gitignored). + +## Configuration + +| Variable | Description | +| -------- | ----------- | +| `AZURE_STORAGE_ACCOUNT_NAME` | Storage account name | +| `AZURE_STORAGE_ACCOUNT_KEY` | Storage account access key | +| `FILE_TYPE` | Extension to match (e.g. `.vhd` or `vhd`) | + +If any variable is missing, the program prompts for it on stdin. + +## Usage + +**With environment variables:** + +```bash +export AZURE_STORAGE_ACCOUNT_NAME=myaccount +export AZURE_STORAGE_ACCOUNT_KEY='...' +export FILE_TYPE=.vhd +python main.py +``` + +**Console script (after install):** + +```bash +filetype-size-azure +``` + +**Interactive (no env vars):** + +```bash +python main.py +``` + +Example output: + +``` +Welcome to the file size calculator for Azure Storage on Python +Printing out containers: + my-container: +Blob name: backups/disk.vhd +Blob size: 1073741824 +Total size of .vhd files: 1073741824 +``` + +## Running tests + +Tests run fully offline with mocked Azure APIs: + +```bash +ruff check . +ruff format --check . +pytest --cov=filetype_size +``` + +## Project structure + +``` +├── filetype_size/ +│ ├── azure_client.py # BlobServiceClient helpers +│ ├── calculator.py # Extension matching and totals +│ └── cli.py # Prompts / env / output +├── main.py # Entry point +├── tests/ +├── pyproject.toml # Package metadata and tool config +├── requirements.txt # Runtime pins (source) +└── requirements.lock # Locked versions for CI/reproducible installs +``` + +## License + +MIT License — see [LICENSE](LICENSE) (Copyright © 2026 Joel Shearon). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..89f48ce --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,24 @@ +# Security Policy + +## Supported versions + +| Version | Supported | +| ------- | --------- | +| 2.x | Yes | + +## Reporting a vulnerability + +If you discover a security issue, please **do not** open a public GitHub issue with sensitive details. + +1. Open a private security advisory on GitHub for this repository, or contact the repository owner directly. +2. Include steps to reproduce, impact, and any suggested fix. + +## Secrets and credentials + +- Never commit storage account keys, connection strings, or SAS tokens to the repository. +- Configure credentials via environment variables (`AZURE_STORAGE_ACCOUNT_NAME`, `AZURE_STORAGE_ACCOUNT_KEY`) or a local `.env` file that is listed in `.gitignore`. +- Rotate any credential that may have been exposed in git history or logs. + +## Dependencies + +Dependency updates are managed via Dependabot. Keep `azure-storage-blob` and other packages current to receive security fixes from upstream. diff --git a/filetype_size/__init__.py b/filetype_size/__init__.py new file mode 100644 index 0000000..5830995 --- /dev/null +++ b/filetype_size/__init__.py @@ -0,0 +1,3 @@ +"""Calculate blob sizes by file extension in Azure Blob Storage.""" + +__version__ = "2.0.0" diff --git a/filetype_size/azure_client.py b/filetype_size/azure_client.py new file mode 100644 index 0000000..ce75bfa --- /dev/null +++ b/filetype_size/azure_client.py @@ -0,0 +1,35 @@ +"""Azure Blob Storage client helpers.""" + +from __future__ import annotations + +from collections.abc import Iterator + +from azure.storage.blob import BlobServiceClient + +from filetype_size.calculator import BlobInfo + + +class ListedBlob: + """Adapter exposing name and size for calculator protocols.""" + + def __init__(self, name: str, size: int) -> None: + self.name = name + self.size = size + + +def create_blob_service_client(account_name: str, account_key: str) -> BlobServiceClient: + account_url = f"https://{account_name}.blob.core.windows.net" + return BlobServiceClient(account_url=account_url, credential=account_key) + + +def iter_containers_with_blobs( + client: BlobServiceClient, +) -> Iterator[tuple[str, list[BlobInfo]]]: + """List every container and its blobs (name + size).""" + for container in client.list_containers(): + name = container.name + container_client = client.get_container_client(name) + blobs: list[BlobInfo] = [ + ListedBlob(blob.name, blob.size) for blob in container_client.list_blobs() + ] + yield name, blobs diff --git a/filetype_size/calculator.py b/filetype_size/calculator.py new file mode 100644 index 0000000..af5a50c --- /dev/null +++ b/filetype_size/calculator.py @@ -0,0 +1,73 @@ +"""Core logic for summing blob sizes by file extension.""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass +from typing import Protocol + + +class BlobInfo(Protocol): + """Minimal blob shape used by the calculator (Azure SDK or test doubles).""" + + name: str + size: int + + +@dataclass(frozen=True) +class ContainerScanResult: + """Blobs discovered in one container and running total for the file type.""" + + container_name: str + blobs: tuple[BlobInfo, ...] + extension_total_bytes: int + + +def normalize_extension(file_type: str) -> str: + """Ensure the extension begins with a dot for endswith matching.""" + trimmed = file_type.strip() + if not trimmed: + raise ValueError("file type extension cannot be empty") + return trimmed if trimmed.startswith(".") else f".{trimmed}" + + +def blob_matches_extension(blob_name: str, extension: str) -> bool: + return blob_name.endswith(extension) + + +def sum_matching_blob_sizes(blobs: Iterable[BlobInfo], extension: str) -> int: + """Return total byte size of blobs whose names end with ``extension``.""" + normalized = normalize_extension(extension) + total = 0 + for blob in blobs: + if blob_matches_extension(blob.name, normalized): + total += blob.size + return total + + +def scan_containers( + containers: Iterable[tuple[str, Iterable[BlobInfo]]], + extension: str, +) -> tuple[list[ContainerScanResult], int]: + """ + Scan containers and compute per-container blob listings plus a grand total. + + ``containers`` yields ``(container_name, blobs_iterable)`` pairs. + """ + normalized = normalize_extension(extension) + results: list[ContainerScanResult] = [] + grand_total = 0 + + for container_name, blobs_iter in containers: + blobs = tuple(blobs_iter) + container_total = sum_matching_blob_sizes(blobs, normalized) + grand_total += container_total + results.append( + ContainerScanResult( + container_name=container_name, + blobs=blobs, + extension_total_bytes=container_total, + ) + ) + + return results, grand_total diff --git a/filetype_size/cli.py b/filetype_size/cli.py new file mode 100644 index 0000000..6f9a42c --- /dev/null +++ b/filetype_size/cli.py @@ -0,0 +1,79 @@ +"""Command-line interface (interactive prompts or environment variables).""" + +from __future__ import annotations + +import os +import sys +from typing import TextIO + +from azure.core.exceptions import AzureError + +from filetype_size.azure_client import create_blob_service_client, iter_containers_with_blobs +from filetype_size.calculator import normalize_extension, scan_containers + + +def _read_config( + stdin: TextIO, + stdout: TextIO, +) -> tuple[str, str, str]: + account = os.environ.get("AZURE_STORAGE_ACCOUNT_NAME", "").strip() + key = os.environ.get("AZURE_STORAGE_ACCOUNT_KEY", "").strip() + file_type = os.environ.get("FILE_TYPE", "").strip() + + if not account: + stdout.write("What is your storage account name?\n") + stdout.flush() + account = stdin.readline().strip() + if not key: + stdout.write("What is your storage account key?\n") + stdout.flush() + key = stdin.readline().strip() + if not file_type: + stdout.write( + "What kind of file path are you curious about getting the size on? " + "(ex. .vhd, .png, .gif, etc.)\n" + ) + stdout.flush() + file_type = stdin.readline().strip() + + if not account or not key or not file_type: + raise ValueError("Storage account name, key, and file type are all required.") + + return account, key, file_type + + +def run(stdout: TextIO | None = None, stdin: TextIO | None = None) -> int: + """Run the size calculator; returns process exit code.""" + out = stdout or sys.stdout + inp = stdin or sys.stdin + + out.write("Welcome to the file size calculator for Azure Storage on Python\n") + + try: + account_name, account_key, file_type = _read_config(inp, out) + extension = normalize_extension(file_type) + except ValueError as exc: + out.write(f"Error: {exc}\n") + return 1 + + try: + client = create_blob_service_client(account_name, account_key) + container_data = list(iter_containers_with_blobs(client)) + results, grand_total = scan_containers(container_data, extension) + except AzureError as exc: + out.write(f"Azure Storage error: {exc}\n") + return 1 + + out.write("Printing out containers:\n") + for result in results: + out.write(f" {result.container_name}:\n") + for blob in result.blobs: + out.write(f"Blob name: {blob.name}\n") + out.write(f"Blob size: {blob.size}\n") + + out.write(f"Total size of {extension} files: {grand_total}\n") + return 0 + + +def main() -> None: + raise SystemExit(run()) diff --git a/main.py b/main.py index 0acad4d..072b748 100644 --- a/main.py +++ b/main.py @@ -1,30 +1,6 @@ -from azure.storage.blob import * +"""Entry point for running the Azure file-type size calculator.""" +from filetype_size.cli import main -print("Welcome to the file size calculator for Azure Storage on Python") -print("What is your storage account name?") -SAName = input() -print("what is your key?") -SAKey = input() -print("what kind of file path are you curious about getting the size on? (ex. .vhd, .png, .gif, etc.") -fileType = input() - -block_blob_service = BlockBlobService(account_name=SAName, account_key=SAKey) -containers = block_blob_service.list_containers() - -marker = None; -sizeOfFileTypes = 0 -blobsList = [] - -print("Printing out containers:") -for c in containers: - print(" " + c.name + ":") - blobs = block_blob_service.list_blobs(c.name, marker=marker) - blobsList.extend(blobs) - for blob in blobsList: - blob_property = BlockBlobService.get_blob_properties(block_blob_service, c.name, blob.name) - print("Blob name: " + blob.name) - print("Blob size: " + str(blob_property.properties.content_length)) - if str(blob_property.name).endswith(fileType): - sizeOfFileTypes += blob_property.properties.content_length -print("Total size of " + fileType + " files: " + str(sizeOfFileTypes)) \ No newline at end of file +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c6e87d2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,43 @@ +[build-system] +requires = ["setuptools>=75.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "filetype-size-azure-storage" +version = "2.0.0" +description = "Calculate total byte size of blobs by file extension in Azure Blob Storage" +readme = "README.md" +requires-python = ">=3.12" +license = { text = "MIT" } +authors = [{ name = "Joel Shearon" }] +dependencies = [ + "azure-storage-blob>=12.24.0,<13", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.3.0", + "pytest-cov>=6.0.0", + "ruff>=0.9.0", +] + +[project.scripts] +filetype-size-azure = "filetype_size.cli:main" + +[tool.setuptools.packages.find] +where = ["."] +include = ["filetype_size*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" + +[tool.ruff] +target-version = "py312" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] + +[tool.coverage.run] +source = ["filetype_size"] diff --git a/requirements.lock b/requirements.lock new file mode 100644 index 0000000..7ec90e8 --- /dev/null +++ b/requirements.lock @@ -0,0 +1,21 @@ +# Pinned dependencies for reproducible CI installs (pip install -r requirements.lock && pip install -e . --no-deps) +azure-core==1.41.0 +azure-storage-blob==12.30.3 +certifi==2026.7.22 +cffi==2.1.1 +charset-normalizer==3.5.1 +coverage==7.16.1 +cryptography==50.0.1 +idna==3.20 +iniconfig==2.3.0 +isodate==0.7.2 +packaging==26.3 +pluggy==1.6.0 +pycparser==3.0 +Pygments==2.21.0 +pytest==9.1.1 +pytest-cov==7.1.0 +requests==2.34.2 +ruff==0.16.9 +typing_extensions==4.16.0 +urllib3==2.8.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..91f10b7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +# Runtime dependencies (see requirements.lock for pinned versions) +azure-storage-blob>=12.24.0,<13 diff --git a/tests/test_azure_client.py b/tests/test_azure_client.py new file mode 100644 index 0000000..bbd4ae1 --- /dev/null +++ b/tests/test_azure_client.py @@ -0,0 +1,33 @@ +"""Tests for Azure client wiring (mocked SDK).""" + +from unittest.mock import MagicMock, patch + +from filetype_size.azure_client import create_blob_service_client, iter_containers_with_blobs + + +def test_create_blob_service_client_url(): + with patch("filetype_size.azure_client.BlobServiceClient") as mock_cls: + create_blob_service_client("myacct", "secret") + mock_cls.assert_called_once_with( + account_url="https://myacct.blob.core.windows.net", + credential="secret", + ) + + +def test_iter_containers_with_blobs(): + client = MagicMock() + container = MagicMock() + container.name = "logs" + client.list_containers.return_value = [container] + + container_client = MagicMock() + blob = MagicMock() + blob.name = "app.log" + blob.size = 99 + container_client.list_blobs.return_value = [blob] + client.get_container_client.return_value = container_client + + pairs = list(iter_containers_with_blobs(client)) + assert pairs == [("logs", [pairs[0][1][0]])] + assert pairs[0][1][0].name == "app.log" + assert pairs[0][1][0].size == 99 diff --git a/tests/test_calculator.py b/tests/test_calculator.py new file mode 100644 index 0000000..d1411b4 --- /dev/null +++ b/tests/test_calculator.py @@ -0,0 +1,55 @@ +"""Unit tests for extension matching and size aggregation.""" + +from dataclasses import dataclass + +import pytest + +from filetype_size.calculator import ( + blob_matches_extension, + normalize_extension, + scan_containers, + sum_matching_blob_sizes, +) + + +@dataclass +class FakeBlob: + name: str + size: int + + +def test_normalize_extension_adds_leading_dot(): + assert normalize_extension("vhd") == ".vhd" + assert normalize_extension(".png") == ".png" + + +def test_normalize_extension_rejects_empty(): + with pytest.raises(ValueError): + normalize_extension(" ") + + +def test_sum_matching_blob_sizes(): + blobs = [ + FakeBlob("data/file.vhd", 100), + FakeBlob("data/file.png", 50), + FakeBlob("other.vhd", 25), + ] + assert sum_matching_blob_sizes(blobs, ".vhd") == 125 + + +def test_blob_matches_extension(): + assert blob_matches_extension("path/to/blob.VHD", ".VHD") is True + assert blob_matches_extension("path/to/blob.vhd", ".vhd") is True + assert blob_matches_extension("path/to/blob.vhd", ".VHD") is False + + +def test_scan_containers_grand_total(): + containers = [ + ("c1", [FakeBlob("a.vhd", 10), FakeBlob("b.txt", 5)]), + ("c2", [FakeBlob("c.vhd", 20)]), + ] + results, total = scan_containers(containers, "vhd") + assert len(results) == 2 + assert total == 30 + assert results[0].extension_total_bytes == 10 + assert results[1].extension_total_bytes == 20 diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..7ba322e --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,51 @@ +"""CLI tests with mocked Azure client.""" + +from io import StringIO +from unittest.mock import MagicMock, patch + +from filetype_size.azure_client import ListedBlob +from filetype_size.cli import run + + +@patch("filetype_size.cli.create_blob_service_client") +@patch("filetype_size.cli.iter_containers_with_blobs") +def test_run_with_env_vars(mock_iter, mock_create): + mock_create.return_value = MagicMock() + mock_iter.return_value = [ + ("images", [ListedBlob("photo.png", 100), ListedBlob("disk.vhd", 500)]), + ] + + stdin = StringIO("") + stdout = StringIO() + env = { + "AZURE_STORAGE_ACCOUNT_NAME": "myaccount", + "AZURE_STORAGE_ACCOUNT_KEY": "fake-key", + "FILE_TYPE": ".vhd", + } + + with patch.dict("os.environ", env, clear=False): + code = run(stdout=stdout, stdin=stdin) + + assert code == 0 + output = stdout.getvalue() + assert "images" in output + assert "Total size of .vhd files: 500" in output + mock_create.assert_called_once_with("myaccount", "fake-key") + + +@patch("filetype_size.cli.create_blob_service_client") +def test_run_interactive_prompts(mock_create): + mock_create.return_value = MagicMock() + + stdin = StringIO("acct\nkey\n.png\n") + stdout = StringIO() + + with patch.dict("os.environ", {}, clear=True): + with patch( + "filetype_size.cli.iter_containers_with_blobs", + return_value=[("c", [ListedBlob("x.png", 42)])], + ): + code = run(stdout=stdout, stdin=stdin) + + assert code == 0 + assert "Total size of .png files: 42" in stdout.getvalue()