From 41a72cae9d729d53c6471c9394b7819c469fd3af Mon Sep 17 00:00:00 2001 From: Blake Moore Date: Wed, 22 Apr 2026 20:57:48 +0100 Subject: [PATCH 01/12] #231 add branch parameter to job_start() for git-based projects --- domino/domino.py | 18 ++++++++++++++++- tests/test_jobs.py | 49 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/domino/domino.py b/domino/domino.py index 119c4b5a..ff157909 100644 --- a/domino/domino.py +++ b/domino/domino.py @@ -345,6 +345,7 @@ def job_start( external_volume_mounts: Optional[List[str]] = None, title: Optional[str] = None, main_repo_git_ref: Optional[dict] = None, + branch: Optional[str] = None, ) -> dict: """ Starts a Domino Job via V4 API @@ -415,9 +416,24 @@ def job_start( "type": "branches", "value": "my-feature-branch" } - Supported types: "branches", "tags" + Supported types: "branches", "tags". + Cannot be combined with branch. + :param branch: string (Optional) + Convenience parameter. For git-based projects, launch the job + from the tip of the specified branch. Cannot be combined with + commit_id or main_repo_git_ref. :return: Returns created Job details (number, id etc) """ + if branch and commit_id: + raise ValueError( + "Only one of branch or commit_id may be specified, not both." + ) + if branch and main_repo_git_ref: + raise ValueError( + "Only one of branch or main_repo_git_ref may be specified, not both." + ) + if branch: + main_repo_git_ref = {"type": "branches", "value": branch} def validate_on_demand_spark_cluster_properties(max_execution_slot_per_user): self.log.debug( diff --git a/tests/test_jobs.py b/tests/test_jobs.py index bbdd4d3c..6538b0f6 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -376,6 +376,55 @@ def test_job_start_sends_main_repo_git_ref(requests_mock, dummy_hostname): assert jobs_start_request.json()["mainRepoGitRef"] == git_ref +@pytest.mark.usefixtures("clear_token_file_from_env", "mock_job_start_blocking_setup") +def test_job_start_branch_sets_main_repo_git_ref(requests_mock, dummy_hostname): + """ + Confirm that the branch convenience parameter is translated to mainRepoGitRef + in the jobs/start request body. + """ + requests_mock.get( + f"{dummy_hostname}/v4/jobs/{MOCK_JOB_ID}", + json=MOCK_JOB_RESPONSE_COMPLETED, + ) + + d = Domino(host=dummy_hostname, project="anyuser/anyproject", api_key="whatever") + d.job_start_blocking( + command="foo.py", branch="my-feature-branch", poll_freq=1, max_poll_time=1 + ) + + jobs_start_request = next( + req for req in requests_mock.request_history if req.path == "/v4/jobs/start" + ) + assert jobs_start_request.json()["mainRepoGitRef"] == { + "type": "branches", + "value": "my-feature-branch", + } + + +@pytest.mark.usefixtures("clear_token_file_from_env", "mock_job_start_blocking_setup") +def test_job_start_raises_if_branch_and_commit_id_both_provided(dummy_hostname): + """ + Confirm that providing both branch and commit_id raises a ValueError. + """ + d = Domino(host=dummy_hostname, project="anyuser/anyproject", api_key="whatever") + with pytest.raises(ValueError, match="Only one of branch or commit_id"): + d.job_start(command="foo.py", branch="my-branch", commit_id="abc123") + + +@pytest.mark.usefixtures("clear_token_file_from_env", "mock_job_start_blocking_setup") +def test_job_start_raises_if_branch_and_main_repo_git_ref_both_provided(dummy_hostname): + """ + Confirm that providing both branch and main_repo_git_ref raises a ValueError. + """ + d = Domino(host=dummy_hostname, project="anyuser/anyproject", api_key="whatever") + with pytest.raises(ValueError, match="Only one of branch or main_repo_git_ref"): + d.job_start( + command="foo.py", + branch="my-branch", + main_repo_git_ref={"type": "branches", "value": "other-branch"}, + ) + + @pytest.mark.usefixtures("clear_token_file_from_env", "mock_job_start_blocking_setup") def test_job_status_ignores_RequestException_and_times_out( requests_mock, dummy_hostname From 04e72a7c7656107073108defb6c39aba4b2754da Mon Sep 17 00:00:00 2001 From: Blake Moore Date: Wed, 22 Apr 2026 21:04:40 +0100 Subject: [PATCH 02/12] Cherry pick --- domino/domino.py | 279 +++++++++++++++++++++++++++++++++------------ tests/test_app.py | 36 +++++- tests/test_apps.py | 31 ++++- 3 files changed, 273 insertions(+), 73 deletions(-) diff --git a/domino/domino.py b/domino/domino.py index ff157909..ad072b0c 100644 --- a/domino/domino.py +++ b/domino/domino.py @@ -129,22 +129,44 @@ def runs_list(self): def runs_start( self, command, - isDirect=False, - commitId=None, + is_direct=False, + commit_id=None, title=None, tier=None, - publishApiEndpoint=None, + publish_api_endpoint=None, + **kwargs, ): + if "isDirect" in kwargs: + warnings.warn( + "isDirect is deprecated, use is_direct", + DeprecationWarning, + stacklevel=2, + ) + is_direct = kwargs.pop("isDirect") + if "commitId" in kwargs: + warnings.warn( + "commitId is deprecated, use commit_id", + DeprecationWarning, + stacklevel=2, + ) + commit_id = kwargs.pop("commitId") + if "publishApiEndpoint" in kwargs: + warnings.warn( + "publishApiEndpoint is deprecated, use publish_api_endpoint", + DeprecationWarning, + stacklevel=2, + ) + publish_api_endpoint = kwargs.pop("publishApiEndpoint") url = self._routes.runs_start() request = { "command": command, - "isDirect": isDirect, - "commitId": commitId, + "isDirect": is_direct, + "commitId": commit_id, "title": title, "tier": tier, - "publishApiEndpoint": publishApiEndpoint, + "publishApiEndpoint": publish_api_endpoint, } try: response = self.request_manager.post(url, json=request) @@ -158,14 +180,15 @@ def runs_start( def runs_start_blocking( self, command, - isDirect=False, - commitId=None, + is_direct=False, + commit_id=None, title=None, tier=None, - publishApiEndpoint=None, + publish_api_endpoint=None, poll_freq=5, max_poll_time=6000, retry_count=5, + **kwargs, ): """ Run a tasks that runs in a blocking loop that periodically checks to @@ -179,12 +202,12 @@ def runs_start_blocking( example: >> domino.runs_start(["main.py", "arg1", "arg2"]) - isDirect : boolean (Optional) + is_direct : boolean (Optional) Whether or not this command should be passed directly to a shell. - commitId : string (Optional) - The commitId to launch from. If not provided, will launch + commit_id : string (Optional) + The commit_id to launch from. If not provided, will launch from latest commit. title : string (Optional) @@ -194,7 +217,7 @@ def runs_start_blocking( The hardware tier to use for the run. Will use project default tier if not provided. - publishApiEndpoint : boolean (Optional) + publish_api_endpoint : boolean (Optional) Whether or not to publish an API endpoint from the resulting output. @@ -212,8 +235,30 @@ def runs_start_blocking( (in-case of transient http errors). If this threshold exceeds, an exception is raised. """ + if "isDirect" in kwargs: + warnings.warn( + "isDirect is deprecated, use is_direct", + DeprecationWarning, + stacklevel=2, + ) + is_direct = kwargs.pop("isDirect") + if "commitId" in kwargs: + warnings.warn( + "commitId is deprecated, use commit_id", + DeprecationWarning, + stacklevel=2, + ) + commit_id = kwargs.pop("commitId") + if "publishApiEndpoint" in kwargs: + warnings.warn( + "publishApiEndpoint is deprecated, use publish_api_endpoint", + DeprecationWarning, + stacklevel=2, + ) + publish_api_endpoint = kwargs.pop("publishApiEndpoint") + run_response = self.runs_start( - command, isDirect, commitId, title, tier, publishApiEndpoint + command, is_direct, commit_id, title, tier, publish_api_endpoint ) run_id = run_response["runId"] @@ -256,7 +301,7 @@ def runs_start_blocking( # once task has finished running check to see if it was successful else: - stdout_msg = self.get_run_log(runId=run_id, includeSetupLog=False) + stdout_msg = self.get_run_log(run_id=run_id, include_setup_log=False) if run_info["status"] != "Succeeded": self.process_log(stdout_msg) @@ -268,31 +313,60 @@ def runs_start_blocking( return run_response - def run_stop(self, runId, saveChanges=True): + def run_stop(self, run_id=None, save_changes=True, **kwargs): + if "runId" in kwargs: + warnings.warn( + "runId is deprecated, use run_id", DeprecationWarning, stacklevel=2 + ) + run_id = kwargs.pop("runId") + if "saveChanges" in kwargs: + warnings.warn( + "saveChanges is deprecated, use save_changes", + DeprecationWarning, + stacklevel=2, + ) + save_changes = kwargs.pop("saveChanges") self.log.warning("Use job_stop method instead") - return self.job_stop(job_id=runId, commit_results=saveChanges) + return self.job_stop(job_id=run_id, commit_results=save_changes) - def runs_status(self, runId): - url = self._routes.runs_status(runId) + def runs_status(self, run_id=None, **kwargs): + if "runId" in kwargs: + warnings.warn( + "runId is deprecated, use run_id", DeprecationWarning, stacklevel=2 + ) + run_id = kwargs.pop("runId") + url = self._routes.runs_status(run_id) return self._get(url) - def get_run_log(self, runId, includeSetupLog=True): + def get_run_log(self, run_id=None, include_setup_log=True, **kwargs): """ Get the unified log for a run (setup + stdout). parameters ---------- - runId : string + run_id : string the id associated with the run. - includeSetupLog : bool + include_setup_log : bool whether or not to include the setup log in the output. """ + if "runId" in kwargs: + warnings.warn( + "runId is deprecated, use run_id", DeprecationWarning, stacklevel=2 + ) + run_id = kwargs.pop("runId") + if "includeSetupLog" in kwargs: + warnings.warn( + "includeSetupLog is deprecated, use include_setup_log", + DeprecationWarning, + stacklevel=2, + ) + include_setup_log = kwargs.pop("includeSetupLog") - url = self._routes.runs_stdout(runId) + url = self._routes.runs_stdout(run_id) logs = list() - if includeSetupLog: + if include_setup_log: logs.append(self._get(url)["setup"]) logs.append(self._get(url)["stdout"]) @@ -304,15 +378,20 @@ def get_run_info(self, run_id): if run_info["id"] == run_id: return run_info - def runs_stdout(self, runId): + def runs_stdout(self, run_id=None, **kwargs): """ Get std out emitted by a particular run. parameters ---------- - runId : string + run_id : string the id associated with the run. """ + if "runId" in kwargs: + warnings.warn( + "runId is deprecated, use run_id", DeprecationWarning, stacklevel=2 + ) + run_id = kwargs.pop("runId") html_start_tags = ( "
> domino.job_start(command="main.py arg1 arg2")
         :param commit_id:                           string (Optional)
