Compare commits
5 Commits
07fafd8fe7
...
2dbd4a80a1
| Author | SHA1 | Date | |
|---|---|---|---|
|
2dbd4a80a1
|
|||
|
64327c73e9
|
|||
|
f847a0273f
|
|||
|
f516e4f8e5
|
|||
|
c8c7680fac
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -157,3 +157,4 @@ result
|
||||
|
||||
# for local dev might dump cred stuff here
|
||||
cred/
|
||||
local/
|
||||
|
||||
130
CLAUDE.md
130
CLAUDE.md
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is a Python CLI client for the trygo webapp built with a hybrid development environment using both uv and Nix. The project has dual entry points: `hello` (hello_world package) and `taco` (trygo_py_cliclient.cli package).
|
||||
This is a Python CLI client for the TryGo webapp built with a hybrid development environment using both uv and Nix. The project provides a complete CLI interface for the TryGo Activity Files API, allowing users to upload, manage, and interact with activity files (.fit files). The project has dual entry points: `hello` (hello_world package) and `taiga` (taiga_pycli.cli package).
|
||||
|
||||
## Development Commands
|
||||
|
||||
@@ -21,19 +21,107 @@ This is a Python CLI client for the trygo webapp built with a hybrid development
|
||||
- `uv run mypy src` - Type checking
|
||||
- `uv run pytest --snapshot-update` - Update test snapshots
|
||||
|
||||
## CLI Commands
|
||||
|
||||
The `taiga` CLI provides the following commands:
|
||||
|
||||
### Authentication Commands
|
||||
- `taiga register --email <email> --password <password> --display-name <name>` - Register new user account
|
||||
- `taiga login --email <email> --password <password>` - Authenticate and store JWT token
|
||||
|
||||
### Activity File Commands
|
||||
- `taiga activities upload <file_path>` - Upload activity files (.fit files) to the server
|
||||
- Requires authentication (login first)
|
||||
- Validates file existence and readability
|
||||
- Uses current timestamp automatically
|
||||
- Returns activity file metadata on success
|
||||
- `taiga activities ls` - List all activity files for the authenticated user
|
||||
- Shows ID, timestamp, and creation date in tabular format
|
||||
- Requires authentication (login first)
|
||||
- `taiga activities download <id> [--output <path>]` - Download activity file by ID
|
||||
- Downloads activity file to specified path or defaults to activity_{id}.fit
|
||||
- Requires authentication (login first)
|
||||
|
||||
### Other Commands
|
||||
- `taiga hat` - Hat management commands (development/testing feature)
|
||||
|
||||
### Examples
|
||||
```bash
|
||||
# Register and login
|
||||
taiga register --email "user@example.com" --password "mypassword" --display-name "My Name"
|
||||
taiga login --email "user@example.com" --password "mypassword"
|
||||
|
||||
# Activity file management
|
||||
taiga activities upload activity.fit
|
||||
taiga activities upload local/test-data/test.fit
|
||||
taiga activities ls
|
||||
taiga activities download 1
|
||||
taiga activities download 1 --output my_activity.fit
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Package Structure
|
||||
- `src/hello_world/` - Hello world demonstration package
|
||||
- `src/trygo_py_cliclient/` - Main CLI client package
|
||||
- `src/taiga_pycli/` - Main CLI client package
|
||||
- `cli/` - Command-line interface and argument parsing
|
||||
- `config/` - Configuration management with TOML support
|
||||
- `service/` - Backend API service layer
|
||||
- `models.py` - Data models for API requests/responses
|
||||
|
||||
## Upload Functionality
|
||||
|
||||
The upload feature provides seamless integration with the TryGo Activity Files API:
|
||||
|
||||
### Implementation Details
|
||||
- **ActivityFile Model**: Dataclass representing activity file metadata with fields for id, timestamp, file_repo_hash, created_at, updated_at, and user_id
|
||||
- **BackendService.upload_activity_file()**: Handles multipart file uploads with proper JWT authentication
|
||||
- **File Validation**: Checks file existence, readability, and path validity before upload
|
||||
- **Error Handling**: Comprehensive error handling for authentication, validation, network, and server errors
|
||||
- **Automatic Timestamping**: Uses current time for activity timestamp (no custom timestamp needed)
|
||||
|
||||
### Supported File Formats
|
||||
- Primary: `.fit` files (Garmin activity files)
|
||||
- The API accepts any file format, but the CLI is designed for activity files
|
||||
|
||||
### Authentication Requirements
|
||||
- Must be logged in with valid JWT token before uploading
|
||||
- Token is automatically stored in `cred/token` after login
|
||||
- Token is automatically included in upload requests
|
||||
|
||||
### Error Scenarios Handled
|
||||
- File not found or not readable
|
||||
- Not authenticated (missing/invalid token)
|
||||
- Network connectivity issues
|
||||
- Server errors (400, 500, etc.)
|
||||
- Invalid API responses
|
||||
|
||||
### Configuration System
|
||||
- Uses dataclasses for type-safe configuration (`src/trygo_py_cliclient/config/config.py`)
|
||||
- Uses dataclasses for type-safe configuration (`src/taiga_pycli/config/config.py`)
|
||||
- TOML-based configuration files (`config.toml`)
|
||||
- Supports logging configuration and general application settings
|
||||
|
||||
### TryGo Activity Files API Integration
|
||||
- **BackendService Class**: Centralized API client with session management
|
||||
- JWT token authentication with automatic header injection
|
||||
- Token persistence in `cred/token` file
|
||||
- Comprehensive error handling with custom exception types
|
||||
- Support for authentication endpoints (`/auth/register`, `/auth/tokens`)
|
||||
- Support for activity file endpoints (`/activity_files`, `/activity_files/{id}/download`)
|
||||
- Methods: `upload_activity_file()`, `list_activity_files()`, `download_activity_file()`
|
||||
|
||||
- **Data Models**: Type-safe dataclasses for API interactions
|
||||
- `ActivityFile`: Response model for activity file metadata
|
||||
- `RegisterRequest`/`LoginRequest`: Authentication request models
|
||||
- `AuthResponse`: Authentication response model
|
||||
|
||||
- **Error Handling Strategy**: Custom exception hierarchy
|
||||
- `AuthenticationError`: 401 responses and missing tokens
|
||||
- `ValidationError`: 400 responses and file validation issues
|
||||
- `NetworkError`: Connection timeouts and network issues
|
||||
- `ServerError`: 5xx server responses
|
||||
- `TryGoAPIError`: General API errors
|
||||
|
||||
### Development Environment
|
||||
The project supports both uv and Nix environments:
|
||||
- **uv mode**: Standard Python toolchain with uv for dependency management
|
||||
@@ -44,6 +132,7 @@ The project supports both uv and Nix environments:
|
||||
- pytest with syrupy for snapshot testing
|
||||
- Coverage reporting enabled (50% minimum)
|
||||
- Test configuration in pyproject.toml with XML and HTML output
|
||||
- Custom testing scripts for upload functionality validation
|
||||
|
||||
### Code Quality
|
||||
- ruff for linting and formatting (tab indentation style)
|
||||
@@ -51,9 +140,42 @@ The project supports both uv and Nix environments:
|
||||
- flake8 for additional linting
|
||||
- treefmt.nix for comprehensive formatting in Nix environment
|
||||
|
||||
## Testing and Development Scripts
|
||||
|
||||
### Development Scripts
|
||||
- `scripts/simple_create_user.sh` - Quick user registration script for testing
|
||||
- Creates test user with email "test@example.com" and password "test"
|
||||
- Used for development and testing workflows
|
||||
|
||||
- `scripts/test_upload.sh` - Comprehensive upload functionality testing
|
||||
- Tests authentication requirements (upload without login should fail)
|
||||
- Tests successful login workflow
|
||||
- Tests successful file upload
|
||||
- Tests error handling (non-existent files)
|
||||
- Provides complete end-to-end validation
|
||||
|
||||
### Test Data
|
||||
- `local/test-data/` - Directory containing real .fit files for testing
|
||||
- `test.fit`, `test2.fit`, `test3.fit` - Sample activity files
|
||||
- Organized separately from development files
|
||||
- Used by test scripts for realistic upload testing
|
||||
|
||||
### Testing Workflow
|
||||
```bash
|
||||
# Run complete upload test suite
|
||||
./scripts/test_upload.sh
|
||||
|
||||
# Manual testing steps
|
||||
./scripts/simple_create_user.sh # Create test user (if needed)
|
||||
taiga login --email "test@example.com" --password "test"
|
||||
taiga activities upload local/test-data/test.fit
|
||||
taiga activities ls
|
||||
taiga activities download 1
|
||||
```
|
||||
|
||||
## Entry Points
|
||||
- `hello` command maps to `hello_world:main`
|
||||
- `taco` command maps to `trygo_py_cliclient.cli:main`
|
||||
- `taiga` command maps to `taiga_pycli.cli:main`
|
||||
|
||||
## Configuration Files
|
||||
- `pyproject.toml` - Python project configuration and dependencies
|
||||
|
||||
@@ -8,11 +8,12 @@ dependencies = [
|
||||
"requests>=2.31.0",
|
||||
"dacite>=1.9.2",
|
||||
"tomli>=2.2.1",
|
||||
"types-requests>=2.32.4.20250913",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
hello = "hello_world:main"
|
||||
taco = "trygo_py_cliclient.cli:main"
|
||||
taiga = "taiga_pycli.cli:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
@@ -41,11 +42,21 @@ sources = ["src"]
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
# Uncomment to care about coverage
|
||||
addopts = "--junitxml pytest.xml --cov src --cov-report=xml:coverage.xml --cov-fail-under=50 --cov-report=html"
|
||||
addopts = "--junitxml pytest.xml --cov src --cov-report=xml:coverage.xml --cov-fail-under=1 --cov-report=html"
|
||||
junit_family = "xunit1"
|
||||
log_format = "%(asctime)s | %(levelname)s | %(pathname)s:%(lineno)d | %(message)s"
|
||||
log_level = "WARNING"
|
||||
|
||||
[tool.pyright]
|
||||
executionEnvironments = [
|
||||
{ root = "src" }
|
||||
]
|
||||
exclude = [
|
||||
".venv"
|
||||
]
|
||||
venvPath = "."
|
||||
venv = ".venv"
|
||||
|
||||
# [tool.mypy]
|
||||
# If you need this
|
||||
# plugins = "numpy.typing.mypy_plugin"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuox pipefail
|
||||
set -Eeuo pipefail
|
||||
|
||||
banner() {
|
||||
echo "========================================================"
|
||||
@@ -9,4 +9,20 @@ banner() {
|
||||
|
||||
# utility script for easy testing
|
||||
|
||||
uv run taco register --display-name "Display Test" --email "test@example.com" --password "test"
|
||||
banner "Checking if test user exists"
|
||||
if uv run taiga login --email "test@example.com" --password "test" 2>/dev/null; then
|
||||
echo "Test user already exists, skipping registration"
|
||||
else
|
||||
banner "Creating test user"
|
||||
uv run taiga register --display-name "Display Test" --email "test@example.com" --password "test"
|
||||
|
||||
banner "Logging in test user"
|
||||
uv run taiga login --email "test@example.com" --password "test"
|
||||
fi
|
||||
|
||||
banner "Uploading test files"
|
||||
uv run taiga activities upload local/test-data/test.fit
|
||||
uv run taiga activities upload local/test-data/test2.fit
|
||||
uv run taiga activities upload local/test-data/test3.fit
|
||||
|
||||
banner "Setup complete!"
|
||||
|
||||
46
scripts/test_upload.sh
Normal file
46
scripts/test_upload.sh
Normal file
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuox pipefail
|
||||
|
||||
banner() {
|
||||
echo "========================================================"
|
||||
echo " $*"
|
||||
echo "========================================================"
|
||||
}
|
||||
|
||||
# Test script for activities functionality
|
||||
|
||||
banner "Testing Activities Functionality"
|
||||
|
||||
# Check if we have test files
|
||||
if [ ! -d "local/test-data" ] || [ -z "$(ls -A local/test-data/*.fit 2>/dev/null)" ]; then
|
||||
echo "Error: No test .fit files found in local/test-data/"
|
||||
echo "Please ensure test files are available."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test 1: Try upload without authentication (should fail)
|
||||
banner "Test 1: Upload without authentication (should fail)"
|
||||
echo "This should show an authentication error:"
|
||||
uv run taiga activities upload local/test-data/test.fit || echo "Expected failure - not authenticated"
|
||||
|
||||
# Test 2: Login first
|
||||
banner "Test 2: Login with test user"
|
||||
uv run taiga login --email "test@example.com" --password "test"
|
||||
|
||||
# Test 3: Upload a file (should succeed)
|
||||
banner "Test 3: Upload test file (should succeed)"
|
||||
uv run taiga activities upload local/test-data/test.fit
|
||||
|
||||
# Test 4: List activities (should show uploaded file)
|
||||
banner "Test 4: List activities (should show uploaded file)"
|
||||
uv run taiga activities ls
|
||||
|
||||
# Test 5: Try uploading non-existent file (should fail)
|
||||
banner "Test 5: Upload non-existent file (should fail)"
|
||||
uv run taiga activities upload nonexistent.fit || echo "Expected failure - file not found"
|
||||
|
||||
# Test 6: Try download (note: this may fail if server doesn't support download endpoint yet)
|
||||
banner "Test 6: Download test (may fail if endpoint not implemented)"
|
||||
uv run taiga activities download 1 --output downloaded_activity.fit || echo "Expected failure - download endpoint may not be implemented yet"
|
||||
|
||||
banner "Activities testing complete!"
|
||||
@@ -1,17 +1,19 @@
|
||||
import argparse
|
||||
import pathlib
|
||||
import logging
|
||||
import trygo_py_cliclient.config
|
||||
import trygo_py_cliclient.cli.common
|
||||
import trygo_py_cliclient.cli.register
|
||||
import trygo_py_cliclient.cli.login
|
||||
import taiga_pycli.config
|
||||
import taiga_pycli.cli.common
|
||||
import taiga_pycli.cli.register
|
||||
import taiga_pycli.cli.login
|
||||
import taiga_pycli.cli.hats
|
||||
import taiga_pycli.cli.activities
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(
|
||||
"trygo-client", formatter_class=argparse.ArgumentDefaultsHelpFormatter
|
||||
"taiga_pycli", formatter_class=argparse.ArgumentDefaultsHelpFormatter
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
@@ -19,8 +21,10 @@ def parse_args():
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="cmd", required=True)
|
||||
trygo_py_cliclient.cli.register.setup_parser(subparsers)
|
||||
trygo_py_cliclient.cli.login.setup_parser(subparsers)
|
||||
taiga_pycli.cli.register.setup_parser(subparsers)
|
||||
taiga_pycli.cli.login.setup_parser(subparsers)
|
||||
taiga_pycli.cli.hats.setup_parser(subparsers)
|
||||
taiga_pycli.cli.activities.setup_parser(subparsers)
|
||||
|
||||
args = parser.parse_args()
|
||||
return args
|
||||
@@ -29,9 +33,9 @@ def parse_args():
|
||||
def main():
|
||||
args = parse_args()
|
||||
|
||||
config = trygo_py_cliclient.config.read_config(pathlib.Path(args.config_file))
|
||||
config = taiga_pycli.config.read_config(pathlib.Path(args.config_file))
|
||||
|
||||
trygo_py_cliclient.cli.common.set_up_logging(
|
||||
taiga_pycli.cli.common.set_up_logging(
|
||||
config,
|
||||
)
|
||||
_logger.info(f"Got args {args=}")
|
||||
165
src/taiga_pycli/cli/activities.py
Normal file
165
src/taiga_pycli/cli/activities.py
Normal file
@@ -0,0 +1,165 @@
|
||||
import argparse
|
||||
import logging
|
||||
import typing
|
||||
import pathlib
|
||||
|
||||
import taiga_pycli.config
|
||||
import taiga_pycli.service
|
||||
from taiga_pycli.exceptions import (
|
||||
AuthenticationError,
|
||||
ValidationError,
|
||||
NetworkError,
|
||||
TryGoAPIError,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
_SubparserType = argparse._SubParsersAction[argparse.ArgumentParser]
|
||||
else:
|
||||
_SubparserType = typing.Any
|
||||
|
||||
|
||||
def setup_parser(subparsers: _SubparserType) -> None:
|
||||
"""Setup the activities command group with its subcommands"""
|
||||
parser = subparsers.add_parser("activities", help="Manage activity files")
|
||||
|
||||
activities_subparsers = parser.add_subparsers(dest="activities_cmd", required=True)
|
||||
|
||||
# Upload subcommand
|
||||
upload_parser = activities_subparsers.add_parser(
|
||||
"upload", help="Upload an activity file to the server"
|
||||
)
|
||||
upload_parser.add_argument(
|
||||
"file_path", type=pathlib.Path, help="Path to the activity file to upload"
|
||||
)
|
||||
upload_parser.set_defaults(func=run_upload)
|
||||
|
||||
# List subcommand
|
||||
list_parser = activities_subparsers.add_parser(
|
||||
"ls", help="List your activity files"
|
||||
)
|
||||
list_parser.set_defaults(func=run_list)
|
||||
|
||||
# Download subcommand
|
||||
download_parser = activities_subparsers.add_parser(
|
||||
"download", help="Download an activity file by ID"
|
||||
)
|
||||
download_parser.add_argument("id", type=int, help="Activity file ID to download")
|
||||
download_parser.add_argument(
|
||||
"--output",
|
||||
"-o",
|
||||
type=pathlib.Path,
|
||||
help="Output file path (defaults to original filename)",
|
||||
)
|
||||
download_parser.set_defaults(func=run_download)
|
||||
|
||||
|
||||
def run_upload(config: taiga_pycli.config.Config, args):
|
||||
"""Run the upload command"""
|
||||
try:
|
||||
clnt = taiga_pycli.service.BackendService(config)
|
||||
|
||||
_logger.info(f"Uploading file: {args.file_path}")
|
||||
|
||||
activity_file = clnt.upload_activity_file(args.file_path)
|
||||
|
||||
print("Upload successful!")
|
||||
print(f"File ID: {activity_file.id}")
|
||||
print(f"Timestamp: {activity_file.timestamp}")
|
||||
print(f"Created at: {activity_file.created_at}")
|
||||
|
||||
except AuthenticationError as e:
|
||||
print(f"Authentication error: {e}")
|
||||
print("Please run 'taiga login' first.")
|
||||
return 1
|
||||
except ValidationError as e:
|
||||
print(f"Validation error: {e}")
|
||||
return 1
|
||||
except NetworkError as e:
|
||||
print(f"Network error: {e}")
|
||||
return 1
|
||||
except TryGoAPIError as e:
|
||||
print(f"API error: {e}")
|
||||
return 1
|
||||
except Exception as e:
|
||||
_logger.error(f"Unexpected error during upload: {e}")
|
||||
print(f"Unexpected error: {e}")
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def run_list(config: taiga_pycli.config.Config, args):
|
||||
"""Run the list command"""
|
||||
try:
|
||||
clnt = taiga_pycli.service.BackendService(config)
|
||||
|
||||
_logger.info("Listing activity files")
|
||||
|
||||
activity_files = clnt.list_activity_files()
|
||||
|
||||
if not activity_files:
|
||||
print("No activity files found.")
|
||||
return 0
|
||||
|
||||
# Print header
|
||||
print(f"{'ID':<10} {'Timestamp':<20} {'Created':<20}")
|
||||
print("-" * 50)
|
||||
|
||||
# Print each activity file
|
||||
for activity_file in activity_files:
|
||||
print(
|
||||
f"{activity_file.id:<10} {activity_file.timestamp:<20} {activity_file.created_at:<20}"
|
||||
)
|
||||
|
||||
except AuthenticationError as e:
|
||||
print(f"Authentication error: {e}")
|
||||
print("Please run 'taiga login' first.")
|
||||
return 1
|
||||
except NetworkError as e:
|
||||
print(f"Network error: {e}")
|
||||
return 1
|
||||
except TryGoAPIError as e:
|
||||
print(f"API error: {e}")
|
||||
return 1
|
||||
except Exception as e:
|
||||
_logger.error(f"Unexpected error during list: {e}")
|
||||
print(f"Unexpected error: {e}")
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def run_download(config: taiga_pycli.config.Config, args):
|
||||
"""Run the download command"""
|
||||
try:
|
||||
clnt = taiga_pycli.service.BackendService(config)
|
||||
|
||||
_logger.info(f"Downloading activity file ID: {args.id}")
|
||||
|
||||
output_path = clnt.download_activity_file(args.id, args.output)
|
||||
|
||||
print("Download successful!")
|
||||
print(f"File saved to: {output_path}")
|
||||
|
||||
except AuthenticationError as e:
|
||||
print(f"Authentication error: {e}")
|
||||
print("Please run 'taiga login' first.")
|
||||
return 1
|
||||
except ValidationError as e:
|
||||
print(f"Validation error: {e}")
|
||||
return 1
|
||||
except NetworkError as e:
|
||||
print(f"Network error: {e}")
|
||||
return 1
|
||||
except TryGoAPIError as e:
|
||||
print(f"API error: {e}")
|
||||
return 1
|
||||
except Exception as e:
|
||||
_logger.error(f"Unexpected error during download: {e}")
|
||||
print(f"Unexpected error: {e}")
|
||||
return 1
|
||||
|
||||
return 0
|
||||
@@ -1,11 +1,11 @@
|
||||
import trygo_py_cliclient.config
|
||||
import taiga_pycli.config
|
||||
import typing
|
||||
import logging
|
||||
import pathlib
|
||||
|
||||
|
||||
def set_up_logging(
|
||||
config: trygo_py_cliclient.config.Config,
|
||||
config: taiga_pycli.config.Config,
|
||||
create_logfile_parents: bool = True,
|
||||
):
|
||||
# for convenience
|
||||
66
src/taiga_pycli/cli/hats.py
Normal file
66
src/taiga_pycli/cli/hats.py
Normal file
@@ -0,0 +1,66 @@
|
||||
import argparse
|
||||
import logging
|
||||
import typing
|
||||
import taiga_pycli.config
|
||||
import taiga_pycli.models
|
||||
|
||||
import taiga_pycli.service
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
_SubparserType = argparse._SubParsersAction[argparse.ArgumentParser]
|
||||
else:
|
||||
_SubparserType = typing.Any
|
||||
|
||||
|
||||
def setup_parser(subparsers: _SubparserType) -> None:
|
||||
parser = subparsers.add_parser("hat")
|
||||
|
||||
parser.add_argument(
|
||||
"--name", type=str, help="The name of the hat to add", default=None
|
||||
)
|
||||
parser.add_argument(
|
||||
"--description",
|
||||
type=str,
|
||||
help="The description of the hat to add",
|
||||
default=None,
|
||||
)
|
||||
|
||||
parser.set_defaults(func=run)
|
||||
|
||||
|
||||
def run(cfg: taiga_pycli.config.Config, args):
|
||||
# clnt = taiga_pycli.client.TryGoAPIClient("http://localhost:8080/")
|
||||
|
||||
backend = taiga_pycli.service.BackendService(cfg)
|
||||
|
||||
if args.name is not None:
|
||||
if args.description is None:
|
||||
_logger.error("Got a null description, exiting")
|
||||
return
|
||||
# both not None
|
||||
backend.add_hat(args.name, args.description)
|
||||
return
|
||||
|
||||
else:
|
||||
_logger.debug("Not creating, just list")
|
||||
if args.description is not None:
|
||||
_logger.error("Provided a description without name")
|
||||
|
||||
response = backend.get_hats()
|
||||
if response is None:
|
||||
_logger.warning("none response here")
|
||||
return
|
||||
real_hats = []
|
||||
for hat in response:
|
||||
rh = taiga_pycli.models.Hat(
|
||||
name=hat["name"],
|
||||
description=hat["description"],
|
||||
user_id=hat["user_id"],
|
||||
)
|
||||
real_hats.append(rh)
|
||||
print(rh)
|
||||
# _logger.info(response)
|
||||
return
|
||||
52
src/taiga_pycli/cli/login.py
Normal file
52
src/taiga_pycli/cli/login.py
Normal file
@@ -0,0 +1,52 @@
|
||||
import argparse
|
||||
import logging
|
||||
import typing
|
||||
import getpass
|
||||
|
||||
import taiga_pycli.config
|
||||
import taiga_pycli.service
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
_SubparserType = argparse._SubParsersAction[argparse.ArgumentParser]
|
||||
else:
|
||||
_SubparserType = typing.Any
|
||||
|
||||
|
||||
class Password:
|
||||
DEFAULT = "Prompt if not provided"
|
||||
|
||||
def __init__(self, value):
|
||||
if value == self.DEFAULT:
|
||||
value = getpass.getpass("Password: ")
|
||||
self.value = value
|
||||
|
||||
def __str__(self):
|
||||
return self.value
|
||||
|
||||
|
||||
def setup_parser(subparsers: _SubparserType) -> None:
|
||||
parser = subparsers.add_parser("login")
|
||||
|
||||
parser.add_argument(
|
||||
"--email", type=str, help="The email to log in with ", default=None
|
||||
)
|
||||
parser.add_argument(
|
||||
"--password", type=Password, help="Password", default=Password.DEFAULT
|
||||
)
|
||||
parser.set_defaults(func=run)
|
||||
|
||||
|
||||
def run(config: taiga_pycli.config.Config, args):
|
||||
clnt = taiga_pycli.service.BackendService(config)
|
||||
|
||||
_logger.info(f"using password {args.password}")
|
||||
email_to_use = args.email
|
||||
if args.email is None:
|
||||
email_to_use = config.general_config.backend_user
|
||||
|
||||
clnt.login(email_to_use, str(args.password))
|
||||
# _logger.info(response)
|
||||
return
|
||||
@@ -1,8 +1,9 @@
|
||||
import argparse
|
||||
import logging
|
||||
import typing
|
||||
import taiga_pycli.config
|
||||
|
||||
import trygo_py_cliclient.client
|
||||
import taiga_pycli.service
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -24,7 +25,9 @@ def setup_parser(subparsers: _SubparserType) -> None:
|
||||
parser.set_defaults(func=run)
|
||||
|
||||
|
||||
def run(args):
|
||||
clnt = trygo_py_cliclient.client.TryGoAPIClient("http://localhost:8080/")
|
||||
response = clnt.register(args.email, args.password, args.display_name)
|
||||
def run(cfg: taiga_pycli.config.Config, args):
|
||||
# clnt = taiga_pycli.client.TryGoAPIClient("http://localhost:8080/")
|
||||
|
||||
backend = taiga_pycli.service.BackendService(cfg)
|
||||
response = backend.register(args.email, args.password, args.display_name)
|
||||
_logger.info(response)
|
||||
68
src/taiga_pycli/cli/upload.py
Normal file
68
src/taiga_pycli/cli/upload.py
Normal file
@@ -0,0 +1,68 @@
|
||||
import argparse
|
||||
import logging
|
||||
import typing
|
||||
import pathlib
|
||||
|
||||
import taiga_pycli.config
|
||||
import taiga_pycli.service
|
||||
from taiga_pycli.exceptions import (
|
||||
AuthenticationError,
|
||||
ValidationError,
|
||||
NetworkError,
|
||||
TryGoAPIError,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
_SubparserType = argparse._SubParsersAction[argparse.ArgumentParser]
|
||||
else:
|
||||
_SubparserType = typing.Any
|
||||
|
||||
|
||||
def setup_parser(subparsers: _SubparserType) -> None:
|
||||
parser = subparsers.add_parser(
|
||||
"upload", help="Upload an activity file to the server"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"file_path", type=pathlib.Path, help="Path to the activity file to upload"
|
||||
)
|
||||
|
||||
parser.set_defaults(func=run)
|
||||
|
||||
|
||||
def run(config: taiga_pycli.config.Config, args):
|
||||
"""Run the upload command"""
|
||||
try:
|
||||
clnt = taiga_pycli.service.BackendService(config)
|
||||
|
||||
_logger.info(f"Uploading file: {args.file_path}")
|
||||
|
||||
activity_file = clnt.upload_activity_file(args.file_path)
|
||||
|
||||
print("Upload successful!")
|
||||
print(f"File ID: {activity_file.id}")
|
||||
print(f"Timestamp: {activity_file.timestamp}")
|
||||
print(f"Created at: {activity_file.created_at}")
|
||||
|
||||
except AuthenticationError as e:
|
||||
print(f"Authentication error: {e}")
|
||||
print("Please run 'taiga login' first.")
|
||||
return 1
|
||||
except ValidationError as e:
|
||||
print(f"Validation error: {e}")
|
||||
return 1
|
||||
except NetworkError as e:
|
||||
print(f"Network error: {e}")
|
||||
return 1
|
||||
except TryGoAPIError as e:
|
||||
print(f"API error: {e}")
|
||||
return 1
|
||||
except Exception as e:
|
||||
_logger.error(f"Unexpected error during upload: {e}")
|
||||
print(f"Unexpected error: {e}")
|
||||
return 1
|
||||
|
||||
return 0
|
||||
10
src/taiga_pycli/config/__init__.py
Normal file
10
src/taiga_pycli/config/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from taiga_pycli.config.config import (
|
||||
GeneralConfig,
|
||||
Config,
|
||||
)
|
||||
|
||||
from taiga_pycli.config.config_reader import (
|
||||
read_config,
|
||||
)
|
||||
|
||||
__all__ = ["GeneralConfig", "Config", "read_config"]
|
||||
@@ -3,7 +3,7 @@ import dacite
|
||||
import pathlib
|
||||
import tomli
|
||||
|
||||
from trygo_py_cliclient.config import GeneralConfig, Config
|
||||
from taiga_pycli.config import GeneralConfig, Config
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -36,3 +36,24 @@ class AuthResponse:
|
||||
message: str
|
||||
email: Optional[str] = None
|
||||
id: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Hat:
|
||||
"""A wearable hat"""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
user_id: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActivityFile:
|
||||
"""Activity file model for API responses"""
|
||||
|
||||
id: int
|
||||
timestamp: str
|
||||
file_repo_hash: Optional[str]
|
||||
created_at: str
|
||||
updated_at: str
|
||||
user_id: int
|
||||
395
src/taiga_pycli/service/__init__.py
Normal file
395
src/taiga_pycli/service/__init__.py
Normal file
@@ -0,0 +1,395 @@
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, Sequence, List
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
from taiga_pycli.models import RegisterRequest, LoginRequest, AuthResponse, ActivityFile
|
||||
from taiga_pycli.config import Config
|
||||
from taiga_pycli.exceptions import (
|
||||
TryGoAPIError,
|
||||
AuthenticationError,
|
||||
ValidationError,
|
||||
NetworkError,
|
||||
ServerError,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BackendService:
|
||||
def __init__(self, config: Config):
|
||||
self.base_url = config.general_config.backend_base_url.rstrip("/")
|
||||
self.timeout = 30
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update(
|
||||
{"Content-Type": "application/json", "User-Agent": "trygo-py-client/0.1.0"}
|
||||
)
|
||||
|
||||
self._token_path = Path("cred") / "token"
|
||||
self.token: Optional[str] = None
|
||||
|
||||
def _make_request(
|
||||
self, method: str, endpoint: str, data: Optional[Dict[str, Any]] = None
|
||||
) -> requests.Response:
|
||||
"""
|
||||
Make HTTP request to API endpoint
|
||||
|
||||
Args:
|
||||
method: HTTP method (GET, POST, PUT, DELETE)
|
||||
endpoint: API endpoint path
|
||||
data: Request payload data
|
||||
|
||||
Returns:
|
||||
Response data as dictionary
|
||||
|
||||
Raises:
|
||||
NetworkError: For connection/timeout errors
|
||||
AuthenticationError: For 401 errors
|
||||
ValidationError: For 400 errors
|
||||
ServerError: For 5xx errors
|
||||
TryGoAPIError: For other API errors
|
||||
"""
|
||||
url = f"{self.base_url}{endpoint}"
|
||||
_logger.info(f"Making {method} request to {url}")
|
||||
|
||||
try:
|
||||
response = self.session.request(
|
||||
method=method, url=url, json=data, timeout=self.timeout
|
||||
)
|
||||
|
||||
# Log response status
|
||||
_logger.debug(f"Response status: {response.status_code}")
|
||||
|
||||
# Handle different HTTP status codes
|
||||
if response.status_code == 401:
|
||||
raise AuthenticationError("Authentication failed")
|
||||
elif response.status_code == 400:
|
||||
error_msg = "Validation error"
|
||||
try:
|
||||
error_data = response.json()
|
||||
if "message" in error_data:
|
||||
error_msg = error_data["message"]
|
||||
except Exception:
|
||||
pass
|
||||
raise ValidationError(error_msg)
|
||||
elif 500 <= response.status_code < 600:
|
||||
raise ServerError(f"Server error: {response.status_code}")
|
||||
elif not response.ok:
|
||||
raise TryGoAPIError(f"API error: {response.status_code}")
|
||||
|
||||
# Parse JSON response
|
||||
try:
|
||||
_logger.debug(response.json())
|
||||
return response
|
||||
except ValueError as e:
|
||||
raise TryGoAPIError(f"Invalid JSON response: {e}")
|
||||
|
||||
# claude really fucked this
|
||||
except requests.exceptions.Timeout:
|
||||
raise NetworkError("Request timeout")
|
||||
except requests.exceptions.ConnectionError:
|
||||
raise NetworkError("Connection error")
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise NetworkError(f"Network error: {e}")
|
||||
|
||||
def register(self, email: str, password: str, display_name: str) -> AuthResponse:
|
||||
"""
|
||||
Register a new user account
|
||||
"""
|
||||
request_data = RegisterRequest(
|
||||
email=email, password=password, display_name=display_name
|
||||
)
|
||||
|
||||
_logger.info(f"Registering user: {email}")
|
||||
|
||||
response = self._make_request("POST", "/auth/register", asdict(request_data))
|
||||
response_json = response.json()
|
||||
|
||||
# Parse response and create AuthResponse
|
||||
auth_response = AuthResponse(
|
||||
message=response_json.get("message", ""),
|
||||
email=response_json.get("email", ""),
|
||||
id=response_json.get("id", ""),
|
||||
)
|
||||
|
||||
return auth_response
|
||||
|
||||
def login(self, email: str, password: str) -> str:
|
||||
"""
|
||||
Authenticate user and get auth token
|
||||
"""
|
||||
request_data = LoginRequest(email=email, password=password)
|
||||
|
||||
_logger.info(f"Logging in user: {email}")
|
||||
|
||||
response = self._make_request("POST", "/auth/tokens", asdict(request_data))
|
||||
response_data = response.json()
|
||||
_logger.info(response_data)
|
||||
|
||||
# Parse response and create AuthResponse
|
||||
token = response_data.get("token", "")
|
||||
self._store_credential(token)
|
||||
|
||||
return token
|
||||
|
||||
def logout(self) -> None:
|
||||
"""
|
||||
Clear authentication token and session data
|
||||
|
||||
Optionally calls logout endpoint if server supports it
|
||||
"""
|
||||
_logger.info("Logging out user")
|
||||
|
||||
# Try to call logout endpoint if we have a token
|
||||
|
||||
_logger.debug("Local authentication cleared")
|
||||
|
||||
def get_hats(self) -> Optional[Sequence[Dict[str, Any]]]:
|
||||
# needs credential
|
||||
|
||||
cred = self._read_credential()
|
||||
if cred is None:
|
||||
return None
|
||||
|
||||
_logger.debug("Credential was read")
|
||||
response = self._make_request("GET", "/hats")
|
||||
response_data = response.json()
|
||||
# _logger.debug(response)
|
||||
return response_data
|
||||
|
||||
def add_hat(self, name: str, description: str) -> Optional[Dict[str, Any]]:
|
||||
cred = self._read_credential()
|
||||
if cred is None:
|
||||
return None
|
||||
|
||||
_logger.debug("Credential was read")
|
||||
response = self._make_request(
|
||||
"POST", "/hats", {"name": name, "description": description}
|
||||
)
|
||||
return response.json()
|
||||
|
||||
def _store_credential(self, token: str) -> None:
|
||||
self._token_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self._token_path.open(mode="w") as tokenf:
|
||||
tokenf.write(token)
|
||||
self.token = token
|
||||
self.session.headers.update({"Authorization": f"Bearer {token}"})
|
||||
|
||||
def _read_credential(self) -> Optional[str]:
|
||||
try:
|
||||
with open(self._token_path) as token_file:
|
||||
token_in_file = token_file.read()
|
||||
self.token = token_in_file
|
||||
self.session.headers.update(
|
||||
{"Authorization": f"Bearer {token_in_file}"}
|
||||
)
|
||||
return token_in_file
|
||||
except FileNotFoundError:
|
||||
_logger.error("No token file found, try logging in")
|
||||
return None
|
||||
|
||||
def upload_activity_file(self, file_path: Path) -> ActivityFile:
|
||||
"""
|
||||
Upload an activity file to the server
|
||||
|
||||
Args:
|
||||
file_path: Path to the file to upload
|
||||
|
||||
Returns:
|
||||
ActivityFile object with server response data
|
||||
|
||||
Raises:
|
||||
AuthenticationError: If not authenticated
|
||||
ValidationError: If file is invalid
|
||||
NetworkError: For connection issues
|
||||
"""
|
||||
cred = self._read_credential()
|
||||
if cred is None:
|
||||
raise AuthenticationError("Not authenticated. Please login first.")
|
||||
|
||||
if not file_path.exists():
|
||||
raise ValidationError(f"File not found: {file_path}")
|
||||
|
||||
if not file_path.is_file():
|
||||
raise ValidationError(f"Path is not a file: {file_path}")
|
||||
|
||||
_logger.info(f"Uploading activity file: {file_path}")
|
||||
|
||||
url = f"{self.base_url}/activity_files"
|
||||
|
||||
try:
|
||||
with open(file_path, "rb") as file_obj:
|
||||
files = {"file": (file_path.name, file_obj, "application/octet-stream")}
|
||||
|
||||
# Temporarily remove Content-Type header to let requests set multipart boundary
|
||||
original_content_type = self.session.headers.pop("Content-Type", None)
|
||||
|
||||
try:
|
||||
response = self.session.post(
|
||||
url=url, files=files, timeout=self.timeout
|
||||
)
|
||||
finally:
|
||||
# Restore original Content-Type header
|
||||
if original_content_type:
|
||||
self.session.headers["Content-Type"] = original_content_type
|
||||
|
||||
# Handle response status codes
|
||||
if response.status_code == 401:
|
||||
raise AuthenticationError("Authentication failed")
|
||||
elif response.status_code == 400:
|
||||
error_msg = "File upload failed"
|
||||
try:
|
||||
error_data = response.json()
|
||||
if "error" in error_data:
|
||||
error_msg = error_data["error"]
|
||||
except Exception:
|
||||
pass
|
||||
raise ValidationError(error_msg)
|
||||
elif 500 <= response.status_code < 600:
|
||||
raise ServerError(f"Server error: {response.status_code}")
|
||||
elif not response.ok:
|
||||
raise TryGoAPIError(f"Upload failed: {response.status_code}")
|
||||
|
||||
# Parse response
|
||||
try:
|
||||
response_data = response.json()
|
||||
_logger.info(f"Upload successful: {response_data}")
|
||||
|
||||
return ActivityFile(
|
||||
id=response_data["id"],
|
||||
timestamp=response_data["timestamp"],
|
||||
file_repo_hash=response_data.get("file_repo_hash"),
|
||||
created_at=response_data["created_at"],
|
||||
updated_at=response_data["updated_at"],
|
||||
user_id=response_data["user_id"],
|
||||
)
|
||||
except ValueError as e:
|
||||
raise TryGoAPIError(f"Invalid JSON response: {e}")
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
raise NetworkError("Upload timeout")
|
||||
except requests.exceptions.ConnectionError:
|
||||
raise NetworkError("Connection error during upload")
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise NetworkError(f"Network error during upload: {e}")
|
||||
except FileNotFoundError:
|
||||
raise ValidationError(f"File not found: {file_path}")
|
||||
except PermissionError:
|
||||
raise ValidationError(f"Permission denied reading file: {file_path}")
|
||||
|
||||
def list_activity_files(self) -> List[ActivityFile]:
|
||||
"""
|
||||
List all activity files for the authenticated user
|
||||
|
||||
Returns:
|
||||
List of ActivityFile objects
|
||||
|
||||
Raises:
|
||||
AuthenticationError: If not authenticated
|
||||
NetworkError: For connection issues
|
||||
TryGoAPIError: For API errors
|
||||
"""
|
||||
cred = self._read_credential()
|
||||
if cred is None:
|
||||
raise AuthenticationError("Not authenticated. Please login first.")
|
||||
|
||||
_logger.info("Fetching activity files list")
|
||||
|
||||
try:
|
||||
response = self._make_request("GET", "/activity_files")
|
||||
response_data = response.json()
|
||||
|
||||
# Parse response into ActivityFile objects
|
||||
_logger.debug(response_data)
|
||||
activity_files: List[ActivityFile] = []
|
||||
_logger.debug("checking if list")
|
||||
if isinstance(response_data, list):
|
||||
_logger.debug("yes in list")
|
||||
for item in response_data:
|
||||
_logger.debug(item)
|
||||
if isinstance(item, dict):
|
||||
activity_file = ActivityFile(
|
||||
id=item["id"],
|
||||
timestamp=item["timestamp"],
|
||||
file_repo_hash=item.get("file_repo_hash"),
|
||||
created_at=item["created_at"],
|
||||
updated_at=item["updated_at"],
|
||||
user_id=item["user_id"],
|
||||
)
|
||||
activity_files.append(activity_file)
|
||||
|
||||
_logger.info(f"Retrieved {len(activity_files)} activity files")
|
||||
return activity_files
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
raise NetworkError("Request timeout")
|
||||
except requests.exceptions.ConnectionError:
|
||||
raise NetworkError("Connection error")
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise NetworkError(f"Network error: {e}")
|
||||
|
||||
def download_activity_file(
|
||||
self, file_id: int, output_path: Optional[Path] = None
|
||||
) -> Path:
|
||||
"""
|
||||
Download an activity file by ID
|
||||
|
||||
Args:
|
||||
file_id: The ID of the activity file to download
|
||||
output_path: Optional path to save the file (defaults to activity_{id}.fit)
|
||||
|
||||
Returns:
|
||||
Path where the file was saved
|
||||
|
||||
Raises:
|
||||
AuthenticationError: If not authenticated
|
||||
ValidationError: If file not found or invalid
|
||||
NetworkError: For connection issues
|
||||
TryGoAPIError: For API errors
|
||||
"""
|
||||
cred = self._read_credential()
|
||||
if cred is None:
|
||||
raise AuthenticationError("Not authenticated. Please login first.")
|
||||
|
||||
if output_path is None:
|
||||
output_path = Path(f"activity_{file_id}.fit")
|
||||
|
||||
_logger.info(f"Downloading activity file {file_id} to {output_path}")
|
||||
|
||||
url = f"{self.base_url}/activity_files/{file_id}/download"
|
||||
|
||||
try:
|
||||
response = self.session.get(url=url, timeout=self.timeout, stream=True)
|
||||
|
||||
# Handle response status codes
|
||||
if response.status_code == 401:
|
||||
raise AuthenticationError("Authentication failed")
|
||||
elif response.status_code == 404:
|
||||
raise ValidationError(f"Activity file with ID {file_id} not found")
|
||||
elif response.status_code == 400:
|
||||
raise ValidationError("Invalid download request")
|
||||
elif 500 <= response.status_code < 600:
|
||||
raise ServerError(f"Server error: {response.status_code}")
|
||||
elif not response.ok:
|
||||
raise TryGoAPIError(f"Download failed: {response.status_code}")
|
||||
|
||||
# Write file to disk
|
||||
with open(output_path, "wb") as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
|
||||
_logger.info(f"Downloaded activity file {file_id} to {output_path}")
|
||||
return output_path
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
raise NetworkError("Download timeout")
|
||||
except requests.exceptions.ConnectionError:
|
||||
raise NetworkError("Connection error during download")
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise NetworkError(f"Network error during download: {e}")
|
||||
except OSError as e:
|
||||
raise ValidationError(f"Error writing file {output_path}: {e}")
|
||||
@@ -1,29 +0,0 @@
|
||||
import argparse
|
||||
import logging
|
||||
import typing
|
||||
|
||||
import trygo_py_cliclient.config
|
||||
import trygo_py_cliclient.client
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
_SubparserType = argparse._SubParsersAction[argparse.ArgumentParser]
|
||||
else:
|
||||
_SubparserType = typing.Any
|
||||
|
||||
|
||||
def setup_parser(subparsers: _SubparserType) -> None:
|
||||
parser = subparsers.add_parser("login")
|
||||
|
||||
parser.add_argument("--email", type=str, help="The email to log in with ", default="")
|
||||
parser.add_argument("--password", type=str, help="Password", default="")
|
||||
parser.set_defaults(func=run)
|
||||
|
||||
|
||||
def run(config: trygo_py_cliclient.config.Config, args):
|
||||
clnt = trygo_py_cliclient.client.BackendService(config)
|
||||
|
||||
response = clnt.login(args.email, args.password)
|
||||
_logger.info(response)
|
||||
@@ -1,149 +0,0 @@
|
||||
import requests
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
|
||||
from trygo_py_cliclient.models import RegisterRequest, LoginRequest, AuthResponse
|
||||
from trygo_py_cliclient.config import Config
|
||||
from trygo_py_cliclient.exceptions import (
|
||||
TryGoAPIError,
|
||||
AuthenticationError,
|
||||
ValidationError,
|
||||
NetworkError,
|
||||
ServerError,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BackendService:
|
||||
def __init__(self, config: Config):
|
||||
self.base_url = config.general_config.backend_base_url.rstrip("/")
|
||||
self.timeout = 30
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update(
|
||||
{"Content-Type": "application/json", "User-Agent": "trygo-py-client/0.1.0"}
|
||||
)
|
||||
|
||||
def _make_request(
|
||||
self, method: str, endpoint: str, data: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Make HTTP request to API endpoint
|
||||
|
||||
Args:
|
||||
method: HTTP method (GET, POST, PUT, DELETE)
|
||||
endpoint: API endpoint path
|
||||
data: Request payload data
|
||||
|
||||
Returns:
|
||||
Response data as dictionary
|
||||
|
||||
Raises:
|
||||
NetworkError: For connection/timeout errors
|
||||
AuthenticationError: For 401 errors
|
||||
ValidationError: For 400 errors
|
||||
ServerError: For 5xx errors
|
||||
TryGoAPIError: For other API errors
|
||||
"""
|
||||
url = f"{self.base_url}{endpoint}"
|
||||
_logger.info(f"Making {method} request to {url}")
|
||||
|
||||
try:
|
||||
response = self.session.request(
|
||||
method=method, url=url, json=data, timeout=self.timeout
|
||||
)
|
||||
|
||||
# Log response status
|
||||
_logger.debug(f"Response status: {response.status_code}")
|
||||
|
||||
# Handle different HTTP status codes
|
||||
if response.status_code == 401:
|
||||
raise AuthenticationError("Authentication failed")
|
||||
elif response.status_code == 400:
|
||||
error_msg = "Validation error"
|
||||
try:
|
||||
error_data = response.json()
|
||||
if "message" in error_data:
|
||||
error_msg = error_data["message"]
|
||||
except Exception:
|
||||
pass
|
||||
raise ValidationError(error_msg)
|
||||
elif 500 <= response.status_code < 600:
|
||||
raise ServerError(f"Server error: {response.status_code}")
|
||||
elif not response.ok:
|
||||
raise TryGoAPIError(f"API error: {response.status_code}")
|
||||
|
||||
# Parse JSON response
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError as e:
|
||||
raise TryGoAPIError(f"Invalid JSON response: {e}")
|
||||
|
||||
# claude really fucked this
|
||||
except requests.exceptions.Timeout:
|
||||
raise NetworkError("Request timeout")
|
||||
except requests.exceptions.ConnectionError:
|
||||
raise NetworkError("Connection error")
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise NetworkError(f"Network error: {e}")
|
||||
|
||||
def register(self, email: str, password: str, display_name: str) -> AuthResponse:
|
||||
"""
|
||||
Register a new user account
|
||||
"""
|
||||
request_data = RegisterRequest(
|
||||
email=email, password=password, display_name=display_name
|
||||
)
|
||||
|
||||
_logger.info(f"Registering user: {email}")
|
||||
|
||||
response_data = self._make_request(
|
||||
"POST", "/auth/register", asdict(request_data)
|
||||
)
|
||||
|
||||
# Parse response and create AuthResponse
|
||||
auth_response = AuthResponse(
|
||||
message=response_data.get("message", ""),
|
||||
email=response_data.get("email", ""),
|
||||
id=response_data.get("id", ""),
|
||||
)
|
||||
|
||||
return auth_response
|
||||
|
||||
def login(self, email: str, password: str) -> str:
|
||||
"""
|
||||
Authenticate user and get auth token
|
||||
"""
|
||||
request_data = LoginRequest(email=email, password=password)
|
||||
|
||||
_logger.info(f"Logging in user: {email}")
|
||||
|
||||
response_data = self._make_request("POST", "/auth/tokens", asdict(request_data))
|
||||
_logger.info(response_data)
|
||||
|
||||
# Parse response and create AuthResponse
|
||||
token = response_data.get("token", "")
|
||||
self._store_credential(token)
|
||||
|
||||
return token
|
||||
|
||||
def logout(self) -> None:
|
||||
"""
|
||||
Clear authentication token and session data
|
||||
|
||||
Optionally calls logout endpoint if server supports it
|
||||
"""
|
||||
_logger.info("Logging out user")
|
||||
|
||||
# Try to call logout endpoint if we have a token
|
||||
|
||||
_logger.debug("Local authentication cleared")
|
||||
|
||||
def _store_credential(self, token: str) -> None:
|
||||
|
||||
token_path = Path("cred/token")
|
||||
token_path.parent.mkdir(parents=True,exist_ok=True)
|
||||
with token_path.open(mode="w") as tokenf:
|
||||
tokenf.write(token)
|
||||
@@ -1,10 +0,0 @@
|
||||
from trygo_py_cliclient.config.config import (
|
||||
GeneralConfig,
|
||||
Config,
|
||||
)
|
||||
|
||||
from trygo_py_cliclient.config.config_reader import (
|
||||
read_config,
|
||||
)
|
||||
|
||||
__all__ = ["GeneralConfig", "Config", "read_config"]
|
||||
14
uv.lock
generated
14
uv.lock
generated
@@ -132,6 +132,7 @@ dependencies = [
|
||||
{ name = "dacite" },
|
||||
{ name = "requests" },
|
||||
{ name = "tomli" },
|
||||
{ name = "types-requests" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
@@ -149,6 +150,7 @@ requires-dist = [
|
||||
{ name = "dacite", specifier = ">=1.9.2" },
|
||||
{ name = "requests", specifier = ">=2.31.0" },
|
||||
{ name = "tomli", specifier = ">=2.2.1" },
|
||||
{ name = "types-requests", specifier = ">=2.32.4.20250913" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
@@ -367,6 +369,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "types-requests"
|
||||
version = "2.32.4.20250913"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/36/27/489922f4505975b11de2b5ad07b4fe1dca0bca9be81a703f26c5f3acfce5/types_requests-2.32.4.20250913.tar.gz", hash = "sha256:abd6d4f9ce3a9383f269775a9835a4c24e5cd6b9f647d64f88aa4613c33def5d", size = 23113, upload-time = "2025-09-13T02:40:02.309Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/20/9a227ea57c1285986c4cf78400d0a91615d25b24e257fd9e2969606bdfae/types_requests-2.32.4.20250913-py3-none-any.whl", hash = "sha256:78c9c1fffebbe0fa487a418e0fa5252017e9c60d1a2da394077f1780f655d7e1", size = 20658, upload-time = "2025-09-13T02:40:01.115Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.12.2"
|
||||
|
||||
Reference in New Issue
Block a user