Skip to content

ERA-12688: Add GPX file upload and status tracking to both clients#43

Open
JoshuaVulcan wants to merge 5 commits intomainfrom
ERA-12688/gpx-upload
Open

ERA-12688: Add GPX file upload and status tracking to both clients#43
JoshuaVulcan wants to merge 5 commits intomainfrom
ERA-12688/gpx-upload

Conversation

@JoshuaVulcan
Copy link
Contributor

@JoshuaVulcan JoshuaVulcan commented Feb 11, 2026

Summary

  • Add upload_gpx(source_id, filepath=None, file=None) to both ERClient and AsyncERClient -- POSTs a multipart form to source/{id}/gpxdata with the gpx_file field, accepting either a file path on disk or a pre-opened file-like object
  • Add get_gpx_upload_status(source_id, task_id) to both clients -- GETs source/{id}/gpxdata/status/{task_id} to poll background task progress
  • Both methods maintain full sync/async parity

Test plan

  • 12 async tests covering upload success (file object & filepath), input validation (ValueError), not found, forbidden, timeout, gateway timeout, and status polling (pending, success, not found, forbidden, timeout)
  • 11 sync tests covering the same scenarios with unittest.mock patching
  • All 113 tests pass (existing + new)

Endpoints covered

Method Endpoint Description
POST source/{id}/gpxdata Upload GPX file for a source (returns task reference)
GET source/{id}/gpxdata/status/{task_id} Check GPX upload task status

Add upload_gpx() and get_gpx_upload_status() methods to ERClient (sync)
and AsyncERClient (async) for uploading GPX files to a source and
polling the resulting background task for completion.

- upload_gpx(source_id, filepath=None, file=None) -- POST multipart
  form to source/{id}/gpxdata, accepts either a file path or an
  already-opened file object
- get_gpx_upload_status(source_id, task_id) -- GET the task status at
  source/{id}/gpxdata/status/{task_id}

Includes comprehensive tests for both clients covering success, error
handling (not found, forbidden, timeout, server error), and input
validation (no file provided raises ValueError).

Co-authored-by: Cursor <cursoragent@cursor.com>
@JoshuaVulcan JoshuaVulcan added autoreviewing PR is currently being auto-reviewed and removed autoreviewing PR is currently being auto-reviewed labels Feb 11, 2026
@JoshuaVulcan JoshuaVulcan requested a review from a team as a code owner February 12, 2026 01:54
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds GPX upload support and background-task status polling to both the sync (ERClient) and async (AsyncERClient) EarthRanger clients, along with new test coverage for the new endpoints.

Changes:

  • Add upload_gpx(source_id, filepath=None, file=None) to sync + async clients (multipart upload to source/{id}/gpxdata).
  • Add get_gpx_upload_status(source_id, task_id) to sync + async clients (poll source/{id}/gpxdata/status/{task_id}).
  • Add new sync + async test modules covering success and several error scenarios.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 6 comments.

File Description
erclient/client.py Adds new sync/async client methods for GPX upload and task-status polling.
tests/sync_client/test_gpx_upload.py New sync tests for GPX upload + status polling (currently missing some scenarios claimed in PR description).
tests/async_client/test_gpx_upload.py New async tests for GPX upload + status polling.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

Comment on lines +1651 to +1670
async def upload_gpx(self, source_id, filepath=None, file=None):
"""
Upload a GPX file for a source. Returns an async task reference.

:param source_id: The source UUID
:param filepath: Path to a .gpx file on disk (mutually exclusive with file)
:param file: An already-opened file-like object (mutually exclusive with filepath)
:return: Task data dict (contains task_id for status polling)
"""
self.logger.debug(f'Uploading GPX for source {source_id}')
path = f'source/{source_id}/gpxdata'
if file:
files = {'gpx_file': file}
return await self._post_form(path, files=files)
elif filepath:
with open(filepath, 'rb') as f:
files = {'gpx_file': f}
return await self._post_form(path, files=files)
else:
raise ValueError('Either filepath or file must be provided')
Comment on lines +15 to +17
from .conftest import _mock_response


Comment on lines +93 to +141
class TestUploadGpx:
def test_upload_gpx_with_file_object_success(self, er_client, gpx_file, gpx_upload_response):
mock_resp = _mock_response(201, json_data=gpx_upload_response)
with patch.object(requests, "post", return_value=mock_resp) as mock_post:
result = er_client.upload_gpx(source_id=SOURCE_ID, file=gpx_file)
assert mock_post.called
assert result == gpx_upload_response["data"]
assert result["process_status"]["task_id"] == TASK_ID

def test_upload_gpx_with_filepath_success(self, er_client, gpx_file_content, gpx_upload_response, tmp_path):
gpx_path = tmp_path / "test_track.gpx"
gpx_path.write_bytes(gpx_file_content)

mock_resp = _mock_response(201, json_data=gpx_upload_response)
with patch.object(requests, "post", return_value=mock_resp) as mock_post:
result = er_client.upload_gpx(source_id=SOURCE_ID, filepath=str(gpx_path))
assert mock_post.called
assert result == gpx_upload_response["data"]

def test_upload_gpx_no_file_raises_error(self, er_client):
with pytest.raises(ValueError, match="Either filepath or file must be provided"):
er_client.upload_gpx(source_id=SOURCE_ID)

def test_upload_gpx_not_found(self, er_client, gpx_file):
not_found = {"status": {"code": 404, "detail": "source not found"}}
mock_resp = _mock_response(404, json_data=not_found)
with patch.object(requests, "post", return_value=mock_resp):
with pytest.raises(ERClientNotFound):
er_client.upload_gpx(source_id=SOURCE_ID, file=gpx_file)

def test_upload_gpx_forbidden(self, er_client, gpx_file):
forbidden = {
"status": {
"code": 403,
"detail": "You do not have permission to perform this action.",
}
}
mock_resp = _mock_response(403, json_data=forbidden)
with patch.object(requests, "post", return_value=mock_resp):
with pytest.raises(ERClientPermissionDenied):
er_client.upload_gpx(source_id=SOURCE_ID, file=gpx_file)

def test_upload_gpx_server_error(self, er_client, gpx_file):
error_resp = {"status": {"code": 500, "message": "Internal Server Error"}}
mock_resp = _mock_response(500, json_data=error_resp)
with patch.object(requests, "post", return_value=mock_resp):
with pytest.raises(ERClientException):
er_client.upload_gpx(source_id=SOURCE_ID, file=gpx_file)

Comment on lines +1 to +8
import io
import json
import re

import httpx
import pytest
import respx

Comment on lines +1056 to +1075
def upload_gpx(self, source_id, filepath=None, file=None):
"""
Upload a GPX file for a source. Returns an async task reference.

:param source_id: The source UUID
:param filepath: Path to a .gpx file on disk (mutually exclusive with file)
:param file: An already-opened file-like object (mutually exclusive with filepath)
:return: Task data dict (contains task_id for status polling)
"""
self.logger.debug(f'Uploading GPX for source {source_id}')
path = f'source/{source_id}/gpxdata'
if file:
files = {'gpx_file': file}
return self._post_form(path, files=files)
elif filepath:
with open(filepath, 'rb') as f:
files = {'gpx_file': f}
return self._post_form(path, files=files)
else:
raise ValueError('Either filepath or file must be provided')
Comment on lines +1069 to +1073
return self._post_form(path, files=files)
elif filepath:
with open(filepath, 'rb') as f:
files = {'gpx_file': f}
return self._post_form(path, files=files)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants