Repository: ansible/ansible-navigator (language: python)

Issue:
Implement the `:settings` subcommand
Depends on: #1033 
Depends on: #1031 
Depends on: #1026 
Depends on: #1024 
Depends on: #1014 


Fixes https://github.com/ansible/ansible-navigator/issues/577

- Add settings action
- Add settings subcommand
- Modify constant values in definitions for readability
- Read and transform settings in subsystem utils for future use
- Add settings tests and fixtures
- Docstring fix in _yaml.py

Demo:
[![asciicast demo](https://asciinema.org/a/0W9EX1fIlKpGc4Rv8pbNTN1F1.svg)](https://asciinema.org/a/0W9EX1fIlKpGc4Rv8pbNTN1F1)


Discussion / hints:
> [Demo](https://asciinema.org/a/0W9EX1fIlKpGc4Rv8pbNTN1F1)

Not sure if this is in the scope but looking at the demo, I noticed a very low contrast in these places:
![ansible-navigator-timeout-setting-screen-low-contrast](https://user-images.githubusercontent.com/578543/154846073-5811397d-282f-4eb2-b6d6-c77473b81bf2.png)

This is a UX problem and if anything's in the scope, that should probably need to be addressed by making sure that the text foreground-to-background color has high contrast ratio.

> Not sure if this is in the scope but looking at the demo, I noticed a very low contrast in these places: 

It's an issue with asciinema and OSC4 terminal support.  This is more what a user would see:

https://asciinema.org/a/O6qkQHvwclPF0MIuInaWsYgOv


> It's an issue with asciinema and OSC4 terminal support. This is more what a user would see:
> 
> [asciinema.org/a/O6qkQHvwclPF0MIuInaWsYgOv](https://asciinema.org/a/O6qkQHvwclPF0MIuInaWsYgOv)

I see... Thanks for the explanation, this is indeed much better. Although, I'd argue that the line number coloring remains problematic (though probably is a separate issue, not something to fix in this specific PR).
There's been a significant amount of refactoring in utils.py and in the settings action itself. If you can, please re-review and leave any additional comments. 
converted to draft as it is being broken into pieces

Fix patch:
diff --git a/docs/conf.py b/docs/conf.py
index b71caed41..b96b0000d 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -104,6 +104,12 @@ def parse_with_fetch(*args, **kwargs) -> str:
 nitpicky = True
 nitpick_ignore = [
     ("py:class", "_Rule"),
+    (
+        "py:class",
+        "ansible_navigator.configuration_subsystem.defs_presentable.PresentableSettingsEntries",
+    ),
+    ("py:class", "ansible_navigator.configuration_subsystem.defs_presentable.TCli"),
+    ("py:class", "ansible_navigator.configuration_subsystem.defs_presentable.TEnt"),
     ("py:class", "ansible_navigator.steps.T"),
     ("py:class", "ansible_navigator.tm_tokenize.fchainmap.TKey"),
     ("py:class", "ansible_navigator.tm_tokenize.fchainmap.TValue"),
diff --git a/src/ansible_navigator/configuration_subsystem/__init__.py b/src/ansible_navigator/configuration_subsystem/__init__.py
index 2fe5996ee..a8e593edd 100644
--- a/src/ansible_navigator/configuration_subsystem/__init__.py
+++ b/src/ansible_navigator/configuration_subsystem/__init__.py
@@ -4,7 +4,10 @@
 from .definitions import ApplicationConfiguration
 from .definitions import Constants
 from .definitions import SettingsEntry
+from .defs_presentable import PresentableSettingsEntries
+from .defs_presentable import PresentableSettingsEntry
 from .navigator_configuration import NavigatorConfiguration
+from .transform import to_presentable
 
 
 __all__ = (
@@ -12,5 +15,8 @@
     "Configurator",
     "Constants",
     "NavigatorConfiguration",
+    "PresentableSettingsEntry",
+    "PresentableSettingsEntries",
     "SettingsEntry",
+    "to_presentable",
 )
diff --git a/src/ansible_navigator/configuration_subsystem/definitions.py b/src/ansible_navigator/configuration_subsystem/definitions.py
index 23939af28..5505132d6 100644
--- a/src/ansible_navigator/configuration_subsystem/definitions.py
+++ b/src/ansible_navigator/configuration_subsystem/definitions.py
@@ -1,5 +1,8 @@
 """configuration definitions
 """
+
+import copy
+
 from dataclasses import dataclass
 from dataclasses import field
 from enum import Enum
@@ -29,25 +32,34 @@ class CliParameters:
     short: Optional[str] = None
     metavar: Optional[str] = None
 
+    def long(self, name_dashed: str) -> str:
+        """Provide a cli long parameter.
+
+        :param name_dashed: The dashed name of the parent settings entry
+        :returns: The long cli parameter
+        """
+        long = self.long_override or f"--{name_dashed}"
+        return long
+
 
 class Constants(Enum):
     """Mapping some constants to friendly text"""
 
-    ALL = "All the things"
-    DEFAULT_CFG = "default configuration value"
-    ENVIRONMENT_VARIABLE = "environment variable"
-    NONE = "None of the things"
-    NOT_SET = "value has not been set"
-    PREVIOUS_CLI = "previous cli command"
+    ALL = "All"
+    DEFAULT_CFG = "Defaults"
+    ENVIRONMENT_VARIABLE = "Environment variable"
+    NONE = "None"
+    NOT_SET = "Not set"
+    PREVIOUS_CLI = "Previous cli command"
     SAME_SUBCOMMAND = (
-        "used to determine if an entry should be used when"
+        "Used to determine if an entry should be used when"
         " applying previous cli common entries, this indicates"
         " that it will only be used if the subcommand is the same"
     )
     SEARCH_PATH = "Found using search path"
-    SENTINEL = "indicates a nonvalue"
-    USER_CFG = "user provided configuration file"
-    USER_CLI = "cli parameters"
+    SENTINEL = "Indicates a nonvalue"
+    USER_CFG = "User-provided configuration file"
+    USER_CLI = "Provided at command line"
 
 
 @dataclass
@@ -61,6 +73,34 @@ class SettingsEntryValue:
     #: Indicates where the current value came from
     source: Constants = Constants.NOT_SET
 
+    @property
+    def is_default(self):
+        """Determine if the current value is the default value.
+
+        :returns: Indication of if the current is the default
+        """
+        result = self.default == self.current
+        return result
+
+    @property
+    def resolved(self):
+        """Transform this entry to an entry without internal constants.
+
+        This would typically be used when the attributes need to be presented to the user.
+        Constants are resolved to their value.
+
+        :returns: An entry without internal constants for attributes
+        """
+        new_entry = copy.deepcopy(self)
+
+        if isinstance(self.current, Constants):
+            new_entry.current = self.current.value
+
+        if isinstance(self.default, Constants):
+            new_entry.default = self.default.value
+
+        return new_entry
+
 
 @dataclass
 class SettingsEntry:
@@ -155,6 +195,11 @@ class ApplicationConfiguration:
 
     initial: Any = None
 
+    @property
+    def application_name_dashed(self) -> str:
+        """Generate a dashed version of the application name"""
+        return self.application_name.replace("_", "-")
+
     def _get_by_name(self, name, kind):
         try:
             return next(entry for entry in super().__getattribute__(kind) if entry.name == name)
diff --git a/src/ansible_navigator/configuration_subsystem/defs_presentable.py b/src/ansible_navigator/configuration_subsystem/defs_presentable.py
new file mode 100644
index 000000000..60eab04d2
--- /dev/null
+++ b/src/ansible_navigator/configuration_subsystem/defs_presentable.py
@@ -0,0 +1,208 @@
+"""Object definitions for the presentable transformation of the settings."""
+
+from dataclasses import dataclass
+from typing import ClassVar
+from typing import Dict
+from typing import List
+from typing import NewType
+from typing import Optional
+from typing import Tuple
+from typing import Type
+from typing import TypeVar
+from typing import Union
+
+from .definitions import CliParameters
+from .definitions import Constants as C
+from .definitions import SettingsEntry
+from .navigator_configuration import Internals
+from .utils import create_settings_file_sample
+
+
+PresentableSettingsEntryValue = Union[bool, Dict, str, List]
+
+TCli = TypeVar("TCli", bound="PresentableCliParameters")
+TEnt = TypeVar("TEnt", bound="PresentableSettingsEntry")
+
+
+@dataclass(frozen=True)
+class PresentableCliParameters:
+    """A settings entry's cli parameters in a presentable structure."""
+
+    NO_LONG_MSG: ClassVar[str] = "No long CLI parameter"
+    NO_SHORT_MSG: ClassVar[str] = "No short CLI parameter"
+
+    long: str = NO_LONG_MSG
+    """The long cli parameter value"""
+    short: str = NO_SHORT_MSG
+    """The short cli parameter value"""
+
+    @classmethod
+    def from_cli_params(
+        cls: Type[TCli],
+        cli_parameters: Optional[CliParameters],
+        name_dashed: str,
+    ) -> TCli:
+        """Create an ``_HRCliParameters`` based on an entry's cli parameters.
+
+        :param cli_parameters: The entry's cli parameters
+        :param name_dashed: The dashed name of the parent settings entry
+        :returns: The instance of self based on an entry's cli parameters
+        """
+        if isinstance(cli_parameters, CliParameters):
+            short = cli_parameters.short or cls.NO_SHORT_MSG
+            long = cli_parameters.long(name_dashed)
+            return cls(long=long, short=short)
+        return cls()
+
+
+@dataclass(frozen=True)
+class PresentableSettingsEntry:
+    # pylint: disable=too-many-instance-attributes
+    """A settings entry in a presentable structure."""
+
+    choices: List
+    """The possible values"""
+    current_settings_file: str
+    """The path to the current settings file"""
+    current_value: PresentableSettingsEntryValue
+    """The current, effective value"""
+    default_value: PresentableSettingsEntryValue
+    """The default value"""
+    default: bool
+    """Indicates if the current value == the default"""
+    description: str
+    """A short description"""
+    env_var: str
+    """The environment variable"""
+    name: str
+    """The name"""
+    settings_file_sample: Union[str, Dict]
+    """A sample settings file snippet"""
+    source: str
+    """The source of the current value"""
+    subcommands: List
+    """A list of subcommands where this entry is available"""
+    cli_parameters: PresentableCliParameters = PresentableCliParameters()
+    """The CLI parameters, long and short"""
+
+    @property
+    def current(self) -> str:
+        """Convert the current value into a string.
+
+        :returns: The current value as a string
+        """
+        return str(self.current_value)
+
+    def get(self, attribute: str):
+        """Allow this dataclass to be treated like a dictionary.
+
+        This is a work around until the UI fully supports dataclasses
+        at which time this can be removed.
+
+        Default is intentionally not implemented as a safeguard to enure
+        this is not more work than necessary to remove in the future
+        and will only return attributes in existence.
+
+        :param attribute: The attribute to get
+        :returns: The gotten attribute
+        """
+        return getattr(self, attribute)
+
+    def __lt__(self, other):
+        """Compare based on name, called by sort, sorted.
+
+        :param other: The entry to compare this to
+        :returns: Indication of less than the other
+        """
+        return self.name < other.name
+
+    @classmethod
+    def for_settings_file(
+        cls: Type[TEnt],
+        all_subcommands: List,
+        application_name: str,
+        internals: Internals,
+    ) -> TEnt:
+        """Create an ``PresentableSettingsEntry`` containing the details for the settings file.
+
+        :param all_subcommands: All application subcommands
+        :param application_name: The application name
+        :param internals: The internal storage for settings information
+        :returns: The settings file entry
+        """
+        description = (
+            "The path to the current settings file. Possible locations are"
+            " {CWD}/ansible-navigator.{ext} or {HOME}/.ansible-navigator.{ext}"
+            " where ext is yml, yaml or json."
+        )
+        return cls(
+            choices=[],
+            current_settings_file=internals.settings_file_path or C.NONE.value,
+            current_value=internals.settings_file_path or C.NONE.value,
+            default=internals.settings_source is C.NONE,
+            default_value=C.NONE.value,
+            description=description,
+            name="Current settings file",
+            env_var=f"{application_name.upper()}_CONFIG",
+            settings_file_sample="Not applicable",
+            source=internals.settings_source.value,
+            subcommands=all_subcommands,
+        )
+
+    @classmethod
+    def from_settings_entry(
+        cls: Type[TEnt],
+        all_subcommands: List,
+        application_name_dashed: str,
+        entry: SettingsEntry,
+        settings_file_path: str,
+    ) -> TEnt:
+        """Create an ``PresentableSettingsEntry`` containing the details for one settings entry.
+
+        :param application_name_dashed: The application name, dashed
+        :param all_subcommands: All application subcommands
+        :param entry: A settings entry
+        :param settings_file_path: The path to the settings file
+
+        :return: The settings file entry
+        """
+        cli_parameters = PresentableCliParameters.from_cli_params(
+            cli_parameters=entry.cli_parameters,
+            name_dashed=entry.name_dashed,
+        )
+
+        env_var = entry.environment_variable(application_name_dashed)
+        entry_value_resolved = entry.value.resolved
+
+        path = entry.settings_file_path(application_name_dashed)
+        settings_file_sample = create_settings_file_sample(path, placeholder="<------")
+
+        if isinstance(entry.subcommands, C):
+            if entry.subcommands is C.ALL:
+                subcommands = all_subcommands
+            else:
+                subcommands = [entry.subcommands.value]
+        else:
+            subcommands = entry.subcommands
+
+        result = cls(
+            choices=list(entry.choices),  # May be a tuple e.g. PLUGIN_TYPES
+            cli_parameters=cli_parameters,
+            current_settings_file=str(settings_file_path),
+            current_value=entry_value_resolved.current,
+            default=entry_value_resolved.is_default,
+            default_value=entry_value_resolved.default,
+            description=entry.short_description,
+            env_var=env_var,
+            name=entry.name.replace("_", " ").capitalize(),
+            settings_file_sample=settings_file_sample,
+            source=entry.value.source.value,
+            subcommands=subcommands,
+        )
+        return result
+
+
+PresentableSettingsEntries = NewType(
+    "PresentableSettingsEntries",
+    Tuple[PresentableSettingsEntry, ...],
+)
diff --git a/src/ansible_navigator/configuration_subsystem/transform.py b/src/ansible_navigator/configuration_subsystem/transform.py
new file mode 100644
index 000000000..ef68ca562
--- /dev/null
+++ b/src/ansible_navigator/configuration_subsystem/transform.py
@@ -0,0 +1,36 @@
+"""Methods of transforming the settings."""
+
+from .definitions import ApplicationConfiguration
+from .defs_presentable import PresentableSettingsEntries
+from .defs_presentable import PresentableSettingsEntry
+
+
+def to_presentable(settings: ApplicationConfiguration) -> PresentableSettingsEntries:
+    """Transform the current settings into a structure that can be presented with the TUI.
+
+    :param settings: The current settings
+    :returns: The settings represented as a list of dictionaries
+    """
+    settings_list = []
+
+    all_subcommands = sorted([subcommand.name for subcommand in settings.subcommands])
+
+    settings_file_entry = PresentableSettingsEntry.for_settings_file(
+        all_subcommands=all_subcommands,
+        application_name=settings.application_name_dashed,
+        internals=settings.internals,
+    )
+    settings_list.append(settings_file_entry)
+
+    settings_file_path = settings_file_entry.current_settings_file
+    for entry in settings.entries:
+        human_readable_entry = PresentableSettingsEntry.from_settings_entry(
+            entry=entry,
+            all_subcommands=all_subcommands,
+            application_name_dashed=settings.application_name_dashed,
+            settings_file_path=settings_file_path,
+        )
+        settings_list.append(human_readable_entry)
+
+    settings_list.sort()
+    return PresentableSettingsEntries(tuple(settings_list))
diff --git a/src/ansible_navigator/configuration_subsystem/utils.py b/src/ansible_navigator/configuration_subsystem/utils.py
new file mode 100644
index 000000000..6bf7e0be6
--- /dev/null
+++ b/src/ansible_navigator/configuration_subsystem/utils.py
@@ -0,0 +1,22 @@
+"""Utilities related to the configuration subsystem."""
+from typing import Dict
+from typing import Union
+
+
+SettingsFileSample = Dict[str, Union[Dict, str]]
+
+
+def create_settings_file_sample(
+    settings_path: str,
+    placeholder: str = "",
+) -> SettingsFileSample:
+    """Generate a settings file sample.
+
+    :param settings_path: The dot delimited settings file path for a settings entry
+    :param placeholder: String used to identify the placement of a settings value
+    :returns: A sample of the settings file
+    """
+    if "." not in settings_path:
+        return {settings_path: placeholder}
+    key, remainder = settings_path.split(".", 1)
+    return {key: create_settings_file_sample(remainder, placeholder)}


Test patch:
diff --git a/tests/unit/configuration_subsystem/test_presentable.py b/tests/unit/configuration_subsystem/test_presentable.py
new file mode 100644
index 000000000..33ea2a4d4
--- /dev/null
+++ b/tests/unit/configuration_subsystem/test_presentable.py
@@ -0,0 +1,110 @@
+"""Tests for the transformation of settings to a presentable structure."""
+
+from dataclasses import asdict
+
+import pytest
+
+from ansible_navigator.configuration_subsystem import ApplicationConfiguration
+from ansible_navigator.configuration_subsystem import Configurator
+from ansible_navigator.configuration_subsystem import Constants
+from ansible_navigator.configuration_subsystem import PresentableSettingsEntry
+from ansible_navigator.configuration_subsystem import SettingsEntry
+from ansible_navigator.configuration_subsystem import to_presentable
+from ansible_navigator.configuration_subsystem.definitions import CliParameters
+from ansible_navigator.configuration_subsystem.definitions import SettingsEntryValue
+from ansible_navigator.configuration_subsystem.definitions import SubCommand
+from ansible_navigator.configuration_subsystem.navigator_configuration import Internals
+from ansible_navigator.configuration_subsystem.navigator_post_processor import (
+    NavigatorPostProcessor,
+)
+
+
+@pytest.fixture(name="sample_settings")
+def _sample_settings():
+    return ApplicationConfiguration(
+        application_name="app",
+        application_version="1.0",
+        internals=Internals(settings_file_path="/test/path"),
+        post_processor=NavigatorPostProcessor(),
+        subcommands=[
+            SubCommand(name="subcommand_1", description="subcommand_1"),
+        ],
+        entries=[],
+    )
+
+
+@pytest.fixture(name="settings_file_dict")
+def _settings_file_dict():
+    return {
+        "choices": [],
+        "current_settings_file": "/test/path",
+        "current_value": "/test/path",
+        "default_value": "None",
+        "default": False,
+        "description": (
+            "The path to the current settings file. Possible locations are"
+            " {CWD}/ansible-navigator.{ext} or {HOME}/.ansible-navigator.{ext}"
+            " where ext is yml, yaml or json."
+        ),
+        "env_var": "APP_CONFIG",
+        "name": "Current settings file",
+        "settings_file_sample": "Not applicable",
+        "source": "Not set",
+        "subcommands": ["subcommand_1"],
+        "cli_parameters": {"long": "No long CLI parameter", "short": "No short CLI parameter"},
+    }
+
+
+def test_settings_file_entry(sample_settings, settings_file_dict):
+    """Ensure the settings file entry is properly constructed.
+
+    :param sample_settings: A sample application configuration (settings)
+    :param settings_file_dict: The expected settings as a dictionary
+    """
+    configurator = Configurator(params=[], application_configuration=sample_settings)
+    configurator._post_process()  # pylint: disable=protected-access
+    presentable = to_presentable(sample_settings)
+    # pylint: disable=not-an-iterable # https://github.com/PyCQA/pylint/issues/2296
+    assert all(isinstance(p, PresentableSettingsEntry) for p in presentable)
+    assert asdict(presentable[0]) == settings_file_dict
+
+
+def test_settings_entry(sample_settings, settings_file_dict):
+    """Ensure a settings entry is properly constructed.
+
+    :param sample_settings: A sample application configuration (settings)
+    :param settings_file_dict: The expected settings as a dictionary
+    """
+    entry = SettingsEntry(
+        name="se_1",
+        choices=["choice_1", "choice_2"],
+        cli_parameters=CliParameters(short="-se1"),
+        short_description="description",
+        value=SettingsEntryValue(
+            current="current",
+            default="default",
+            source=Constants.ENVIRONMENT_VARIABLE,
+        ),
+    )
+    sample_settings.entries = [entry]
+    configurator = Configurator(params=[], application_configuration=sample_settings)
+    configurator._post_process()  # pylint: disable=protected-access
+    presentable = to_presentable(sample_settings)
+    # pylint: disable=not-an-iterable # https://github.com/PyCQA/pylint/issues/2296
+    assert all(isinstance(p, PresentableSettingsEntry) for p in presentable)
+    assert asdict(presentable[0]) == settings_file_dict
+    entry_dict = {
+        "choices": ["choice_1", "choice_2"],
+        "current_settings_file": "/test/path",
+        "current_value": "current",
+        "default_value": "default",
+        "default": False,
+        "description": "description",
+        "env_var": "APP_SE_1",
+        "name": "Se 1",
+        "settings_file_sample": {"app": {"se-1": "<------"}},
+        "source": "Environment variable",
+        "subcommands": ["subcommand_1"],
+        "cli_parameters": {"long": "--se-1", "short": "-se1"},
+    }
+    assert asdict(presentable[1]) == entry_dict