diff --git a/README.adoc b/README.adoc index 3a8d6e0b..b8f06ce1 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`^] @@ -523,29 +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") -==== app_unpublish() +# 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. + +[source,python] +---- +print(d.app_id) # e.g. "aabbccddeeff001122334457" +---- + === Jobs -==== 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): +NOTE: Prefer `job_start` over `runs_start` for all new work. See the <> section for a full comparison. + +==== 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. +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. @@ -593,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 3afd4f3e..42051fe2 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) @@ -532,34 +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 -### 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): +> **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, 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. +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 diff --git a/domino/domino.py b/domino/domino.py index 119c4b5a..ff918be6 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 @@ -129,22 +129,48 @@ 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, ): + """ + 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", + 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 +184,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 +206,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 +221,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 +239,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 +305,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 +317,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 +382,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 = ( "
 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")
         :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
@@ -415,9 +501,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(
@@ -757,12 +858,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):
@@ -791,6 +899,24 @@ 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:
+            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):
         url = self._routes.fork_project(self.project_id)
         request = {"name": target_name}
@@ -806,11 +932,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},
         }
 
@@ -984,56 +1117,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)
 
@@ -1944,7 +2130,13 @@ 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):
+            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"]:
@@ -2069,20 +2261,24 @@ 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)
+
+    @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"}
diff --git a/tests/test_app.py b/tests/test_app.py
index 0c94dc84..76c97886 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"},
@@ -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")
 
 
@@ -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
diff --git a/tests/test_files.py b/tests/test_files.py
new file mode 100644
index 00000000..5ac4c536
--- /dev/null
+++ b/tests/test_files.py
@@ -0,0 +1,79 @@
+"""
+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
+
+
+@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")
diff --git a/tests/test_jobs.py b/tests/test_jobs.py
index bbdd4d3c..7816f4b2 100644
--- a/tests/test_jobs.py
+++ b/tests/test_jobs.py
@@ -376,6 +376,119 @@ 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")
+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")
+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