Repository: m3-learning/DataFed_TorchFlow
Pull request #28: Start cleaning things up
Author: login: theodore-s-beers
Created: 2025-05-06T17:41:42Z | Merged: 2025-05-07T20:20:50Z

## Summary by Sourcery

Clean up and improve type hints, error handling, and code quality across the DataFed TorchFlow library

Bug Fixes:
- Fixed potential None-related errors in various methods
- Added missing initializations
- Corrected some type-related edge cases

Enhancements:
- Added type hints to method signatures
- Improved error handling with explicit None checks
- Refactored type checking using isinstance()
- Simplified some conditional logic

Chores:
- Updated dependencies in setup.cfg
- Added development tools like pyright and ruff


Diff:
diff --git a/examples/PyTorchModelLogger.ipynb b/examples/PyTorchModelLogger.ipynb
index 11c3db4..0fb8f34 100644
--- a/examples/PyTorchModelLogger.ipynb
+++ b/examples/PyTorchModelLogger.ipynb
@@ -256,7 +256,7 @@
     "        if batch_idx % 100 == 0:\n",
     "            print(\n",
     "                f\"Train Epoch: {epoch} [{batch_idx * len(data)}/{len(train_loader.dataset)} \"\n",
-    "                f\"({100. * batch_idx / len(train_loader):.0f}%)]\\tLoss: {loss.item():.6f}\"\n",
+    "                f\"({100.0 * batch_idx / len(train_loader):.0f}%)]\\tLoss: {loss.item():.6f}\"\n",
     "            )\n",
     "\n",
     "    file_name = f\"MNSIT_epoch_{epoch}_loss_{loss.item():.4e}\"\n",
diff --git a/examples/PyTorchModelLogger.old.ipynb b/examples/PyTorchModelLogger.old.ipynb
index d6e3297..f70787e 100644
--- a/examples/PyTorchModelLogger.old.ipynb
+++ b/examples/PyTorchModelLogger.old.ipynb
@@ -223,7 +223,7 @@
     "        if batch_idx % 100 == 0:\n",
     "            print(\n",
     "                f\"Train Epoch: {epoch} [{batch_idx * len(data)}/{len(train_loader.dataset)} \"\n",
-    "                f\"({100. * batch_idx / len(train_loader):.0f}%)]\\tLoss: {loss.item():.6f}\"\n",
+    "                f\"({100.0 * batch_idx / len(train_loader):.0f}%)]\\tLoss: {loss.item():.6f}\"\n",
     "            )\n",
     "\n",
     "    file_name = f\"MNSIT_epoch_{epoch}_loss_{loss.item():.4e}\"\n",
diff --git a/setup.cfg b/setup.cfg
index cd8a8f1..3967ff6 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -49,12 +49,15 @@ package_dir =
 # For more information, check out https://semver.org/.
 install_requires =
     importlib-metadata; python_version<"3.8"
+    datafed
     GPutil
+    m3learning-util
     psutil
-    datafed
+    pyright
+    ruff
+    sphinx
     torch
     torchvision
-    m3learning-util
 
 [options.packages.find]
 where = src
diff --git a/src/datafed_torchflow/JSON.py b/src/datafed_torchflow/JSON.py
index 3341c01..ee1f12c 100644
--- a/src/datafed_torchflow/JSON.py
+++ b/src/datafed_torchflow/JSON.py
@@ -8,7 +8,7 @@ class UniversalEncoder(json.JSONEncoder):
     A custom JSON encoder that can handle numpy data types, sets, and objects with __dict__ attributes.
     """
 
-    def default(self, obj):
+    def default(self, o):
         """
         Override the default method to provide custom serialization for unsupported data types.
 
