Repository: AgnostiqHQ/covalent
Pull request #1854 (2023-11-20 20:18:11)

Expose programmatic equivalent of CLI commands
Implements feature requested in https://github.com/AgnostiqHQ/covalent/issues/1853

edit: UX updated to
```python
import covalent as ct

# start covalent
ct.covalent_start()

# stop covalent
ct.covalent_stop()
```


- [x] I have added the tests to cover my changes.
- [x] I have updated the documentation and CHANGELOG accordingly.
- [x] I have read the CONTRIBUTING document.


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/AgnostiqHQ/covalent/pull/1854?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=AgnostiqHQ) Report
> Merging [#1854](https://app.codecov.io/gh/AgnostiqHQ/covalent/pull/1854?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=AgnostiqHQ) (fab67cd) into [develop](https://app.codecov.io/gh/AgnostiqHQ/covalent/commit/f2ad0f680e8c0ffc59ccd1e3a0c776e087192b33?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=AgnostiqHQ) (f2ad0f6) will **increase** coverage by `0.33%`.
> Report is 1 commits behind head on develop.
> The diff coverage is `43.33%`.

<details><summary>Additional details and impacted files</summary>


[![Impacted file tree graph](https://app.codecov.io/gh/AgnostiqHQ/covalent/pull/1854/graphs/tree.svg?width=650&height=150&src=pr&token=YGHCB3DE4P&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=AgnostiqHQ)](https://app.codecov.io/gh/AgnostiqHQ/covalent/pull/1854?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=AgnostiqHQ)

```diff
@@             Coverage Diff             @@
##           develop    #1854      +/-   ##
===========================================
+ Coverage    84.01%   84.34%   +0.33%     
===========================================
  Files          290      177     -113     
  Lines        14125    10931    -3194     
  Branches       193        0     -193     
===========================================
- Hits         11867     9220    -2647     
+ Misses        2125     1711     -414     
+ Partials       133        0     -133     
```

| [Flag](https://app.codecov.io/gh/AgnostiqHQ/covalent/pull/1854/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=AgnostiqHQ) | Coverage Δ | | *Carryforward flag |
|---|---|---|---|
| [Dispatcher](https://app.codecov.io/gh/AgnostiqHQ/covalent/pull/1854/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=AgnostiqHQ) | `92.09% <43.33%> (-0.14%)` | :arrow_down: | |
| [Functional_Tests](https://app.codecov.io/gh/AgnostiqHQ/covalent/pull/1854/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=AgnostiqHQ) | `?` | | |
| [SDK](https://app.codecov.io/gh/AgnostiqHQ/covalent/pull/1854/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=AgnostiqHQ) | `78.38% <ø> (ø)` | | Carriedforward from [f2ad0f6](https://app.codecov.io/gh/AgnostiqHQ/covalent/commit/f2ad0f680e8c0ffc59ccd1e3a0c776e087192b33?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=AgnostiqHQ) |
| [UI_Backend](https://app.codecov.io/gh/AgnostiqHQ/covalent/pull/1854/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=AgnostiqHQ) | `?` | | |
| [UI_Frontend](https://app.codecov.io/gh/AgnostiqHQ/covalent/pull/1854/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=AgnostiqHQ) | `?` | | |

*This pull request uses carry forward flags. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=AgnostiqHQ) to find out more.

</details>
santoshkumarradha: @sriranjanivenkatesan can we pick this up and add it in the docs website please in API section? 
cjao: Quick question @araghukas  -- with this approach, does  `import covalent`  pull in `covalent_dispatcher`? 
cjao: Looks good @araghukas @kessler-frost 
araghukas: @santoshkumarradha @kessler-frost After lots of frustration on my end trying to get the API docs for `covalent_start()` etc. to renger, I noticed that we're getting some dependency issues when building the docs.

Surely this must be the reason why nothing is rendering for the new functions in this PR  (among other things).

See [build logs here](https://readthedocs.org/projects/covalent/builds/22624001/), this happens many times... examples below.

**e.g.**
```
WARNING: autodoc: failed to import function 'covalent_start' from module 'covalent'; the following exception was raised:
Traceback (most recent call last):
  File "/home/docs/checkouts/readthedocs.org/user_builds/covalent/envs/1854/lib/python3.8/site-packages/sphinx/ext/autodoc/importer.py", line 70, in import_module
    return importlib.import_module(modname)
  File "/home/docs/.asdf/installs/python/3.8.18/lib/python3.8/importlib/__init__.py", line 127, in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
  File "<frozen importlib._bootstrap>", line 1014, in _gcd_import
  File "<frozen importlib._bootstrap>", line 991, in _find_and_load
  File "<frozen importlib._bootstrap>", line 975, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 671, in _load_unlocked
  File "<frozen importlib._bootstrap_external>", line 843, in exec_module
  File "<frozen importlib._bootstrap>", line 219, in _call_with_frames_removed
  File "/home/docs/checkouts/readthedocs.org/user_builds/covalent/envs/1854/lib/python3.8/site-packages/covalent/__init__.py", line 22, in <module>
    from . import executor, leptons  # nopycln: import
  File "/home/docs/checkouts/readthedocs.org/user_builds/covalent/envs/1854/lib/python3.8/site-packages/covalent/executor/__init__.py", line 372, in <module>
    _executor_manager = _ExecutorManager()
  File "/home/docs/checkouts/readthedocs.org/user_builds/covalent/envs/1854/lib/python3.8/site-packages/covalent/executor/__init__.py", line 58, in __init__
    self.generate_plugins_list()
  File "/home/docs/checkouts/readthedocs.org/user_builds/covalent/envs/1854/lib/python3.8/site-packages/covalent/executor/__init__.py", line 104, in generate_plugins_list
    self._load_installed_plugins()
  File "/home/docs/checkouts/readthedocs.org/user_builds/covalent/envs/1854/lib/python3.8/site-packages/covalent/executor/__init__.py", line 220, in _load_installed_plugins
    the_module = entry.load()
  File "/home/docs/checkouts/readthedocs.org/user_builds/covalent/envs/1854/lib/python3.8/site-packages/pkg_resources/__init__.py", line 2515, in load
    self.require(*args, **kwargs)
  File "/home/docs/checkouts/readthedocs.org/user_builds/covalent/envs/1854/lib/python3.8/site-packages/pkg_resources/__init__.py", line 2538, in require
    items = working_set.resolve(reqs, env, installer, extras=self.extras)
  File "/home/docs/checkouts/readthedocs.org/user_builds/covalent/envs/1854/lib/python3.8/site-packages/pkg_resources/__init__.py", line 829, in resolve
    dist = self._resolve_dist(
  File "/home/docs/checkouts/readthedocs.org/user_builds/covalent/envs/1854/lib/python3.8/site-packages/pkg_resources/__init__.py", line 875, in _resolve_dist
    raise VersionConflict(dist, req).with_context(dependent_req)
pkg_resources.ContextualVersionConflict: (pydantic 2.5.1 (/home/docs/checkouts/readthedocs.org/user_builds/covalent/envs/1854/lib/python3.8/site-packages), Requirement.parse('pydantic<2.0,>=1.9'), {'amazon-braket-default-simulator'})
```

**e.g.**
```
WARNING: autodoc: failed to import decorator 'electron' from module 'covalent'; the following exception was raised:
Traceback (most recent call last):
  File "/home/docs/checkouts/readthedocs.org/user_builds/covalent/envs/1854/lib/python3.8/site-packages/sphinx/ext/autodoc/importer.py", line 70, in import_module
    return importlib.import_module(modname)
  File "/home/docs/.asdf/installs/python/3.8.18/lib/python3.8/importlib/__init__.py", line 127, in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
  File "<frozen importlib._bootstrap>", line 1014, in _gcd_import
  File "<frozen importlib._bootstrap>", line 991, in _find_and_load
  File "<frozen importlib._bootstrap>", line 975, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 671, in _load_unlocked
  File "<frozen importlib._bootstrap_external>", line 843, in exec_module
  File "<frozen importlib._bootstrap>", line 219, in _call_with_frames_removed
  File "/home/docs/checkouts/readthedocs.org/user_builds/covalent/envs/1854/lib/python3.8/site-packages/covalent/__init__.py", line 22, in <module>
    from . import executor, leptons  # nopycln: import
  File "/home/docs/checkouts/readthedocs.org/user_builds/covalent/envs/1854/lib/python3.8/site-packages/covalent/executor/__init__.py", line 372, in <module>
    _executor_manager = _ExecutorManager()
  File "/home/docs/checkouts/readthedocs.org/user_builds/covalent/envs/1854/lib/python3.8/site-packages/covalent/executor/__init__.py", line 58, in __init__
    self.generate_plugins_list()
  File "/home/docs/checkouts/readthedocs.org/user_builds/covalent/envs/1854/lib/python3.8/site-packages/covalent/executor/__init__.py", line 104, in generate_plugins_list
    self._load_installed_plugins()
  File "/home/docs/checkouts/readthedocs.org/user_builds/covalent/envs/1854/lib/python3.8/site-packages/covalent/executor/__init__.py", line 220, in _load_installed_plugins
    the_module = entry.load()
  File "/home/docs/checkouts/readthedocs.org/user_builds/covalent/envs/1854/lib/python3.8/site-packages/pkg_resources/__init__.py", line 2515, in load
    self.require(*args, **kwargs)
  File "/home/docs/checkouts/readthedocs.org/user_builds/covalent/envs/1854/lib/python3.8/site-packages/pkg_resources/__init__.py", line 2538, in require
    items = working_set.resolve(reqs, env, installer, extras=self.extras)
  File "/home/docs/checkouts/readthedocs.org/user_builds/covalent/envs/1854/lib/python3.8/site-packages/pkg_resources/__init__.py", line 829, in resolve
    dist = self._resolve_dist(
  File "/home/docs/checkouts/readthedocs.org/user_builds/covalent/envs/1854/lib/python3.8/site-packages/pkg_resources/__init__.py", line 875, in _resolve_dist
    raise VersionConflict(dist, req).with_context(dependent_req)
pkg_resources.ContextualVersionConflict: (pydantic 2.5.1 (/home/docs/checkouts/readthedocs.org/user_builds/covalent/envs/1854/lib/python3.8/site-packages), Requirement.parse('pydantic<2.0,>=1.9'), {'amazon-braket-default-simulator'})
```
santoshkumarradha: @araghukas we can follow up the doc with next PR. But could you let @sriranjanivenkatesan know where to add these function in main docs ? 

Meanwhile did directly referencing it from main covalent namespace work? Like covalent.start etc... instead of the actual module path? 

Unblocking 
araghukas: > @araghukas we can follow up the doc with next PR. But could you let @sriranjanivenkatesan know where to add these function in main docs ?
> 
> Meanwhile did directly referencing it from main covalent namespace work? Like covalent.start etc... instead of the actual module path?
> 
> Unblocking

@santoshkumarradha No, it does not seem to matter how we reference the function. I believe it *finds* the code just fine in either case, but fails to import and therefore analyze it because of the dependency conflict.

@sriranjanivenkatesan The goal is to have the `covalent_start()`, `covalent_is_running()`, and `covalent_stop()` functions exposed in the [API docs](https://docs.covalent.xyz/docs/user-documentation/api-reference/cov-api). Like how we have them [here in this file](https://github.com/AgnostiqHQ/covalent/pull/1854/files#diff-03900a834459be4e5fd742b7204ab4778acbc8fa672b631cf66abb4dbc3c4606R28).
- Add the "Covalent Server" section at the top of the API docs (above "Workflow Components")

@sriranjanivenkatesan btw, on the same [API docs](https://docs.covalent.xyz/docs/user-documentation/api-reference/cov-api) page, we have "Workflow *Contents*" in the index versus "Workflow *Components*" in the actual API below. Can we please fix it to have "Components" for both. 🙏 

P.S. @santoshkumarradha We might want to add a couple sentences about the Python functions for start/stop on [this how-to page](https://docs.covalent.xyz/docs/user-documentation/how-to/execution/covalent-cli/) as well.
araghukas: Marking ready for review since the added tests are indeed passing (`tests/covalent_tests/programmatic/commands_test.py`)

Test failures now are due to UI backend tests...

Is this expected @kessler-frost?
kessler-frost: Yep, I have fixed them in this PR: https://github.com/AgnostiqHQ/covalent/pull/1856, @AlejandroEsquivel will merge it soon, then we can merge this as well.

Patch:
diff --git a/.github/workflows/requirements.yml b/.github/workflows/requirements.yml
index 8d692b0f..30198b6b 100644
--- a/.github/workflows/requirements.yml
+++ b/.github/workflows/requirements.yml
@@ -54,6 +54,7 @@ jobs:
           --ignore-file=covalent/triggers/**
           --ignore-file=covalent/cloud_resource_manager/**
           --ignore-file=covalent/quantum/qserver/**
+          --ignore-file=covalent/_programmatic/**
           covalent
 
       - name: Check missing dispatcher requirements
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f3710d7e..7b80bd16 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [UNRELEASED]
 
+
+### Added
+
+- Programmatic equivalents of CLI commands `covalent start` and `covalent stop`
+
 ### Changed
 
 - Changed the azurebatch.rst banner from default covalent jpg to azure batch's svg file
diff --git a/covalent/__init__.py b/covalent/__init__.py
index aed63a72..52495277 100644
--- a/covalent/__init__.py
+++ b/covalent/__init__.py
@@ -25,6 +25,11 @@ from ._dispatcher_plugins import local_dispatch_sync as dispatch_sync  # nopycln
 from ._dispatcher_plugins import local_redispatch as redispatch  # nopycln: import
 from ._dispatcher_plugins import stop_triggers  # nopycln: import
 from ._file_transfer import strategies as fs_strategies  # nopycln: import
+from ._programmatic.commands import (  # nopycln: import
+    covalent_start,
+    covalent_stop,
+    is_covalent_running,
+)
 from ._results_manager.results_manager import (  # nopycln: import
     cancel,
     get_result,
diff --git a/covalent/_programmatic/__init__.py b/covalent/_programmatic/__init__.py
new file mode 100644
index 00000000..fa08678a
--- /dev/null
+++ b/covalent/_programmatic/__init__.py
@@ -0,0 +1,20 @@
+# Copyright 2021 Agnostiq Inc.
+#
+# This file is part of Covalent.
+#
+# Licensed under the Apache License 2.0 (the "License"). A copy of the
+# License may be obtained with this software package or at
+#
+#     https://www.apache.org/licenses/LICENSE-2.0
+#
+# Use of this file is prohibited except in compliance with the License.
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+NOTE: This package exists to avoid circular imports that would be encountered if
+`covalent` imports from `covalent_dispatcher._cli`.
+"""
diff --git a/covalent/_programmatic/commands.py b/covalent/_programmatic/commands.py
new file mode 100644
index 00000000..2ea5f68d
--- /dev/null
+++ b/covalent/_programmatic/commands.py
@@ -0,0 +1,161 @@
+# Copyright 2021 Agnostiq Inc.
+#
+# This file is part of Covalent.
+#
+# Licensed under the Apache License 2.0 (the "License"). A copy of the
+# License may be obtained with this software package or at
+#
+#     https://www.apache.org/licenses/LICENSE-2.0
+#
+# Use of this file is prohibited except in compliance with the License.
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Functions providing programmatic access to Covalent CLI commands."""
+import subprocess
+from typing import List, Optional
+
+import psutil
+
+from .._shared_files import logger
+from .._shared_files.config import get_config
+
+__all__ = ["is_covalent_running", "covalent_start", "covalent_stop"]
+
+
+app_log = logger.app_log
+
+_MISSING_SERVER_WARNING = "Covalent has not been installed with the server component."
+
+
+def _call_cli_command(cmd: List[str], *, quiet: bool = False) -> subprocess.CompletedProcess:
+    """
+    Call a CLI command with the specified kwargs.
+
+    Args:
+        func: The CLI command to call.
+        quiet: Suppress stdout. Defaults to :code:`False`.
+    """
+
+    if quiet:
+        return subprocess.run(
+            cmd,
+            stdout=subprocess.DEVNULL,
+            stderr=subprocess.DEVNULL,
+            check=True,
+        )
+
+    return subprocess.run(cmd, check=True)
+
+
+def is_covalent_running() -> bool:
+    """
+    Check if the Covalent server is running.
+
+    Returns:
+        :code:`True` if the Covalent server is in a ready state, :code:`False` otherwise.
+    """
+    try:
+        from covalent_dispatcher._cli.service import _read_pid
+
+        pid = _read_pid(get_config("dispatcher.cache_dir") + "/ui.pid")
+        return (
+            pid != -1
+            and psutil.pid_exists(pid)
+            and get_config("dispatcher.address") != ""
+            and get_config("dispatcher.port") != ""
+        )
+
+    except ModuleNotFoundError:
+        # If the covalent_dispatcher is not installed, assume Covalent is not running.
+        app_log.warning(_MISSING_SERVER_WARNING)
+        return False
+
+
+def covalent_start(
+    develop: bool = False,
+    port: Optional[str] = None,
+    mem_per_worker: Optional[str] = None,
+    workers: Optional[int] = None,
+    threads_per_worker: Optional[int] = None,
+    ignore_migrations: bool = False,
+    no_cluster: bool = False,
+    no_triggers: bool = False,
+    triggers_only: bool = False,
+    *,
+    quiet: bool = False,
+) -> None:
+    """
+    Start the Covalent server. Wrapper for the :code:`covalent start` CLI command.
+    This function returns immediately if the local Covalent server is already running.
+
+    Args:
+        develop: Start local server in develop mode. Defaults to :code:`False`.
+        port: Local server port number. Defaults to :code:`"48008"`.
+        mem_per_worker: Memory limit per worker in GB. Defaults to auto.
+        workers: Number of Dask workers. Defaults to 8.
+        threads_per_worker: Number of threads per Dask worker. Defaults to 1.
+        ignore_migrations: Start server without database migrations. Defaults to :code:`False`.
+        no_cluster: Start server without Dask cluster. Defaults to :code:`False`.
+        no_triggers: Start server without a triggers server. Defaults to :code:`False`.
+        triggers_only: Start only the triggers server. Defaults to :code:`False`.
+        quiet: Suppress stdout. Defaults to :code:`False`.
+    """
+
+    if is_covalent_running():
+        msg = "Covalent server is already running."
+        if not quiet:
+            print(msg)
+
+        app_log.debug(msg)
+        return
+
+    flags = {
+        "--develop": develop,
+        "--ignore-migrations": ignore_migrations,
+        "--no-cluster": no_cluster,
+        "--no-triggers": no_triggers,
+        "--triggers-only": triggers_only,
+    }
+
+    args = {
+        "--port": port or get_config("dispatcher.port"),
+        "--mem-per-worker": mem_per_worker or get_config("dask.mem_per_worker"),
+        "--workers": workers or get_config("dask.num_workers"),
+        "--threads-per-worker": threads_per_worker or get_config("dask.threads_per_worker"),
+    }
+
+    cmd = ["covalent", "start"]
+    cmd.extend(flag for flag, value in flags.items() if value)
+
+    for arg, value in args.items():
+        cmd.extend((arg, str(value)))
+
+    # Run the `covalent start [OPTIONS]` command.
+    app_log.debug("Starting Covalent server programmatically...")
+    _call_cli_command(cmd, quiet=quiet)
+
+
+def covalent_stop(*, quiet: bool = False) -> None:
+    """
+    Stop the Covalent server. Wrapper for the :code:`covalent stop` CLI command.
+    This function returns immediately if the local Covalent server is not running.
+
+    Args:
+        quiet: Suppress stdout. Defaults to :code:`False`.
+    """
+
+    if not is_covalent_running():
+        msg = "Covalent server is not running."
+        if not quiet:
+            print(msg)
+
+        app_log.debug(msg)
+        return
+
+    # Run the `covalent stop` command.
+    app_log.debug("Stopping Covalent server programmatically...")
+    _call_cli_command(["covalent", "stop"], quiet=quiet)
diff --git a/doc/source/api/api.rst b/doc/source/api/api.rst
index 2a603c72..01b69467 100644
--- a/doc/source/api/api.rst
+++ b/doc/source/api/api.rst
@@ -6,6 +6,7 @@ Covalent API
 
 The following API documentation describes how to use Covalent.
 
+- The :ref:`covalent_server` manages workflow dispatch, orchestration, and metadata
 - :ref:`electrons_api` and :ref:`lattices_api` are used for constructing workflows
 - :ref:`qelectrons_api` are used to customize and track quantum circuit execution
 - :ref:`qclusters_api` are used to distribute Quantum Electrons across multiple quantum backends.
@@ -22,6 +23,29 @@ The following API documentation describes how to use Covalent.
 - :ref:`dispatcher_interface` is used for dispatching workflows and stopping triggered dispatches
 - The :ref:`dispatcher_server_api` is used for interfacing with the Covalent server
 
+.. _covalent_server:
+
+Covalent Server
+"""""""""""""""""""""""""""
+A Covalent server must be running in order to dispatch workflows. The Covalent CLI provides various utilities for starting, stopping, and managing a Covalent server. For more information, see:
+
+.. code-block:: bash
+
+    covalent --help
+
+The Covalent SDK also includes a Python interface for starting and stopping the Covalent server.
+
+.. autofunction:: covalent._programmatic.commands.is_covalent_running
+
+
+.. autofunction:: covalent._programmatic.commands.covalent_start
+
+
+.. autofunction:: covalent._programmatic.commands.covalent_stop
+
+
+----------------------------------------------------------------
+
 .. _electrons_api:
 
 Electron


Test patch:
diff --git a/tests/covalent_tests/programmatic/__init__.py b/tests/covalent_tests/programmatic/__init__.py
new file mode 100644
index 00000000..cfc23bfd
--- /dev/null
+++ b/tests/covalent_tests/programmatic/__init__.py
@@ -0,0 +1,15 @@
+# Copyright 2021 Agnostiq Inc.
+#
+# This file is part of Covalent.
+#
+# Licensed under the Apache License 2.0 (the "License"). A copy of the
+# License may be obtained with this software package or at
+#
+#     https://www.apache.org/licenses/LICENSE-2.0
+#
+# Use of this file is prohibited except in compliance with the License.
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
diff --git a/tests/covalent_tests/programmatic/commands_test.py b/tests/covalent_tests/programmatic/commands_test.py
new file mode 100644
index 00000000..32498411
--- /dev/null
+++ b/tests/covalent_tests/programmatic/commands_test.py
@@ -0,0 +1,139 @@
+# Copyright 2021 Agnostiq Inc.
+#
+# This file is part of Covalent.
+#
+# Licensed under the Apache License 2.0 (the "License"). A copy of the
+# License may be obtained with this software package or at
+#
+#     https://www.apache.org/licenses/LICENSE-2.0
+#
+# Use of this file is prohibited except in compliance with the License.
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import pytest
+
+import covalent as ct
+from covalent._programmatic.commands import _MISSING_SERVER_WARNING
+
+
+def test_is_covalent_running(mocker):
+    """Check that `is_covalent_running` returns True when the server is running."""
+    try:
+        from covalent_dispatcher._cli.service import _read_pid  # nopycln: import
+    except ModuleNotFoundError:
+        pytest.xfail("`covalent_dispatcher` not installed")
+
+    # Simulate server running
+    mocker.patch("covalent_dispatcher._cli.service._read_pid", return_value=1)
+    mocker.patch("psutil.pid_exists", return_value=True)
+    mocker.patch(
+        "covalent._shared_files.config.get_config",
+        return_value={"port": 48008, "host": "localhost"},
+    )
+    assert ct.is_covalent_running()
+
+    # Simulate server stopped
+    mocker.patch("covalent_dispatcher._cli.service._read_pid", return_value=-1)
+    assert not ct.is_covalent_running()
+
+
+def test_is_covalent_running_import_error(mocker):
+    """Check that `is_covalent_running` catches the `ModuleNotFoundError`."""
+    try:
+        from covalent_dispatcher._cli.service import _read_pid  # nopycln: import
+    except ModuleNotFoundError:
+        pytest.xfail("`covalent_dispatcher` not installed")
+
+    mocker.patch(
+        "covalent_dispatcher._cli.service._read_pid",
+        side_effect=ModuleNotFoundError(),
+    )
+
+    mock_app_log = mocker.patch("covalent._programmatic.commands.app_log")
+
+    assert not ct.is_covalent_running()
+    mock_app_log.warning.assert_called_once_with(_MISSING_SERVER_WARNING)
+
+
+def test_covalent_start(mocker):
+    """Test the `covalent_start` function without actually starting server."""
+
+    mocker.patch("subprocess.run")
+
+    # Simulate server running
+    mocker.patch(
+        "covalent._programmatic.commands.is_covalent_running",
+        return_value=True,
+    )
+    ct.covalent_start()
+
+    # Simulate server stopped
+    mocker.patch(
+        "covalent._programmatic.commands.is_covalent_running",
+        return_value=False,
+    )
+    ct.covalent_start()
+
+
+def test_covalent_start_quiet(mocker):
+    """Test the `covalent_start` function without actually starting server."""
+
+    mocker.patch("subprocess.run")
+
+    # Simulate server running
+    mocker.patch(
+        "covalent._programmatic.commands.is_covalent_running",
+        return_value=True,
+    )
+    ct.covalent_start(quiet=True)
+
+    # Simulate server stopped
+    mocker.patch(
+        "covalent._programmatic.commands.is_covalent_running",
+        return_value=False,
+    )
+    ct.covalent_start(quiet=True)
+
+
+def test_covalent_stop(mocker):
+    """Test the `covalent_start` function without actually starting server."""
+
+    mocker.patch("subprocess.run")
+
+    # Simulate server running
+    mocker.patch(
+        "covalent._programmatic.commands.is_covalent_running",
+        return_value=True,
+    )
+    ct.covalent_stop()
+
+    # Simulate server stopped
+    mocker.patch(
+        "covalent._programmatic.commands.is_covalent_running",
+        return_value=False,
+    )
+    ct.covalent_stop()
+
+
+def test_covalent_stop_quiet(mocker):
+    """Test the `covalent_start` function without actually starting server."""
+
+    mocker.patch("subprocess.run")
+
+    # Simulate server running
+    mocker.patch(
+        "covalent._programmatic.commands.is_covalent_running",
+        return_value=True,
+    )
+    ct.covalent_stop(quiet=True)
+
+    # Simulate server stopped
+    mocker.patch(
+        "covalent._programmatic.commands.is_covalent_running",
+        return_value=False,
+    )
+    ct.covalent_stop(quiet=True)