From 73beee6d075e3985ef0fc5cd604d3be190345cfd Mon Sep 17 00:00:00 2001 From: Igor Djachenko Date: Wed, 29 Jul 2026 04:58:09 +0700 Subject: [PATCH 1/6] chore: fix pyproject.toml inline table syntax --- pyproject.toml | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c858381..93b853e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,9 @@ description = "Upload office files to Google Drive with auto-conversion to Docs/ readme = "README.md" license = "MIT" license-files = ["LICENSE"] -authors = [{ name = "Igor Djachenko" }] +authors = [ + { name = "Igor Djachenko" } +] requires-python = ">=3.11" classifiers = [ "Development Status :: 3 - Alpha", @@ -30,8 +32,15 @@ dependencies = [ ] [project.optional-dependencies] -test = ["pytest", "ruff==0.15.16", "mypy"] -release = ["build", "python-semantic-release"] +test = [ + "pytest", + "ruff >= 0.15, < 0.16", + "mypy" +] +release = [ + "build", + "python-semantic-release" +] [tool.setuptools.packages.find] where = ["src"] From 56574ddca07346832103626ccd7bcfcc0a2ae08d Mon Sep 17 00:00:00 2001 From: Igor Djachenko Date: Wed, 29 Jul 2026 04:58:13 +0700 Subject: [PATCH 2/6] refactor: convert to class-based DI architecture --- CLAUDE.md | 9 ++-- src/goodoc/app.py | 64 +++++++++++++++++++++++++++ src/goodoc/auth.py | 40 ++++++++--------- src/goodoc/drive.py | 50 +++++++++++---------- src/goodoc/main.py | 66 +++++++--------------------- src/goodoc/setup.py | 105 +++++++++++++++++++++++--------------------- 6 files changed, 187 insertions(+), 147 deletions(-) create mode 100644 src/goodoc/app.py diff --git a/CLAUDE.md b/CLAUDE.md index c0d5429..4b1b8c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,12 +51,13 @@ goodoc/ └── src/ └── goodoc/ ├── __init__.py - ├── main.py # CLI: upload (по умолчанию) / login / logout + ├── main.py # CLI: тонкий слой Typer, делегирует в App + ├── app.py # App — точка сборки, бизнес-логика команд ├── config.py # пути и scopes - ├── auth.py # Auth — получение и обновление токена + ├── auth.py # Auth(config, setup) — токен, OAuth flow ├── client.py # встроенный OAuth-клиент автора + хеш ключа доступа - ├── drive.py # загрузка в Drive - └── setup.py # визард первого запуска (свой Cloud-проект) + ├── drive.py # Drive(auth) — загрузка в Google Drive + └── setup.py # Setup(config) — визард первого запуска ``` --- diff --git a/src/goodoc/app.py b/src/goodoc/app.py new file mode 100644 index 0000000..5fe1ad1 --- /dev/null +++ b/src/goodoc/app.py @@ -0,0 +1,64 @@ +import webbrowser +from pathlib import Path + +import typer + +from goodoc.auth import Auth +from goodoc.config import Config +from goodoc.drive import MIME_MAP, Drive + + +class App: + def __init__(self, config: Config, auth: Auth, drive: Drive) -> None: + self._config = config + self._auth = auth + self._drive = drive + + def upload(self, files: list[Path], open_browser: bool) -> None: + for file in files: + if error_message := self.validate(file): + typer.echo(error_message, err=True) + + raise typer.Exit(1) + + for file in files: + typer.echo(f"Uploading {file.name}...") + + url = self._drive.upload(file) + typer.echo(url) + + if open_browser: + webbrowser.open(url) + + def login(self, key: str | None = None) -> None: + if key is None: + self._auth.get_credentials() + else: + try: + self._auth.login_shared(key) + except ValueError as error: + typer.echo(str(error), err=True) + + raise typer.Exit(1) + + typer.echo("Logged in.") + + def logout(self) -> None: + if self._config.token_path.exists(): + self._config.token_path.unlink() + + typer.echo("Logged out.") + else: + typer.echo("Not logged in.") + + @staticmethod + def validate(file: Path) -> str | None: + if not file.exists(): + return f"File not found: {file}" + + if file.suffix.lower() not in MIME_MAP: + supported = ", ".join(MIME_MAP) + + return f"Unsupported format: {file.suffix}. Supported: {supported}" + + return None diff --git a/src/goodoc/auth.py b/src/goodoc/auth.py index ed949f3..fc18701 100644 --- a/src/goodoc/auth.py +++ b/src/goodoc/auth.py @@ -6,49 +6,49 @@ from goodoc.client import ACCESS_KEY_HASH from goodoc.config import Config -from goodoc.setup import authorize_shared, first_run_wizard +from goodoc.setup import Setup class Auth: - @staticmethod - def get_credentials(config: Config) -> Credentials: + def __init__(self, config: Config, setup: Setup) -> None: + self.config = config + self._setup = setup + + def get_credentials(self) -> Credentials: creds = None - if config.token_path.exists(): - creds = Credentials.from_authorized_user_file(str(config.token_path), config.scopes) + if self.config.token_path.exists(): + creds = Credentials.from_authorized_user_file(str(self.config.token_path), self.config.scopes) if not creds or not creds.valid: if creds and creds.expired and creds.refresh_token: creds.refresh(Request()) else: - creds = Auth._authorize(config) + creds = self._authorize() - Auth._save(config, creds) + self._save(creds) return creds - @staticmethod - def login_shared(config: Config, access_key: str) -> Credentials: + def login_shared(self, access_key: str) -> Credentials: if hashlib.sha256(access_key.encode()).hexdigest() != ACCESS_KEY_HASH: raise ValueError("Invalid access key.") - creds = authorize_shared(config, access_key) - Auth._save(config, creds) + creds = self._setup.authorize_shared(access_key) + self._save(creds) return creds - @staticmethod - def _authorize(config: Config) -> Credentials: - if config.credentials_path.exists(): - flow = InstalledAppFlow.from_client_secrets_file(str(config.credentials_path), config.scopes) + def _authorize(self) -> Credentials: + if self.config.credentials_path.exists(): + flow = InstalledAppFlow.from_client_secrets_file(str(self.config.credentials_path), self.config.scopes) return flow.run_local_server(port=0) - return first_run_wizard(config) + return self._setup.first_run_wizard() - @staticmethod - def _save(config: Config, creds: Credentials) -> None: - config.token_path.parent.mkdir(parents=True, exist_ok=True) + def _save(self, creds: Credentials) -> None: + self.config.token_path.parent.mkdir(parents=True, exist_ok=True) - with config.token_path.open("w") as f: + with self.config.token_path.open("w") as f: f.write(creds.to_json()) diff --git a/src/goodoc/drive.py b/src/goodoc/drive.py index 81e2760..2b229ea 100644 --- a/src/goodoc/drive.py +++ b/src/goodoc/drive.py @@ -1,10 +1,11 @@ from pathlib import Path import typer -from google.oauth2.credentials import Credentials from googleapiclient.discovery import build from googleapiclient.http import MediaFileUpload +from goodoc.auth import Auth + MIME_MAP = { ".doc": ( "application/msword", @@ -37,31 +38,36 @@ } -def upload(path: Path, creds: Credentials) -> str: - suffix = path.suffix.lower() +class Drive: + def __init__(self, auth: Auth) -> None: + self._auth = auth + + def upload(self, path: Path) -> str: + suffix = path.suffix.lower() - if suffix not in MIME_MAP: - supported = ", ".join(MIME_MAP) - typer.echo(f"Unsupported format: {suffix}. Supported: {supported}", err=True) + if suffix not in MIME_MAP: + supported = ", ".join(MIME_MAP) + typer.echo(f"Unsupported format: {suffix}. Supported: {supported}", err=True) - raise typer.Exit(1) + raise typer.Exit(1) - source_mime, target_mime = MIME_MAP[suffix] + source_mime, target_mime = MIME_MAP[suffix] + creds = self._auth.get_credentials() - service = build("drive", "v3", credentials=creds) - media = MediaFileUpload(str(path), mimetype=source_mime, resumable=False) + service = build("drive", "v3", credentials=creds) + media = MediaFileUpload(str(path), mimetype=source_mime, resumable=False) - result = ( - service.files() - .create( - body={ - "name": path.stem, - "mimeType": target_mime, - }, - media_body=media, - fields="id,webViewLink", + result = ( + service.files() + .create( + body={ + "name": path.stem, + "mimeType": target_mime, + }, + media_body=media, + fields="id,webViewLink", + ) + .execute() ) - .execute() - ) - return result["webViewLink"] + return result["webViewLink"] diff --git a/src/goodoc/main.py b/src/goodoc/main.py index 418e2fa..f655f69 100644 --- a/src/goodoc/main.py +++ b/src/goodoc/main.py @@ -1,13 +1,14 @@ -import webbrowser from pathlib import Path from typing import Any import typer from typer.core import TyperGroup +from goodoc.app import App from goodoc.auth import Auth from goodoc.config import Config -from goodoc.drive import MIME_MAP, upload +from goodoc.drive import MIME_MAP, Drive +from goodoc.setup import Setup DEFAULT_COMMAND = "upload" @@ -20,44 +21,27 @@ def parse_args(self, ctx: Any, args: list[str]) -> list[str]: return super().parse_args(ctx, args) -app = typer.Typer(cls=DefaultCommandGroup, no_args_is_help=True) - +def _create_app() -> App: + config = Config.default() + setup = Setup(config) + auth = Auth(config, setup) + drive = Drive(auth) + local_app = App(config, auth, drive) -def validate_file(file: Path) -> str | None: - if not file.exists(): - return f"File not found: {file}" + return local_app - if file.suffix.lower() not in MIME_MAP: - supported = ", ".join(MIME_MAP) - return f"Unsupported format: {file.suffix}. Supported: {supported}" - return None +app = typer.Typer(cls=DefaultCommandGroup, no_args_is_help=True) +_app = _create_app() @app.command(DEFAULT_COMMAND) -def upload_files( +def upload( files: list[Path] = typer.Argument(..., help=f"Paths to files ({' / '.join(MIME_MAP)})"), no_open: bool = typer.Option(False, "--no-open", help="Do not open in browser"), ) -> None: """Upload office files to Google Drive and open them in the browser.""" - config = Config.default() - - for file in files: - if error := validate_file(file): - typer.echo(error, err=True) - - raise typer.Exit(1) - - creds = Auth.get_credentials(config) - - for file in files: - typer.echo(f"Uploading {file.name}...") - - url = upload(file, creds) - typer.echo(url) - - if not no_open: - webbrowser.open(url) + _app.upload(files, open_browser=not no_open) @app.command() @@ -65,31 +49,13 @@ def login( key: str | None = typer.Option(None, "--key", help="Access key for the author's shared client"), ) -> None: """Authenticate with Google (without uploading a file).""" - config = Config.default() - - if key is None: - Auth.get_credentials(config) - else: - try: - Auth.login_shared(config, key) - except ValueError as error: - typer.echo(str(error), err=True) - - raise typer.Exit(1) - - typer.echo("Logged in.") + _app.login(key) @app.command() def logout() -> None: """Remove stored token (re-authentication will be required on next run).""" - config = Config.default() - - if config.token_path.exists(): - config.token_path.unlink() - typer.echo("Logged out.") - else: - typer.echo("Not logged in.") + _app.logout() if __name__ == "__main__": diff --git a/src/goodoc/setup.py b/src/goodoc/setup.py index c4b8468..a3d9415 100644 --- a/src/goodoc/setup.py +++ b/src/goodoc/setup.py @@ -11,74 +11,77 @@ from goodoc.config import Config -def authorize_shared(config: Config, access_key: str) -> Credentials: - flow = InstalledAppFlow.from_client_config(client_config(access_key), config.scopes) +class Setup: + def __init__(self, config: Config) -> None: + self._config = config - return flow.run_local_server(port=0) + def authorize_shared(self, access_key: str) -> Credentials: + flow = InstalledAppFlow.from_client_config(client_config(access_key), self._config.scopes) + return flow.run_local_server(port=0) -def _acquire_credentials(config: Config) -> None: - typer.echo("Set up your own Google Cloud project:") - typer.echo() - typer.echo(" 1. Open Google Cloud Console → APIs & Services → Credentials") - typer.echo(" 2. Create a project (or select existing)") - typer.echo(" 3. Enable the Google Drive API:") - typer.echo(" APIs & Services → Library → search 'Google Drive API' → Enable") - typer.echo(" 4. Create OAuth credentials:") - typer.echo(" Credentials → + Create Credentials → OAuth client ID") - typer.echo(" Application type: Desktop app") - typer.echo(" 5. Download the JSON file") - typer.echo() + def first_run_wizard(self) -> Credentials: + if not sys.stdin.isatty(): + typer.echo("goodoc is not configured. Run 'goodoc ' from Terminal first.", err=True) - typer.echo("Opening Google Cloud Console in browser...") - webbrowser.open("https://console.cloud.google.com/apis/credentials") - typer.echo() + raise typer.Exit(1) - downloads = Path.home() / "Downloads" - suggestions = sorted(downloads.glob("client_secret_*.json"), key=lambda p: p.stat().st_mtime, reverse=True) + typer.echo("First run — goodoc needs a Google Cloud project of its own.") + typer.echo("Takes a few minutes, once.") + typer.echo() + typer.echo("Got an access key from the author? Cancel and run instead:") + typer.echo(" goodoc login --key ") + typer.echo() - hint = f" [{suggestions[0]}]" if suggestions else "" + self._acquire_credentials() - raw = typer.prompt(f"Path to downloaded credentials JSON{hint}").strip() + typer.echo() + typer.echo("A browser window will open — sign in and allow access.") + typer.echo() - if raw: - src = Path(raw).expanduser() - elif suggestions: - src = suggestions[0] - else: - typer.echo("No path provided.", err=True) + flow = InstalledAppFlow.from_client_secrets_file(str(self._config.credentials_path), self._config.scopes) - raise typer.Exit(1) + return flow.run_local_server(port=0) - if not src.exists(): - typer.echo(f"File not found: {src}", err=True) + def _acquire_credentials(self) -> None: + typer.echo("Set up your own Google Cloud project:") + typer.echo() + typer.echo(" 1. Open Google Cloud Console → APIs & Services → Credentials") + typer.echo(" 2. Create a project (or select existing)") + typer.echo(" 3. Enable the Google Drive API:") + typer.echo(" APIs & Services → Library → search 'Google Drive API' → Enable") + typer.echo(" 4. Create OAuth credentials:") + typer.echo(" Credentials → + Create Credentials → OAuth client ID") + typer.echo(" Application type: Desktop app") + typer.echo(" 5. Download the JSON file") + typer.echo() - raise typer.Exit(1) + typer.echo("Opening Google Cloud Console in browser...") + webbrowser.open("https://console.cloud.google.com/apis/credentials") + typer.echo() - config.goodoc_dir.mkdir(parents=True, exist_ok=True) - shutil.copy2(src, config.credentials_path) - typer.echo(f"Saved to {config.credentials_path}") + downloads = Path.home() / "Downloads" + suggestions = sorted(downloads.glob("client_secret_*.json"), key=lambda p: p.stat().st_mtime, reverse=True) + hint = f" [{suggestions[0]}]" if suggestions else "" -def first_run_wizard(config: Config) -> Credentials: - if not sys.stdin.isatty(): - typer.echo("goodoc is not configured. Run 'goodoc ' from Terminal first.", err=True) + raw = typer.prompt(f"Path to downloaded credentials JSON{hint}").strip() - raise typer.Exit(1) + if raw: + src = Path(raw).expanduser() + elif suggestions: + src = suggestions[0] + else: + typer.echo("No path provided.", err=True) - typer.echo("First run — goodoc needs a Google Cloud project of its own.") - typer.echo("Takes a few minutes, once.") - typer.echo() - typer.echo("Got an access key from the author? Cancel and run instead:") - typer.echo(" goodoc login --key ") - typer.echo() + raise typer.Exit(1) - _acquire_credentials(config) + if not src.exists(): + typer.echo(f"File not found: {src}", err=True) - typer.echo() - typer.echo("A browser window will open — sign in and allow access.") - typer.echo() + raise typer.Exit(1) - flow = InstalledAppFlow.from_client_secrets_file(str(config.credentials_path), config.scopes) + self._config.goodoc_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, self._config.credentials_path) - return flow.run_local_server(port=0) + typer.echo(f"Saved to {self._config.credentials_path}") From 09dd99ef5453f5fbb5b8fcbb60a8b8aeec1ae2cf Mon Sep 17 00:00:00 2001 From: Igor Djachenko Date: Wed, 29 Jul 2026 04:58:22 +0700 Subject: [PATCH 3/6] test: update and extend tests for class-based DI --- tests/conftest.py | 14 ++-- tests/test_app.py | 116 ++++++++++++++++++++++++++++++ tests/test_auth.py | 55 ++++++++------ tests/test_drive.py | 42 +++++------ tests/test_main.py | 170 +++++++++----------------------------------- 5 files changed, 212 insertions(+), 185 deletions(-) create mode 100644 tests/test_app.py diff --git a/tests/conftest.py b/tests/conftest.py index 9ccae9f..98a7bf9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -30,24 +30,30 @@ def mock_creds(): return MagicMock() +@pytest.fixture +def docx_file(tmp_path, create_files): + create_files(tmp_path, {"doc.docx": None}) + return tmp_path / "doc.docx" + + @pytest.fixture def mock_get_credentials(monkeypatch): - mock = MagicMock() - monkeypatch.setattr("goodoc.main.Auth.get_credentials", mock) + mock = MagicMock(return_value=MagicMock()) + monkeypatch.setattr("goodoc.auth.Auth.get_credentials", mock) return mock @pytest.fixture def mock_upload(monkeypatch): mock = MagicMock(return_value="https://docs.google.com/doc") - monkeypatch.setattr("goodoc.main.upload", mock) + monkeypatch.setattr("goodoc.drive.Drive.upload", mock) return mock @pytest.fixture def mock_browser(monkeypatch): mock = MagicMock() - monkeypatch.setattr("goodoc.main.webbrowser.open", mock) + monkeypatch.setattr("goodoc.app.webbrowser.open", mock) return mock diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..07b04df --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,116 @@ +from pathlib import Path + +import pytest +import typer +from unittest.mock import MagicMock + +from goodoc.app import App +from goodoc.auth import Auth +from goodoc.config import Config +from goodoc.drive import Drive + + +@pytest.fixture +def config(tmp_path): + return Config(goodoc_dir=tmp_path, scopes=[]) + + +@pytest.fixture +def mock_auth(): + return MagicMock(spec=Auth) + + +@pytest.fixture +def mock_drive(): + mock = MagicMock(spec=Drive) + mock.upload.return_value = "https://docs.google.com/doc" + return mock + + +@pytest.fixture +def app(config, mock_auth, mock_drive): + return App(config, mock_auth, mock_drive) + + +class TestValidateFile: + def test_returns_none_on_valid_file(self, docx_file): + assert App.validate(docx_file) is None + + def test_returns_error_on_missing_file(self): + assert App.validate(Path("missing.docx")) is not None + + def test_returns_error_on_unsupported_format(self, tmp_path, create_files): + create_files(tmp_path, {"data.txt": None}) + + assert App.validate(tmp_path / "data.txt") is not None + + def test_missing_takes_priority_over_format(self): + assert App.validate(Path("missing.txt")) is not None + + +class TestUpload: + def test_uploads_file(self, app, mock_drive, docx_file): + app.upload([docx_file], open_browser=False) + + mock_drive.upload.assert_called_once_with(docx_file) + + def test_uploads_each_file(self, app, mock_drive, tmp_path, create_files): + create_files(tmp_path, {"a.docx": None, "b.docx": None}) + files = [tmp_path / "a.docx", tmp_path / "b.docx"] + + app.upload(files, open_browser=False) + + assert mock_drive.upload.call_count == 2 + + def test_opens_browser(self, app, mock_drive, docx_file, mock_browser): + app.upload([docx_file], open_browser=True) + + mock_browser.assert_called_once_with("https://docs.google.com/doc") + + def test_skips_browser(self, app, mock_drive, docx_file, mock_browser): + app.upload([docx_file], open_browser=False) + + mock_browser.assert_not_called() + + def test_missing_file_exits(self, app, mock_drive): + with pytest.raises(typer.Exit): + app.upload([Path("missing.docx")], open_browser=False) + + mock_drive.upload.assert_not_called() + + def test_validates_all_before_uploading(self, app, mock_drive, docx_file): + with pytest.raises(typer.Exit): + app.upload([docx_file, Path("missing.docx")], open_browser=False) + + mock_drive.upload.assert_not_called() + + +class TestLogin: + def test_no_key_calls_get_credentials(self, app, mock_auth): + app.login() + + mock_auth.get_credentials.assert_called_once() + + def test_key_calls_login_shared(self, app, mock_auth): + app.login(key="my-key") + + mock_auth.login_shared.assert_called_once_with("my-key") + + def test_invalid_key_exits(self, app, mock_auth): + mock_auth.login_shared.side_effect = ValueError("Invalid access key.") + + with pytest.raises(typer.Exit): + app.login(key="wrong") + + +class TestLogout: + def test_removes_token(self, app, config): + config.token_path.parent.mkdir(parents=True, exist_ok=True) + config.token_path.write_text("{}") + + app.logout() + + assert not config.token_path.exists() + + def test_no_token_no_error(self, app): + app.logout() diff --git a/tests/test_auth.py b/tests/test_auth.py index b5f2e64..2dd249e 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,8 +1,11 @@ +import hashlib + import pytest from unittest.mock import MagicMock, patch from goodoc.auth import Auth from goodoc.config import Config +from goodoc.setup import Setup @pytest.fixture @@ -10,18 +13,28 @@ def config(tmp_path): return Config(goodoc_dir=tmp_path, scopes=[]) +@pytest.fixture +def mock_setup(): + return MagicMock(spec=Setup) + + +@pytest.fixture +def auth(config, mock_setup): + return Auth(config, mock_setup) + + class TestGetCredentials: - def test_no_credentials_runs_wizard(self, config): + def test_no_credentials_runs_wizard(self, auth, mock_setup): wizard_creds = MagicMock() wizard_creds.to_json.return_value = "{}" + mock_setup.first_run_wizard.return_value = wizard_creds - with patch("goodoc.auth.first_run_wizard", return_value=wizard_creds) as wizard: - result = Auth.get_credentials(config) + result = auth.get_credentials() - wizard.assert_called_once_with(config) + mock_setup.first_run_wizard.assert_called_once() assert result is wizard_creds - def test_valid_token_loaded_from_file(self, config): + def test_valid_token_loaded_from_file(self, auth, config): config.credentials_path.write_text("{}") config.token_path.write_text("{}") @@ -29,14 +42,14 @@ def test_valid_token_loaded_from_file(self, config): with patch("goodoc.auth.Credentials.from_authorized_user_file", return_value=creds): with patch("goodoc.auth.InstalledAppFlow.from_client_secrets_file") as flow_factory: - result = Auth.get_credentials(config) + result = auth.get_credentials() assert result is creds creds.refresh.assert_not_called() flow_factory.assert_not_called() assert config.token_path.read_text() == "{}" - def test_expired_token_refreshed_and_written(self, config): + def test_expired_token_refreshed_and_written(self, auth, config): config.credentials_path.write_text("{}") config.token_path.write_text("{}") @@ -47,13 +60,13 @@ def test_expired_token_refreshed_and_written(self, config): with patch("goodoc.auth.Credentials.from_authorized_user_file", return_value=creds): with patch("goodoc.auth.Request"): - result = Auth.get_credentials(config) + result = auth.get_credentials() creds.refresh.assert_called_once() assert result is creds assert config.token_path.read_text() == refreshed_token - def test_no_token_runs_flow_and_writes(self, config): + def test_no_token_runs_flow_and_writes(self, auth, config): config.credentials_path.write_text("{}") fresh_token = '{"token": "fresh"}' @@ -65,37 +78,33 @@ def test_no_token_runs_flow_and_writes(self, config): flow.run_local_server.return_value = new_creds with patch("goodoc.auth.InstalledAppFlow.from_client_secrets_file", return_value=flow): - result = Auth.get_credentials(config) + result = auth.get_credentials() assert result is new_creds assert config.token_path.read_text() == fresh_token class TestLoginShared: - def test_invalid_key_rejected(self, config): - with patch("goodoc.auth.authorize_shared") as shared: - with pytest.raises(ValueError): - Auth.login_shared(config, "wrong-key") + def test_invalid_key_rejected(self, auth, mock_setup): + with pytest.raises(ValueError): + auth.login_shared("wrong-key") - shared.assert_not_called() - assert not config.token_path.exists() + mock_setup.authorize_shared.assert_not_called() - def test_valid_key_authorizes_and_writes(self, config): + def test_valid_key_authorizes_and_writes(self, auth, config, mock_setup): shared_token = '{"token": "shared"}' creds = MagicMock() creds.to_json.return_value = shared_token + mock_setup.authorize_shared.return_value = creds - with patch("goodoc.auth.authorize_shared", return_value=creds) as shared: - with patch("goodoc.auth.ACCESS_KEY_HASH", _sha256("right-key")): - result = Auth.login_shared(config, "right-key") + with patch("goodoc.auth.ACCESS_KEY_HASH", _sha256("right-key")): + result = auth.login_shared("right-key") - shared.assert_called_once_with(config, "right-key") + mock_setup.authorize_shared.assert_called_once_with("right-key") assert result is creds assert config.token_path.read_text() == shared_token def _sha256(value: str) -> str: - import hashlib - return hashlib.sha256(value.encode()).hexdigest() diff --git a/tests/test_drive.py b/tests/test_drive.py index 79b6c88..86f25f4 100644 --- a/tests/test_drive.py +++ b/tests/test_drive.py @@ -1,7 +1,15 @@ import pytest import typer +from unittest.mock import MagicMock -from goodoc.drive import MIME_MAP, upload +from goodoc.drive import MIME_MAP, Drive + + +@pytest.fixture +def drive(mock_creds): + mock_auth = MagicMock() + mock_auth.get_credentials.return_value = mock_creds + return Drive(mock_auth) class TestMimeMap: @@ -19,38 +27,30 @@ def test_extensions_map_to_google_formats(self, extension, expected_target): @pytest.mark.usefixtures("mock_drive_build") class TestUpload: - def test_unsupported_extension_exits(self, tmp_path, mock_creds): - file = tmp_path / "doc.pdf" - file.touch() + def test_unsupported_extension_exits(self, drive, tmp_path, create_files): + create_files(tmp_path, {"doc.pdf": None}) with pytest.raises(typer.Exit) as exc_info: - upload(file, mock_creds) + drive.upload(tmp_path / "doc.pdf") assert exc_info.value.exit_code == 1 @pytest.mark.parametrize("extension", MIME_MAP.keys()) - def test_supported_extension_returns_url(self, extension, tmp_path, mock_creds): - file = tmp_path / f"doc{extension}" - file.touch() + def test_supported_extension_returns_url(self, drive, extension, tmp_path, create_files): + create_files(tmp_path, {f"doc{extension}": None}) - url = upload(file, mock_creds) - - assert url == "https://docs.google.com/doc" + assert drive.upload(tmp_path / f"doc{extension}") == "https://docs.google.com/doc" @pytest.mark.parametrize("filename", ["DOC.DOCX", "Doc.Docx", "sheet.XLSX"]) - def test_uppercase_extension_accepted(self, filename, tmp_path, mock_creds): - file = tmp_path / filename - file.touch() - - assert upload(file, mock_creds) == "https://docs.google.com/doc" + def test_uppercase_extension_accepted(self, drive, filename, tmp_path, create_files): + create_files(tmp_path, {filename: None}) - def test_creates_with_stem_name_and_target_mime(self, tmp_path, mock_creds, mock_drive_build): - file = tmp_path / "report.docx" - file.touch() + assert drive.upload(tmp_path / filename) == "https://docs.google.com/doc" - upload(file, mock_creds) + def test_creates_with_stem_name_and_target_mime(self, drive, docx_file, mock_drive_build): + drive.upload(docx_file) _, kwargs = mock_drive_build.files.return_value.create.call_args - assert kwargs["body"]["name"] == "report" + assert kwargs["body"]["name"] == "doc" assert kwargs["body"]["mimeType"] == "application/vnd.google-apps.document" diff --git a/tests/test_main.py b/tests/test_main.py index 162614b..098681b 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,148 +1,44 @@ -from pathlib import Path - import pytest +from unittest.mock import MagicMock -from goodoc.main import app, validate_file +from goodoc.app import App +from goodoc.main import app -class TestValidateFile: - def test_returns_none_on_valid_file(self, tmp_path, create_files): - filename = "doc.docx" - create_files(tmp_path, { - filename: None, - }) +@pytest.fixture +def mock_app(monkeypatch): + mock = MagicMock(spec=App) + monkeypatch.setattr("goodoc.main._app", mock) + return mock - assert validate_file(tmp_path / filename) is None - def test_returns_error_on_missing_file(self): - assert validate_file(Path("missing.docx")) is not None +class TestCLI: + def test_upload_is_default_command(self, runner, mock_app, docx_file): + runner.invoke(app, [str(docx_file)]) - def test_returns_error_on_unsupported_format(self, tmp_path, create_files): - filename = "data.txt" - create_files(tmp_path, { - filename: None, - }) + mock_app.upload.assert_called_once() - assert validate_file(tmp_path / filename) is not None + def test_open_browser_by_default(self, runner, mock_app, docx_file): + runner.invoke(app, [str(docx_file)]) - def test_missing_takes_priority_over_format(self): - assert validate_file(Path("missing.txt")) is not None + mock_app.upload.assert_called_once_with([docx_file], open_browser=True) + def test_no_open_flag(self, runner, mock_app, docx_file): + runner.invoke(app, [str(docx_file), "--no-open"]) -class TestCLI: - def test_file_not_found(self, runner, mock_get_credentials, mock_upload): - result = runner.invoke(app, ["missing.docx"]) - - assert result.exit_code == 1 - assert "File not found" in result.output - - def test_single_file_uploads(self, runner, tmp_path, create_files, mock_get_credentials, mock_upload, mock_browser): - filename = "doc.docx" - create_files(tmp_path, { - filename: None, - }) - - result = runner.invoke(app, [str(tmp_path / filename)]) - - assert result.exit_code == 0 - mock_upload.assert_called_once() - - def test_url_printed(self, runner, tmp_path, create_files, mock_get_credentials, mock_upload, mock_browser): - filename = "doc.docx" - create_files(tmp_path, { - filename: None, - }) - - result = runner.invoke(app, [str(tmp_path / filename)]) - - assert "https://docs.google.com/doc" in result.output - - def test_browser_opened(self, runner, tmp_path, create_files, mock_get_credentials, mock_upload, mock_browser): - filename = "doc.docx" - create_files(tmp_path, { - filename: None, - }) - - runner.invoke(app, [str(tmp_path / filename)]) - - mock_browser.assert_called_once_with("https://docs.google.com/doc") - - @pytest.mark.parametrize("count", [1, 2, 3, 5]) - def test_multiple_files_all_uploaded( - self, - runner, - tmp_path, - create_files, - mock_get_credentials, - mock_upload, - mock_browser, - count, - ): - structure = {f"doc{i}.docx": None for i in range(count)} - create_files(tmp_path, structure) - files = [tmp_path / f"doc{i}.docx" for i in range(count)] - - result = runner.invoke(app, [str(f) for f in files]) - - assert result.exit_code == 0 - assert mock_upload.call_count == count - - @pytest.mark.parametrize("count", [2, 3, 5]) - def test_credentials_fetched_once( - self, - runner, - tmp_path, - create_files, - mock_get_credentials, - mock_upload, - mock_browser, - count, - ): - """get_credentials вызывается один раз независимо от числа файлов.""" - structure = {f"doc{i}.docx": None for i in range(count)} - create_files(tmp_path, structure) - files = [tmp_path / f"doc{i}.docx" for i in range(count)] - - runner.invoke(app, [str(f) for f in files]) - - mock_get_credentials.assert_called_once() - - def test_no_open_skips_browser(self, runner, tmp_path, create_files, mock_get_credentials, mock_upload, mock_browser): - filename = "doc.docx" - create_files(tmp_path, { - filename: None, - }) - - result = runner.invoke(app, [str(tmp_path / filename), "--no-open"]) - - assert result.exit_code == 0 - mock_browser.assert_not_called() - - def test_stops_on_first_missing_file(self, runner, tmp_path, create_files, mock_get_credentials, mock_upload, mock_browser): - """При отсутствующем файле не загружает следующие.""" - filename = "doc.docx" - create_files(tmp_path, { - filename: None, - }) - - result = runner.invoke(app, ["missing.docx", str(tmp_path / filename)]) - - assert result.exit_code == 1 - mock_upload.assert_not_called() - mock_get_credentials.assert_not_called() - - def test_no_auth_on_unsupported_format(self, runner, tmp_path, create_files, mock_get_credentials, mock_upload): - valid = "doc.docx" - invalid = "data.txt" - create_files(tmp_path, { - valid: None, - invalid: None, - }) - - result = runner.invoke(app, [ - str(tmp_path / valid), - str(tmp_path / invalid), - ]) - - assert result.exit_code == 1 - mock_get_credentials.assert_not_called() + mock_app.upload.assert_called_once_with([docx_file], open_browser=False) + + def test_login_command(self, runner, mock_app): + runner.invoke(app, ["login"]) + + mock_app.login.assert_called_once_with(None) + + def test_login_with_key(self, runner, mock_app): + runner.invoke(app, ["login", "--key", "abc"]) + + mock_app.login.assert_called_once_with("abc") + + def test_logout_command(self, runner, mock_app): + runner.invoke(app, ["logout"]) + + mock_app.logout.assert_called_once() From 6d7607821417376a9ee09f5b25ea40cd1eb3fc96 Mon Sep 17 00:00:00 2001 From: Igor Djachenko Date: Wed, 29 Jul 2026 05:00:45 +0700 Subject: [PATCH 4/6] chore: upgrade ruff to 0.16, fix new lint errors --- pyproject.toml | 2 +- src/goodoc/config.py | 2 +- src/goodoc/main.py | 2 +- tests/test_app.py | 2 +- tests/test_auth.py | 18 +++++++++++------- tests/test_drive.py | 3 ++- tests/test_main.py | 3 ++- 7 files changed, 19 insertions(+), 13 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 93b853e..bbf5c20 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ dependencies = [ [project.optional-dependencies] test = [ "pytest", - "ruff >= 0.15, < 0.16", + "ruff >= 0.16, < 0.17", "mypy" ] release = [ diff --git a/src/goodoc/config.py b/src/goodoc/config.py index ce9efa7..c6b8416 100644 --- a/src/goodoc/config.py +++ b/src/goodoc/config.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from functools import cached_property from pathlib import Path -from typing import Self, ClassVar +from typing import ClassVar, Self @dataclass(frozen=True) diff --git a/src/goodoc/main.py b/src/goodoc/main.py index f655f69..707c1ea 100644 --- a/src/goodoc/main.py +++ b/src/goodoc/main.py @@ -37,7 +37,7 @@ def _create_app() -> App: @app.command(DEFAULT_COMMAND) def upload( - files: list[Path] = typer.Argument(..., help=f"Paths to files ({' / '.join(MIME_MAP)})"), + files: list[Path] = typer.Argument(..., help=f"Paths to files ({' / '.join(MIME_MAP)})"), # noqa: B008 no_open: bool = typer.Option(False, "--no-open", help="Do not open in browser"), ) -> None: """Upload office files to Google Drive and open them in the browser.""" diff --git a/tests/test_app.py b/tests/test_app.py index 07b04df..d28a30d 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,8 +1,8 @@ from pathlib import Path +from unittest.mock import MagicMock import pytest import typer -from unittest.mock import MagicMock from goodoc.app import App from goodoc.auth import Auth diff --git a/tests/test_auth.py b/tests/test_auth.py index 2dd249e..a9aa4cf 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,7 +1,7 @@ import hashlib +from unittest.mock import MagicMock, patch import pytest -from unittest.mock import MagicMock, patch from goodoc.auth import Auth from goodoc.config import Config @@ -40,9 +40,11 @@ def test_valid_token_loaded_from_file(self, auth, config): creds = MagicMock(valid=True) - with patch("goodoc.auth.Credentials.from_authorized_user_file", return_value=creds): - with patch("goodoc.auth.InstalledAppFlow.from_client_secrets_file") as flow_factory: - result = auth.get_credentials() + with ( + patch("goodoc.auth.Credentials.from_authorized_user_file", return_value=creds), + patch("goodoc.auth.InstalledAppFlow.from_client_secrets_file") as flow_factory, + ): + result = auth.get_credentials() assert result is creds creds.refresh.assert_not_called() @@ -58,9 +60,11 @@ def test_expired_token_refreshed_and_written(self, auth, config): creds = MagicMock(valid=False, expired=True, refresh_token="rt") creds.to_json.return_value = refreshed_token - with patch("goodoc.auth.Credentials.from_authorized_user_file", return_value=creds): - with patch("goodoc.auth.Request"): - result = auth.get_credentials() + with ( + patch("goodoc.auth.Credentials.from_authorized_user_file", return_value=creds), + patch("goodoc.auth.Request"), + ): + result = auth.get_credentials() creds.refresh.assert_called_once() assert result is creds diff --git a/tests/test_drive.py b/tests/test_drive.py index 86f25f4..6f64890 100644 --- a/tests/test_drive.py +++ b/tests/test_drive.py @@ -1,6 +1,7 @@ +from unittest.mock import MagicMock + import pytest import typer -from unittest.mock import MagicMock from goodoc.drive import MIME_MAP, Drive diff --git a/tests/test_main.py b/tests/test_main.py index 098681b..d3e12c1 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,6 +1,7 @@ -import pytest from unittest.mock import MagicMock +import pytest + from goodoc.app import App from goodoc.main import app From 7af8c44ad6d68c8a52a3b515a7beaa2542fe046e Mon Sep 17 00:00:00 2001 From: Igor Djachenko Date: Wed, 29 Jul 2026 05:06:21 +0700 Subject: [PATCH 5/6] chore: move B008 ignore to pyproject per-file-ignores --- pyproject.toml | 4 ++++ src/goodoc/main.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index bbf5c20..8673605 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,10 @@ goodoc = "goodoc.main:app" [tool.mypy] ignore_missing_imports = true +[tool.ruff.lint.per-file-ignores] +# B008: typer.Argument/Option in defaults is the intended Typer pattern +"src/goodoc/main.py" = ["B008"] + [tool.semantic_release] version_toml = ["pyproject.toml:project.version"] branch = "master" diff --git a/src/goodoc/main.py b/src/goodoc/main.py index 707c1ea..f655f69 100644 --- a/src/goodoc/main.py +++ b/src/goodoc/main.py @@ -37,7 +37,7 @@ def _create_app() -> App: @app.command(DEFAULT_COMMAND) def upload( - files: list[Path] = typer.Argument(..., help=f"Paths to files ({' / '.join(MIME_MAP)})"), # noqa: B008 + files: list[Path] = typer.Argument(..., help=f"Paths to files ({' / '.join(MIME_MAP)})"), no_open: bool = typer.Option(False, "--no-open", help="Do not open in browser"), ) -> None: """Upload office files to Google Drive and open them in the browser.""" From 58c5b73d3cdc4e0d925f36aaca7397e05fe43c4c Mon Sep 17 00:00:00 2001 From: Igor Djachenko Date: Wed, 29 Jul 2026 05:16:16 +0700 Subject: [PATCH 6/6] style: make Auth config field private --- src/goodoc/auth.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/goodoc/auth.py b/src/goodoc/auth.py index fc18701..f433871 100644 --- a/src/goodoc/auth.py +++ b/src/goodoc/auth.py @@ -11,14 +11,14 @@ class Auth: def __init__(self, config: Config, setup: Setup) -> None: - self.config = config + self._config = config self._setup = setup def get_credentials(self) -> Credentials: creds = None - if self.config.token_path.exists(): - creds = Credentials.from_authorized_user_file(str(self.config.token_path), self.config.scopes) + if self._config.token_path.exists(): + creds = Credentials.from_authorized_user_file(str(self._config.token_path), self._config.scopes) if not creds or not creds.valid: if creds and creds.expired and creds.refresh_token: @@ -40,15 +40,15 @@ def login_shared(self, access_key: str) -> Credentials: return creds def _authorize(self) -> Credentials: - if self.config.credentials_path.exists(): - flow = InstalledAppFlow.from_client_secrets_file(str(self.config.credentials_path), self.config.scopes) + if self._config.credentials_path.exists(): + flow = InstalledAppFlow.from_client_secrets_file(str(self._config.credentials_path), self._config.scopes) return flow.run_local_server(port=0) return self._setup.first_run_wizard() def _save(self, creds: Credentials) -> None: - self.config.token_path.parent.mkdir(parents=True, exist_ok=True) + self._config.token_path.parent.mkdir(parents=True, exist_ok=True) - with self.config.token_path.open("w") as f: + with self._config.token_path.open("w") as f: f.write(creds.to_json())