From 6885c96f5bb6cfb4afcf223f32fd166d380490b0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 26 Sep 2026 16:24:45 +0000 Subject: [PATCH] Repo health: modernize Azure SDK, tests, CI, and docs - Replace deprecated azure-storage BlockBlobService with azure-storage-blob - Fix container scoping, blob accumulation, and credential logging bugs - Add Python 3.12+ packaging, Ruff, pytest suite, GitHub Actions, Dependabot - Add MIT license, SECURITY.md, README, .gitignore, and .editorconfig Co-authored-by: Joel Town Road --- .editorconfig | 15 ++++ .env.example | 8 ++ .github/dependabot.yml | 12 +++ .github/workflows/ci.yml | 43 ++++++++++ .gitignore | 47 +++++++++++ LICENSE | 21 +++++ README.md | 99 +++++++++++++++++++++- SECURITY.md | 19 +++++ get_blobs_of_file_type/__init__.py | 3 + get_blobs_of_file_type/cli.py | 127 +++++++++++++++++++++++++++++ get_blobs_of_file_type/scanner.py | 68 +++++++++++++++ main.py | 74 +---------------- pyproject.toml | 43 ++++++++++ requirements-lock.txt | 51 ++++++++++++ requirements.txt | 3 + tests/test_cli.py | 46 +++++++++++ tests/test_scanner.py | 72 ++++++++++++++++ 17 files changed, 679 insertions(+), 72 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 get_blobs_of_file_type/__init__.py create mode 100644 get_blobs_of_file_type/cli.py create mode 100644 get_blobs_of_file_type/scanner.py create mode 100644 pyproject.toml create mode 100644 requirements-lock.txt create mode 100644 requirements.txt create mode 100644 tests/test_cli.py create mode 100644 tests/test_scanner.py diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..fe044c6 --- /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,toml}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0aca686 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# Azure Blob Storage credentials (use one approach) + +# Option 1: account name + key +AZURE_STORAGE_ACCOUNT_NAME=your_storage_account +AZURE_STORAGE_ACCOUNT_KEY=your_account_key + +# Option 2: full connection string (overrides name/key when set) +# AZURE_STORAGE_CONNECTION_STRING=DefaultEndpointsProtocol=https;AccountName=... diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..b59ea21 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: + - package-ecosystem: pip + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 10 + + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0589ce1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ +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 + python -m pip install -e ".[dev]" + + - name: Ruff + run: ruff check . + + - name: Ruff format + run: ruff format --check . + + - name: Build package + run: python -m pip install build && python -m build + + - name: Tests + run: pytest --cov=get_blobs_of_file_type --cov-report=term-missing diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1943d62 --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +# 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 +.venv/ +venv/ +ENV/ +env/ + +# Testing / tooling +.pytest_cache/ +.coverage +htmlcov/ +.ruff_cache/ +.mypy_cache/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Secrets (never commit) +.env +.env.* +!.env.example 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 70313eb..12b8126 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,100 @@ # GetBlobsOfFileTypePython -print("Welcome to the storage utility. Shows you blobs of a certain file type in each container you provide!") +[![CI](https://github.com/hippi345/GetBlobsOfFileTypePython/actions/workflows/ci.yml/badge.svg)](https://github.com/hippi345/GetBlobsOfFileTypePython/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -print("Usage: python main.py -s storageAccountName -k key -c [containers] -f fileType") +Command-line utility that lists blobs in Azure Blob Storage containers whose names end with a given file type (extension), and reports the total size of matching files. + +## Features + +- Scan one or more containers in a storage account +- Filter blobs by filename suffix (e.g. `.pdf`) +- Print per-blob sizes and a total size summary +- Credentials via CLI flags or environment variables (keys are never printed) + +## Requirements + +- Python 3.12 or newer +- An Azure Storage account with access to the target containers + +## Setup + +```bash +git clone https://github.com/hippi345/GetBlobsOfFileTypePython.git +cd GetBlobsOfFileTypePython +python -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -e ".[dev]" +``` + +Copy `.env.example` to `.env` for local reference (do not commit `.env`). + +## Configuration + +| Variable | Description | +|----------|-------------| +| `AZURE_STORAGE_ACCOUNT_NAME` | Storage account name (used with account key) | +| `AZURE_STORAGE_ACCOUNT_KEY` | Storage account key | +| `AZURE_STORAGE_CONNECTION_STRING` | Full connection string (optional; takes precedence when set) | + +CLI flags `-s` / `-k` override environment variables for account name and key when not using a connection string. + +## Usage + +```bash +python main.py -s myaccount -k "$AZURE_STORAGE_ACCOUNT_KEY" -c container1 container2 -f .pdf +``` + +Or with environment variables: + +```bash +export AZURE_STORAGE_ACCOUNT_NAME=myaccount +export AZURE_STORAGE_ACCOUNT_KEY='...' +python main.py -c logs backups -f .json +``` + +Example output: + +``` +Welcome to the storage utility... + The containers are: ['logs', 'backups'] + The Storage account is: myaccount + The key is: [redacted] + The file type for search is: .pdf + logs: +2024/report.pdf: 1024 bytes +Total size of .pdf files: 1024 bytes +``` + +Installed package entry point (optional): + +```bash +get-blobs-of-file-type -c mycontainer -f .txt -s myaccount -k "$AZURE_STORAGE_ACCOUNT_KEY" +``` + +## Running tests + +Tests run offline with mocked storage clients (no Azure credentials required): + +```bash +pytest +ruff check . +ruff format --check . +``` + +## Project structure + +``` +├── get_blobs_of_file_type/ # Package: scanner + CLI +│ ├── cli.py +│ └── scanner.py +├── main.py # Backward-compatible entry point +├── tests/ +├── pyproject.toml +├── .github/workflows/ci.yml +└── SECURITY.md +``` + +## License + +MIT License — Copyright (c) 2026 Joel Shearon. See [LICENSE](LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..73bb9d8 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,19 @@ +# Security Policy + +## Supported versions + +Security fixes are applied on the latest release on the default branch. + +## Reporting a vulnerability + +If you discover a security issue, please **do not** open a public GitHub issue with sensitive details. Contact the repository owner privately so the issue can be addressed before disclosure. + +## Secrets and credentials + +- Never commit storage account keys, connection strings, or other secrets to the repository. +- Configure credentials via environment variables (`AZURE_STORAGE_ACCOUNT_NAME`, `AZURE_STORAGE_ACCOUNT_KEY`, or `AZURE_STORAGE_CONNECTION_STRING`) or CLI flags at runtime. +- If a secret was ever committed to git history, rotate it immediately in Azure Portal and purge it from history if required by your policy. + +## Dependencies + +Dependency updates are managed via Dependabot. Run `pip install -e ".[dev]"` and keep `azure-storage-blob` current for security patches. diff --git a/get_blobs_of_file_type/__init__.py b/get_blobs_of_file_type/__init__.py new file mode 100644 index 0000000..2427042 --- /dev/null +++ b/get_blobs_of_file_type/__init__.py @@ -0,0 +1,3 @@ +"""Azure Blob Storage utility to list blobs by file extension.""" + +__version__ = "1.0.0" diff --git a/get_blobs_of_file_type/cli.py b/get_blobs_of_file_type/cli.py new file mode 100644 index 0000000..4acbc20 --- /dev/null +++ b/get_blobs_of_file_type/cli.py @@ -0,0 +1,127 @@ +"""Command-line interface for the blob scanner.""" + +from __future__ import annotations + +import argparse +import os +import sys + +from azure.storage.blob import BlobServiceClient + +from get_blobs_of_file_type.scanner import ( + ScanResult, + create_blob_service_client, + create_blob_service_client_from_connection_string, + scan_containers, +) + +EXIT_USAGE = 69 + + +class CredentialError(Exception): + """Raised when required Azure credentials are missing.""" + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="main.py", + description=( + "Shows blobs of a certain file type in each Azure storage container you provide." + ), + ) + parser.add_argument( + "-s", + "--storage-account", + dest="storage_account", + help="Azure storage account name (or set AZURE_STORAGE_ACCOUNT_NAME)", + ) + parser.add_argument( + "-k", + "--key", + dest="account_key", + help="Azure storage account key (or set AZURE_STORAGE_ACCOUNT_KEY)", + ) + parser.add_argument( + "-c", + "--containers", + nargs="+", + required=True, + metavar="CONTAINER", + help="One or more container names to scan", + ) + parser.add_argument( + "-f", + "--file-type", + dest="file_type", + required=True, + help="File suffix to match (e.g. .pdf or pdf)", + ) + return parser + + +def resolve_credentials(args: argparse.Namespace) -> BlobServiceClient: + connection_string = os.environ.get("AZURE_STORAGE_CONNECTION_STRING") + if connection_string: + return create_blob_service_client_from_connection_string(connection_string) + + account_name = args.storage_account or os.environ.get("AZURE_STORAGE_ACCOUNT_NAME") + account_key = args.account_key or os.environ.get("AZURE_STORAGE_ACCOUNT_KEY") + if not account_name or not account_key: + raise CredentialError( + "Error: provide -s/-k or set AZURE_STORAGE_ACCOUNT_NAME and " + "AZURE_STORAGE_ACCOUNT_KEY (or AZURE_STORAGE_CONNECTION_STRING)." + ) + return create_blob_service_client(account_name, account_key) + + +def format_scan_output(result: ScanResult, file_type: str) -> str: + lines: list[str] = [] + by_container: dict[str, list] = {} + for match in result.matches: + by_container.setdefault(match.container, []).append(match) + + for container in sorted(by_container): + lines.append(f" {container}:") + for match in by_container[container]: + lines.append(f"{match.name}: {match.size_bytes} bytes") + + lines.append(f"Total size of {file_type} files: {result.total_size_bytes} bytes") + return "\n".join(lines) + + +def run(argv: list[str] | None = None) -> int: + if argv is None: + argv = sys.argv[1:] + + if not argv or argv[0] in {"help", "h", "-help", "-h", "--help"}: + build_parser().print_help() + return EXIT_USAGE + + parser = build_parser() + args = parser.parse_args(argv) + + print( + "Welcome to the storage utility. Shows you blobs of a certain file type " + "in each container you provide!" + ) + print(f" The containers are: {args.containers}") + account_label = args.storage_account or os.environ.get( + "AZURE_STORAGE_ACCOUNT_NAME", "(from env)" + ) + print(f" The Storage account is: {account_label}") + print(" The key is: [redacted]") + print(f" The file type for search is: {args.file_type}") + + try: + service = resolve_credentials(args) + except CredentialError as exc: + print(str(exc), file=sys.stderr) + return EXIT_USAGE + + result = scan_containers(service, args.containers, args.file_type) + print(format_scan_output(result, args.file_type)) + return 0 + + +def main() -> None: + sys.exit(run()) diff --git a/get_blobs_of_file_type/scanner.py b/get_blobs_of_file_type/scanner.py new file mode 100644 index 0000000..a48ff5f --- /dev/null +++ b/get_blobs_of_file_type/scanner.py @@ -0,0 +1,68 @@ +"""Core logic for scanning Azure Blob containers.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + +from azure.storage.blob import BlobServiceClient + + +@dataclass(frozen=True) +class BlobMatch: + """A blob whose name matches the requested file type.""" + + container: str + name: str + size_bytes: int + + +@dataclass(frozen=True) +class ScanResult: + """Aggregated scan output for one or more containers.""" + + matches: tuple[BlobMatch, ...] + + @property + def total_size_bytes(self) -> int: + return sum(m.size_bytes for m in self.matches) + + +class BlobServiceLike(Protocol): + """Subset of BlobServiceClient used by the scanner (for testing).""" + + def get_container_client(self, container: str): ... + + +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 create_blob_service_client_from_connection_string(connection_string: str) -> BlobServiceClient: + return BlobServiceClient.from_connection_string(connection_string) + + +def blob_name_matches(blob_name: str, file_type: str) -> bool: + """Return True if blob_name ends with the given file type suffix.""" + return blob_name.endswith(file_type) + + +def scan_containers( + service: BlobServiceLike, + container_names: list[str], + file_type: str, +) -> ScanResult: + """ + List blobs in each container whose names end with file_type. + + Raises azure.core.exceptions.ResourceNotFoundError if a container does not exist. + """ + matches: list[BlobMatch] = [] + for container_name in container_names: + container_client = service.get_container_client(container_name) + for blob in container_client.list_blobs(): + if blob_name_matches(blob.name, file_type): + size = blob.size if blob.size is not None else 0 + matches.append(BlobMatch(container=container_name, name=blob.name, size_bytes=size)) + return ScanResult(matches=tuple(matches)) diff --git a/main.py b/main.py index 7bcb850..dd1104f 100644 --- a/main.py +++ b/main.py @@ -1,72 +1,6 @@ -from azure.storage.blob import * -import sys +"""Entry point for python main.py (backward-compatible CLI).""" -# Usage function -def helpUsage(): - print("Usage: python main.py -s storageAccountName -k key -c [containers] -f fileType") - sys.exit(69) +from get_blobs_of_file_type.cli import main -print("Welcome to the storage utility. Shows you blobs of a certain file type in each container you provide!") -print("Usage: python main.py -s storageAccountName -k key -c [containers] -f fileType") -print("Args from command line are: ") -print(sys.argv) - -marker = None -SAName = "" -SAKey = "" -fileType = "" -containers = [] -blobsList = [] -totalSize = 0 - -# help section -for arg in sys.argv: - if arg == "help": - helpUsage() -for arg in sys.argv: - if arg == "h": - helpUsage() -for arg in sys.argv: - if arg == "-help": - helpUsage() -for arg in sys.argv: - if arg == "-h": - helpUsage() - -for x in range(len(sys.argv)): - if sys.argv[x] == "-s": - SAName = sys.argv[x+1] - if sys.argv[x] == "-k": - SAKey = sys.argv[x+1] - if sys.argv[x] == "-f": - fileType = sys.argv[x+1] - if sys.argv[x] == "-c": - for y in range(x+1, len(sys.argv)): - if sys.argv[y] == "-s": - break - if sys.argv[y] == "-f": - break - if sys.argv[y] == "-k": - break - else: - containers.append(sys.argv[y]) - -print("The containers are: ") -print(containers) -print(" The Storage account is: " + SAName) -print(" The key is: " + SAKey) -print(" The file type for search is: " + fileType) - -block_blob_service = BlockBlobService(account_name=SAName, account_key=SAKey) -serviceContainers = block_blob_service.list_containers() - -for c in serviceContainers: - 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) - if str(blob_property.name).endswith(fileType): - print(blob_property.name + ": " + str(blob_property.properties.content_length) + " bytes") - totalSize += blob_property.properties.content_length -print("Total size of " + fileType + " files: " + str(totalSize) + " bytes") +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..51c650e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,43 @@ +[build-system] +requires = ["setuptools>=75.0.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "get-blobs-of-file-type" +version = "1.0.0" +description = "CLI utility to list Azure Blob Storage blobs matching a file extension" +readme = "README.md" +requires-python = ">=3.12" +license = "MIT" +authors = [{ name = "Joel Shearon" }] +dependencies = [ + "azure-storage-blob>=12.24.0,<13", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.3.0,<9", + "pytest-cov>=6.0.0,<7", + "ruff>=0.9.0,<1", +] + +[project.scripts] +get-blobs-of-file-type = "get_blobs_of_file_type.cli:main" + +[tool.setuptools.packages.find] +where = ["."] +include = ["get_blobs_of_file_type*"] + +[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.ruff.lint.per-file-ignores] +"tests/*" = ["B011"] diff --git a/requirements-lock.txt b/requirements-lock.txt new file mode 100644 index 0000000..4e24909 --- /dev/null +++ b/requirements-lock.txt @@ -0,0 +1,51 @@ +ansible==9.2.0 +ansible-core==2.16.3 +argcomplete==3.1.4 +azure-core==1.41.0 +azure-storage-blob==12.30.3 +blinker==1.7.0 +certifi==2026.4.22 +charset-normalizer==3.4.7 +coverage==7.16.1 +cryptography==41.0.7 +dbus-python==1.3.2 +distro==1.9.0 +dnspython==2.6.1 +-e git+https://github.com/hippi345/GetBlobsOfFileTypePython@5286c3ef1b519e4a6ef6e8ecd0146973f419e5be#egg=get_blobs_of_file_type +httplib2==0.20.4 +idna==3.13 +iniconfig==2.3.0 +isodate==0.7.2 +Jinja2==3.1.2 +jwcrypto==1.5.7 +launchpadlib==1.11.0 +lazr.restfulclient==0.14.6 +lazr.uri==1.0.6 +MarkupSafe==2.1.5 +netaddr==0.8.0 +numpy==2.4.4 +oauthlib==3.2.2 +packaging==24.0 +pluggy==1.6.0 +Pygments==2.21.0 +PyGObject==3.48.2 +PyJWT==2.7.0 +pyparsing==3.1.1 +pytest==8.4.2 +pytest-cov==6.3.0 +python-apt==2.7.7+ubuntu5.2 +PyYAML==6.0.1 +redis==7.4.0 +requests==2.33.1 +resolvelib==1.0.1 +ruff==0.16.9 +setuptools==68.1.2 +six==1.16.0 +toml==0.10.2 +typing_extensions==4.15.0 +urllib3==2.6.3 +wadllib==1.3.6 +websockify==0.13.0 +wheel==0.42.0 +xmltodict==0.13.0 +yq==3.1.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0061f07 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +# Runtime dependencies (see pyproject.toml for authoritative versions). +# Install with: pip install -r requirements.txt +azure-storage-blob>=12.24.0,<13 diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..7d31187 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,46 @@ +"""CLI tests with mocked Azure client.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from get_blobs_of_file_type.cli import run +from get_blobs_of_file_type.scanner import BlobMatch, ScanResult + + +def test_help_exits_with_usage_code(): + assert run(["--help"]) == 69 + + +def test_missing_credentials_exits_usage(capsys): + code = run(["-c", "mycontainer", "-f", ".pdf", "-s", "acct"]) + assert code == 69 + err = capsys.readouterr().err + assert "AZURE_STORAGE_ACCOUNT_KEY" in err + + +@patch("get_blobs_of_file_type.cli.scan_containers") +@patch("get_blobs_of_file_type.cli.create_blob_service_client") +def test_run_success_redacts_key(mock_create, mock_scan, capsys): + mock_create.return_value = MagicMock() + mock_scan.return_value = ScanResult( + matches=(BlobMatch(container="c1", name="file.pdf", size_bytes=42),) + ) + code = run( + [ + "-s", + "myaccount", + "-k", + "supersecret", + "-c", + "c1", + "-f", + ".pdf", + ] + ) + assert code == 0 + out = capsys.readouterr().out + assert "supersecret" not in out + assert "[redacted]" in out + assert "file.pdf: 42 bytes" in out + mock_create.assert_called_once_with("myaccount", "supersecret") diff --git a/tests/test_scanner.py b/tests/test_scanner.py new file mode 100644 index 0000000..ade055b --- /dev/null +++ b/tests/test_scanner.py @@ -0,0 +1,72 @@ +"""Unit tests for blob scanning logic (offline, no Azure credentials).""" + +from __future__ import annotations + +from collections.abc import Iterator +from dataclasses import dataclass + +from get_blobs_of_file_type.scanner import ( + blob_name_matches, + scan_containers, +) + + +@dataclass +class FakeBlob: + name: str + size: int | None = 0 + + +class FakeContainerClient: + def __init__(self, blobs: list[FakeBlob]) -> None: + self._blobs = blobs + + def list_blobs(self) -> Iterator[FakeBlob]: + return iter(self._blobs) + + +class FakeBlobService: + def __init__(self, containers: dict[str, list[FakeBlob]]) -> None: + self._containers = containers + + def get_container_client(self, container: str) -> FakeContainerClient: + if container not in self._containers: + raise KeyError(container) + return FakeContainerClient(self._containers[container]) + + +def test_blob_name_matches_suffix(): + assert blob_name_matches("folder/report.pdf", ".pdf") + assert not blob_name_matches("folder/report.pdf", ".doc") + + +def test_scan_containers_filters_by_file_type(): + service = FakeBlobService( + { + "c1": [ + FakeBlob("a.pdf", 100), + FakeBlob("b.txt", 50), + ], + "c2": [ + FakeBlob("nested/c.pdf", 200), + ], + } + ) + result = scan_containers(service, ["c1", "c2"], ".pdf") + assert len(result.matches) == 2 + assert result.total_size_bytes == 300 + names = {m.name for m in result.matches} + assert names == {"a.pdf", "nested/c.pdf"} + + +def test_scan_containers_empty_when_no_matches(): + service = FakeBlobService({"c1": [FakeBlob("readme.md", 10)]}) + result = scan_containers(service, ["c1"], ".pdf") + assert result.matches == () + assert result.total_size_bytes == 0 + + +def test_scan_containers_handles_none_size(): + service = FakeBlobService({"c1": [FakeBlob("x.pdf", None)]}) + result = scan_containers(service, ["c1"], ".pdf") + assert result.matches[0].size_bytes == 0