Skip to content

Commit

Permalink
Merge pull request #4 from lgarber-akamai/ref/spec-classes
Browse files Browse the repository at this point in the history
ref: (BREAKING) Rewrite to use classes and objects rather than dicts
  • Loading branch information
lgarber-akamai authored Jan 25, 2023
2 parents 1f604a7 + 99d4a15 commit 430b571
Show file tree
Hide file tree
Showing 7 changed files with 255 additions and 123 deletions.
2 changes: 1 addition & 1 deletion .pylintrc
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
[MASTER]

[MESSAGES CONTROL]
disable=exec-used, too-many-branches, too-few-public-methods, unspecified-encoding, consider-using-f-string
disable=exec-used, too-many-branches, too-few-public-methods, unspecified-encoding, consider-using-f-string, too-many-instance-attributes
31 changes: 18 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,38 +28,43 @@ optional arguments:
## Specification Format

### Module Metadata
The `ansible-specdoc` specification format requires that each module exports a `specdoc_meta` dict with the following structure:
The `ansible-specdoc` specification format requires that each module exports a `SPECDOC_META` object with the following structure:

```python
specdoc_meta = dict(
SPECDOC_META = SpecDocMeta(
description=['Module Description'],
requirements=['python >= 3.6'],
author=['Author Name'],
spec=module_spec,
options=module_spec,
examples=[
'example module usage'
],
return_values=dict(
my_return_value=dict(
return_values={
'my_return_value': SpecReturnValue(
description='A generic return value.',
type='str',
type=FieldType.string,
sample=['sample response']
),
)
}
)
```

### Argument Specification