-                                                    The commitId to launch from. If not provided, will launch
+                                                    The commit_id to launch from. If not provided, will launch
                                                     from latest commit.
         :param hardware_tier_id:                    string (Optional)
                                                     The hardware tier ID to launch job in. If not provided
@@ -626,10 +705,8 @@ def validate_is_external_volume_mounts_supported():
                 "masterHardwareTierId": master_hardware_tier_id,
             }
 
-        resolved_hardware_tier_id = hardware_tier_id or (
-            self.get_hardware_tier_id_from_name(hardware_tier_name)
-            if hardware_tier_name
-            else None
+        resolved_hardware_tier_id = (
+            hardware_tier_id or self.get_hardware_tier_id_from_name(hardware_tier_name)
         )
         url = self._routes.job_start()
         payload = {
@@ -773,12 +850,19 @@ def get_job_status(job_identifier):
             step=poll_freq,
             log_error=self.log.level,
         )
-        stdout_msg = self.get_run_log(runId=job_id, includeSetupLog=False)
+        stdout_msg = self.get_run_log(run_id=job_id, include_setup_log=False)
         self.process_log(stdout_msg)
         return job_status
 
-    def files_list(self, commitId, path="/"):
-        url = self._routes.files_list(commitId, path)
+    def files_list(self, commit_id=None, path="/", **kwargs):
+        if "commitId" in kwargs:
+            warnings.warn(
+                "commitId is deprecated, use commit_id",
+                DeprecationWarning,
+                stacklevel=2,
+            )
+            commit_id = kwargs.pop("commitId")
+        url = self._routes.files_list(commit_id, path)
         return self._get(url)
 
     def files_upload(self, path, file):
@@ -822,11 +906,18 @@ def endpoint_unpublish(self):
         response = self.request_manager.delete(url)
         return response
 
-    def endpoint_publish(self, file, function, commitId):
+    def endpoint_publish(self, file, function, commit_id=None, **kwargs):
+        if "commitId" in kwargs:
+            warnings.warn(
+                "commitId is deprecated, use commit_id",
+                DeprecationWarning,
+                stacklevel=2,
+            )
+            commit_id = kwargs.pop("commitId")
         url = self._routes.endpoint_publish()
 
         request = {
-            "commitId": commitId,
+            "commitId": commit_id,
             "bindingDefinition": {"file": file, "function": function},
         }
 
@@ -1000,56 +1091,109 @@ def collaborators_remove(self, username_or_email):
     # App functions
     def app_publish(
         self,
-        unpublishRunningApps=True,
-        hardwareTierId=None,
-        environmentId=None,
-        externalVolumeMountIds=None,
-        commitId=None,
+        unpublish_running_apps=True,
+        hardware_tier_id=None,
+        environment_id=None,
+        external_volume_mount_ids=None,
+        commit_id=None,
         branch=None,
-        appId=None,
+        app_id=None,
+        **kwargs,
     ):
-        if commitId and branch:
+        if "unpublishRunningApps" in kwargs:
+            warnings.warn(
+                "unpublishRunningApps is deprecated, use unpublish_running_apps",
+                DeprecationWarning,
+                stacklevel=2,
+            )
+            unpublish_running_apps = kwargs.pop("unpublishRunningApps")
+        if "hardwareTierId" in kwargs:
+            warnings.warn(
+                "hardwareTierId is deprecated, use hardware_tier_id",
+                DeprecationWarning,
+                stacklevel=2,
+            )
+            hardware_tier_id = kwargs.pop("hardwareTierId")
+        if "environmentId" in kwargs:
+            warnings.warn(
+                "environmentId is deprecated, use environment_id",
+                DeprecationWarning,
+                stacklevel=2,
+            )
+            environment_id = kwargs.pop("environmentId")
+        if "externalVolumeMountIds" in kwargs:
+            warnings.warn(
+                "externalVolumeMountIds is deprecated, use external_volume_mount_ids",
+                DeprecationWarning,
+                stacklevel=2,
+            )
+            external_volume_mount_ids = kwargs.pop("externalVolumeMountIds")
+        if "commitId" in kwargs:
+            warnings.warn(
+                "commitId is deprecated, use commit_id",
+                DeprecationWarning,
+                stacklevel=2,
+            )
+            commit_id = kwargs.pop("commitId")
+        if "appId" in kwargs:
+            warnings.warn(
+                "appId is deprecated, use app_id", DeprecationWarning, stacklevel=2
+            )
+            app_id = kwargs.pop("appId")
+
+        if commit_id and branch:
             raise ValueError(
-                "Only one of commitId or branch may be specified, not both."
+                "Only one of commit_id or branch may be specified, not both."
             )
-        app_id = appId or self._app_id
-        if unpublishRunningApps:
-            self.app_unpublish(appId=app_id)
+        app_id = app_id or self.app_id
+        if unpublish_running_apps:
+            self.app_unpublish(app_id)
         if app_id is None:
             # No App Exists creating one
-            app_id = self.__app_create(hardware_tier_id=hardwareTierId)
+            app_id = self.__app_create(hardware_tier_id=hardware_tier_id)
         url = self._routes.app_start(app_id)
-        if commitId:
-            main_repo_git_ref = {"type": "commitId", "value": commitId}
+        if commit_id:
+            main_repo_git_ref = {"type": "commitId", "value": commit_id}
         elif branch:
             main_repo_git_ref = {"type": "branches", "value": branch}
         else:
             main_repo_git_ref = None
         request = {
-            "hardwareTierId": hardwareTierId,
-            "environmentId": environmentId,
-            "externalVolumeMountIds": externalVolumeMountIds,
+            "hardwareTierId": hardware_tier_id,
+            "environmentId": environment_id,
+            "externalVolumeMountIds": external_volume_mount_ids,
             "mainRepoGitRef": main_repo_git_ref,
         }
         omitting_null = {k: v for (k, v) in request.items() if v is not None}
         response = self.request_manager.post(url, json=omitting_null)
         return response
 
-    def app_unpublish(self, appId=None):
-        app_id = appId or self._app_id
+    def app_unpublish(self, app_id=None, **kwargs):
+        if "appId" in kwargs:
+            warnings.warn(
+                "appId is deprecated, use app_id", DeprecationWarning, stacklevel=2
+            )
+            app_id = kwargs.pop("appId")
+        app_id = app_id or self.app_id
         if app_id is None:
             return
-        status = self.__app_get_status(app_id)
+        status = self.app_get_status(app_id)
         self.log.debug(f"App {app_id} status={status}")
         if status and status != "Stopped" and status != "Failed":
             url = self._routes.app_stop(app_id)
             response = self.request_manager.post(url)
             return response
 
-    def __app_get_status(self, id) -> Optional[str]:
-        if id is None:
+    def app_get_status(self, app_id: str) -> Optional[str]:
+        """
+        Return the current status of an app, or None if the app does not exist.
+
+        :param app_id: The ID of the app to query.
+        :return: Status string (e.g. "Running", "Stopped", "Failed") or None.
+        """
+        if app_id is None:
             return None
-        url = self._routes.app_get(id)
+        url = self._routes.app_get(app_id)
         response = self.request_manager.get(url).json()
         return response.get("status", None)
 
@@ -1549,7 +1693,7 @@ def hardware_tiers_list(self):
         url = self._routes.hardware_tiers_list(self.project_id)
         return self._get(url)
 
-    def get_hardware_tier_id_from_name(self, hardware_tier_name: str):
+    def get_hardware_tier_id_from_name(self, hardware_tier_name: Optional[str]):
         for hardware_tier in self.hardware_tiers_list():
             if hardware_tier_name == hardware_tier["hardwareTier"]["name"]:
                 return hardware_tier["hardwareTier"]["id"]
@@ -2085,20 +2229,15 @@ def project_id(self):
             f"Project '{self._project_name}' not found for owner '{self._owner_username}'"
         )
 
