Repository: AcademySoftwareFoundation/OpenTimelineIO
Pull request #1801 (2024-10-08 01:59:53)

Added support for custom adapter hooks
**Summarize your change.**

_Rebased PR #1711_

Required by https://github.com/OpenTimelineIO/otio-aaf-adapter/pull/43

This adds support for attributing custom hooks to adapters and executing them with `hook_function_argument_map` being passed along through the adapter IO functions.

**Describe the reason for the change.**

I added two custom hooks to the OTIO AAF adapter (https://github.com/OpenTimelineIO/otio-aaf-adapter/pull/43), allowing for embedding of media essence into the resulting AAF. This was needed to facilitate just-in-time DNXHR transcoding of the media for the AAF creation and adding a certain level of control and flexibility to the feature. 

These features to the core are required in order to properly pass the hook argument map along to potential custom hooks run by the adapter. I tried to work with `_FEATURE_MAP` instead of creating a new version of the Adapter schema in order to minimize the impact of this change, while adding the necessary changes to facilitate custom hooks for adapters.

**Reference associated tests.**

```
tests/test_adapter_plugin.py
tests/test_hooks_plugins.py
```

timlehr: @ssteinbach I finally had some time to revive #1711. Please have a look when you find some time. Thank you! 🙏 
codecov-commenter: ## [Codecov](https://app.codecov.io/gh/AcademySoftwareFoundation/OpenTimelineIO/pull/1801?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=AcademySoftwareFoundation) Report
Attention: Patch coverage is `90.69767%` with `4 lines` in your changes missing coverage. Please review.
> Project coverage is 84.81%. Comparing base [(`c0e97b0`)](https://app.codecov.io/gh/AcademySoftwareFoundation/OpenTimelineIO/commit/c0e97b0c195881bec166c0a581dbae2999217265?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=AcademySoftwareFoundation) to head [(`23b28a6`)](https://app.codecov.io/gh/AcademySoftwareFoundation/OpenTimelineIO/commit/23b28a6715a754122a50086197d2fe31c06cf48a?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=AcademySoftwareFoundation).
> Report is 41 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/AcademySoftwareFoundation/OpenTimelineIO/pull/1801?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=AcademySoftwareFoundation) | Patch % | Lines |
|---|---|---|
| [...-opentimelineio/opentimelineio/adapters/adapter.py](https://app.codecov.io/gh/AcademySoftwareFoundation/OpenTimelineIO/pull/1801?src=pr&el=tree&filepath=src%2Fpy-opentimelineio%2Fopentimelineio%2Fadapters%2Fadapter.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=AcademySoftwareFoundation#diff-c3JjL3B5LW9wZW50aW1lbGluZWlvL29wZW50aW1lbGluZWlvL2FkYXB0ZXJzL2FkYXB0ZXIucHk=) | 73.33% | [2 Missing and 2 partials :warning: ](https://app.codecov.io/gh/AcademySoftwareFoundation/OpenTimelineIO/pull/1801?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=AcademySoftwareFoundation) |

:x: Your changes status has failed because you have indirect coverage changes. Learn more about [Unexpected Coverage Changes](https://docs.codecov.com/docs/unexpected-coverage-changes) and [reasons for indirect coverage changes](https://docs.codecov.com/docs/unexpected-coverage-changes#reasons-for-indirect-changes).

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


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

```diff
@@            Coverage Diff             @@
##             main    #1801      +/-   ##
==========================================
+ Coverage   84.11%   84.81%   +0.69%     
==========================================
  Files         198      177      -21     
  Lines       22241    12779    -9462     
  Branches     4687     1186    -3501     
==========================================
- Hits        18709    10839    -7870     
+ Misses       2610     1760     -850     
+ Partials      922      180     -742     
```

| [Flag](https://app.codecov.io/gh/AcademySoftwareFoundation/OpenTimelineIO/pull/1801/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=AcademySoftwareFoundation) | Coverage Δ | |
|---|---|---|
| [py-unittests](https://app.codecov.io/gh/AcademySoftwareFoundation/OpenTimelineIO/pull/1801/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=AcademySoftwareFoundation) | `84.81% <90.69%> (+0.69%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=AcademySoftwareFoundation#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Files with missing lines](https://app.codecov.io/gh/AcademySoftwareFoundation/OpenTimelineIO/pull/1801?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=AcademySoftwareFoundation) | Coverage Δ | |
|---|---|---|
| [...sts/baselines/custom\_adapter\_hookscript\_example.py](https://app.codecov.io/gh/AcademySoftwareFoundation/OpenTimelineIO/pull/1801?src=pr&el=tree&filepath=tests%2Fbaselines%2Fcustom_adapter_hookscript_example.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=AcademySoftwareFoundation#diff-dGVzdHMvYmFzZWxpbmVzL2N1c3RvbV9hZGFwdGVyX2hvb2tzY3JpcHRfZXhhbXBsZS5weQ==) | `100.00% <100.00%> (ø)` | |
| [tests/baselines/example.py](https://app.codecov.io/gh/AcademySoftwareFoundation/OpenTimelineIO/pull/1801?src=pr&el=tree&filepath=tests%2Fbaselines%2Fexample.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=AcademySoftwareFoundation#diff-dGVzdHMvYmFzZWxpbmVzL2V4YW1wbGUucHk=) | `100.00% <100.00%> (ø)` | |
| [tests/test\_adapter\_plugin.py](https://app.codecov.io/gh/AcademySoftwareFoundation/OpenTimelineIO/pull/1801?src=pr&el=tree&filepath=tests%2Ftest_adapter_plugin.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=AcademySoftwareFoundation#diff-dGVzdHMvdGVzdF9hZGFwdGVyX3BsdWdpbi5weQ==) | `91.27% <100.00%> (+3.43%)` | :arrow_up: |
| [tests/test\_hooks\_plugins.py](https://app.codecov.io/gh/AcademySoftwareFoundation/OpenTimelineIO/pull/1801?src=pr&el=tree&filepath=tests%2Ftest_hooks_plugins.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=AcademySoftwareFoundation#diff-dGVzdHMvdGVzdF9ob29rc19wbHVnaW5zLnB5) | `98.01% <100.00%> (+0.29%)` | :arrow_up: |
| [...-opentimelineio/opentimelineio/adapters/adapter.py](https://app.codecov.io/gh/AcademySoftwareFoundation/OpenTimelineIO/pull/1801?src=pr&el=tree&filepath=src%2Fpy-opentimelineio%2Fopentimelineio%2Fadapters%2Fadapter.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=AcademySoftwareFoundation#diff-c3JjL3B5LW9wZW50aW1lbGluZWlvL29wZW50aW1lbGluZWlvL2FkYXB0ZXJzL2FkYXB0ZXIucHk=) | `78.18% <73.33%> (-10.82%)` | :arrow_down: |

... and [126 files with indirect coverage changes](https://app.codecov.io/gh/AcademySoftwareFoundation/OpenTimelineIO/pull/1801/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=AcademySoftwareFoundation)

------

[Continue to review full report in Codecov by Sentry](https://app.codecov.io/gh/AcademySoftwareFoundation/OpenTimelineIO/pull/1801?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=AcademySoftwareFoundation).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=AcademySoftwareFoundation)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://app.codecov.io/gh/AcademySoftwareFoundation/OpenTimelineIO/pull/1801?dropdown=coverage&src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=AcademySoftwareFoundation). Last update [987d00a...23b28a6](https://app.codecov.io/gh/AcademySoftwareFoundation/OpenTimelineIO/pull/1801?dropdown=coverage&src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=AcademySoftwareFoundation). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=AcademySoftwareFoundation).

</details>
<details><summary>🚀 New features to boost your workflow: </summary>

- ❄ [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
timlehr: @ssteinbach @jminor Finally rebased this again 👍 
timlehr: Thanks @ssteinbach! I forgot about that. I now added some documentation!

Patch:
diff --git a/docs/tutorials/write-a-hookscript.md b/docs/tutorials/write-a-hookscript.md
index cbcb192..42714f1 100644
--- a/docs/tutorials/write-a-hookscript.md
+++ b/docs/tutorials/write-a-hookscript.md
@@ -126,3 +126,92 @@ def hook_function(in_timeline, argument_map=None):
 ```
 
 Please note that if a "post adapter write hook" changes `in_timeline` in any way, the api will not automatically update the already serialized file.  The changes will only exist in the in-memory object, because the hook runs _after_ the file is serialized to disk.
+
+## Implementing Adapter-specific hooks
+
+While OTIO ships with a set of pre-defined hooks (e.g. `pre_adapter_write`), you can also define your own hooks in your adapter.
+These can be useful to give the user more fine-grained control over the execution of your adapter and make it work for their specific workflow.
+A good example is media embedding within Avids AAF files: Depending on the workflow, media references might have to be transcoded to be compatible with the AAF format.
+To achieve this, the AAF adapter could define a hook which users can leverage to transcode the files before embedding is attempted.
+
+To define a custom hook in your adapter, you need to implement the `adapter_hook_names` function in your adapter module.
+You can define as many hooks as you like, but try to use the native hooks where possible to keep the API consistent. 
+
+```python
+# my_aaf_adapter.py
+
+def read_from_file(self, filepath, **kwargs):
+    ...
+
+def write_to_file(self, timeline, filepath, **kwargs):
+    ...
+
+def adapter_hook_names() -> List[str]:
+    """Returns names of custom hooks implemented by this adapter."""
+    return [
+        "my_custom_adapter_hook"
+    ]
+```
+
+The new hooks also need to be added to the adapter plugin manifest.
+
+```json
+{
+    "OTIO_SCHEMA" : "PluginManifest.1",
+    "adapters" : [
+        {
+            "OTIO_SCHEMA" : "Adapter.1",
+            "name" : "My AAF Adapter",
+            "execution_scope" : "in process",
+            "filepath" : "adapters/my_aaf_adapter.py",
+            "suffixes" : ["aaf"]
+        }
+    ],
+    "hook_scripts" : [
+        {
+            "OTIO_SCHEMA" : "HookScript.1",
+            "name" : "script_attached_to_custom_adapter_hook",
+            "filepath" : "my_custom_adapter_hook_script.py"
+        }
+    ],
+    "hooks" : {
+        "pre_adapter_write" : [],
+        "post_adapter_read" : [],
+        "post_adapter_write" : [],
+        "post_media_linker" : [],
+        "my_custom_adapter_hook" : ["script_attached_to_custom_adapter_hook"]
+    }
+}
+```
+
+A custom hook script might look like this:
+
+```python
+# my_custom_adapter_hook_script.py
+
+def hook_function(timeline, custom_argument, argument_map=None):
+    # Do something with the timeline
+    print(
+        f"Running custom adapter hook with custom argument value '{custom_argument}'"
+        f"and argument map: {argument_map}"
+    )
+    return timeline
+```
+
+Attached hook scripts can then be run anywhere using the `otio.hooks.run` function:
+
+```python
+# my_aaf_adapter.py
+
+def write_to_file(self, timeline, filepath, **kwargs):
+    # Do something
+    ...
+    # Run custom hook script with it's custom arguments and pass hook_argument_map along
+    otio.hooks.run(
+        "my_custom_adapter_hook", timeline, 
+        custom_argument="some_value", 
+        argument_map=kwargs.get("hook_argument_map", {})
+    )
+    ...
+    # Do something more
+```
\ No newline at end of file
diff --git a/src/py-opentimelineio/opentimelineio/adapters/adapter.py b/src/py-opentimelineio/opentimelineio/adapters/adapter.py
index 3da4098..528f847 100644
--- a/src/py-opentimelineio/opentimelineio/adapters/adapter.py
+++ b/src/py-opentimelineio/opentimelineio/adapters/adapter.py
@@ -10,6 +10,7 @@ https://opentimelineio.readthedocs.io/en/latest/tutorials/write-an-adapter.html
 import inspect
 import collections
 import copy
+from typing import List
 
 from .. import (
     core,
@@ -99,6 +100,21 @@ class Adapter(plugins.PythonPlugin):
             media_linker_argument_map or {}
         )
 
+        hook_function_argument_map = copy.deepcopy(
+            hook_function_argument_map or {}
+        )
+        hook_function_argument_map['adapter_arguments'] = copy.deepcopy(
+            adapter_argument_map
+        )
+        hook_function_argument_map['media_linker_argument_map'] = (
+            media_linker_argument_map
+        )
+
+        if self.has_feature("hooks"):
+            adapter_argument_map[
+                "hook_function_argument_map"
+            ] = hook_function_argument_map
+
         result = None
 
         if (
@@ -119,15 +135,6 @@ class Adapter(plugins.PythonPlugin):
                 **adapter_argument_map
             )
 
-        hook_function_argument_map = copy.deepcopy(
-            hook_function_argument_map or {}
-        )
-        hook_function_argument_map['adapter_arguments'] = copy.deepcopy(
-            adapter_argument_map
-        )
-        hook_function_argument_map['media_linker_argument_map'] = (
-            media_linker_argument_map
-        )
         result = hooks.run(
             "post_adapter_read",
             result,
@@ -174,6 +181,11 @@ class Adapter(plugins.PythonPlugin):
         # Store file path for use in hooks
         hook_function_argument_map['_filepath'] = filepath
 
+        if self.has_feature("hooks"):
+            adapter_argument_map[
+                "hook_function_argument_map"
+            ] = hook_function_argument_map
+
         input_otio = hooks.run("pre_adapter_write", input_otio,
                                extra_args=hook_function_argument_map)
         if (
@@ -210,13 +222,6 @@ class Adapter(plugins.PythonPlugin):
         **adapter_argument_map
     ):
         """Call the read_from_string function on this adapter."""
-
-        result = self._execute_function(
-            "read_from_string",
-            input_str=input_str,
-            **adapter_argument_map
-        )
-
         hook_function_argument_map = copy.deepcopy(
             hook_function_argument_map or {}
         )
@@ -227,6 +232,17 @@ class Adapter(plugins.PythonPlugin):
             media_linker_argument_map
         )
 
+        if self.has_feature("hooks"):
+            adapter_argument_map[
+                "hook_function_argument_map"
+            ] = hook_function_argument_map
+
+        result = self._execute_function(
+            "read_from_string",
+            input_str=input_str,
+            **adapter_argument_map
+        )
+
         result = hooks.run(
             "post_adapter_read",
             result,
@@ -277,6 +293,16 @@ class Adapter(plugins.PythonPlugin):
             **adapter_argument_map
         )
 
+    def adapter_hook_names(self) -> List[str]:
+        """Returns a list of hooks claimed by the adapter.
+
+        In addition to the hook being declared in the manifest, it should also be
+        returned here, so it can be attributed to the adapter.
+        """
+        if not self.has_feature("hooks"):
+            return []
+        return self._execute_function("adapter_hook_names")
+
     def __str__(self):
         return (
             "Adapter("
@@ -312,8 +338,9 @@ class Adapter(plugins.PythonPlugin):
         result["supported features"] = features
 
         for feature in sorted(_FEATURE_MAP.keys()):
-            if feature in ["read", "write"]:
+            if feature in ["read", "write", "hooks"]:
                 continue
+
             if self.has_feature(feature):
                 features[feature] = collections.OrderedDict()
 
@@ -330,6 +357,14 @@ class Adapter(plugins.PythonPlugin):
                     features[feature]["args"] = args.args
                     features[feature]["doc"] = docs
 
+        # check if there are any adapter specific-hooks and get their names
+        if self.has_feature("hooks"):
+            adapter_hooks_names_fn = getattr(
+                self.module(), _FEATURE_MAP["hooks"][0], None
+            )
+            if adapter_hooks_names_fn:
+                features["hooks"] = adapter_hooks_names_fn()
+
         return result
 
 
@@ -372,5 +407,6 @@ _FEATURE_MAP = {
     'read': ['read_from_file', 'read_from_string'],
     'write_to_file': ['write_to_file'],
     'write_to_string': ['write_to_string'],
-    'write': ['write_to_file', 'write_to_string']
+    'write': ['write_to_file', 'write_to_string'],
+    'hooks': ['adapter_hook_names']
 }
diff --git a/src/py-opentimelineio/opentimelineio/console/otiopluginfo.py b/src/py-opentimelineio/opentimelineio/console/otiopluginfo.py
index 804a66d..c51de70 100644
--- a/src/py-opentimelineio/opentimelineio/console/otiopluginfo.py
+++ b/src/py-opentimelineio/opentimelineio/console/otiopluginfo.py
@@ -75,7 +75,16 @@ def _supported_features_formatted(feature_map, _):
     if feature_map:
         print("    explicit supported features:")
     for thing, args in feature_map.items():
+        # skip hooks, they are treated separately, see below
+        if thing == "hooks":
+            continue
         print("      {} args: {}".format(thing, args['args']))
+
+    # check if there are any adapter specific-hooks implemented
+    adapter_hook_names = feature_map.get("hooks", [])
+    if adapter_hook_names:
+        print("      adapter hooks: {}".format(adapter_hook_names))
+
     extra_features = []
     for kind in ["read", "write"]:
         if (


Test patch:
diff --git a/tests/baselines/adapter_plugin_manifest.plugin_manifest.json b/tests/baselines/adapter_plugin_manifest.plugin_manifest.json
index 839dcad..5d18c55 100644
--- a/tests/baselines/adapter_plugin_manifest.plugin_manifest.json
+++ b/tests/baselines/adapter_plugin_manifest.plugin_manifest.json
@@ -16,13 +16,17 @@
         },
         {
             "FROM_TEST_FILE" : "post_write_hookscript_example.json"
+        },
+        {
+            "FROM_TEST_FILE" : "custom_adapter_hookscript_example.json"
         }
     ],
     "hooks" : {
         "pre_adapter_write" : ["example hook", "example hook"],
         "post_adapter_read" : [],
         "post_adapter_write" : ["post write example hook"],
-        "post_media_linker" : ["example hook"]
+        "post_media_linker" : ["example hook"],
+        "custom_adapter_hook": ["custom adapter hook"]
     },
     "version_manifests" : {
         "TEST_FAMILY_NAME": {
diff --git a/tests/baselines/custom_adapter_hookscript_example.json b/tests/baselines/custom_adapter_hookscript_example.json
new file mode 100644
index 0000000..608c5a1
--- /dev/null
+++ b/tests/baselines/custom_adapter_hookscript_example.json
@@ -0,0 +1,5 @@
+{
+    "OTIO_SCHEMA" : "HookScript.1",
+    "name" : "custom adapter hook",
+    "filepath" : "custom_adapter_hookscript_example.py"
+}
diff --git a/tests/baselines/custom_adapter_hookscript_example.py b/tests/baselines/custom_adapter_hookscript_example.py
new file mode 100644
index 0000000..e891040
--- /dev/null
+++ b/tests/baselines/custom_adapter_hookscript_example.py
@@ -0,0 +1,13 @@
+# SPDX-License-Identifier: Apache-2.0
+# Copyright Contributors to the OpenTimelineIO project
+
+"""This file is here to support the test_adapter_plugin unittest, specifically adapters
+that implement their own hooks.
+If you want to learn how to write your own adapter plugin, please read:
+https://opentimelineio.readthedocs.io/en/latest/tutorials/write-an-adapter.html
+"""
+
+
+def hook_function(in_timeline, argument_map=None):
+    in_timeline.metadata["custom_hook"] = dict(argument_map)
+    return in_timeline
diff --git a/tests/baselines/example.py b/tests/baselines/example.py
index b31abf9..522c3f5 100644
--- a/tests/baselines/example.py
+++ b/tests/baselines/example.py
@@ -6,23 +6,41 @@ If you want to learn how to write your own adapter plugin, please read:
 https://opentimelineio.readthedocs.io/en/latest/tutorials/write-an-adapter.html
 """
 
-import opentimelineio as otio
 
+# `hook_function_argument_map` is only a required argument for adapters that implement
+# custom hooks.
+def read_from_file(filepath, suffix="", hook_function_argument_map=None):
+    import opentimelineio as otio
 
-def read_from_file(filepath, suffix=""):
     fake_tl = otio.schema.Timeline(name=filepath + str(suffix))
     fake_tl.tracks.append(otio.schema.Track())
     fake_tl.tracks[0].append(otio.schema.Clip(name=filepath + "_clip"))
+
+    if (hook_function_argument_map and
+            hook_function_argument_map.get("run_custom_hook", False)):
+        return otio.hooks.run(hook="custom_adapter_hook", tl=fake_tl,
+                              extra_args=hook_function_argument_map)
+
     return fake_tl
 
 
-def read_from_string(input_str, suffix=""):
-    return read_from_file(input_str, suffix)
+# `hook_function_argument_map` is only a required argument for adapters that implement
+# custom hooks.
+def read_from_string(input_str, suffix="", hook_function_argument_map=None):
+    tl = read_from_file(input_str, suffix, hook_function_argument_map)
+    return tl
+
+
+# this is only required for adapters that implement custom hooks
+def adapter_hook_names():
+    return ["custom_adapter_hook"]
 
 
 # in practice, these will be in separate plugins, but for simplicity in the
 # unit tests, we put this in the same file as the example adapter.
 def link_media_reference(in_clip, media_linker_argument_map):
+    import opentimelineio as otio
+
     d = {'from_test_linker': True}
     d.update(media_linker_argument_map)
     return otio.schema.MissingReference(
diff --git a/tests/test_adapter_plugin.py b/tests/test_adapter_plugin.py
index f855f50..ffc4535 100755
--- a/tests/test_adapter_plugin.py
+++ b/tests/test_adapter_plugin.py
@@ -89,6 +89,7 @@ class TestPluginAdapters(unittest.TestCase):
         self.assertTrue(self.adp.has_feature("read"))
         self.assertTrue(self.adp.has_feature("read_from_file"))
         self.assertFalse(self.adp.has_feature("write"))
+        self.assertTrue(self.adp.has_feature("hooks"))
 
     def test_pass_arguments_to_adapter(self):
         self.assertEqual(self.adp.read_from_file("foo", suffix=3).name, "foo3")
diff --git a/tests/test_hooks_plugins.py b/tests/test_hooks_plugins.py
index 97d0367..e9cfc79 100644
--- a/tests/test_hooks_plugins.py
+++ b/tests/test_hooks_plugins.py
@@ -19,6 +19,7 @@ import tempfile
 
 HOOKSCRIPT_PATH = "hookscript_example"
 POST_WRITE_HOOKSCRIPT_PATH = "post_write_hookscript_example"
+CUSTOM_ADAPTER_HOOKSCRIPT_PATH = "custom_adapter_hookscript_example"
 
 POST_RUN_NAME = "hook ran and did stuff"
 TEST_METADATA = {'extra_data': True}
@@ -71,8 +72,17 @@ class TestPluginHookSystem(unittest.TestCase):
             "baselines",
             POST_WRITE_HOOKSCRIPT_PATH
         )
-        self.man.hook_scripts = [self.hsf, self.post_hsf]
-
+        self.adapter_hook_jsn = baseline_reader.json_baseline_as_string(
+            CUSTOM_ADAPTER_HOOKSCRIPT_PATH
+        )
+        self.adapter_hookscript = otio.adapters.otio_json.read_from_string(
+            self.adapter_hook_jsn)
+        self.adapter_hookscript._json_path = os.path.join(
+            baseline_reader.MODPATH,
+            "baselines",
+            HOOKSCRIPT_PATH
+        )
+        self.man.hook_scripts = [self.hsf, self.post_hsf, self.adapter_hookscript]
         self.orig_manifest = otio.plugins.manifest._MANIFEST
         otio.plugins.manifest._MANIFEST = self.man
 
@@ -83,6 +93,8 @@ class TestPluginHookSystem(unittest.TestCase):
     def test_plugin_adapter(self):
         self.assertEqual(self.hsf.name, "example hook")
         self.assertEqual(self.hsf.filepath, "example.py")
+        self.assertEqual(otio.adapters.from_name("example").adapter_hook_names(),
+                         ["custom_adapter_hook"])
 
     def test_load_adapter_module(self):
         target = os.path.join(
@@ -101,15 +113,25 @@ class TestPluginHookSystem(unittest.TestCase):
         self.assertEqual(result.name, POST_RUN_NAME)
         self.assertEqual(result.metadata.get("extra_data"), True)
 
+    def test_run_custom_hook_function(self):
+        tl = otio.schema.Timeline()
+        result = otio.hooks.run(hook="custom_adapter_hook", tl=tl,
+                                extra_args=TEST_METADATA)
+        self.assertEqual(result.metadata["custom_hook"], TEST_METADATA)
+
     def test_run_hook_through_adapters(self):
+        hook_map = dict(TEST_METADATA)
+        hook_map["run_custom_hook"] = True
+
         result = otio.adapters.read_from_string(
             'foo', adapter_name='example',
             media_linker_name='example',
-            hook_function_argument_map=TEST_METADATA
+            hook_function_argument_map=hook_map
         )
 
         self.assertEqual(result.name, POST_RUN_NAME)
         self.assertEqual(result.metadata.get("extra_data"), True)
+        self.assertEqual(result.metadata["custom_hook"]["extra_data"], True)
 
     def test_post_write_hook(self):
         self.man.adapters.extend(self.orig_manifest.adapters)
@@ -161,11 +183,11 @@ class TestPluginHookSystem(unittest.TestCase):
         # for not just assert that it returns a non-empty list
         self.assertEqual(
             list(otio.hooks.available_hookscripts()),
-            [self.hsf, self.post_hsf]
+            [self.hsf, self.post_hsf, self.adapter_hookscript]
         )
         self.assertEqual(
             otio.hooks.available_hookscript_names(),
-            [self.hsf.name, self.post_hsf.name]
+            [self.hsf.name, self.post_hsf.name, self.adapter_hookscript.name]
         )
 
     def test_manifest_hooks(self):
@@ -173,7 +195,8 @@ class TestPluginHookSystem(unittest.TestCase):
             sorted(list(otio.hooks.names())),
             sorted(
                 ["post_adapter_read", "post_media_linker",
-                 "pre_adapter_write", "post_adapter_write"]
+                 "pre_adapter_write", "post_adapter_write",
+                 "custom_adapter_hook"]
             )
         )
 
@@ -204,6 +227,13 @@ class TestPluginHookSystem(unittest.TestCase):
             ]
         )
 
+        self.assertEqual(
+            list(otio.hooks.scripts_attached_to("custom_adapter_hook")),
+            [
+                self.adapter_hookscript.name
+            ]
+        )
+
         tl = otio.schema.Timeline()
         result = otio.hooks.run("pre_adapter_write", tl, TEST_METADATA)
         self.assertEqual(result.name, POST_RUN_NAME)