@@ -19,16 +19,16 @@ def default(self, obj):
         any: The serialized form of the object.
         """
         # Convert numpy types to their Python equivalents
-        if isinstance(obj, np.integer):
-            return int(obj)  # Convert numpy integers to Python int
-        elif isinstance(obj, np.floating):
-            return float(obj)  # Convert numpy floats to Python float
-        elif isinstance(obj, np.ndarray):
-            return obj.tolist()  # Convert numpy arrays to lists
-        elif isinstance(obj, set):
-            return list(obj)  # Convert sets to lists
-        elif hasattr(obj, "__dict__"):
-            return obj.__dict__  # Serialize object attributes
+        if isinstance(o, np.integer):
+            return int(o)  # Convert numpy integers to Python int
+        elif isinstance(o, np.floating):
+            return float(o)  # Convert numpy floats to Python float
+        elif isinstance(o, np.ndarray):
+            return o.tolist()  # Convert numpy arrays to lists
+        elif isinstance(o, set):
+            return list(o)  # Convert sets to lists
+        elif hasattr(o, "__dict__"):
+            return o.__dict__  # Serialize object attributes
         else:
             # Call the default method for other cases
-            return super().default(obj)
+            return super().default(o)
diff --git a/src/datafed_torchflow/computer.py b/src/datafed_torchflow/computer.py
index c5816a4..b08d136 100644
--- a/src/datafed_torchflow/computer.py
+++ b/src/datafed_torchflow/computer.py
@@ -52,9 +52,9 @@ def get_memory_info():
     """
     mem = psutil.virtual_memory()
     return {
-        "total": f"{mem.total / (1024 ** 3):.2f} GB",
-        "available": f"{mem.available / (1024 ** 3):.2f} GB",
-        "used": f"{mem.used / (1024 ** 3):.2f} GB",
+        "total": f"{mem.total / (1024**3):.2f} GB",
+        "available": f"{mem.available / (1024**3):.2f} GB",
+        "used": f"{mem.used / (1024**3):.2f} GB",
         "percent": mem.percent,
     }
 
diff --git a/src/datafed_torchflow/datafed.py b/src/datafed_torchflow/datafed.py
index 01a4c9b..7a1fdb0 100644
--- a/src/datafed_torchflow/datafed.py
+++ b/src/datafed_torchflow/datafed.py
@@ -2,6 +2,7 @@
 import os
 import traceback
 from datetime import datetime
+from typing import Optional
 
 import numpy as np
 from datafed.CommandLib import API