-    # This will fetch app_id of app in current project
     @property
-    def _app_id(self):
+    def app_id(self) -> Optional[str]:
+        """Return the ID of the first app in the current project, or None if no app exists."""
         url = self._routes.app_list(self.project_id)
         response = self._get(url)
         if len(response) != 0:
             app = response[0]
         else:
             return None
-        key = "id"
-        if key in app.keys():
-            app_id = app[key]
-        else:
-            app_id = None
-        return app_id
+        return app.get("id", None)
 
     _csrf_no_check_header = {"Csrf-Token": "nocheck"}
diff --git a/tests/test_app.py b/tests/test_app.py
index 0c94dc84..5dad603a 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -17,7 +17,7 @@ def mock_app_publish_setup(requests_mock, dummy_hostname):
     """
     requests_mock.get(f"{dummy_hostname}/version", json={"version": "9.9.9"})
 
-    # Mock app status lookup (GET) used by app_unpublish via __app_get_status
+    # Mock app status lookup (GET) used by app_unpublish via app_get_status
     requests_mock.get(
         f"{dummy_hostname}/v4/modelproducts/{MOCK_APP_ID}",
         json={"id": MOCK_APP_ID, "status": "Running"},
@@ -152,3 +152,37 @@ def test_app_publish_targets_specific_app_id(requests_mock, dummy_hostname):
         if req.path == f"/v4/modelproducts/{MOCK_APP_ID}/start"
     ]
     assert len(start_requests) == 1
+
+
+@pytest.mark.usefixtures("clear_token_file_from_env")
+def test_app_get_status_returns_status(requests_mock, dummy_hostname):
+    """
+    Confirm that app_get_status() is publicly accessible and returns the
+    status string from the API response.
+    """
+    requests_mock.get(f"{dummy_hostname}/version", json={"version": "9.9.9"})
+    requests_mock.get(
+        f"{dummy_hostname}/v4/modelproducts/{MOCK_APP_ID}",
+        json={"id": MOCK_APP_ID, "status": "Running"},
+    )
+
+    d = Domino(host=dummy_hostname, project="anyuser/anyproject", api_key="whatever")
+    status = d.app_get_status(MOCK_APP_ID)
+    assert status == "Running"
+
+
+@pytest.mark.usefixtures("clear_token_file_from_env")
+def test_app_get_status_returns_none_when_status_missing(requests_mock, dummy_hostname):
+    """
+    Confirm that app_get_status() returns None when the API response
+    does not contain a status field.
+    """
+    requests_mock.get(f"{dummy_hostname}/version", json={"version": "9.9.9"})
+    requests_mock.get(
+        f"{dummy_hostname}/v4/modelproducts/{MOCK_APP_ID}",
+        json={"id": MOCK_APP_ID},
+    )
+
+    d = Domino(host=dummy_hostname, project="anyuser/anyproject", api_key="whatever")
+    status = d.app_get_status(MOCK_APP_ID)
+    assert status is None
diff --git a/tests/test_apps.py b/tests/test_apps.py
index 67ce9088..bb19c678 100644
--- a/tests/test_apps.py
+++ b/tests/test_apps.py
@@ -30,7 +30,7 @@ def base_mocks(requests_mock, dummy_hostname):
 
 @pytest.mark.usefixtures("clear_token_file_from_env", "base_mocks")
 def test_app_publish_starts_existing_app(requests_mock, dummy_hostname):
-    # _app_id: returns existing app
+    # app_id: returns existing app
     requests_mock.get(
         f"{dummy_hostname}/v4/modelProducts?projectId={MOCK_PROJECT_ID}",
         json=[MOCK_APP],
@@ -52,7 +52,7 @@ def test_app_publish_starts_existing_app(requests_mock, dummy_hostname):
 
 @pytest.mark.usefixtures("clear_token_file_from_env", "base_mocks")
 def test_app_publish_creates_app_when_none_exists(requests_mock, dummy_hostname):
-    # _app_id returns empty list — no app exists
+    # app_id returns empty list — no app exists
     requests_mock.get(
         f"{dummy_hostname}/v4/modelProducts?projectId={MOCK_PROJECT_ID}", json=[]
     )
@@ -174,3 +174,30 @@ def test_app_unpublish_does_nothing_when_app_already_stopped(
     d = Domino(host=dummy_hostname, project="anyuser/anyproject", api_key="whatever")
     result = d.app_unpublish()
     assert result is None
+
+
+@pytest.mark.usefixtures("clear_token_file_from_env", "base_mocks")
+def test_app_id_returns_id_when_app_exists(requests_mock, dummy_hostname):
+    """
+    Confirm that the public app_id property returns the ID of the first app
+    in the current project.
+    """
+    requests_mock.get(
+        f"{dummy_hostname}/v4/modelProducts?projectId={MOCK_PROJECT_ID}",
+        json=[MOCK_APP],
+    )
+    d = Domino(host=dummy_hostname, project="anyuser/anyproject", api_key="whatever")
+    assert d.app_id == MOCK_APP_ID
+
+
+@pytest.mark.usefixtures("clear_token_file_from_env", "base_mocks")
+def test_app_id_returns_none_when_no_app_exists(requests_mock, dummy_hostname):
+    """
+    Confirm that the public app_id property returns None when the project
+    has no apps.
+    """
+    requests_mock.get(
+        f"{dummy_hostname}/v4/modelProducts?projectId={MOCK_PROJECT_ID}", json=[]
+    )
+    d = Domino(host=dummy_hostname, project="anyuser/anyproject", api_key="whatever")
+    assert d.app_id is None

From c6de5704dd7f0fca3beda1fe94959dcb013b4bb3 Mon Sep 17 00:00:00 2001
From: Blake Moore 
Date: Wed, 22 Apr 2026 21:07:29 +0100
Subject: [PATCH 03/12] #174 - handle dict input in _validate_hardware_tier_id

---
 domino/domino.py   |  6 ++++--
 tests/test_jobs.py | 44 ++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 48 insertions(+), 2 deletions(-)

diff --git a/domino/domino.py b/domino/domino.py
index ad072b0c..65d6b1e5 100644
--- a/domino/domino.py
+++ b/domino/domino.py
@@ -5,7 +5,7 @@
 import re
 import time
 import warnings
-from typing import Any, Dict, List, Optional, Tuple
+from typing import Any, Dict, List, Optional, Tuple, Union
 
 import polling2
 import requests
@@ -2104,7 +2104,9 @@ def _validate_environment_id(self, environment_id) -> bool:
             f"{environment_id} environment not found"
         )
 
-    def _validate_hardware_tier_id(self, hardware_tier_id: str) -> bool:
+    def _validate_hardware_tier_id(self, hardware_tier_id: Union[str, Dict]) -> bool:
+        if isinstance(hardware_tier_id, dict):
+            hardware_tier_id = hardware_tier_id.get("value", hardware_tier_id)
         self.log.debug(f"Validating hardware tier id: {hardware_tier_id}")
         for hardware_tier in self.hardware_tiers_list():
             if hardware_tier_id == hardware_tier["hardwareTier"]["id"]:
diff --git a/tests/test_jobs.py b/tests/test_jobs.py
index 6538b0f6..24985c05 100644
--- a/tests/test_jobs.py
+++ b/tests/test_jobs.py
@@ -425,6 +425,50 @@ def test_job_start_raises_if_branch_and_main_repo_git_ref_both_provided(dummy_ho
         )
 
 
+@pytest.mark.usefixtures("clear_token_file_from_env")
+def test_validate_hardware_tier_id_accepts_dict(requests_mock, dummy_hostname):
+    """
+    Confirm that _validate_hardware_tier_id extracts the string value from a dict
+    instead of raising HardwareTierNotFoundException (#174).
+
+    Some call paths (e.g. compute_cluster_properties["workerHardwareTierId"]) pass
+    a dict like {"value": "small-k8s"} rather than a plain string.
+    """
+    requests_mock.get(f"{dummy_hostname}/version", json={"version": "9.9.9"})
+    requests_mock.get(
+        f"{dummy_hostname}/v4/gateway/projects/findProjectByOwnerAndName"
+        "?ownerName=anyuser&projectName=anyproject",
+        json={"id": MOCK_PROJECT_ID},
+    )
+    requests_mock.get(
+        f"{dummy_hostname}/v4/projects/{MOCK_PROJECT_ID}/hardwareTiers",
+        json=[{"hardwareTier": {"id": "small-k8s", "name": "Small"}}],
+    )
+
+    d = Domino(host=dummy_hostname, project="anyuser/anyproject", api_key="whatever")
+    assert d._validate_hardware_tier_id({"value": "small-k8s"}) is True
+
+
+@pytest.mark.usefixtures("clear_token_file_from_env")
+def test_validate_hardware_tier_id_accepts_string(requests_mock, dummy_hostname):
+    """
+    Confirm that _validate_hardware_tier_id still works with a plain string ID.
+    """
+    requests_mock.get(f"{dummy_hostname}/version", json={"version": "9.9.9"})
+    requests_mock.get(
+        f"{dummy_hostname}/v4/gateway/projects/findProjectByOwnerAndName"
+        "?ownerName=anyuser&projectName=anyproject",
+        json={"id": MOCK_PROJECT_ID},
+    )
+    requests_mock.get(
+        f"{dummy_hostname}/v4/projects/{MOCK_PROJECT_ID}/hardwareTiers",
+        json=[{"hardwareTier": {"id": "small-k8s", "name": "Small"}}],
+    )
+
+    d = Domino(host=dummy_hostname, project="anyuser/anyproject", api_key="whatever")
+    assert d._validate_hardware_tier_id("small-k8s") is True
+
+
 @pytest.mark.usefixtures("clear_token_file_from_env", "mock_job_start_blocking_setup")
 def test_job_status_ignores_RequestException_and_times_out(
     requests_mock, dummy_hostname

From 274efe2b335b3d4d12c901af97f9eae1730e8278 Mon Sep 17 00:00:00 2001
From: Blake Moore 
Date: Wed, 22 Apr 2026 21:09:19 +0100
Subject: [PATCH 04/12] #24 / #31 - add files_download() convenience method

---
 domino/domino.py    | 12 +++++++++
 tests/test_files.py | 61 +++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 73 insertions(+)
 create mode 100644 tests/test_files.py

diff --git a/domino/domino.py b/domino/domino.py
index 65d6b1e5..c8a70795 100644
--- a/domino/domino.py
+++ b/domino/domino.py
@@ -891,6 +891,18 @@ def blobs_get_v2(self, path, commit_id, project_id):
         url = self._routes.blobs_get_v2(path, commit_id, project_id)
         return self.request_manager.get_raw(url)
 
+    def files_download(self, path: str, commit_id: Optional[str] = None):
+        """
+        Download a file from the project by path.
+
+        :param path: Path to the file within the project (e.g. "/README.md").
+        :param commit_id: The commit to download from. Defaults to the latest commit.
+        :return: Raw file content (urllib3 response stream).
+        """
+        if commit_id is None:
+            commit_id = self.commits_list()[0]["id"]
+        return self.blobs_get_v2(path, commit_id, self.project_id)
+
     def fork_project(self, target_name):
         url = self._routes.fork_project(self.project_id)
         request = {"name": target_name}
diff --git a/tests/test_files.py b/tests/test_files.py
new file mode 100644
index 00000000..8c2c20f6
--- /dev/null
+++ b/tests/test_files.py
@@ -0,0 +1,61 @@
+"""
+Unit tests for files_download() convenience method.
+All tests use requests_mock — no live Domino deployment required.
+"""
+
+import pytest
+
+from domino import Domino
+
+MOCK_PROJECT_ID = "aabbccddeeff001122334455"
+MOCK_COMMIT_ID = "aabbcc112233"
+FILE_CONTENT = b"hello world"
+
+
+@pytest.fixture
+def base_mocks(requests_mock, dummy_hostname):
+    requests_mock.get(f"{dummy_hostname}/version", json={"version": "9.9.9"})
+    requests_mock.get(
+        f"{dummy_hostname}/v4/gateway/projects/findProjectByOwnerAndName"
+        "?ownerName=anyuser&projectName=anyproject",
+        json={"id": MOCK_PROJECT_ID},
+    )
+    yield
+
+
+@pytest.mark.usefixtures("clear_token_file_from_env", "base_mocks")
+def test_files_download_with_explicit_commit_id(requests_mock, dummy_hostname):
+    """
+    Confirm that files_download() uses the provided commit_id and does not
+    call commits_list (#24, #31).
+    """
+    requests_mock.get(
+        f"{dummy_hostname}/api/projects/v1/projects/{MOCK_PROJECT_ID}"
+        f"/files/{MOCK_COMMIT_ID}//README.md/content",
+        content=FILE_CONTENT,
+    )
+
+    d = Domino(host=dummy_hostname, project="anyuser/anyproject", api_key="whatever")
+    result = d.files_download("/README.md", commit_id=MOCK_COMMIT_ID)
+    assert result.read() == FILE_CONTENT
+
+
+@pytest.mark.usefixtures("clear_token_file_from_env", "base_mocks")
+def test_files_download_defaults_to_latest_commit(requests_mock, dummy_hostname):
+    """
+    Confirm that files_download() fetches the latest commit when commit_id
+    is omitted (#24, #31).
+    """
+    requests_mock.get(
+        f"{dummy_hostname}/v1/projects/anyuser/anyproject/commits",
+        json=[{"id": MOCK_COMMIT_ID}, {"id": "oldercommit"}],
+    )
+    requests_mock.get(
+        f"{dummy_hostname}/api/projects/v1/projects/{MOCK_PROJECT_ID}"
+        f"/files/{MOCK_COMMIT_ID}//README.md/content",
+        content=FILE_CONTENT,
+    )
+
+    d = Domino(host=dummy_hostname, project="anyuser/anyproject", api_key="whatever")
+    result = d.files_download("/README.md")
+    assert result.read() == FILE_CONTENT

From 2d834373a487611700d84e51ab9fb7b9d349e7f3 Mon Sep 17 00:00:00 2001
From: Blake Moore 
Date: Wed, 22 Apr 2026 21:11:22 +0100
Subject: [PATCH 05/12] #122 - document runs_start vs jobs_start

---
 README.adoc      | 17 ++++++++++++++++-
 README.md        | 20 +++++++++++++++++++-
 domino/domino.py |  8 +++++++-
 3 files changed, 42 insertions(+), 3 deletions(-)

diff --git a/README.adoc b/README.adoc
index 3a8d6e0b..eaf17463 100644
--- a/README.adoc
+++ b/README.adoc
@@ -426,6 +426,19 @@ Remove a tag from a project.
 
 === Executions
 
+NOTE: *`runs_start` vs `job_start` — which should I use?* +
+The SDK exposes two ways to start an execution. `job_start` uses the v4 Jobs API and is recommended for all new work. `runs_start` uses the legacy v1 API and is retained for backwards compatibility only.
+[cols="1,2,2", options="header"]
+|===
+| | `runs_start` / `runs_start_blocking` | `job_start` / `job_start_blocking`
+| *API version* | v1 (legacy) | v4 (current)
+| *Command format* | List of strings: `["main.py", "arg1"]` | Single string: `"main.py arg1"`
+| *Compute clusters* | Not supported | Spark, Ray, Dask, MPI via `compute_cluster_properties`
+| *Git ref targeting* | Commit ID only | Commit ID, branch, or `main_repo_git_ref` dict
+| *External volumes* | Not supported | Supported via `external_volume_mounts`
+| *Recommended for* | Legacy scripts | All new work
+|===
+
 See these code example files:
 
 * {python-domino-repo}/blob/Release-{latest-version}/examples/start_run_and_check_status.py[`start_run_and_check_status.py`^]
@@ -538,9 +551,11 @@ Stop the running app in the project.
 
 === Jobs
 
+NOTE: Prefer `job_start` over `runs_start` for all new work. See the <> section for a full comparison.
+
 ==== job_start(command, commit_id=None, hardware_tier_name=None, environment_id=None, on_demand_spark_cluster_properties=None, compute_cluster_properties=None, external_volume_mounts=None, title=None):
 
-Start a new job (execution) in the project.
+Start a new job (execution) in the project using the v4 Jobs API.
 
 * _command (string):_ Command to execute in Job.
 For example: `domino.job_start(command="main.py arg1 arg2")`
diff --git a/README.md b/README.md
index 3afd4f3e..95c4d8e9 100644
--- a/README.md
+++ b/README.md
@@ -404,6 +404,22 @@ Remove a tag from a project.
 
 ## Executions
 
+> **`runs_start` vs `job_start` — which should I use?**
+>
+> The SDK exposes two ways to start an execution:
+>
+> | | `runs_start` / `runs_start_blocking` | `job_start` / `job_start_blocking` |
+> |---|---|---|
+> | **API version** | v1 (legacy) | v4 (current) |
+> | **Command format** | List of strings: `["main.py", "arg1"]` | Single string: `"main.py arg1"` |
+> | **Hardware tier** | By name (`tier`) | By name (`hardware_tier_name`) or ID (`hardware_tier_id`) |
+> | **Compute clusters** | Not supported | Spark, Ray, Dask, MPI via `compute_cluster_properties` |
+> | **Git ref targeting** | By commit ID only | By commit ID, branch, or raw `main_repo_git_ref` dict |
+> | **External volumes** | Not supported | Supported via `external_volume_mounts` |
+> | **Recommended for** | Legacy scripts and simple use cases | All new work |
+>
+> Use `job_start` for all new development. `runs_start` is retained for backwards compatibility.
+
 See these code example files:
 
 -   [`start_run_and_check_status.py`](https://github.com/dominodatalab/python-domino/blob/Release-2.1.0/examples/start_run_and_check_status.py)
@@ -551,9 +567,11 @@ Stop the running app in the project.
 
 ## Jobs
 
+> **Prefer `job_start` over `runs_start` for all new work.** See the [Executions](#executions) section for a full comparison.
+
 ### job_start(command, commit_id=None, hardware_tier_name=None, environment_id=None, on_demand_spark_cluster_properties=None, compute_cluster_properties=None, external_volume_mounts=None, title=None, main_repo_git_ref=None):
 
-Start a new job (execution) in the project.
+Start a new job (execution) in the project using the v4 Jobs API.
 
 -   *command (string):* Command to execute in Job. For example:
     `domino.job_start(command="main.py arg1 arg2")`
diff --git a/domino/domino.py b/domino/domino.py
index c8a70795..9e763099 100644
--- a/domino/domino.py
+++ b/domino/domino.py
@@ -136,6 +136,10 @@ def runs_start(
         publish_api_endpoint=None,
         **kwargs,
     ):
+        """
+        Start a run via the legacy v1 Runs API. For new work, prefer job_start() which uses
+        the v4 Jobs API and supports compute clusters, external volumes, and branch targeting.
+        """
         if "isDirect" in kwargs:
             warnings.warn(
                 "isDirect is deprecated, use is_direct",
@@ -427,7 +431,9 @@ def job_start(  # noqa: C901
         branch: Optional[str] = None,
     ) -> dict:
         """
-        Starts a Domino Job via V4 API
+        Start a Domino Job via the v4 Jobs API. Preferred over runs_start() for all new work —
+        supports hardware tiers, compute clusters (Spark/Ray/Dask/MPI), external volumes, and
+        git ref targeting by branch or commit.
         :param command:                             string
                                                     Command to execute in Job
                                                     >> domino.job_start(command="main.py arg1 arg2")

From fa0373810c99e2af44d2eacc9cda65375b50c22f Mon Sep 17 00:00:00 2001
From: Blake Moore 
Date: Wed, 22 Apr 2026 21:13:36 +0100
Subject: [PATCH 06/12] Update README with the changes

---
 README.adoc | 73 +++++++++++++++++++++++++++++++++++++----
 README.md   | 93 +++++++++++++++++++++++++++++++++++++++++++++++------
 2 files changed, 150 insertions(+), 16 deletions(-)

diff --git a/README.adoc b/README.adoc
index eaf17463..b8f06ce1 100644
--- a/README.adoc
+++ b/README.adoc
@@ -536,31 +536,87 @@ Retrieve a file from the Domino server in a project from its path and commit id.
 * _commit_id:_ ID of the commit to retrieve the file from.
 * _project_id:_ ID of the project to retrieve the file from.
 
+==== files_download(path, commit_id=None)
+
+Convenience wrapper around `blobs_get_v2` that downloads a file by path without
+needing to look up a commit ID first.
+
+* _path (string):_ Path to the file within the project, e.g. `"/README.md"`.
+* _commit_id (string):_ (Optional) The commit to download from. Defaults to the latest commit.
+
+Returns a raw file content stream (urllib3 response).
+
+[source,python]
+----
+content = d.files_download("/results/output.csv").read()
+----
+
 === Apps
 
-==== app_publish(unpublishRunningApps=True, hardwareTierId=None)
+==== app_publish(unpublish_running_apps=True, hardware_tier_id=None, environment_id=None, external_volume_mount_ids=None, commit_id=None, branch=None, app_id=None)
 
 Publish an app within a project, or republish an existing app.
 
-* _unpublishRunningApps:_ (Defaults to True) Check for an active app instance in the current project and unpublish it before re/publishing.
-* _hardwareTierId:_ (Optional) Launch the app on the specified hardware tier.
+* _unpublish_running_apps:_ (Defaults to `true`) Check for an active app instance in the current project and stop it before re/publishing.
+* _hardware_tier_id:_ (Optional) Launch the app on the specified hardware tier ID.
+* _environment_id:_ (Optional) Launch the app with the specified environment ID.
+* _external_volume_mount_ids:_ (Optional) List of external volume mount IDs to attach to the app.
+* _commit_id:_ (Optional) Launch the app from a specific commit. Cannot be combined with `branch`.
+* _branch:_ (Optional) Launch the app from the tip of a specific branch. Cannot be combined with `commit_id`.
+* _app_id:_ (Optional) The ID of the app to publish. If omitted, the project's default app is used (or a new one is created if none exists).
+
+[source,python]
+----
+# Publish from a specific branch
+d.app_publish(branch="my-feature-branch")
+
+# Publish from a specific commit
+d.app_publish(commit_id="abc123def456")
+
+# Publish a specific app by ID
+d.app_publish(app_id="aabbccddeeff001122334457")
+----
+
+NOTE: The parameters `unpublishRunningApps`, `hardwareTierId`, `environmentId`, `externalVolumeMountIds`, `commitId`, and `appId` are deprecated and will be removed in the next major version. Use the `snake_case` equivalents listed above.
 
-==== app_unpublish()
+==== app_unpublish(app_id=None)
 
 Stop the running app in the project.
 
+* _app_id:_ (Optional) The ID of the app to stop. If omitted, the project's default app is used.
+
+==== app_get_status(app_id)
+
+Return the current status of an app.
+
+* _app_id (string):_ The ID of the app to query.
+
+Returns the status string (e.g. `"Running"`, `"Stopped"`, `"Failed"`), or `None` if the app does not exist.
+
+==== app_id
+
+Read-only property. Returns the ID of the first app in the current project, or `None` if no app exists.
+
+[source,python]
+----
+print(d.app_id)  # e.g. "aabbccddeeff001122334457"
+----
+
 === Jobs
 
 NOTE: Prefer `job_start` over `runs_start` for all new work. See the <> section for a full comparison.
 
-==== job_start(command, commit_id=None, hardware_tier_name=None, environment_id=None, on_demand_spark_cluster_properties=None, compute_cluster_properties=None, external_volume_mounts=None, title=None):
+==== job_start(command, commit_id=None, branch=None, hardware_tier_name=None, environment_id=None, on_demand_spark_cluster_properties=None, compute_cluster_properties=None, external_volume_mounts=None, title=None, main_repo_git_ref=None):
 
 Start a new job (execution) in the project using the v4 Jobs API.
 
 * _command (string):_ Command to execute in Job.
 For example: `domino.job_start(command="main.py arg1 arg2")`
-* _commit_id (string):_ (Optional) The `commitId` to launch from.
-If not provided, the job launches from the latest commit.
+* _commit_id (string):_ (Optional) The commit ID to launch from.
+If not provided, the job launches from the latest commit. Mutually exclusive with `branch`.
+* _branch (string):_ (Optional) The branch name to launch from.
+If not provided, the job launches from the latest commit on the default branch.
+Mutually exclusive with `commit_id` and `main_repo_git_ref`.
 * _hardware_tier_name (string):_ (Optional) The hardware tier NAME to launch job in.
 If not provided, the project's default tier is used.
 * _environment_id (string):_ (Optional) The environment ID with which to launch the job.
@@ -608,6 +664,9 @@ If `on_demand_spark_cluster_properties` and `compute_cluster_properties` are bot
 * _external_volume_mounts (List[string]):_ (Optional) External volume mount IDs to mount to execution.
 If not provided, the job launches with no external volumes mounted.
 * _title (string):_ (Optional) Title for Job.
+* _main_repo_git_ref (dict):_ (Optional) Raw git ref dict for advanced use cases.
+For example: `{"type": "branches", "value": "my-feature-branch"}` or `{"type": "tags", "value": "v1.2.3"}`.
+Mutually exclusive with `branch`.
 
 ==== job_stop(job_id, commit_results=True):
 
diff --git a/README.md b/README.md
index 95c4d8e9..42051fe2 100644
--- a/README.md
+++ b/README.md
@@ -548,36 +548,111 @@ Retrieve a file from the Domino server in a project from its path and commit id.
 -   *commit_id:* ID of the commit to retrieve the file from.
 -   *project_id:* ID of the project to retrieve the file from.
 
+### files_download(path, commit_id=None)
+
+Convenience wrapper around `blobs_get_v2` that downloads a file by path without
+needing to look up a commit ID first.
+
+-   *path (string):* Path to the file within the project, e.g. `"/README.md"`.
+
+-   *commit_id (string):* (Optional) The commit to download from. Defaults to
+    the latest commit in the project.
+
+Returns a raw file content stream (urllib3 response). Example:
+
+```python
+content = d.files_download("/results/output.csv").read()
+```
+
 ## Apps
 
-### app_publish(unpublishRunningApps=True, hardwareTierId=None)
+### app_publish(unpublish_running_apps=True, hardware_tier_id=None, environment_id=None, external_volume_mount_ids=None, commit_id=None, branch=None, app_id=None)
 
 Publish an app within a project, or republish an existing app.
 
--   *unpublishRunningApps:* (Defaults to True) Check for an active app
-    instance in the current project and unpublish it before
+-   *unpublish_running_apps:* (Defaults to `True`) Check for an active
+    app instance in the current project and stop it before
     re/publishing.
 
--   *hardwareTierId:* (Optional) Launch the app on the specified
-    hardware tier.
+-   *hardware_tier_id:* (Optional) Launch the app on the specified
+    hardware tier ID.
+
+-   *environment_id:* (Optional) Launch the app with the specified
+    environment ID.
+
+-   *external_volume_mount_ids:* (Optional) List of external volume
+    mount IDs to attach to the app.
+
+-   *commit_id:* (Optional) Launch the app from a specific commit.
+    Cannot be combined with `branch`.
+
+-   *branch:* (Optional) Launch the app from the tip of a specific
+    branch. Cannot be combined with `commit_id`.
+
+-   *app_id:* (Optional) The ID of the app to publish. If omitted, the
+    project's default app is used (or a new one is created if none
+    exists).
+
+```python
+# Publish from a specific branch
+d.app_publish(branch="my-feature-branch")
 
-### app_unpublish()
+# Publish from a specific commit
+d.app_publish(commit_id="abc123def456")
+
+# Publish a specific app by ID
+d.app_publish(app_id="aabbccddeeff001122334457")
+```
+
+> **Note:** The parameters `unpublishRunningApps`, `hardwareTierId`,
+> `environmentId`, `externalVolumeMountIds`, `commitId`, and `appId`
+> are deprecated and will be removed in the next major version.
+> Use the `snake_case` equivalents listed above.
+
+### app_unpublish(app_id=None)
 
 Stop the running app in the project.
 
+-   *app_id:* (Optional) The ID of the app to stop. If omitted, the
+    project's default app is used.
+
+### app_get_status(app_id)
+
+Return the current status of an app.
+
+-   *app_id (string):* The ID of the app to query.
+
+Returns the status string (e.g. `"Running"`, `"Stopped"`, `"Failed"`), or
+`None` if the app does not exist.
+
+### app_id
+
+Read-only property. Returns the ID of the first app in the current project,
+or `None` if no app exists. Useful when you need the app ID to pass to
+`app_get_status()` or `app_publish()`.
+
+```python
+print(d.app_id)  # e.g. "aabbccddeeff001122334457"
+```
+
 ## Jobs
 
 > **Prefer `job_start` over `runs_start` for all new work.** See the [Executions](#executions) section for a full comparison.
 
-### job_start(command, commit_id=None, hardware_tier_name=None, environment_id=None, on_demand_spark_cluster_properties=None, compute_cluster_properties=None, external_volume_mounts=None, title=None, main_repo_git_ref=None):
+### job_start(command, commit_id=None, branch=None, hardware_tier_name=None, environment_id=None, on_demand_spark_cluster_properties=None, compute_cluster_properties=None, external_volume_mounts=None, title=None, main_repo_git_ref=None):
 
 Start a new job (execution) in the project using the v4 Jobs API.
 
 -   *command (string):* Command to execute in Job. For example:
     `domino.job_start(command="main.py arg1 arg2")`
 
--   *commit_id (string):* (Optional) The `commitId` to launch from. If
-    not provided, the job launches from the latest commit.
+-   *commit_id (string):* (Optional) The commit ID to launch from. If
+    not provided, the job launches from the latest commit. Mutually
+    exclusive with `branch`.
+
+-   *branch (string):* (Optional) The branch name to launch from. If
+    not provided, the job launches from the latest commit on the default
+    branch. Mutually exclusive with `commit_id` and `main_repo_git_ref`.
 
 -   *hardware_tier_name (string):* (Optional) The hardware tier NAME
     to launch job in. If not provided, the project’s default tier is

From 14c6ab8228f06f52ef2308e456e4b77437b58fc2 Mon Sep 17 00:00:00 2001
From: Blake Moore 
Date: Wed, 22 Apr 2026 23:08:45 +0100
Subject: [PATCH 07/12] fix: update stale error message match in
 test_app_publish_raises_if_both_branch_and_commit_id_provided

---
 tests/test_app.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/tests/test_app.py b/tests/test_app.py
index 5dad603a..76c97886 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -100,7 +100,7 @@ def test_app_publish_raises_if_both_branch_and_commit_id_provided(dummy_hostname
     Confirm that providing both branch and commitId raises a ValueError.
     """
     d = Domino(host=dummy_hostname, project="anyuser/anyproject", api_key="whatever")
-    with pytest.raises(ValueError, match="Only one of commitId or branch"):
+    with pytest.raises(ValueError, match="Only one of commit_id or branch"):
         d.app_publish(appId=MOCK_APP_ID, branch="my-branch", commitId="abc123")
 
 

From eadc0a6e00f285c5ea11634d3925bda018fd22ec Mon Sep 17 00:00:00 2001
From: Blake Moore 
Date: Tue, 12 May 2026 14:31:02 +0100
Subject: [PATCH 08/12] fix: only resolve hardware tier name when one was
 actually passed

---
 domino/domino.py | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)

diff --git a/domino/domino.py b/domino/domino.py
index 9e763099..d322d5a4 100644
--- a/domino/domino.py
+++ b/domino/domino.py
@@ -711,8 +711,10 @@ def validate_is_external_volume_mounts_supported():
                 "masterHardwareTierId": master_hardware_tier_id,
             }
 
-        resolved_hardware_tier_id = (
-            hardware_tier_id or self.get_hardware_tier_id_from_name(hardware_tier_name)
+        resolved_hardware_tier_id = hardware_tier_id or (
+            self.get_hardware_tier_id_from_name(hardware_tier_name)
+            if hardware_tier_name
+            else None
         )
         url = self._routes.job_start()
         payload = {

From 4140ce36cca7d6426c604fa7e7cc771d6bb46da7 Mon Sep 17 00:00:00 2001
From: Blake Moore 
Date: Tue, 12 May 2026 14:33:37 +0100
Subject: [PATCH 09/12] drop Optional from get_hardware_tier_id_from_name
 signature

---
 domino/domino.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/domino/domino.py b/domino/domino.py
index d322d5a4..a2c6df5e 100644
--- a/domino/domino.py
+++ b/domino/domino.py
@@ -1713,7 +1713,7 @@ def hardware_tiers_list(self):
         url = self._routes.hardware_tiers_list(self.project_id)
         return self._get(url)
 
-    def get_hardware_tier_id_from_name(self, hardware_tier_name: Optional[str]):
+    def get_hardware_tier_id_from_name(self, hardware_tier_name: str):
         for hardware_tier in self.hardware_tiers_list():
             if hardware_tier_name == hardware_tier["hardwareTier"]["name"]:
                 return hardware_tier["hardwareTier"]["id"]

From 016ab9636dd32bbe417c39bbed4d3de34667a314 Mon Sep 17 00:00:00 2001
From: Blake Moore 
Date: Tue, 12 May 2026 14:50:59 +0100
Subject: [PATCH 10/12] add deprecated _app_id alias for backwards
 compatibility

---
 domino/domino.py | 9 +++++++++
 1 file changed, 9 insertions(+)

diff --git a/domino/domino.py b/domino/domino.py
index a2c6df5e..6df8b3c8 100644
--- a/domino/domino.py
+++ b/domino/domino.py
@@ -2262,4 +2262,13 @@ def app_id(self) -> Optional[str]:
             return None
         return app.get("id", None)
 
+    @property
+    def _app_id(self) -> Optional[str]:
+        warnings.warn(
+            "_app_id is deprecated, use app_id",
+            DeprecationWarning,
+            stacklevel=2,
+        )
+        return self.app_id
+
     _csrf_no_check_header = {"Csrf-Token": "nocheck"}

From dff187285bb816ceb5ecf3f818dce21ab6a62bb8 Mon Sep 17 00:00:00 2001
From: Blake Moore 
Date: Tue, 12 May 2026 14:52:33 +0100
Subject: [PATCH 11/12] raise clear error when hardware_tier_id dict is missing
 'value' key

---
 domino/domino.py   |  6 +++++-
 tests/test_jobs.py | 20 ++++++++++++++++++++
 2 files changed, 25 insertions(+), 1 deletion(-)

diff --git a/domino/domino.py b/domino/domino.py
index 6df8b3c8..1183176c 100644
--- a/domino/domino.py
+++ b/domino/domino.py
@@ -2126,7 +2126,11 @@ def _validate_environment_id(self, environment_id) -> bool:
 
     def _validate_hardware_tier_id(self, hardware_tier_id: Union[str, Dict]) -> bool:
         if isinstance(hardware_tier_id, dict):
-            hardware_tier_id = hardware_tier_id.get("value", hardware_tier_id)
+            if "value" not in hardware_tier_id:
+                raise ValueError(
+                    f"hardware_tier_id dict missing 'value' key: {hardware_tier_id}"
+                )
+            hardware_tier_id = hardware_tier_id["value"]
         self.log.debug(f"Validating hardware tier id: {hardware_tier_id}")
         for hardware_tier in self.hardware_tiers_list():
             if hardware_tier_id == hardware_tier["hardwareTier"]["id"]:
diff --git a/tests/test_jobs.py b/tests/test_jobs.py
index 24985c05..7816f4b2 100644
--- a/tests/test_jobs.py
+++ b/tests/test_jobs.py
@@ -469,6 +469,26 @@ def test_validate_hardware_tier_id_accepts_string(requests_mock, dummy_hostname)
     assert d._validate_hardware_tier_id("small-k8s") is True
 
 
+@pytest.mark.usefixtures("clear_token_file_from_env")
+def test_validate_hardware_tier_id_rejects_dict_without_value_key(
+    requests_mock, dummy_hostname
+):
+    """
+    Confirm that a dict missing the 'value' key raises a clear ValueError
+    instead of falling through to the misleading "tier not found" error.
+    """
+    requests_mock.get(f"{dummy_hostname}/version", json={"version": "9.9.9"})
+    requests_mock.get(
+        f"{dummy_hostname}/v4/gateway/projects/findProjectByOwnerAndName"
+        "?ownerName=anyuser&projectName=anyproject",
+        json={"id": MOCK_PROJECT_ID},
+    )
+
+    d = Domino(host=dummy_hostname, project="anyuser/anyproject", api_key="whatever")
+    with pytest.raises(ValueError, match="missing 'value' key"):
+        d._validate_hardware_tier_id({"name": "small-k8s"})
+
+
 @pytest.mark.usefixtures("clear_token_file_from_env", "mock_job_start_blocking_setup")
 def test_job_status_ignores_RequestException_and_times_out(
     requests_mock, dummy_hostname

From b4b90d9556eef2f7c08a88ffa68192562acca06d Mon Sep 17 00:00:00 2001
From: Blake Moore 
Date: Tue, 12 May 2026 14:58:59 +0100
Subject: [PATCH 12/12] guard files_download against empty commits_list

---
 domino/domino.py    |  8 +++++++-
 tests/test_files.py | 18 ++++++++++++++++++
 2 files changed, 25 insertions(+), 1 deletion(-)

diff --git a/domino/domino.py b/domino/domino.py
index 1183176c..ff918be6 100644
--- a/domino/domino.py
+++ b/domino/domino.py
@@ -908,7 +908,13 @@ def files_download(self, path: str, commit_id: Optional[str] = None):
         :return: Raw file content (urllib3 response stream).
         """
         if commit_id is None:
-            commit_id = self.commits_list()[0]["id"]
+            commits = self.commits_list()
+            if not commits:
+                raise ValueError(
+                    "Project has no commits; cannot resolve latest commit. "
+                    "Pass commit_id explicitly."
+                )
+            commit_id = commits[0]["id"]
         return self.blobs_get_v2(path, commit_id, self.project_id)
 
     def fork_project(self, target_name):
diff --git a/tests/test_files.py b/tests/test_files.py
index 8c2c20f6..5ac4c536 100644
--- a/tests/test_files.py
+++ b/tests/test_files.py
@@ -59,3 +59,21 @@ def test_files_download_defaults_to_latest_commit(requests_mock, dummy_hostname)
     d = Domino(host=dummy_hostname, project="anyuser/anyproject", api_key="whatever")
     result = d.files_download("/README.md")
     assert result.read() == FILE_CONTENT
+
+
+@pytest.mark.usefixtures("clear_token_file_from_env", "base_mocks")
+def test_files_download_raises_when_project_has_no_commits(
+    requests_mock, dummy_hostname
+):
+    """
+    Confirm that files_download() raises a clear ValueError instead of
+    IndexError when commits_list() returns empty and commit_id is omitted.
+    """
+    requests_mock.get(
+        f"{dummy_hostname}/v1/projects/anyuser/anyproject/commits",
+        json=[],
+    )
+
+    d = Domino(host=dummy_hostname, project="anyuser/anyproject", api_key="whatever")
+    with pytest.raises(ValueError, match="Project has no commits"):
+        d.files_download("/README.md")