Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions gcalcli/argparsers.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@
'type': str,
'help': 'API client_secret',
},
'--service-account': {
'default': None,
'type': str,
'help': 'Path to Service Account key file (JSON)',
},
'--noauth_local_server': {
'action': 'store_false',
'dest': 'auth_local_server',
Expand Down
7 changes: 7 additions & 0 deletions gcalcli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import socket
from google.auth.transport.requests import Request
from google_auth_oauthlib.flow import InstalledAppFlow
from google.oauth2 import service_account
from google.oauth2.credentials import Credentials
from gcalcli.printer import Printer

Expand Down Expand Up @@ -94,3 +95,9 @@ def creds_from_legacy_json(data):
)
}
return Credentials(data['access_token'], **kwargs)


def load_service_account(key_file_path):
return service_account.Credentials.from_service_account_file(
key_file_path, scopes=["https://www.googleapis.com/auth/calendar"]
)
Comment on lines +100 to +103
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

load_service_account() duplicates the calendar scope string used in authenticate(). To avoid future drift, consider defining a module-level constant (e.g., CALENDAR_SCOPES) and reusing it in both places.

Copilot uses AI. Check for mistakes.
12 changes: 12 additions & 0 deletions gcalcli/gcal.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,18 @@ def _load_credentials(self):
if self.userless_mode:
return

# Check for Service Account first (bypasses cache)
key_path = self.options.get('service_account')
if key_path:
self.printer.debug_msg(f'Loading Service Account from {key_path}\n')
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For consistency with other path-related messages in this file, consider shortening the printed key path (e.g., via utils.shorten_path(...)) rather than emitting the full filesystem path.

Suggested change
self.printer.debug_msg(f'Loading Service Account from {key_path}\n')
self.printer.debug_msg(
f'Loading Service Account from {utils.shorten_path(key_path)}\n'
)

Copilot uses AI. Check for mistakes.
try:
self.credentials = auth.load_service_account(key_path)
return
except Exception as e:
raise GcalcliError(
f'Failed to load service account: {e}'
)
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrapping all exceptions here loses the original traceback, which makes service-account failures harder to debug. Consider exception chaining (e.g., raising GcalcliError(...) from the caught exception) so the root cause is preserved.

Suggested change
)
) from e

Copilot uses AI. Check for mistakes.

# Try loading cached credentials
oauth_filepath = self.data_file_path('oauth')
if not oauth_filepath.exists():
Expand Down
41 changes: 41 additions & 0 deletions tests/test_service_account.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@

import pytest
from gcalcli.gcal import GoogleCalendarInterface
Comment on lines +2 to +3
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused imports here (pytest and GoogleCalendarInterface) will fail ruff check (F401). Please remove them or use them in the tests.

Suggested change
import pytest
from gcalcli.gcal import GoogleCalendarInterface

Copilot uses AI. Check for mistakes.
from gcalcli import auth

Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PEP8 blank-line spacing: top-level functions should be preceded by two blank lines. As written, def test_service_account_loading comes immediately after the imports with only one blank line, which will fail ruff (E302).

Suggested change

Copilot uses AI. Check for mistakes.
def test_service_account_loading(PatchedGCalI, monkeypatch):
# Mock auth.load_service_account to return a dummy
mock_creds = "DUMMY_CREDS"

mock_called = False
Comment on lines +8 to +10
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file contains trailing whitespace on blank/comment lines (e.g., around the blank lines after mock_creds / gcal = ...). ruff will flag this as W291/W293 and fail linting; please strip trailing spaces (or run the formatter).

Copilot uses AI. Check for mistakes.
def mock_load(path):
nonlocal mock_called
mock_called = True
assert path == "/tmp/fake-key.json"
return mock_creds

monkeypatch.setattr(auth, 'load_service_account', mock_load)

# Initialize GCal with service_account option
# Note: PatchedGCalI uses default_options fixture usually, we override
gcal = PatchedGCalI(service_account="/tmp/fake-key.json")

# Trigger auth load (lazy init might not have done it yet if cache existed?
# But PatchedGCalI stubs things out. Let's explicitly call _load_credentials or access)
gcal._load_credentials()

assert mock_called
assert gcal.credentials == mock_creds

Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PEP8 blank-line spacing: there should be two blank lines between top-level function definitions. Add an extra blank line between these tests to satisfy ruff (E305).

Suggested change

Copilot uses AI. Check for mistakes.
def test_service_account_skips_oauth_cache(PatchedGCalI, monkeypatch, tmp_path):
# Setup a fake oauth cache file
# PatchedGCalI mocks data_file_path_stub. We need to create the file where it expects.
# But checking if the file is IGNORED is the goal.

# Mock load_service_account
monkeypatch.setattr(auth, 'load_service_account', lambda p: "SA_CREDS")

gcal = PatchedGCalI(service_account="/tmp/key.json")
gcal._load_credentials()

assert gcal.credentials == "SA_CREDS"
Comment on lines +30 to +41
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test_service_account_skips_oauth_cache doesn't currently assert that the OAuth cache is actually bypassed (it only asserts the service-account creds are set). Consider using tmp_path as data_path, creating a fake oauth cache file, and asserting it isn't read (e.g., monkeypatch gcalcli.gcal.pickle.load/Path.open to fail if invoked, or assert data_file_path('oauth') isn't called).

Copilot uses AI. Check for mistakes.