@@ -29,10 +30,10 @@ class DataFed(API):
 
     def __init__(
         self,
-        datafed_path,
+        datafed_path: str,
         local_model_path="./Trained Models",
         log_file_path="log.txt",
-        dataset_id_or_path=None,
+        dataset_id_or_path: Optional[str] = None,
         download_kwargs={"wait": True, "orig_fname": True},
         upload_kwargs={"wait": True},
         logging=False,
@@ -91,11 +92,12 @@ def upload_dataset_to_DataFed(self):
             The DataFed record ID for the dataset files, as a string for a single dataset file and a list of strings for multiple dataset files.
         """
         if self.dataset_id_or_path is not None:
+            ls_resp = self.collectionItemsList(self.collection_id)
+
             if isinstance(
                 self.dataset_id_or_path, list
             ):  # to specify multiple dataset files
                 dataset_ids = []
-                ls_resp = self.collectionItemsList(self.collection_id)
                 for dataset in self.dataset_id_or_path:
                     if dataset.startswith("d/"):
                         dataset_ids.append(dataset)
@@ -163,6 +165,8 @@ def upload_dataset_to_DataFed(self):
                 if self.dataset_id_or_path.startswith("d/"):
                     dataset_ids = self.dataset_id_or_path
                 else:
+                    dataset = self.dataset_id_or_path  # Is this right?
+
                     try:
                         path_id = (
                             ls_resp[0]
@@ -176,6 +180,7 @@ def upload_dataset_to_DataFed(self):
                             ]
                             .id
                         )
+
                         # update record (dependencies have been added)
                         record_id = self.get_notebook_DataFed_ID_from_path_and_title(
                             dataset, path_id=path_id
@@ -345,14 +350,17 @@ def check_if_endpoint_set(self):
             )
 
     @property
-    def user_id(self):
+    def user_id(self) -> str:
         """
         Gets the user ID from the authenticated user's information.
 
         Returns:
             str: The user ID extracted from the authenticated user information.
         """
-        return self.getAuthUser().split("/")[-1]
+        auth_user = self.getAuthUser()
+        if auth_user is None:
+            raise RuntimeError("Failed to get authenticated user information")
+        return auth_user.split("/")[-1]
 
     @staticmethod
     def check_string_for_dot_or_slash(s):
@@ -524,7 +532,7 @@ def get_notebook_DataFed_ID_from_path_and_title(
     def data_record_create(
         self,
         metadata=None,
-        record_title=None,
+        record_title: Optional[str] = None,
         parent_collection=None,
         deps=None,
         **kwargs,
@@ -545,6 +553,9 @@ def data_record_create(
         # make sure the user is logged into DataFed
         self.check_if_logged_in()
 
+        if record_title is None:
+            raise ValueError("record_title cannot be None")
+
         # If the record title is longer than the maximum allowed by DataFed (80 characters)
         # truncate the record title to 80 characters. If logging is true, print out a statement letting the user
         # know the record_title has been truncated.
@@ -593,7 +604,7 @@ def data_record_create(
     def data_record_update(
         self,
         record_id=None,
-        record_title=None,
+        record_title: Optional[str] = None,
         metadata=None,
         deps=None,
         overwrite_metadata=False,
@@ -617,6 +628,9 @@ def data_record_update(
         # make sure the user is logged into DataFed
         self.check_if_logged_in()
 
+        if record_title is None:
+            raise ValueError("record_title cannot be None")
+
         # If the record title is longer than the maximum allowed by DataFed (80 characters)
         # truncate the record title to 80 characters. If logging is true, print out a statement letting the user
         # know the record_title has been truncated.
@@ -835,7 +849,6 @@ def _get_metadata_list(self, record_ids, exclude=None):
 
         return metadata
 
-    @staticmethod
     def required_keys(self, dict_list, required_keys):
         """
         Filters a list of dictionaries to include only those that contain all specified required keys.
@@ -1155,12 +1168,16 @@ def getFileExtension(self):
         # Split the file name by '.' and return the last part as the extension
         return "." + self.getFileName(self.dataset_id_or_path).split(".")[-1]
 
-    def getData(self, dataset_id=None):
+    def getData(self, dataset_id: Optional[str] = None):
         """
         Downloads the data from the dataset
         """
 
+        self.data_path = None  # Bandaid fix; this needs to be defined for the class!
+
         if dataset_id is None:
+            if self.dataset_id_or_path is None:
+                raise ValueError("dataset_id_or_path is not set")
             dataset_id = self.dataset_id_or_path
 
         # if a data path is not provided, download the data to the current directory
diff --git a/src/datafed_torchflow/pytorch.py b/src/datafed_torchflow/pytorch.py
index 805ad11..a61df2a 100644
--- a/src/datafed_torchflow/pytorch.py
+++ b/src/datafed_torchflow/pytorch.py
@@ -1,6 +1,7 @@
 import os
 import sys
 from datetime import datetime
+from typing import Any, Optional
 
 import torch
 import torch.nn as nn
@@ -60,7 +61,7 @@ def __init__(
         script_path=None,
         local_model_path="/.",
         log_file_path="log.txt",
-        input_data_shape=None,
+        input_data_shape: Optional[tuple[int, ...]] = None,  # Is this right?
         dataset_id_or_path=None,
         logging=False,
         download_kwargs={"wait": True, "orig_fname": True},
@@ -140,7 +141,12 @@ def optimizer(self, optimizer):
         """
         self._optimizer = optimizer
 
-    def getMetadata(self, local_vars=None, model_hyperparameters=None, **kwargs):
+    def getMetadata(
+        self,
+        local_vars: Optional[list[tuple[str, Any]]] = None,
+        model_hyperparameters: Optional[dict[str, Any]] = None,
+        **kwargs,
+    ):
         """
         Gathers metadata including the serialized model, optimizer, system info, and user details.
 
@@ -170,6 +176,12 @@ def getMetadata(self, local_vars=None, model_hyperparameters=None, **kwargs):
             "System Information": {},
         }
 
+        if local_vars is None:
+            raise ValueError("local_vars cannot be None")
+
+        if model_hyperparameters is None:
+            raise ValueError("model_hyperparameters cannot be None")
+
         # loop through the local variables to add to the metadata dictionary
         for key, value in local_vars:
             # exclude modules and other undesired local variables. Use casefold string matching for flexibility
@@ -259,7 +271,10 @@ def getMetadata(self, local_vars=None, model_hyperparameters=None, **kwargs):
                         Warning(warning_message)
                 # put the model hyperparameters in the Model Hyperparameters sub-dictionary (the hyperparameters might be 1-value torch tensors or just floats)
                 elif key in model_hyperparameters.keys():
-                    if type(value) in [np.ndarray, torch.Tensor]:
+                    if (
+                        isinstance(value, (np.ndarray, torch.Tensor))
+                        and self.input_data_shape is not None
+                    ):
                         if value.shape < self.input_data_shape:
                             DataFed_record_metadata["Model Parameters"][
                                 "Model Hyperparameters"
@@ -271,8 +286,11 @@ def getMetadata(self, local_vars=None, model_hyperparameters=None, **kwargs):
 
                 # convert numpy arrays and torch tensors that are small enough (arbitrarily chosen to be smaller than the input data dimensions)
                 # into lists so they can be serialized into JSON
-                elif type(value) in [np.ndarray, torch.Tensor]:
-                    if value.shape < self.input_data_shape:
+                elif isinstance(value, (np.ndarray, torch.Tensor)):
+                    if (
+                        self.input_data_shape is not None
+                        and value.shape < self.input_data_shape
+                    ):
                         # put other lists into the Model Parameters dictionary
                         try:
                             json.dumps(value.tolist())
@@ -423,9 +441,12 @@ def save_notebook(self):
 
             # generate a checksum (and scipt path) for the notebook
             self.notebook_metadata = getNotebookMetadata(self.__file__)
+            if self.notebook_metadata is None:
+                raise ValueError(f"Failed to get metadata for notebook {self.__file__}")
 
             # extract the checksum
             new_checksum = self.notebook_metadata["script"]["checksum"]
+            old_checksum = None
 
             # if the notebook has a DataFed record ID, extract the checksum and compare to the new checksum
             if self.notebook_record_id is not None:
@@ -495,10 +516,13 @@ def save(
         """
 
         # include the model architecture state dictionary and model hyperparameters in the checkpoint
-        if not str(local_file_path).endswith(".zip") and not os.path.exists(
-            str(local_file_path)
+        if (
+            local_file_path is not None
+            and not str(local_file_path).endswith(".zip")
+            and not os.path.exists(str(local_file_path))
         ):
-            checkpoint = self.getModelArchitectureStateDict() | model_hyperparameters
+            checkpoint = self.getModelArchitectureStateDict()
+            checkpoint.update(model_hyperparameters or {})
 
             # Save the model state dict locally
             torch.save(checkpoint, local_file_path)


Reviews:
user: login: sourcery-ai[bot]
body: Hey @theodore-s-beers - I've reviewed your changes - here's some feedback:

- The 'Bandaid fix' comment regarding `self.data_path` in `DataFed.getData` should be addressed with a more permanent solution.
- Consider providing a more descriptive PR title and a brief summary in the description to outline the main changes, as 'Start cleaning things up' is quite general.
- The `required_keys` method in `DataFed` no longer references `self` after the `@staticmethod` decorator was removed; evaluate if it should be a static method or a standalone utility function.

<details>
<summary>Here's what I looked at during the review</summary>

- 🟡 **General issues**: 3 issues found
- 🟢 **Security**: all looks good
- 🟢 **Testing**: all looks good
- 🟢 **Documentation**: all looks good
</details>

***

<details>
<summary>Sourcery is free for open source - if you like our reviews please consider sharing them ✨</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20got%20an%20instant%20code%20review%20from%20%40SourceryAI%2C%20and%20it%20was%20brilliant%21%20It%27s%20free%20for%20open%20source%20and%20has%20a%20free%20trial%20for%20private%20code.%20Check%20it%20out%20https%3A//sourcery.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20got%20an%20instant%20code%20review%20from%20%40SourceryAI%2C%20and%20it%20was%20brilliant%21%20It%27s%20free%20for%20open%20source%20and%20has%20a%20free%20trial%20for%20private%20code.%20Check%20it%20out%20https%3A//sourcery.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https://sourcery.ai)
- [Facebook](https://www.facebook.com/sharer/sharer.php?u=https://sourcery.ai)

</details>

<sub>
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
</sub>
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-06T17:43:30Z

Review comments:
diff_hunk: @@ -170,6 +176,12 @@ def getMetadata(self, local_vars=None, model_hyperparameters=None, **kwargs):
             "System Information": {},
         }
 
+        if local_vars is None:
+            raise ValueError("local_vars cannot be None")
path: src/datafed_torchflow/pytorch.py
user: login: sourcery-ai[bot]
body: **question (bug_risk):** Validate non-null local_vars in getMetadata.

Confirm this change aligns with overall logic and won't block valid empty-list use cases.
created_at: 2025-05-06T17:43:29Z
author_association: NONE
diff_hunk: @@ -1155,12 +1168,16 @@ def getFileExtension(self):
         # Split the file name by '.' and return the last part as the extension
         return "." + self.getFileName(self.dataset_id_or_path).split(".")[-1]
 
-    def getData(self, dataset_id=None):
+    def getData(self, dataset_id: Optional[str] = None):
         """
         Downloads the data from the dataset
         """
 
+        self.data_path = None  # Bandaid fix; this needs to be defined for the class!
+
         if dataset_id is None:
path: src/datafed_torchflow/datafed.py
user: login: sourcery-ai[bot]
body: **suggestion:** Address initialization of data_path in getData.

Instead of the temporary None assignment here, define data_path as an instance attribute in the constructor to ensure it’s always initialized.

Suggested implementation:

```python

```

```python
    def __init__(self, *args, **kwargs):
        self.data_path = None
        # constructor implementation (if any)

```

Make sure that the file’s constructor (__init__) is being called appropriately and that no other part of the code relies on defining or reassigning data_path in getData.
created_at: 2025-05-06T17:43:30Z
author_association: NONE
diff_hunk: @@ -545,6 +553,9 @@ def data_record_create(
         # make sure the user is logged into DataFed
         self.check_if_logged_in()
 
+        if record_title is None:
+            raise ValueError("record_title cannot be None")
path: src/datafed_torchflow/datafed.py
user: login: sourcery-ai[bot]
body: **question:** Enforce non-null record_title in data_record_create and update.

Also consider rejecting empty strings as invalid titles if that matches your requirements.
created_at: 2025-05-06T17:43:30Z
author_association: NONE
diff_hunk: @@ -29,10 +30,10 @@ class DataFed(API):
 
     def __init__(
path: src/datafed_torchflow/datafed.py
user: login: sourcery-ai[bot]
body: **issue (code-quality):** Replace mutable default arguments with None ([`default-mutable-arg`](https://docs.sourcery.ai/Reference/Default-Rules/suggestions/default-mutable-arg/))
created_at: 2025-05-06T17:43:30Z
author_association: NONE
diff_hunk: @@ -140,7 +141,12 @@ def optimizer(self, optimizer):
         """
         self._optimizer = optimizer
 
-    def getMetadata(self, local_vars=None, model_hyperparameters=None, **kwargs):
+    def getMetadata(
path: src/datafed_torchflow/pytorch.py
user: login: sourcery-ai[bot]
body: **issue (code-quality):** We've found these issues:

- Simplify logical expression using De Morgan identities ([`de-morgan`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/de-morgan/))
- Use `except Exception:` rather than bare `except:` ([`do-not-use-bare-except`](https://docs.sourcery.ai/Reference/Default-Rules/suggestions/do-not-use-bare-except/))
- Low code quality found in TorchLogger.getMetadata - 1% ([`low-code-quality`](https://docs.sourcery.ai/Reference/Default-Rules/comments/low-code-quality/))

<br/><details><summary>Explanation</summary>

The quality score for this function is below the quality threshold of 25%.
This score is a combination of the method length, cognitive complexity and working memory.

How can you solve this?

It might be worth refactoring this function to make it shorter and more readable.

- Reduce the function length by extracting pieces of functionality out into
  their own functions. This is the most important thing you can do - ideally a
  function should be less than 10 lines.
- Reduce nesting, perhaps by introducing guard clauses to return early.
- Ensure that variables are tightly scoped, so that code using related concepts
  sits together within the function rather than being scattered.</details>
created_at: 2025-05-06T17:43:30Z
author_association: NONE

PR comments:
user: login: sourcery-ai[bot]
created_at: 2025-05-06T17:41:46Z
body: <!-- Generated by sourcery-ai[bot]: start review_guide -->

## Reviewer's Guide

This pull request implements enhancements by adding type hints across modules, introducing validation checks for crucial parameters, and refining code style. Project dependencies have also been updated.

#### Updated Class Diagram for PyTorchModelLogger

```mermaid
classDiagram
    class PyTorchModelLogger {
        +__init__(self, ..., input_data_shape: Optional_tuple_int_Ellipsis, ...)
        +getMetadata(self, local_vars: Optional_list_tuple_str_Any, model_hyperparameters: Optional_dict_str_Any, **kwargs)
        +save_notebook(self)
    }
    note for PyTorchModelLogger "Type hints updated for parameters in __init__ and getMetadata. Added input validation for some parameters."
    note for PyTorchModelLogger.getMetadata "Raises ValueError if local_vars or model_hyperparameters is None."
    note for PyTorchModelLogger.save_notebook "Raises ValueError if notebook metadata cannot be fetched."
```

#### Updated Class Diagram for DataFed

```mermaid
classDiagram
    class API {
        %% Base class, details not shown as not modified in this PR
    }
    class DataFed {
        +__init__(self, ..., datafed_path: str, ..., dataset_id_or_path: Optional_str, ...)
        +user_id(self) : str
        +check_string_for_dot_or_slash(self, s)
        +data_record_create(self, ..., record_title: Optional_str, ...)
        +data_record_update(self, ..., record_title: Optional_str, ...)
        +required_keys(self, dict_list, required_keys)
        +getData(self, dataset_id: Optional_str)
    }
    DataFed --|> API
    note for DataFed "Type hints added/updated for several methods. Methods 'check_string_for_dot_or_slash' and 'required_keys' changed from static to instance methods. Input validation added."
    note for DataFed.user_id "Property; raises RuntimeError if authenticated user info cannot be fetched."
    note for DataFed.data_record_create "Raises ValueError if record_title is None."
    note for DataFed.data_record_update "Raises ValueError if record_title is None."
    note for DataFed.getData "Raises ValueError if dataset_id_or_path is not set and dataset_id is None."
```

#### Updated Class Diagram for UniversalEncoder

```mermaid
classDiagram
    class JSONEncoder {
        %% Base class from json module, details not shown
    }
    class UniversalEncoder {
        +default(self, o)
    }
    UniversalEncoder --|> JSONEncoder
    note for UniversalEncoder "Parameter 'obj' in 'default' method renamed to 'o'."
```

### File-Level Changes

| Change | Details | Files |
| ------ | ------- | ----- |
| Added type annotations and input validation checks. | <ul><li>Applied type hints to function signatures, return types, and attributes.</li><li>Implemented runtime checks for `None` values in critical parameters, raising `ValueError` or `RuntimeError`.</li></ul> | `src/datafed_torchflow/pytorch.py`<br/>`src/datafed_torchflow/datafed.py` |
| Improved code style, Pythonic idioms, and addressed minor logical issues. | <ul><li>Replaced `type() in []` constructs with `isinstance()` for type checking.</li><li>Modified dictionary update operations for broader Python version compatibility.</li><li>Standardized f-string formatting for floating-point numbers in example notebooks.</li><li>Ensured variables are initialized before use to prevent potential errors.</li><li>Refactored a static method to an instance method where appropriate.</li><li>Minor cosmetic changes such as parameter renaming and spacing adjustments.</li></ul> | `src/datafed_torchflow/pytorch.py`<br/>`src/datafed_torchflow/datafed.py`<br/>`src/datafed_torchflow/JSON.py`<br/>`examples/PyTorchModelLogger.ipynb`<br/>`examples/PyTorchModelLogger.old.ipynb`<br/>`src/datafed_torchflow/computer.py` |
| Updated project dependencies and development tooling. | <ul><li>Added `pyright`, `ruff`, and `sphinx` to the list of dependencies.</li><li>Reordered items within the `install_requires` section.</li></ul> | `setup.cfg` |

---

<details>
<summary>Tips and commands</summary>

#### Interacting with Sourcery

- **Trigger a new review:** Comment `@sourcery-ai review` on the pull request.
- **Continue discussions:** Reply directly to Sourcery's review comments.
- **Generate a GitHub issue from a review comment:** Ask Sourcery to create an
  issue from a review comment by replying to it. You can also reply to a
  review comment with `@sourcery-ai issue` to create an issue from it.
- **Generate a pull request title:** Write `@sourcery-ai` anywhere in the pull
  request title to generate a title at any time. You can also comment
  `@sourcery-ai title` on the pull request to (re-)generate the title at any time.
- **Generate a pull request summary:** Write `@sourcery-ai summary` anywhere in
  the pull request body to generate a PR summary at any time exactly where you
  want it. You can also comment `@sourcery-ai summary` on the pull request to
  (re-)generate the summary at any time.
- **Generate reviewer's guide:** Comment `@sourcery-ai guide` on the pull
  request to (re-)generate the reviewer's guide at any time.
- **Resolve all Sourcery comments:** Comment `@sourcery-ai resolve` on the
  pull request to resolve all Sourcery comments. Useful if you've already
  addressed all the comments and don't want to see them anymore.
- **Dismiss all Sourcery reviews:** Comment `@sourcery-ai dismiss` on the pull
  request to dismiss all existing Sourcery reviews. Especially useful if you
  want to start fresh with a new review - don't forget to comment
  `@sourcery-ai review` to trigger a new review!

#### Customizing Your Experience

Access your [dashboard](https://app.sourcery.ai) to:
- Enable or disable review features such as the Sourcery-generated pull request
  summary, the reviewer's guide, and others.
- Change the review language.
- Add, remove or edit custom review instructions.
- Adjust other review settings.

#### Getting Help

- [Contact our support team](mailto:support@sourcery.ai) for questions or feedback.
- Visit our [documentation](https://docs.sourcery.ai) for detailed guides and information.
- Keep in touch with the Sourcery team by following us on [X/Twitter](https://x.com/SourceryAI), [LinkedIn](https://www.linkedin.com/company/sourcery-ai/) or [GitHub](https://github.com/sourcery-ai).

</details>

<!-- Generated by sourcery-ai[bot]: end review_guide -->
author_association: NONE
user: login: theodore-s-beers
created_at: 2025-05-07T20:20:45Z
body: Going to merge this. There may be details to revert later, given some of the guards I've added, but I think it's better to start by imposing constraints.
author_association: CONTRIBUTOR