From 304bb92d4d90ae6aa32ae687b0a93ab6b0dbb41b Mon Sep 17 00:00:00 2001 From: Tanmay Chordia Date: Thu, 4 Jun 2026 13:44:51 -0700 Subject: [PATCH 1/3] feat: add Project.upload_custom_results Co-Authored-By: Claude Opus 4.8 (1M context) --- surge/projects.py | 36 ++++++++++++++++++++++++++++++++++++ tests/test_projects.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/surge/projects.py b/surge/projects.py index e7a623e..cd09eee 100644 --- a/surge/projects.py +++ b/surge/projects.py @@ -2,6 +2,8 @@ import dateutil.parser import datetime import json +import requests +import urllib.request from surge.errors import ( SurgeMissingIDError, @@ -14,6 +16,8 @@ from surge.tasks import Task from surge import utils +CUSTOM_RESULTS_CONTENT_TYPE = "text/html" + class Project(APIResource): @@ -469,3 +473,35 @@ def download_json(self, poll_time=5 * 60, api_key: str = None): return self.Report.download_json(self.id, poll_time=poll_time, api_key=api_key) + + @classmethod + def upload_custom_results(cls, project_id: str, file, api_key: str = None): + """ + Upload a custom HTML results file for a project. The file is rendered + on the project's results page. Re-uploading overwrites the previous file. + + Arguments: + project_id (str): UUID of the project. + file (str or IO): Path to a local HTML file, or a binary file-like + object opened for reading. + + Returns: + dict: The endpoint response, including ``upload_url``. + """ + endpoint = f"{PROJECTS_ENDPOINT}/{project_id}/custom_results" + response_json = cls.post(endpoint, api_key=api_key) + upload_url = response_json["upload_url"] + + if isinstance(file, str): + with open(file, "rb") as f: + data = f.read() + else: + data = file.read() + + put_response = requests.put( + upload_url, + data=data, + headers={"Content-Type": CUSTOM_RESULTS_CONTENT_TYPE}, + ) + put_response.raise_for_status() + return response_json diff --git a/tests/test_projects.py b/tests/test_projects.py index 6919256..034ac83 100644 --- a/tests/test_projects.py +++ b/tests/test_projects.py @@ -1,3 +1,4 @@ +import io from unittest import mock from unittest.mock import MagicMock, patch from datetime import datetime @@ -459,3 +460,38 @@ def test_update_with_fields_template(): {"fields_text": "ABC"}, api_key=None, ) + + +def test_upload_custom_results_posts_then_puts_html(): + put_resp = mock.Mock() + with ( + mock.patch.object( + Project, "post", + return_value={"upload_url": "https://s3.example/put", "upload_url_expires_in": 900}, + ) as mock_post, + mock.patch("surge.projects.requests.put", return_value=put_resp) as mock_put, + ): + result = Project.upload_custom_results("proj-1", io.BytesIO(b"hi")) + + mock_post.assert_called_once_with("projects/proj-1/custom_results", api_key=None) + args, kwargs = mock_put.call_args + assert args[0] == "https://s3.example/put" + assert kwargs["data"] == b"hi" + assert kwargs["headers"]["Content-Type"] == "text/html" + put_resp.raise_for_status.assert_called_once() + assert result["upload_url"] == "https://s3.example/put" + + +def test_upload_custom_results_reads_from_path(tmp_path): + f = tmp_path / "report.html" + f.write_bytes(b"file") + put_resp = mock.Mock() + with ( + mock.patch.object( + Project, "post", + return_value={"upload_url": "https://s3.example/put"}, + ), + mock.patch("surge.projects.requests.put", return_value=put_resp) as mock_put, + ): + Project.upload_custom_results("proj-1", str(f)) + assert mock_put.call_args.kwargs["data"] == b"file" From 5a8794164adee33c471825bd6c32caebb6fa0739 Mon Sep 17 00:00:00 2001 From: Tanmay Chordia Date: Thu, 4 Jun 2026 13:45:29 -0700 Subject: [PATCH 2/3] feat: add Project.get_custom_results_url and download_custom_results Co-Authored-By: Claude Opus 4.8 (1M context) --- surge/projects.py | 36 ++++++++++++++++++++++++++++++++++++ tests/test_projects.py | 22 ++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/surge/projects.py b/surge/projects.py index cd09eee..f8e28df 100644 --- a/surge/projects.py +++ b/surge/projects.py @@ -505,3 +505,39 @@ def upload_custom_results(cls, project_id: str, file, api_key: str = None): ) put_response.raise_for_status() return response_json + + @classmethod + def get_custom_results_url(cls, project_id: str, api_key: str = None): + """ + Get a temporary presigned URL for a project's custom HTML results file. + + Arguments: + project_id (str): UUID of the project. + + Returns: + str: A presigned download URL (valid for a limited time). + + Raises: + SurgeRequestError: If no custom results file exists (404). + """ + endpoint = f"{PROJECTS_ENDPOINT}/{project_id}/custom_results" + response_json = cls.get(endpoint, api_key=api_key) + return response_json["download_url"] + + @classmethod + def download_custom_results(cls, project_id: str, api_key: str = None): + """ + Download the contents of a project's custom HTML results file. + + Arguments: + project_id (str): UUID of the project. + + Returns: + str: The HTML file contents. + + Raises: + SurgeRequestError: If no custom results file exists (404). + """ + url = cls.get_custom_results_url(project_id, api_key=api_key) + with urllib.request.urlopen(url) as remote: + return remote.read().decode("utf-8") diff --git a/tests/test_projects.py b/tests/test_projects.py index 034ac83..723f684 100644 --- a/tests/test_projects.py +++ b/tests/test_projects.py @@ -495,3 +495,25 @@ def test_upload_custom_results_reads_from_path(tmp_path): ): Project.upload_custom_results("proj-1", str(f)) assert mock_put.call_args.kwargs["data"] == b"file" + + +def test_get_custom_results_url_returns_download_url(): + with mock.patch.object( + Project, "get", + return_value={"download_url": "https://s3.example/get", "download_url_expires_in": 900}, + ) as mock_get: + url = Project.get_custom_results_url("proj-1") + mock_get.assert_called_once_with("projects/proj-1/custom_results", api_key=None) + assert url == "https://s3.example/get" + + +def test_download_custom_results_fetches_html_content(): + with ( + mock.patch.object( + Project, "get", return_value={"download_url": "https://s3.example/get"}, + ), + mock.patch("surge.projects.urllib.request.urlopen") as mock_urlopen, + ): + mock_urlopen.return_value.__enter__.return_value = io.BytesIO(b"downloaded") + content = Project.download_custom_results("proj-1") + assert content == "downloaded" From 349e4adf542eac459fe764e8f19daa628bd40e0b Mon Sep 17 00:00:00 2001 From: Tanmay Chordia Date: Thu, 4 Jun 2026 13:46:04 -0700 Subject: [PATCH 3/3] feat: add Project.delete_custom_results Co-Authored-By: Claude Opus 4.8 (1M context) --- surge/projects.py | 14 ++++++++++++++ tests/test_projects.py | 9 +++++++++ 2 files changed, 23 insertions(+) diff --git a/surge/projects.py b/surge/projects.py index f8e28df..d08fcc1 100644 --- a/surge/projects.py +++ b/surge/projects.py @@ -541,3 +541,17 @@ def download_custom_results(cls, project_id: str, api_key: str = None): url = cls.get_custom_results_url(project_id, api_key=api_key) with urllib.request.urlopen(url) as remote: return remote.read().decode("utf-8") + + @classmethod + def delete_custom_results(cls, project_id: str, api_key: str = None): + """ + Delete a project's custom HTML results file. + + Arguments: + project_id (str): UUID of the project. + + Returns: + dict: ``{"success": True}`` + """ + endpoint = f"{PROJECTS_ENDPOINT}/{project_id}/custom_results" + return cls.delete_request(endpoint, api_key=api_key) diff --git a/tests/test_projects.py b/tests/test_projects.py index 723f684..51cb2b4 100644 --- a/tests/test_projects.py +++ b/tests/test_projects.py @@ -517,3 +517,12 @@ def test_download_custom_results_fetches_html_content(): mock_urlopen.return_value.__enter__.return_value = io.BytesIO(b"downloaded") content = Project.download_custom_results("proj-1") assert content == "downloaded" + + +def test_delete_custom_results_calls_delete_endpoint(): + with mock.patch.object( + Project, "delete_request", return_value={"success": True}, + ) as mock_delete: + result = Project.delete_custom_results("proj-1") + mock_delete.assert_called_once_with("projects/proj-1/custom_results", api_key=None) + assert result == {"success": True}