Repository: xlab-steampunk/ansible-doc-extractor (language: python)

Issue:
Configure collection loader before rendering docs
Uses `init_plugin_loader` to initialize the collection loader before trying to use fragment loader to resolve doc_fragments.

This should fix #29 but with the caveat that this piece of api was introduced into ansible-core 2.15, so this will not work in versions of ansible-core lower than 2.15. However all those versions are now out of support https://docs.ansible.com/ansible/latest/reference_appendices/release_and_maintenance.html
Ansible 2.15 errors when using a doc fragment
Hi, 

First off, thank you for the product, its been incredibly helpful.

We have an error appear when using doc fragments (which is somewhat similar to issue #7 but not the same) (see traceback at bottom)
```
    raise AnsibleError('unknown doc_fragment(s) in file {0}: {1}'.format(filename, to_native(', '.join(unknown_fragments))))
ansible.errors.AnsibleError: unknown doc_fragment(s) in file ../plugins/modules/zos_copy.py: ibm.ibm_zos_core.template
```

We do have `ansible-doc-extractor` working with:
```
ansible-core==2.11.12
ansible-doc-extractor==0.1.5
```

Not working with:
```
ansible-core==2.15.2
ansible-doc-extractor==0.1.10
```

Things I tried:
```
export ANSIBLE_COLLECTIONS_PATHS=/Users/ddimatos/.ansible/collections/ansible_collections/ibm/ibm_zos_core/plugins/doc_fragments; ansible-doc-extractor --template templates/module.rst.j2 source/modules ../plugins/modules/*.py

export DOC_FRAGMENT_PLUGIN_PATH=/Users/ddimatos/.ansible/collections/ansible_collections/ibm/ibm_zos_core/plugins/doc;  ansible-doc-extractor --template templates/module.rst.j2 source/modules ../plugins/modules/*.py
```

I did some investigation up to a point and thought I should request help so I can remain on the right track:
- In 2.15, this code [resolved_fqcn](https://github.com/ansible/ansible/blob/3c8a3fa0af78c242e261a1f38bafb00c6f2e2f5b/lib/ansible/plugins/loader.py#L136) was added to PluginLoadContext.
- In 2.11 , the `resolved_fqcn` did not exist in [PluginLoadContext](https://github.com/ansible/ansible/blob/653b4fba7afce579477b18bb97f1043a4799f5a4/lib/ansible/plugins/loader.py#L115C7-L115C24)

I noticed a difference in in the object returned by [find_plugin_with_context](https://github.com/ansible/ansible/blob/653b4fba7afce579477b18bb97f1043a4799f5a4/lib/ansible/plugins/loader.py#L538) where:
   
- in 2.11 it looks correctly populated and discovered:
 ```
{'deprecated': False,
 'deprecation_warnings': [],
 'error_list': [],
 'exit_reason': 'found exact match for '
                'ansible_collections.ibm.ibm_zos_core.plugins.doc_fragments.template '
                'in ibm.ibm_zos_core',
 'import_error_list': [],
 'load_attempts': ['ibm.ibm_zos_core.template'],
 'original_name': 'ibm.ibm_zos_core.template',
 'pending_redirect': None,
 'plugin_resolved_collection': 'ibm.ibm_zos_core',
 'plugin_resolved_name': 'ansible_collections.ibm.ibm_zos_core.plugins.doc_fragments.template',
 'plugin_resolved_path': '/Users/ddimatos/.ansible/collections/ansible_collections/ibm/ibm_zos_core/plugins/doc_fragments/template.py',
 'redirect_list': ['ibm.ibm_zos_core.template'],
 'removal_date': None,
 'removal_version': None,
 'resolved': True}
```
-in 2.15 not so fully discovered:
```
{'_resolved_fqcn': None,
 'action_plugin': None,
 'deprecated': False,
 'deprecation_warnings': [],
 'error_list': [],
 'exit_reason': 'ibm.ibm_zos_core is not eligible for last-chance resolution',
 'import_error_list': [],
 'load_attempts': ['/Users/ddimatos/git/github/ibm_zos_core/venv/venv-2.15/lib/python3.10/site-packages/ansible/plugins/doc_fragments',
                   '/Users/ddimatos/git/github/ibm_zos_core/venv/venv-2.15/lib/python3.10/site-packages/ansible/plugins/doc_fragments/__pycache__'],
 'original_name': 'ibm.ibm_zos_core',
 'pending_redirect': None,
 'plugin_resolved_collection': None,
 'plugin_resolved_name': None,
 'plugin_resolved_path': None,
 'redirect_list': ['ibm.ibm_zos_core'],
 'removal_date': None,
 'removal_version': None,
 'resolved': False}
```

In both 2.15 and 2.11 I can from the command line resolve doc which tells me `ANSIBLE_COLLECTIONS_PATHS` should resolve which confuses me to why I am having issues.

2.15:
```
(venv-2.15) ddimatos:[ ~/git/github/ibm_zos_core/docs ]ansible-doc ibm.ibm_zos_core.zos_copy
> IBM.IBM_ZOS_CORE.ZOS_COPY    (/Users/ddimatos/.ansible/collections/ansible_collections/ibm/ibm_zos_core/plugins/modules/zos_copy.p>

        The zos_copy <./zos_copy.html> module copies a file or data set from a local or a remote machine
        to a location on the remote machine.

ADDED IN: version 1.2.0 of ibm.ibm_zos_core

  * note: This module has a corresponding action plugin.

OPTIONS (= is mandatory):

- backup
        Specifies whether a backup of the destination should be created before copying data.
        When set to `true', the module creates a backup file or data set.
        The backup file name will be returned on either success or failure of module execution such that
        data can be retrieved.
        default: false
        type: bool
```

2.11:
```
(venv-2.11) ddimatos:[ ~/git/github/ibm_zos_core/docs ]ansible-doc ibm.ibm_zos_core.zos_copy
> IBM.IBM_ZOS_CORE.ZOS_COPY    (/Users/ddimatos/.ansible/collections/ansible_collections/ibm/ibm_zos_core/plugins/modules/zos_copy.p>

        The zos_copy <./zos_copy.html> module copies a file or data set from a local or a remote machine
        to a location on the remote machine.

  * note: This module has a corresponding action plugin.

OPTIONS (= is mandatory):

- backup
        Specifies whether a backup of the destination should be created before copying data.
        When set to `true', the module creates a backup file or data set.
        The backup file name will be returned on either success or failure of module execution such that
        data can be retrieved.
        [Default: False]
        type: bool

- backup_name
```

(venv-2.11) ddimatos:[ ~/git/github/ibm_zos_core/docs ]ansible-doc ibm.ibm_zos_core.zos_copy

Traceback:
```
Traceback (most recent call last):
  File "/Users/ddimatos/git/github/ibm_zos_core/venv/venv-2.15/bin/ansible-doc-extractor", line 8, in <module>
    sys.exit(main())
  File "/Users/ddimatos/git/github/ibm_zos_core/venv/venv-2.15/lib/python3.10/site-packages/ansible_doc_extractor/cli.py", line 175, in main
    render_docs(args.output, args.module, args.template, args.markdown)
  File "/Users/ddimatos/git/github/ibm_zos_core/venv/venv-2.15/lib/python3.10/site-packages/ansible_doc_extractor/cli.py", line 128, in render_docs
    render_module_docs(output, module, template, extension)
  File "/Users/ddimatos/git/github/ibm_zos_core/venv/venv-2.15/lib/python3.10/site-packages/ansible_doc_extractor/cli.py", line 66, in render_module_docs
    doc, examples, returndocs, metadata = plugin_docs.get_docstring(
  File "/Users/ddimatos/git/github/ibm_zos_core/venv/venv-2.15/lib/python3.10/site-packages/ansible/utils/plugin_docs.py", line 239, in get_docstring
    add_fragments(data['doc'], filename, fragment_loader=fragment_loader, is_module=is_module)
  File "/Users/ddimatos/git/github/ibm_zos_core/venv/venv-2.15/lib/python3.10/site-packages/ansible/utils/plugin_docs.py", line 214, in add_fragments
    raise AnsibleError('unknown doc_fragment(s) in file {0}: {1}'.format(filename, to_native(', '.join(unknown_fragments))))
ansible.errors.AnsibleError: unknown doc_fragment(s) in file ../plugins/modules/zos_copy.py: ibm.ibm_zos_core.template
```

I know this is mostly in the ansible code that the change occurred but before I engage that community, it would be great to hear back if there is something I am neglecting to do when using 2.15. 


Discussion / hints:
Hi!

Thanks for pull request. Can you add yours code in try/catch/pass statement, so with old Ansibles it will still work as before (partially). And with new Ansibles it will work correctly.

Thanks, Jure 
Hey, sure that sounds good to me, I'll get started on that
Hey @juremedvesek I've wrapped both the import and the function call so it should now work on lower versions of ansible again
I can confirm, it is working with `ansible-core==2.14.9`, but not with  `ansible-core==2.15.3`
reproduced still as of `ansible-core==2.16.0`

Fix patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index bb64c6e..68582fe 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -28,6 +28,10 @@ jobs:
       - name: Install extractor and ansible-core
         run: pip install .[core]
 
-      - name: Run tests
+      - name: Run basic tests
         run: ./run.sh
         working-directory: ./tests/integration/basic
+
+      - name: Run tests with doc fragments
+        run: ./run.sh
+        working-directory: ./tests/integration/doc_fragments


Test patch:
diff --git a/tests/integration/doc_fragments/ansible_collections/sensu/sensu_go/plugins/doc_fragments/auth.py b/tests/integration/doc_fragments/ansible_collections/sensu/sensu_go/plugins/doc_fragments/auth.py
new file mode 100644
index 0000000..64f0c63
--- /dev/null
+++ b/tests/integration/doc_fragments/ansible_collections/sensu/sensu_go/plugins/doc_fragments/auth.py
@@ -0,0 +1,75 @@
+# -*- coding: utf-8 -*-
+# Copyright: (c) 2019, XLAB Steampunk <steampunk@xlab.si>
+#
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+
+class ModuleDocFragment(object):
+    DOCUMENTATION = """
+options:
+  auth:
+    description:
+      - Authentication parameters. Can define each of them with ENV as well.
+    type: dict
+    suboptions:
+      user:
+        description:
+          - The username to use for connecting to the Sensu API.
+            If this is not set the value of the SENSU_USER environment
+            variable will be checked.
+          - This parameter is ignored if the I(auth.api_key) parameter is set.
+        type: str
+        default: admin
+      password:
+        description:
+          - The Sensu user's password.
+            If this is not set the value of the SENSU_PASSWORD environment
+            variable will be checked.
+          - This parameter is ignored if the I(auth.api_key) parameter is set.
+        type: str
+        default: P@ssw0rd!
+      url:
+        description:
+          - Location of the Sensu backend API.
+            If this is not set the value of the SENSU_URL environment variable
+            will be checked.
+        type: str
+        default: http://localhost:8080
+      api_key:
+        description:
+          - The API key that should be used when authenticating. If this is
+            not set, the value of the SENSU_API_KEY environment variable will
+            be checked.
+          - This replaces I(auth.user) and I(auth.password) parameters.
+          - For more information about the API key, refer to the official
+            Sensu documentation at
+            U(https://docs.sensu.io/sensu-go/latest/guides/use-apikey-feature/).
+        type: str
+        version_added: 1.3.0
+      verify:
+        description:
+          - Flag that controls the certificate validation.
+          - If you are using self-signed certificates, you can set this
+            parameter to C(false).
+          - ONLY USE THIS PARAMETER IN DEVELOPMENT SCENARIOS! In you use
+            self-signed certificates in production, see the I(auth.ca_path)
+            parameter.
+          - It is also possible to set this parameter via the I(SENSU_VERIFY)
+            environment variable.
+        type: bool
+        default: true
+        version_added: 1.5.0
+      ca_path:
+        description:
+          - Path to the CA bundle that should be used to validate the backend
+            certificate.
+          - If this parameter is not set, module will use the CA bundle that
+            python is using.
+          - It is also possible to set this parameter via the I(SENSU_CA_PATH)
+            environment variable.
+        type: path
+        version_added: 1.5.0
+"""
\ No newline at end of file
diff --git a/tests/integration/doc_fragments/ansible_collections/sensu/sensu_go/plugins/doc_fragments/name.py b/tests/integration/doc_fragments/ansible_collections/sensu/sensu_go/plugins/doc_fragments/name.py
new file mode 100644
index 0000000..88bd353
--- /dev/null
+++ b/tests/integration/doc_fragments/ansible_collections/sensu/sensu_go/plugins/doc_fragments/name.py
@@ -0,0 +1,25 @@
+# -*- coding: utf-8 -*-
+# Copyright: (c) 2019, XLAB Steampunk <steampunk@xlab.si>
+#
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+
+class ModuleDocFragment(object):
+    DOCUMENTATION = """
+options:
+  name:
+    description:
+      - The Sensu resource's name. This name (in combination with the
+        namespace where applicable) uniquely identifies the resource that
+        Ansible operates on.
+      - If the resource with selected name already exists, Ansible module will
+        update it to match the specification in the task.
+      - Consult the I(name) metadata attribute specification in the upstream
+        docs on U(https://docs.sensu.io/sensu-go/latest/reference/) for
+        more details about valid names and other restrictions.
+    type: str
+    required: yes
+"""
\ No newline at end of file
diff --git a/tests/integration/doc_fragments/ansible_collections/sensu/sensu_go/plugins/doc_fragments/requirements.py b/tests/integration/doc_fragments/ansible_collections/sensu/sensu_go/plugins/doc_fragments/requirements.py
new file mode 100644
index 0000000..8a8f255
--- /dev/null
+++ b/tests/integration/doc_fragments/ansible_collections/sensu/sensu_go/plugins/doc_fragments/requirements.py
@@ -0,0 +1,16 @@
+# -*- coding: utf-8 -*-
+# Copyright: (c) 2021, XLAB Steampunk <steampunk@xlab.si>
+#
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+
+class ModuleDocFragment(object):
+    # We have an empty options key because ansible fails without it.
+    DOCUMENTATION = """
+options: {}
+requirements:
+  - python >= 2.7
+"""
\ No newline at end of file
diff --git a/tests/integration/doc_fragments/ansible_collections/sensu/sensu_go/plugins/doc_fragments/state.py b/tests/integration/doc_fragments/ansible_collections/sensu/sensu_go/plugins/doc_fragments/state.py
new file mode 100644
index 0000000..555e8d8
--- /dev/null
+++ b/tests/integration/doc_fragments/ansible_collections/sensu/sensu_go/plugins/doc_fragments/state.py
@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+# Copyright: (c) 2019, XLAB Steampunk <steampunk@xlab.si>
+#
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+
+class ModuleDocFragment(object):
+    DOCUMENTATION = """
+options:
+  state:
+    description:
+      - Target state of the Sensu object.
+    type: str
+    choices: [ present, absent ]
+    default: present
+"""
\ No newline at end of file
diff --git a/tests/integration/doc_fragments/ansible_collections/sensu/sensu_go/plugins/modules/ad_auth_provider.py b/tests/integration/doc_fragments/ansible_collections/sensu/sensu_go/plugins/modules/ad_auth_provider.py
new file mode 100644
index 0000000..7f32a70
--- /dev/null
+++ b/tests/integration/doc_fragments/ansible_collections/sensu/sensu_go/plugins/modules/ad_auth_provider.py
@@ -0,0 +1,396 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+# Copyright: (c) 2020, XLAB Steampunk <steampunk@xlab.si>
+#
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import absolute_import, division, print_function
+
+__metaclass__ = type
+
+ANSIBLE_METADATA = {
+    "metadata_version": "1.1",
+    "status": ["stableinterface"],
+    "supported_by": "certified",
+}
+
+DOCUMENTATION = """
+module: ad_auth_provider
+
+author:
+  - Aljaz Kosir (@aljazkosir)
+  - Manca Bizjak (@mancabizjak)
+  - Miha Dolinar (@mdolin)
+  - Tadej Borovsak (@tadeboro)
+
+short_description: Manage Sensu AD authentication provider
+
+description:
+  - Create, update or delete a Sensu Go AD authentication provider.
+  - For more information, refer to the Sensu Go documentation at
+    U(https://docs.sensu.io/sensu-go/latest/operations/control-access/ad-auth/).
+
+version_added: 1.10.0
+
+extends_documentation_fragment:
+  - sensu.sensu_go.requirements
+  - sensu.sensu_go.auth
+  - sensu.sensu_go.name
+  - sensu.sensu_go.state
+
+options:
+  servers:
+    description:
+      - An array of AD servers for your directory.
+    type: list
+    elements: dict
+    suboptions:
+      host:
+        description:
+          - AD server IP address.
+        required: true
+        type: str
+      port:
+        description:
+          - AD server port.
+        type: int
+      insecure:
+        description:
+          - Skips SSL certificate verification when set to true.
+        type: bool
+        default: false
+      security:
+        description:
+          - Encryption type to be used for the connection to the AD server.
+        type: str
+        choices: [ insecure, tls, starttls ]
+        default: tls
+      trusted_ca_file:
+        description:
+          - Path to an alternative CA bundle file.
+        type: str
+      client_cert_file:
+        description:
+          - Path to the certificate that should be sent to the server if requested.
+        type: str
+      client_key_file:
+        description:
+          - Path to the key file associated with the client_cert_file.
+          - Required if I(client_cert_file) is present.
+        type: str
+      default_upn_domain:
+        description:
+          - Enables UPN authentication when set. The default UPN suffix that will be appended
+            to the username when a domain is not specified during login
+            (for example, user becomes user@defaultdomain.xyz).
+        type: str
+      include_nested_groups:
+        description:
+          - If true, the group search includes any nested groups a user is a member of.
+            If false, the group search includes only the top-level groups a user is a member of.
+        type: bool
+      binding:
+        description:
+          - The AD account that performs user and group lookups.
+          - If your sever supports anonymous binding, you can omit the user_dn or password
+            attributes to query the directory without credentials.
+        type: dict
+        suboptions:
+          user_dn:
+            description:
+             - The AD account that performs user and group lookups.
+             - If your sever supports anonymous binding, you can omit this attribute.
+            type: str
+            required: true
+          password:
+            description:
+              - Password for the user_dn account.
+              - If your sever supports anonymous binding, you can omit this attribute.
+            type: str
+            required: true
+      group_search:
+        description:
+         - Search configuration for groups.
+        type: dict
+        suboptions:
+          base_dn:
+            description:
+              - Which part of the directory tree to search.
+            required: true
+            type: str
+          attribute:
+            description:
+              - Used for comparing result entries.
+            type: str
+            default: member
+          name_attribute:
+            description:
+              - Represents the attribute to use as the entry name.
+            type: str
+            default: cn
+          object_class:
+            description:
+              - Identifies the class of objects returned in the search result.
+            type: str
+            default: group
+      user_search:
+        description:
+          - Search configuration for users.
+        type: dict
+        suboptions:
+          base_dn:
+            description:
+              - Which part of the directory tree to search.
+            required: true
+            type: str
+          attribute:
+            description:
+              - Used for comparing result entries.
+            type: str
+            default: sAMAccountName
+          name_attribute:
+            description:
+              - Represents the attribute to use as the entry name.
+            type: str
+            default: displayName
+          object_class:
+            description:
+              - Identifies the class of objects returned in the search result.
+            type: str
+            default: person
+  groups_prefix:
+    description:
+      - The prefix added to all AD groups.
+    type: str
+  username_prefix:
+    description:
+      - The prefix added to all AD usernames.
+    type: str
+
+seealso:
+  - module: sensu.sensu_go.auth_provider_info
+  - module: sensu.sensu_go.ldap_auth_provider
+  - module: sensu.sensu_go.oidc_auth_provider
+"""
+
+EXAMPLES = """
+- name: Create a AD auth provider
+  sensu.sensu_go.ad_auth_provider:
+    name: activedirectory
+    servers:
+      - host: 127.0.0.1
+        group_search:
+          base_dn: dc=acme,dc=org
+        user_search:
+          base_dn: dc=acme,dc=org
+- name: Delete a AD auth provider
+  sensu.sensu_go.ad_auth_provider:
+    name: activedirectory
+    state: absent
+"""
+
+RETURN = """
+object:
+  description: Object representing Sensu AD authentication provider.
+  returned: success
+  type: dict
+  sample:
+    metadata:
+      name: 'activedirectory'
+    servers:
+      host: '127.0.0.1'
+      port: '636'
+      insecure: 'False'
+      security: 'tls'
+      trusted_ca_file: '/path/to/trusted-certificate-authorities.pem'
+      client_cert_file: '/path/to/ssl/cert.pem'
+      client_key_file: '/path/to/ssl/key.pem'
+      default_upn_domain: 'example.org'
+      binding:
+        user_dn: 'cn=binder,dc=acme,dc=org'
+      group_search:
+        base_dn: 'dc=acme,dc=org'
+        attribute: 'member'
+        name_attribute': 'cn'
+        object_class: 'group'
+      user_search:
+        base_dn: 'dc=acme,dc=org'
+        attribute: 'sAMAccountName'
+        name_attribute: 'displayName'
+        object_class: 'person'
+    groups_prefix: 'AD'
+    username_prefix: 'AD'
+"""
+
+
+from ansible.module_utils.basic import AnsibleModule
+
+from ..module_utils import arguments, errors, utils
+
+API_GROUP = "enterprise"
+API_VERSION = "authentication/v2"
+
+
+def remove_item(result):
+    if result:
+        for server in result["servers"]:
+            if server["binding"] and "password" in server["binding"]:
+                del server["binding"]["password"]
+
+    return result
+
+
+def _filter(payload):
+    # Remove keys with None values from dict
+    return dict((k, v) for k, v in payload.items() if v is not None)
+
+
+def do_differ(current, desired):
+    if utils.do_differ_v1(current, desired, "servers"):
+        return True
+
+    if len(current["spec"]["servers"]) != len(desired["spec"]["servers"]):
+        return True
+
+    for c, d in zip(current["spec"]["servers"], desired["spec"]["servers"]):
+        if utils.do_differ(c, _filter(d)):
+            return True
+
+    return False
+
+
+def main():
+    required_if = [("state", "present", ["servers"])]
+    module = AnsibleModule(
+        required_if=required_if,
+        supports_check_mode=True,
+        argument_spec=dict(
+            arguments.get_spec(
+                "auth",
+                "name",
+                "state",
+            ),
+            servers=dict(
+                type="list",
+                elements="dict",
+                options=dict(
+                    host=dict(
+                        type="str",
+                        required=True,
+                    ),
+                    port=dict(
+                        type="int",
+                    ),
+                    insecure=dict(
+                        type="bool",
+                        default=False,
+                    ),
+                    security=dict(
+                        type="str",
+                        choices=["insecure", "tls", "starttls"],
+                        default="tls",
+                    ),
+                    trusted_ca_file=dict(
+                        type="str",
+                    ),
+                    client_cert_file=dict(
+                        type="str",
+                    ),
+                    client_key_file=dict(
+                        type="str",
+                    ),
+                    default_upn_domain=dict(
+                        type="str",
+                    ),
+                    include_nested_groups=dict(
+                        type="bool",
+                    ),
+                    binding=dict(
+                        type="dict",
+                        options=dict(
+                            user_dn=dict(
+                                type="str",
+                                required=True,
+                            ),
+                            password=dict(
+                                type="str",
+                                no_log=True,
+                                required=True,
+                            ),
+                        ),
+                    ),
+                    group_search=dict(
+                        type="dict",
+                        options=dict(
+                            base_dn=dict(
+                                type="str",
+                                required=True,
+                            ),
+                            attribute=dict(
+                                type="str",
+                                default="member",
+                            ),
+                            name_attribute=dict(
+                                type="str",
+                                default="cn",
+                            ),
+                            object_class=dict(type="str", default="group"),
+                        ),
+                    ),
+                    user_search=dict(
+                        type="dict",
+                        options=dict(
+                            base_dn=dict(
+                                type="str",
+                                required=True,
+                            ),
+                            attribute=dict(
+                                type="str",
+                                default="sAMAccountName",
+                            ),
+                            name_attribute=dict(
+                                type="str",
+                                default="displayName",
+                            ),
+                            object_class=dict(
+                                type="str",
+                                default="person",
+                            ),
+                        ),
+                    ),
+                ),
+            ),
+            groups_prefix=dict(
+                type="str",
+            ),
+            username_prefix=dict(
+                type="str",
+            ),
+        ),
+    )
+
+    client = arguments.get_sensu_client(module.params["auth"])
+    path = utils.build_url_path(
+        API_GROUP, API_VERSION, None, "authproviders", module.params["name"]
+    )
+
+    payload = dict(
+        type="ad",
+        api_version=API_VERSION,
+        metadata=dict(name=module.params["name"]),
+        spec=arguments.get_spec_payload(
+            module.params, "servers", "groups_prefix", "username_prefix"
+        ),
+    )
+
+    try:
+        changed, ad_provider = utils.sync_v1(
+            module.params["state"], client, path, payload, module.check_mode, do_differ
+        )
+        module.exit_json(changed=changed, object=remove_item(ad_provider))
+    except errors.Error as e:
+        module.fail_json(msg=str(e))
+
+
+if __name__ == "__main__":
+    main()
\ No newline at end of file
diff --git a/tests/integration/doc_fragments/run.sh b/tests/integration/doc_fragments/run.sh
new file mode 100755
index 0000000..a997dbc
--- /dev/null
+++ b/tests/integration/doc_fragments/run.sh
@@ -0,0 +1,5 @@
+#!/bin/bash
+
+set -euo pipefail
+
+ansible-doc-extractor . ./ansible_collections/sensu/sensu_go/plugins/modules/*.py