The `spec` field of the `specdoc_meta` struct should refer to an
[Ansible argument specification](https://docs.ansible.com/ansible/latest/dev_guide/developing_program_flow_modules.html#argument-spec).
Certain fields may automatically be passed into the Ansible-compatible spec dict.

Spec fields may contain an additional `description` field that will appear in the documentation.
Spec fields may additional metadata that will appear in the documentation.

For example:

```python
module_spec = dict(
example_argument=dict(type='str', required=True, description='An example argument.')
)
module_spec = {
'example_argument': SpecField(
type=FieldType.string,
required=True,
description=['An example argument.']
)
}
```

In order to retrieve the Ansible-compatible spec dict, use the `SPECDOC_META.ansible_spec` property.
65 changes: 13 additions & 52 deletions ansible_specdoc/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@
import pathlib
import sys
from types import ModuleType
from typing import Optional, Dict, Any
from typing import Optional

import jinja2
import yaml
from redbaron import RedBaron

SPECDOC_META_VAR = 'specdoc_meta'
from ansible_specdoc.objects import SpecDocMeta

SPECDOC_META_VAR = 'SPECDOC_META'


class SpecDocModule:
Expand All @@ -29,7 +31,7 @@ def __init__(self) -> None:
self._module_spec: Optional[importlib.machinery.ModuleSpec] = None
self._module: Optional[ModuleType] = None

self._metadata: Dict[str, Any] = {}
self._metadata: Optional[SpecDocMeta] = None

def load_file(self, file: str, module_name: str = None) -> None:
"""Loads the given Ansible module file"""
Expand All @@ -45,8 +47,8 @@ def load_file(self, file: str, module_name: str = None) -> None:
self._module_spec.loader.exec_module(self._module)

if not hasattr(self._module, SPECDOC_META_VAR):
raise Exception('failed to parse module file {0}: specdoc_meta is not defined'
.format(self._module_file))
raise Exception('failed to parse module file {0}: {1} is not defined'
.format(self._module_file, SPECDOC_META_VAR))

self._metadata = getattr(self._module, SPECDOC_META_VAR)

Expand All @@ -62,8 +64,8 @@ def load_str(self, content: str, module_name: str) -> None:
exec(content, self._module.__dict__)

if not hasattr(self._module, SPECDOC_META_VAR):
raise Exception('failed to parse module string {0}: specdoc_meta is not defined'
.format(self._module_name))
raise Exception('failed to parse module string {0}: {1} is not defined'
.format(self._module_name, SPECDOC_META_VAR))

self._metadata = getattr(self._module, SPECDOC_META_VAR)

Expand All @@ -72,53 +74,12 @@ def __format_json(data):
print(json.loads(data))
return json.dumps(data, sort_keys=True, indent=4, separators=(',', ': '))

@staticmethod
def __spec_to_doc(spec: Dict[str, Dict]) -> Dict[str, Any]:
result = {}

for key, param in spec.items():
if param.get('doc_hide'):
continue

desc = param.get('description') or []

param_dict = {
'type': param.get('type'),
'required': param.get('required') or False,
'editable': param.get('editable') or False,
'conflicts_with': param.get('conflicts_with') or [],
'description': [desc] if isinstance(desc, str) else desc
}

for field in ['choices', 'default', 'elements']:
if field not in param:
continue

param_dict[field] = param.get(field)

if 'options' in param:
param_dict['suboptions'] = SpecDocModule.__spec_to_doc(param.get('options'))

if 'suboptions' in param:
param_dict['suboptions'] = SpecDocModule.__spec_to_doc(param.get('suboptions'))

result[key] = param_dict

def __generate_doc_dict(self):
"""Generates a dict for use in documentation"""
result = self._metadata.doc_dict
result['module'] = self._module_name
return result

def __generate_doc_dict(self) -> Dict[str, Any]:
desc = self._metadata.get('description')

return {
'module': self._module_name,
'description': [desc] if isinstance(desc, str) else desc,
'requirements': self._metadata.get('requirements'),
'author': self._metadata.get('author'),
'options': self.__spec_to_doc(self._metadata.get('spec')),
'examples': self._metadata.get('examples') or [],
'return_values': self._metadata.get('return_values') or {},
}

def generate_yaml(self) -> str:
"""Generates a YAML documentation string"""
return yaml.dump(self.__generate_doc_dict())
Expand Down
147 changes: 147 additions & 0 deletions ansible_specdoc/objects.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
"""
This module contains various classes to be used in Ansible modules.
"""

from dataclasses import dataclass, field
from typing import List, Optional, Dict, Any


class FieldType:
"""
Enum for Ansible-compatible field types.
"""

list = 'list'
dict = 'dict'
bool = 'bool'
integer = 'int'
string = 'str'
float = 'float'
path = 'path'
raw = 'raw'
json_arg = 'jsonarg'
json = 'json'
bytes = 'bytes'
bits = 'bits'


@dataclass
class SpecField:
"""
A single field to be used in an Ansible module.
"""
type: FieldType

description: Optional[List[str]] = None
required: bool = False
default: Optional[Any] = None
editable: bool = False
conflicts_with: Optional[List[str]] = field(default_factory=lambda: [])
no_log: bool = False
choices: Optional[List[str]] = None
doc_hide: bool = False
aliases: Optional[List[str]] = None

# These fields are only necessary for `list` and `dict` types
element_type: Optional[FieldType] = None
suboptions: Optional[Dict[str, 'SpecField']] = None # Forward-declared

# Additional fields to pass into the output Ansible spec dict
additional_fields: Optional[Dict[str, Any]] = None

@property
def doc_dict(self) -> Optional[Dict[str, Any]]:
"""
Returns the docs dict for this field.
"""

result = self.__dict__
if self.suboptions is not None:
result['suboptions'] = {
k: v.doc_dict for k, v in self.suboptions.items() if not v.doc_hide
}

return result


@property
def ansible_spec(self) -> Dict[str, Any]:
"""
Returns the Ansible-compatible spec for this field.
"""
result = {
'type': str(self.type),
'no_log': self.no_log,
'required': self.required,
}

if self.default is not None:
result['default'] = self.default

if self.choices is not None:
result['choices'] = self.choices

if self.aliases is not None:
result['aliases'] = self.aliases

if self.suboptions is not None:
result['options'] = {k: v.ansible_spec for k, v in self.suboptions.items()}

if self.element_type is not None:
result['elements'] = str(self.element_type)

if self.additional_fields is not None:
result = {**result, **self.additional_fields}

return result


@dataclass
class SpecReturnValue:
"""
A description of an Ansible module's return value.
"""
description: str
type: FieldType

sample: List[str] = field(default_factory=lambda: [])
docs_url: Optional[str] = None
elements: Optional[FieldType] = None

@dataclass
class SpecDocMeta:
"""
The top-level description of an Ansible module.
"""
description: List[str]
options: Dict[str, SpecField]

requirements: Optional[List[str]] = None
author: Optional[List[str]] = None
examples: Optional[List[str]] = field(default_factory=lambda: [])
return_values: Optional[Dict[str, SpecReturnValue]] = field(default_factory=lambda: {})

@property
def doc_dict(self) -> Dict[str, Any]:
"""
Returns the documentation dict for this module.
This isn't implemented as __dict__ because it is not 1:1 with the class layout.
"""

result = self.__dict__

result['options'] = {k: v.doc_dict for k, v in self.options.items() if not v.doc_hide}

if self.return_values is not None:
result['return_values'] = {k: v.__dict__ for k, v in self.return_values.items()}

return result

@property
def ansible_spec(self) -> Dict[str, Any]:
"""
Returns the Ansible-compatible spec for this module.
"""

return {k: v.ansible_spec for k, v in self.options.items()}
Loading

0 comments on commit 430b571

Please sign in to comment.