Updated the CLI interface.

development
Shawn Davis 2 years ago
parent 7a64eeff09
commit 6de3956dc6
  1. 5
      Makefile
  2. 75
      docs/source/reference.rst
  3. 114
      help/docs/commands/django.md
  4. 35
      help/docs/index.md
  5. 17
      help/docs/profiles/centos.md
  6. 17
      help/docs/profiles/ubuntu.md
  7. 25
      help/mkdocs.yml
  8. 8
      meta.ini
  9. 382
      sandbox/cli.py
  10. 7
      sandbox/tease.py
  11. 516
      scripttease/cli/__init__.py
  12. 454
      scripttease/cli/initialize.py
  13. 208
      scripttease/cli/subcommands.py
  14. 6
      scripttease/data/inventory/nextcloud/meta.ini
  15. 6
      scripttease/data/inventory/pgsql/meta.ini
  16. 8
      scripttease/data/inventory/radicale/steps.ini
  17. 6
      scripttease/data/inventory/ubuntu/meta.ini
  18. 17
      scripttease/lib/commands/base.py
  19. 82
      scripttease/lib/commands/django.py
  20. 1
      scripttease/lib/commands/posix.py
  21. 2
      scripttease/lib/commands/ubuntu.py
  22. 2
      scripttease/lib/factories.py
  23. 1
      scripttease/lib/loaders/ini.py
  24. 6
      setup.py
  25. 9
      tests/examples/users.ini
  26. 9
      tests/test_lib_commands.py
  27. 52
      tests/test_lib_commands_base.py
  28. 27
      tests/test_lib_commands_django.py

@ -9,6 +9,11 @@ PROJECT_NAME := python-scripttease
# The directory where test coverage is generated. # The directory where test coverage is generated.
COVERAGE_PATH := docs/build/html/coverage COVERAGE_PATH := docs/build/html/coverage
# $(file) may not work depending upon version of make.
#RELEASE := $(strip $(file < RELEASE.txt))
RELEASE := $(strip `cat RELEASE.txt`)
VERSION := $(strip `cat VERSION.txt`)
# Attempt to load a local makefile which may override any of the values above. # Attempt to load a local makefile which may override any of the values above.
-include local.makefile -include local.makefile

@ -18,18 +18,15 @@ Library
Commands Commands
-------- --------
.. automodule:: scripttease.library.commands.base .. automodule:: scripttease.lib.commands.base
:members: :members:
:show-inheritance: :show-inheritance:
:special-members: __init__ :special-members: __init__
Overlays Centos
--------
Common
...... ......
.. automodule:: scripttease.library.overlays.common .. automodule:: scripttease.lib.commands.centos
:members: :members:
:show-inheritance: :show-inheritance:
:special-members: __init__ :special-members: __init__
@ -37,7 +34,31 @@ Common
Django Django
...... ......
.. automodule:: scripttease.library.overlays.django .. automodule:: scripttease.lib.commands.django
:members:
:show-inheritance:
:special-members: __init__
Messages
........
.. automodule:: scripttease.lib.commands.messages
:members:
:show-inheritance:
:special-members: __init__
MySQL
......
.. automodule:: scripttease.lib.commands.mysql
:members:
:show-inheritance:
:special-members: __init__
PHP
...
.. automodule:: scripttease.lib.commands.php
:members: :members:
:show-inheritance: :show-inheritance:
:special-members: __init__ :special-members: __init__
@ -45,7 +66,7 @@ Django
Postgres Postgres
........ ........
.. automodule:: scripttease.library.overlays.pgsql .. automodule:: scripttease.lib.commands.pgsql
:members: :members:
:show-inheritance: :show-inheritance:
:special-members: __init__ :special-members: __init__
@ -53,7 +74,15 @@ Postgres
Posix Posix
..... .....
.. automodule:: scripttease.library.overlays.posix .. automodule:: scripttease.lib.commands.posix
:members:
:show-inheritance:
:special-members: __init__
Python
......
.. automodule:: scripttease.lib.commands.python
:members: :members:
:show-inheritance: :show-inheritance:
:special-members: __init__ :special-members: __init__
@ -61,34 +90,34 @@ Posix
Ubuntu Ubuntu
...... ......
.. automodule:: scripttease.library.overlays.ubuntu .. automodule:: scripttease.lib.commands.ubuntu
:members: :members:
:show-inheritance: :show-inheritance:
:special-members: __init__ :special-members: __init__
Scripts Contexts
------- ========
.. automodule:: scripttease.library.scripts .. automodule:: scripttease.contexts
:members: :members:
:show-inheritance: :show-inheritance:
:special-members: __init__ :special-members: __init__
Factory Factories
======= =========
.. automodule:: scripttease.factory .. automodule:: scripttease.factories
:members: :members:
:show-inheritance: :show-inheritance:
:special-members: __init__ :special-members: __init__
Parsers Loaders
======= =======
Base Base
---- ----
.. automodule:: scripttease.parsers.base .. automodule:: scripttease.lib.loaders.base
:members: :members:
:show-inheritance: :show-inheritance:
:special-members: __init__ :special-members: __init__
@ -96,15 +125,7 @@ Base
Config (INI) Config (INI)
------------ ------------
.. automodule:: scripttease.parsers.ini .. automodule:: scripttease.lib.loaders.ini
:members:
:show-inheritance:
:special-members: __init__
Utils
-----
.. automodule:: scripttease.parsers.utils
:members: :members:
:show-inheritance: :show-inheritance:
:special-members: __init__ :special-members: __init__

@ -4,29 +4,31 @@ Summary: Work with Django management commands.
## Common Options for Django Commands ## Common Options for Django Commands
You will generally want to include `cd` to change to the project directory and `prefix` to load the virtual environment. You will want to include `cd` to change to the project directory (where `manage.py` lives) and supply `venv` to load the virtual environment.
```ini
[collect static files]
django.static:
cd: /path/to/project/source
venv: ../python
```yaml
- collect static files:
django: static
cd: /path/to/project/source
prefix: source ../python/bin/activate
``` ```
## Automatic Conversion of Django Command Switches ## Automatic Conversion of Django Command Switches
Options provided in the command configuration file are automatically converted to command line switches. Options provided in the command configuration file are automatically converted to command line switches.
```yaml ```ini
- run database migrations: [run database migrations]
django: migrate django.migrate:
settings: tenants.example_com.settings settings: tenants.example_com.settings
[dump some data]
django.dump: path/to/dump.json
indent: 4
natural_foreign: yes
natural_primary: yes
- dump some data:
django: dumpdata
indent: 4
natural_foreign: yes
natural_primary: yes
``` ```
## Available Commands ## Available Commands
@ -35,66 +37,77 @@ Options provided in the command configuration file are automatically converted t
```ini ```ini
[run django checks] [run django checks]
django: check django.check:
stop: yes
```
### collectstatic
Alias: static
Collect static files.
```ini
[collect static files]
django.static:
``` ```
```yaml ### createsuperuser
- run django checks:
django: check Create a superuser account.
```ini
[create the root user account]
django.createsuperuser: root
email: root@example.com
``` ```
### dumpdata ### dumpdata
Alias: dump
Dump fixture data. Dump fixture data.
- app: Required. The name of the app. - target (str): Required. The name of the app or `app.Model`.
- model: Optional. A model name within the app. - format (str): `json` (default) or `xml`.
- path: The path to the JSON file. When a model is provided, this defaults to `fixtures/app/model.json`. Otherwise, it is `fixtures/app/initial.json`. - path (str): The path to the output file. When a model is provided, this defaults to `fixtures/app/model.json`. Otherwise, it is `fixtures/app/initial.json`.
```ini ```ini
[dump project data] [dump project data]
django: dumpdata django.dump: projects
app: projects
[dump project categories] [dump project categories]
django: dumpdata django.dump: projects.Category
app: projects
model: Category
path: local/projects/fixtures/default-categories.json path: local/projects/fixtures/default-categories.json
``` ```
### loaddata ### loaddata
Load fixture data. Alias: load
- path: The path to the JSON file. When a model is provided, this defaults to `fixtures/app/model.json`. Otherwise, it is `fixtures/app/initial.json`. Load fixture data.
### migrate
Run database migrations. - target (str): Required. The name of the app or `app.Model`.
- format (str): `json` (default) or `xml`
- path (str): The path to the JSON file. When a model is provided, this defaults to `fixtures/app/model.json`. Otherwise, it is `fixtures/app/initial.json`.
```ini ```ini
[run database migrations] [load project categories]
django: migrate django.load: projects
``` path: local/projects/fixtures/default-categories.json
```yaml
- run database migrations:
django: migrate
``` ```
### static ### migrate
Collect static files. Run database migrations.
```ini ```ini
[collect static files] [run database migrations]
django: static django.migrate:
``` stop: yes
```yaml
- collect static files:
django: static
``` ```
## Custom or Ad Hoc Commands ## Custom or Ad Hoc Commands
@ -108,11 +121,4 @@ first_option_name: asdf
second_option_name: 1234 second_option_name: 1234
third_option_name: yes third_option_name: yes
``` ```
```yaml
- run any django command:
django: command_name
first_option_name: asdf
second_option_name: 1234
third_option_name: yes
``` ```

@ -6,14 +6,18 @@ Script Tease is a library and command line tool for generating Bash commands pro
The primary focus (and limit) is to convert plain text instructions (in INI or YAML format) into valid command line statements for a given platform. It does *not* provide support for executing those statements. The primary focus (and limit) is to convert plain text instructions (in INI or YAML format) into valid command line statements for a given platform. It does *not* provide support for executing those statements.
!!! warning
YAML support is untested.
## Concepts ## Concepts
### Command Generation ### Command Generation
Script Tease may be used in two (2) ways: Script Tease may be used in three (3) ways:
1. Using the library to programmatically define commands and export them as command line statements. 1. Using the library to programmatically define commands and export them as command line statements.
2. Using the `tease` command to generate commands from a configuration file. See [command file configuration](config/command-file.md). 2. Using the `tease` command to generate commands from a configuration file. See [command file configuration](config/command-file.md).
3. Using the command file format and library to create a custom implementation.
This documentation focuses on the second method, but the developer docs may be used in your own implementation. This documentation focuses on the second method, but the developer docs may be used in your own implementation.
@ -21,29 +25,30 @@ This documentation focuses on the second method, but the developer docs may be u
The format of INI and YAML files is self-documenting. The command comment is this section (INI) or start of a list item (YAML). This ensures that all commands have a basic description of their purpose or intent. The format of INI and YAML files is self-documenting. The command comment is this section (INI) or start of a list item (YAML). This ensures that all commands have a basic description of their purpose or intent.
### Snippets ```ini
[install apache]
An *snippet* is simply a tokenized command that may be customized based on the instructions found in a command file. Related snippets are collected into groups and then merged into a larger set that define the capabilities of a specific operating system. install: apache2
!!! note ```
At present, the only fully defined operating systems are for Cent OS and Ubuntu.
Snippets are defined in Python dictionaries. These include a "canonical" command name as the key and either a string or list which define the command. In both cases, the contents are parsed as Jinja templates. There are various approaches to evaluating a snippet. ```yaml
- install apache
install: apache2
First: The snippet is a simple mapping of command name and command snippet. This is easy. Find the command name in the dictionary, and we have the snippet to be used. For example the `append` command in the `posix` dictionary. ```
Second: The snippet is a mapping of command name and a list of snippets to be combined. Find the command name in the dictionary, and iterate through the snippets. For example, many of the commands in the `posix` dictionary takes this form. Command identification is the same as the first condition. ### Representing Commands
Third: The command is a mapping to informal sub-commands. Examples include `apache` and `system` in the `ubuntu` dictionary. There are a couple of ways to handle this in the config file: All commands are represented by simple, Python functions. These functions are responsible for accepting the arguments provided (usually by a command loader) and converting them into a common `Command` instance. This instance is then capable of generating a finished statement that may be used on the command line.
- Use the outer command as the command with the inner command as the first (and perhaps only) argument. For example `apache: reload` or `system: upgrade`. #### Profiles
- Use a "dotted path" to find the command. For example: `apache.reload: (implicity True)` or `system.upgrade: (implicitly True)`. Or `apache.enable_site: example.com`.
The first approach complicates things when detecting actual sub-commands (below). Script Tease supports both of these approaches. Profiles contain command functions that are specific to an operating system. Not all operating systems support the same commands.
Fourth: The command also expects a sub-command. In some cases, the sub-command may be implicit, like `pip install`. In other cases, a number of sub-commands may be pre-defined, but ad hoc sub-commands should also be supported as with Django commands. !!! note
At present, the only fully defined operating systems are for Cent OS and Ubuntu.
Fifth: Builds upon the third and fourth conditions where the commands have lots of options, some of which may be defined at runtime. Postgres and MySQL may use be presented as informal sub-commands, but there are lots of options and challenges in building the final command. Django management commands have a number of standard options, specific options, and must also support ad hoc commands. Profiles import and appropriate all other available commands.
## Terms and Definitions ## Terms and Definitions

@ -76,11 +76,11 @@ stop: postgresql
### system ### system
With with the system. Work with the system.
- `system.reboot` - `reboot`
- `system.update` - `update`
- `system.upgrade` - `upgrade`
### uninstall ### uninstall
@ -111,14 +111,17 @@ Create a user:
```ini ```ini
[create the deploy user] [create the deploy user]
user.add: deploy user: deploy
groups: www-data groups: sudo, www-data
home: /var/www home: /var/www
sudo: yes
``` ```
Remove a user: Remove a user:
```ini ```ini
[remove bob] [remove bob]
user.remove: bob user: bob
op: remove
sudo: yes
``` ```

@ -76,11 +76,11 @@ stop: postgresql
### system ### system
With with the system. Work with the system.
- `system.reboot` - `reboot`
- `system.update` - `update`
- `system.upgrade` - `upgrade`
### uninstall ### uninstall
@ -111,14 +111,17 @@ Create a user:
```ini ```ini
[create the deploy user] [create the deploy user]
user.add: deploy user: deploy
groups: www-data groups: sudo, www-data
home: /var/www home: /var/www
sudo: yes
``` ```
Remove a user: Remove a user:
```ini ```ini
[remove bob] [remove bob]
user.remove: bob user: bob
op: remove
sudo: yes
``` ```

@ -6,6 +6,7 @@ markdown_extensions:
- admonition - admonition
- attr_list - attr_list
- def_list - def_list
- pymdownx.superfences
nav: nav:
- Home: index.md - Home: index.md
- Configuration: - Configuration:
@ -13,14 +14,30 @@ nav:
- Variables: config/variables.md - Variables: config/variables.md
- Commands: - Commands:
- Django: commands/django.md - Django: commands/django.md
- Messages: commands/messages.md
- MySQL: commands/mysql.md - MySQL: commands/mysql.md
- Messages: commands/messages.md
- PHP: commands/php.md - PHP: commands/php.md
- Postgres: commands/pgsql.md - Postgres: commands/pgsql.md
- POSIX: commands/posix.md - POSIX: commands/posix.md
- Python: commands/python.md - Python: commands/python.md
- Profiles: - Profiles:
- CentOS: profiles/centos.md
- Ubuntu: profiles/ubuntu.md - Ubuntu: profiles/ubuntu.md
# - Developer Reference: /developers/ repo_name: Git Traction
repo_name: GitLab repo_url: https://gittraction.com/diff6/python-scripttease
repo_url: https://git.sixgunsoftware.com/python-scripttease theme:
name: material
palette:
# Palette toggle for light mode
- scheme: default
toggle:
icon: material/brightness-7
name: Switch to dark mode
# Palette toggle for dark mode
- scheme: slate
toggle:
icon: material/brightness-4
name: Switch to light mode

@ -1,9 +1,15 @@
[project] [project]
category = developer category = developer
description = A collection of classes and commands for automated command line scripting using Pythonn. description = A collection of classes and commands for automated command line scripting using Python.
icon = fas fa-scroll
title = Python Script Tease title = Python Script Tease
type = cli type = cli
[business] [business]
code = PTL code = PTL
name = Pleasant Tents, LLC name = Pleasant Tents, LLC
[license]
code = bsd3
name = 3-Clause BSD
url = https://opensource.org/licenses/BSD-3-Clause

@ -1,382 +0,0 @@
#! /usr/bin/env python
from argparse import ArgumentParser, RawDescriptionHelpFormatter
from commonkit import highlight_code, indent, smart_cast, write_file
from commonkit.logging import LoggingHelper
from commonkit.shell import EXIT
from markdown import markdown
import os
import sys
sys.path.insert(0, "../")
from scripttease.constants import LOGGER_NAME
from scripttease.lib.contexts import Context
from scripttease.lib.loaders import load_variables, INILoader, YMLLoader
from scripttease.variables import PATH_TO_SCRIPT_TEASE
from scripttease.version import DATE as VERSION_DATE, VERSION
DEBUG = 10
logging = LoggingHelper(colorize=True, name=LOGGER_NAME)
log = logging.setup()
def execute():
"""Process script configurations."""
__author__ = "Shawn Davis <shawn@develmaycare.com>"
__date__ = VERSION_DATE
__help__ = """NOTES
This command is used to parse configuration files and output the commands.
"""
__version__ = VERSION + "+new"
# Main argument parser from which sub-commands are created.
parser = ArgumentParser(description=__doc__, epilog=__help__, formatter_class=RawDescriptionHelpFormatter)
parser.add_argument(
"path",
default="steps.ini",
nargs="?",
help="The path to the configuration file."
)
parser.add_argument(
"-c",
"--color",
action="store_true",
dest="color_enabled",
help="Enable code highlighting for terminal output."
)
parser.add_argument(
"-C=",
"--context=",
action="append",
dest="variables",
help="Context variables for use in pre-parsing the config and templates. In the form of: name:value"
)
parser.add_argument(
"-d=",
"--docs=",
choices=["html", "markdown", "plain", "rst"],
# default="markdown",
dest="docs",
help="Output documentation instead of code."
)
parser.add_argument(
"-D",
"--debug",
action="store_true",
dest="debug_enabled",
help="Enable debug output."
)
parser.add_argument(
"-f=",
"--filter=",
action="append",
dest="filters",
help="Filter the commands in the form of: attribute:value"
)
parser.add_argument(
"-i=",
"--inventory=",
dest="inventory",
help="Copy an inventory item to a local directory."
)
parser.add_argument(
"-o=",
"--option=",
action="append",
dest="options",
help="Common command options in the form of: name:value"
)
parser.add_argument(
"-P=",
"--profile=",
choices=["centos", "ubuntu"],
default="ubuntu",
dest="profile",
help="The OS profile to use."
)
parser.add_argument(
"-T=",
"--template-path=",
action="append",
dest="template_locations",
help="The location of template files that may be used with the template command."
)
parser.add_argument(
"-w=",
"--write=",
dest="output_file",
help="Write the output to disk."
)
parser.add_argument(
"-V=",
"--variables-file=",
dest="variables_file",
help="Load variables from a file."
)
# Access to the version number requires special consideration, especially
# when using sub parsers. The Python 3.3 behavior is different. See this
# answer: http://stackoverflow.com/questions/8521612/argparse-optional-subparser-for-version
parser.add_argument(
"-v",
action="version",
help="Show version number and exit.",
version=__version__
)
parser.add_argument(
"--version",
action="version",
help="Show verbose version information and exit.",
version="%(prog)s" + " %s %s by %s" % (__version__, __date__, __author__)
)
# Parse arguments.
args = parser.parse_args()
if args.debug_enabled:
log.setLevel(DEBUG)
log.debug("Namespace: %s" % args)
# Create the global context.
context = Context()
if args.variables_file:
variables = load_variables(args.variables_file)
for v in variables:
context.variables[v.name] = v
if args.variables:
for token in args.variables:
try:
key, value = token.split(":")
context.add(key, smart_cast(value))
except ValueError:
context.add(token, True)
# Capture filters.
if args.filters:
filters = dict()
for token in args.filters:
key, value = token.split(":")
if key not in filters:
filters[key] = list()
filters[key].append(value)
# Handle global command options.
options = dict()
if args.options:
for token in args.options:
try:
key, value = token.split(":")
options[key] = smart_cast(value)
except ValueError:
options[token] = True
# The path may have been given as a file name (steps.ini), path, or an inventory name.
input_locations = [
args.path,
os.path.join(PATH_TO_SCRIPT_TEASE, "data", "inventory", args.path, "steps.ini"),
os.path.join(PATH_TO_SCRIPT_TEASE, "data", "inventory", args.path, "steps.yml"),
]
path = None
for location in input_locations:
if os.path.exists(location):
path = location
break
if path is None:
log.warning("Path does not exist: %s" % args.path)
exit(EXIT.INPUT)
# Load the commands.
if path.endswith(".ini"):
loader = INILoader(
path,
context=context,
locations=args.template_locations,
profile=args.profile,
**options
)
elif path.endswith(".yml"):
loader = YMLLoader(
path,
context=context,
locations=args.template_locations,
profile=args.profile,
**options
)
else:
log.error("Unsupported file format: %s" % path)
exit(EXIT.ERROR)
# noinspection PyUnboundLocalVariable
if not loader.load():
log.error("Failed to load the input file: %s" % path)
exit(EXIT.ERROR)
# Validate snippets before continuing.
valid = list()
for snippet in loader.get_snippets():
if snippet.is_valid:
valid.append(True)
else:
log.error("Invalid snippet: %s" % snippet.name)
valid.append(False)
if not all(valid):
exit(EXIT.ERROR)
# Generate output.
if args.docs:
output = list()
for snippet in loader.get_snippets():
# Will this every happen?
# if snippet is None:
# continue
if snippet.name == "explain":
if snippet.header:
if args.docs == "plain":
output.append("***** %s *****" % snippet.name.title())
elif args.docs == "rst":
output.append(snippet.name.title())
output.append("=" * len(snippet.name))
else:
output.append("## %s" % snippet.name.title())
output.append("")
output.append(snippet.content)
output.append("")
elif snippet.name == "screenshot":
if args.docs == "html":
b = list()
b.append('<img src="%s"' % snippet.args[0])
b.append('alt="%s"' % snippet.caption or snippet.comment)
if snippet.classes:
b.append('class="%s"' % snippet.classes)
if snippet.height:
b.append('height="%s"' % snippet.height)
if snippet.width:
b.append('width="%s"' % snippet)
output.append(" ".join(b) + ">")
output.append("")
elif args.docs == "plain":
output.append(snippet.args[0])
output.append("")
elif args.docs == "rst":
output.append(".. figure:: %s" % snippet.args[0])
if snippet.caption:
output.append(indent(":alt: %s" % snippet.caption or snippet.comment))
if snippet.height:
output.append(indent(":height: %s" % snippet.height))
if snippet.width:
output.append(indent(":width: %s" % snippet.width))
output.append("")
else:
output.append("![%s](%s)" % (snippet.caption or snippet.comment, snippet.args[0]))
output.append("")
elif snippet.name == "template":
if args.docs == "plain":
output.append("+++")
output.append(snippet.get_content())
output.append("+++")
elif args.docs == "rst":
output.append(".. code-block:: %s" % snippet.get_target_language())
output.append("")
output.append(indent(snippet.get_content()))
output.append("")
else:
output.append("```%s" % snippet.get_target_language())
output.append(snippet.get_content())
output.append("```")
output.append("")
else:
statement = snippet.get_statement(include_comment=False, include_register=False, include_stop=False)
if statement is not None:
line = snippet.comment.replace("#", "")
output.append("%s:" % line.capitalize())
output.append("")
if args.docs == "plain":
output.append("---")
output.append(statement)
output.append("---")
output.append("")
elif args.docs == "rst":
output.append(".. code-block:: bash")
output.append("")
output.append(indent(statement))
output.append("")
else:
output.append("```bash")
output.append(statement)
output.append("```")
output.append("")
if args.docs == "html":
_output = markdown("\n".join(output), extensions=['fenced_code'])
else:
_output = "\n".join(output)
print(_output)
if args.output_file:
write_file(args.output_file, _output)
else:
commands = list()
for snippet in loader.get_snippets():
# Explanations and screenshots don't produce usable statements but may be added as comments.
if snippet.name in ("explain", "screenshot"):
# commands.append("# %s" % snippet.content)
# commands.append("")
continue
statement = snippet.get_statement()
if statement is not None:
commands.append(statement)
commands.append("")
if args.color_enabled:
print(highlight_code("\n".join(commands), language="bash"))
else:
print("\n".join(commands))
if args.output_file:
write_file(args.output_file, "\n".join(commands))
exit(EXIT.OK)
if __name__ == '__main__':
execute()

@ -5,10 +5,9 @@ import sys
sys.path.insert(0, "../") sys.path.insert(0, "../")
from scripttease.cli import execute from scripttease.cli import main_command
if __name__ == '__main__': if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(execute()) sys.exit(main_command())
# old:
# sys.exit(main_command())

@ -1,22 +1,15 @@
# Imports # Imports
from argparse import ArgumentParser, RawDescriptionHelpFormatter from argparse import ArgumentParser, RawDescriptionHelpFormatter
from commonkit.logging import LoggingHelper from commonkit.logging import LoggingHelper
from ..variables import LOGGER_NAME, PATH_TO_SCRIPT_TEASE from commonkit.shell import EXIT
from ..lib.contexts import Context
from ..lib.loaders import load_variables
from ..variables import LOGGER_NAME
from ..version import DATE as VERSION_DATE, VERSION from ..version import DATE as VERSION_DATE, VERSION
from . import initialize from . import initialize
from . import subcommands from . import subcommands
# New:
from commonkit import highlight_code, indent, smart_cast, write_file
from commonkit.shell import EXIT
from markdown import markdown
import os
from scripttease.lib.contexts import Context
from scripttease.lib.factories import command_factory
from scripttease.lib.loaders import load_variables, INILoader, YMLLoader
DEBUG = 10 DEBUG = 10
logging = LoggingHelper(colorize=True, name=LOGGER_NAME) logging = LoggingHelper(colorize=True, name=LOGGER_NAME)
@ -25,7 +18,7 @@ log = logging.setup()
# Commands # Commands
def execute(): def main_command():
"""Process script configurations.""" """Process script configurations."""
__author__ = "Shawn Davis <shawn@develmaycare.com>" __author__ = "Shawn Davis <shawn@develmaycare.com>"
@ -40,99 +33,14 @@ def execute():
# Main argument parser from which sub-commands are created. # Main argument parser from which sub-commands are created.
parser = ArgumentParser(description=__doc__, epilog=__help__, formatter_class=RawDescriptionHelpFormatter) parser = ArgumentParser(description=__doc__, epilog=__help__, formatter_class=RawDescriptionHelpFormatter)
parser.add_argument( # Initialize sub-commands.
"path", subparsers = parser.add_subparsers(
default="steps.ini", dest="subcommand",
nargs="?", help="Commands",
help="The path to the configuration file." metavar="docs, inventory, script"
)
parser.add_argument(
"-c",
"--color",
action="store_true",
dest="color_enabled",
help="Enable code highlighting for terminal output."
)
parser.add_argument(
"-C=",
"--context=",
action="append",
dest="variables",
help="Context variables for use in pre-parsing the config and templates. In the form of: name:value"
)
parser.add_argument(
"-d=",
"--docs=",
choices=["html", "markdown", "plain", "rst"],
# default="markdown",
dest="docs",
help="Output documentation instead of code."
)
parser.add_argument(
"-D",
"--debug",
action="store_true",
dest="debug_enabled",
help="Enable debug output."
)
parser.add_argument(
"-f=",
"--filter=",
action="append",
dest="filters",
help="Filter the commands in the form of: attribute:value"
)
parser.add_argument(
"-i=",
"--inventory=",
dest="inventory",
help="Copy an inventory item to a local directory."
)
parser.add_argument(
"-o=",
"--option=",
action="append",
dest="options",
help="Common command options in the form of: name:value"
) )
parser.add_argument( initialize.subcommands(subparsers)
"-P=",
"--profile=",
choices=["centos", "ubuntu"],
default="ubuntu",
dest="profile",
help="The OS profile to use."
)
parser.add_argument(
"-T=",
"--template-path=",
action="append",
dest="template_locations",
help="The location of template files that may be used with the template command."
)
parser.add_argument(
"-w=",
"--write=",
dest="output_file",
help="Write the output to disk."
)
parser.add_argument(
"-V=",
"--variables-file=",
dest="variables_file",
help="Load variables from a file."
)
# Access to the version number requires special consideration, especially # Access to the version number requires special consideration, especially
# when using sub parsers. The Python 3.3 behavior is different. See this # when using sub parsers. The Python 3.3 behavior is different. See this
@ -159,6 +67,8 @@ def execute():
log.debug("Namespace: %s" % args) log.debug("Namespace: %s" % args)
# Load resources for docs and script output.
if args.subcommand in ["docs", "script"]:
# Create the global context. # Create the global context.
context = Context() context = Context()
@ -168,394 +78,42 @@ def execute():
context.variables[v.name] = v context.variables[v.name] = v
if args.variables: if args.variables:
for token in args.variables: initialize.variables_from_cli(context, args.variables)
try:
key, value = token.split(":")
context.add(key, smart_cast(value))
except ValueError:
context.add(token, True)
# Capture filters.
if args.filters:
filters = dict()
for token in args.filters:
key, value = token.split(":")
if key not in filters:
filters[key] = list()
filters[key].append(value)
# Handle global command options. # Handle global command options.
options = dict() options = dict()
if args.options: if args.options:
for token in args.options: options = initialize.options_from_cli(args.options)
try:
key, value = token.split(":")
options[key] = smart_cast(value)
except ValueError:
options[token] = True
# The path may have been given as a file name (steps.ini), path, or an inventory name.
input_locations = [
args.path,
os.path.join(PATH_TO_SCRIPT_TEASE, "data", "inventory", args.path, "steps.ini"),
os.path.join(PATH_TO_SCRIPT_TEASE, "data", "inventory", args.path, "steps.yml"),
]
path = None
for location in input_locations:
if os.path.exists(location):
path = location
break
if path is None:
log.warning("Path does not exist: %s" % args.path)
exit(EXIT.INPUT)
# Load the commands. loader = initialize.loader(
if path.endswith(".ini"): args.command_file,
loader = INILoader(
path,
context=context,
locations=args.template_locations,
profile=args.profile,
**options
)
elif path.endswith(".yml"):
loader = YMLLoader(
path,
context=context, context=context,
locations=args.template_locations, options=options,
profile=args.profile, template_locations=args.template_locations
**options
) )
else: if loader is None:
log.error("Unsupported file format: %s" % path)
exit(EXIT.ERROR) exit(EXIT.ERROR)
# Handle sub-commands.
if args.subcommand == "docs":
# noinspection PyUnboundLocalVariable # noinspection PyUnboundLocalVariable
if not loader.load(): exit_code = subcommands.generate_docs(
log.error("Failed to load the input file: %s" % path) loader,
exit(EXIT.ERROR) output_file=args.output_file,
output_format=args.output_format
# Generate output. )
if args.docs: elif args.subcommand in ["inv", "inventory"]:
output = list() exit_code = subcommands.copy_inventory(args.name, to_path=args.to_path)
for command in loader.commands: elif args.subcommand == "script":
# noinspection PyUnboundLocalVariable
# Will this every happen? exit_code = subcommands.generate_script(
# if command is None: loader,
# continue
if command.name == "explain":
if command.header:
if args.docs == "plain":
output.append("***** %s *****" % command.name.title())
elif args.docs == "rst":
output.append(command.name.title())
output.append("=" * len(command.name))
else:
output.append("## %s" % command.name.title())
output.append("")
output.append(command.content)
output.append("")
elif command.name == "screenshot":
if args.docs == "html":
b = list()
b.append('<img src="%s"' % command.args[0])
b.append('alt="%s"' % command.caption or command.comment)
if command.classes:
b.append('class="%s"' % command.classes)
if command.height:
b.append('height="%s"' % command.height)
if command.width:
b.append('width="%s"' % command)
output.append(" ".join(b) + ">")
output.append("")
elif args.docs == "plain":
output.append(command.args[0])
output.append("")
elif args.docs == "rst":
output.append(".. figure:: %s" % command.args[0])
if command.caption:
output.append(indent(":alt: %s" % command.caption or command.comment))
if command.height:
output.append(indent(":height: %s" % command.height))
if command.width:
output.append(indent(":width: %s" % command.width))
output.append("")
else:
output.append("![%s](%s)" % (command.caption or command.comment, command.args[0]))
output.append("")
elif command.name == "template":
if args.docs == "plain":
output.append("+++")
output.append(command.get_content())
output.append("+++")
elif args.docs == "rst":
output.append(".. code-block:: %s" % command.get_target_language())
output.append("")
output.append(indent(command.get_content()))
output.append("")
else:
output.append("```%s" % command.get_target_language())
output.append(command.get_content())
output.append("```")
output.append("")
else:
statement = command.get_statement(include_comment=False, include_register=False, include_stop=False)
if statement is not None:
line = command.comment.replace("#", "")
output.append("%s:" % line.capitalize())
output.append("")
if args.docs == "plain":
output.append("---")
output.append(statement)
output.append("---")
output.append("")
elif args.docs == "rst":
output.append(".. code-block:: bash")
output.append("")
output.append(indent(statement))
output.append("")
else:
output.append("```bash")
output.append(statement)
output.append("```")
output.append("")
if args.docs == "html":
_output = markdown("\n".join(output), extensions=['fenced_code'])
else:
_output = "\n".join(output)
print(_output)
if args.output_file:
write_file(args.output_file, _output)
else:
commands = command_factory(loader)
output = list()
for command in commands:
# print("COMMAND", command)
# Explanations and screenshots don't produce usable statements but may be added as comments.
if command.name in ("explain", "screenshot"):
# commands.append("# %s" % command.content)
# commands.append("")
continue
statement = command.get_statement(include_comment=False)
if statement is not None:
output.append(statement)
output.append("")
if args.color_enabled:
print(highlight_code("\n".join(output), language="bash"))
else:
print("\n".join(output))
if args.output_file:
write_file(args.output_file, "\n".join(output))
exit(EXIT.OK)
def main_command():
"""Process script configurations."""
__author__ = "Shawn Davis <shawn@develmaycare.com>"
__date__ = VERSION_DATE
__help__ = """NOTES
This command is used to parse configuration files and output the commands.
"""
__version__ = VERSION
# Main argument parser from which sub-commands are created.
parser = ArgumentParser(description=__doc__, epilog=__help__, formatter_class=RawDescriptionHelpFormatter)
parser.add_argument(
"path",
default="commands.ini",
nargs="?",
help="The path to the configuration file."
)
parser.add_argument(
"-c",
"--color",
action="store_true",
dest="color_enabled",
help="Enable code highlighting for terminal output."
)
parser.add_argument(
"-C=",
"--context=",
action="append",
dest="variables",
help="Context variables for use in pre-parsing the config and templates. In the form of: name:value"
)
parser.add_argument(
"-d",
"--docs",
action="store_true",
dest="docs_enabled",
help="Output documentation instead of code."
)
# parser.add_argument(
# "-d=",
# "--docs=",
# choices=["html", "markdown", "plain", "rst"],
# dest="docs_enabled",
# help="Output documentation instead of code."
# )
parser.add_argument(
"-D",
"--debug",
action="store_true",
dest="debug_enabled",
help="Enable debug output."
)
parser.add_argument(
"-f=",
"--filter=",
action="append",
dest="filters",
help="Filter the commands in the form of: attribute:value"
)
parser.add_argument(
"-O=",
"--option=",
action="append",
dest="options",
help="Common command options in the form of: name:value"
)
# parser.add_argument(
# "-O=",
# "--output=",
# # default=os.path.join("prototype", "output"),
# dest="output_path",
# help="Output to the given directory. Defaults to ./prototype/output/"
# )
parser.add_argument(
"-s",
"--script",
action="store_true",
dest="script_enabled",
help="Output commands as a script."
)
parser.add_argument(
"-T=",
"--template-path=",
action="append",
dest="template_locations",
help="The location of template files that may be used with the template command."
)
parser.add_argument(
"-w=",
"--write=",
dest="output_file",
help="Write the output to disk."
)
parser.add_argument(
"-V=",
"--variables-file=",
dest="variables_file",
help="Load variables from a file."
)
# Access to the version number requires special consideration, especially
# when using sub parsers. The Python 3.3 behavior is different. See this
# answer: http://stackoverflow.com/questions/8521612/argparse-optional-subparser-for-version
parser.add_argument(
"-v",
action="version",
help="Show version number and exit.",
version=__version__
)
parser.add_argument(
"--version",
action="version",
help="Show verbose version information and exit.",
version="%(prog)s" + " %s %s by %s" % (__version__, __date__, __author__)
)
# Parse arguments.
args = parser.parse_args()
if args.debug_enabled:
log.setLevel(DEBUG)
log.debug("Namespace: %s" % args)
# Load context.
context = dict()
if args.variables:
context = initialize.context_from_cli(args.variables)
# Load additional context from file.
if args.variables_file:
variables = initialize.variables_from_file(args.variables_file)
if variables:
context.update(variables)
# Handle filters.
filters = None
if args.filters:
filters = initialize.filters_from_cli(args.filters)
# Handle options.
options = None
if args.options:
options = initialize.options_from_cli(args.options)
# Process the request.
if args.docs_enabled:
exit_code = subcommands.output_docs(
args.path,
context=context,
filters=filters,
locations=args.template_locations,
options=options
)
elif args.script_enabled:
exit_code = subcommands.output_script(
args.path,
color_enabled=args.color_enabled, color_enabled=args.color_enabled,
context=context, include_shebang=args.include_shebang,
locations=args.template_locations, output_file=args.output_file
options=options
) )
else: else:
exit_code = subcommands.output_commands( print("Unrecognized command: %s" % args.subcommand)
args.path, exit_code = EXIT.USAGE
color_enabled=args.color_enabled,
context=context,
filters=filters,
locations=args.template_locations,
options=options
)
exit(exit_code) exit(exit_code)

@ -1,126 +1,374 @@
# Imports import logging
from commonkit import smart_cast from commonkit import smart_cast
from configparser import ConfigParser from commonkit.shell import EXIT
import logging import logging
import os import os
from ..constants import LOGGER_NAME from ..lib.loaders import load_variables, INILoader, YMLLoader
from ..variables import LOGGER_NAME, PATH_TO_SCRIPT_TEASE
log = logging.getLogger(LOGGER_NAME) log = logging.getLogger(LOGGER_NAME)
# Exports
__all__ = (
"context_from_cli",
"filters_from_cli",
"options_from_cli",
"variables_from_file",
)
# Functions
def context_from_cli(variables):
"""Takes a list of variables given in the form of ``name:value`` and converts them to a dictionary.
:param variables: A list of strings of ``name:value`` pairs.
:type variables: list[str]
:rtype: dict
The ``value`` of the pair passes through "smart casting" to convert it to the appropriate Python data type.
"""
context = dict()
for i in variables:
key, value = i.split(":")
context[key] = smart_cast(value)
return context
def filters_from_cli(filters):
"""Takes a list of filters given in the form of ``name:value`` and converts them to a dictionary.
:param filters: A list of strings of ``attribute:value`` pairs.
:type filters: list[str]
:rtype: dict
"""
_filters = dict()
for i in filters:
key, value = i.split(":")
if key not in filters:
_filters[key] = list()
_filters[key].append(value)
return _filters
def options_from_cli(options): def options_from_cli(options):
"""Takes a list of variables given in the form of ``name:value`` and converts them to a dictionary.
:param options: A list of strings of ``name:value`` pairs.
:type options: list[str]
:rtype: dict
The ``value`` of the pair passes through "smart casting" to convert it to the appropriate Python data type.
"""
_options = dict() _options = dict()
for i in options: for token in options:
key, value = i.split(":") try:
key, value = token.split(":")
_options[key] = smart_cast(value) _options[key] = smart_cast(value)
except ValueError:
_options[token] = True
return _options return _options
def variables_from_file(path): def loader(path, context=None, options=None, template_locations=None):
"""Loads variables from a given INI file. # The path may have been given as a file name (steps.ini), path, or an inventory name.
input_locations = [
:param path: The path to the INI file. path,
:type path: str os.path.join(PATH_TO_SCRIPT_TEASE, "data", "inventory", path, "steps.ini"),
os.path.join(PATH_TO_SCRIPT_TEASE, "data", "inventory", path, "steps.yml"),
:rtype: dict | None ]
path = None
The resulting dictionary flattens the sections and values. For example: for location in input_locations:
if os.path.exists(location):
path = location
break
if path is None:
log.warning("Path does not exist: %s" % path)
return None
.. code-block:: ini # Initialize the loader.
if path.endswith(".ini"):
_loader = INILoader(
path,
context=context,
locations=template_locations,
**options
)
elif path.endswith(".yml"):
_loader = YMLLoader(
path,
context=context,
locations=template_locations,
**options
)
else:
log.error("Unsupported file format: %s" % path)
return None
[copyright] # Load the commands.
name = ACME, Inc. if not _loader.load():
year = 2020 log.error("Failed to load the input file: %s" % path)
return None
[domain] return _loader
name = example.com
tld = example_com
The dictionary would contain:
.. code-block:: python def subcommands(subparsers):
"""Initialize sub-commands.
{ :param subparsers: The subparsers instance from argparse.
'copyright_name': "ACME, Inc.",
'copyright_year': 2020,
'domain_name': "example.com",
'domain_tld': "example_com",
}
""" """
if not os.path.exists(path): sub = SubCommands(subparsers)
log.warning("Variables file does not exist: %s" % path) sub.docs()
return None sub.inventory()
sub.script()
ini = ConfigParser()
ini.read(path)
def variables_from_cli(context, variables):
variables = dict() for token in variables:
for section in ini.sections(): try:
for key, value in ini.items(section): key, value = token.split(":")
key = "%s_%s" % (section, key) context.add(key, smart_cast(value))
variables[key] = smart_cast(value) except ValueError:
context.add(token, True)
return variables
class SubCommands(object):
def __init__(self, subparsers):
self.subparsers = subparsers
def docs(self):
sub = self.subparsers.add_parser(
"docs",
help="Output documentation instead of code."
)
sub.add_argument(
"-o=",
"--output-format=",
choices=["html", "md", "plain", "rst"],
default="md",
dest="output_format",
help="The output format; HTML, Markdown, plain text, or ReStructuredText."
)
self._add_script_options(sub)
self._add_common_options(sub)
def inventory(self):
sub = self.subparsers.add_parser(
"inventory",
aliases=["inv"],
help="Copy an inventory item to a local directory."
)
sub.add_argument(
"name",
help="The name of the inventory item. Use ? to list available items."
)
sub.add_argument(
"-P=",
"--path=",
dest="to_path",
help="The path to where the item should be copied. Defaults to the current working directory."
)
self._add_common_options(sub)
def script(self):
sub = self.subparsers.add_parser(
"script",
help="Output the commands."
)
sub.add_argument(
"-c",
"--color",
action="store_true",
dest="color_enabled",
help="Enable code highlighting for terminal output."
)
sub.add_argument(
"-s",
"--shebang",
action="store_true",
dest="include_shebang",
help="Add the shebang to the beginning of the output."
)
self._add_script_options(sub)
self._add_common_options(sub)
def _add_common_options(self, sub):
sub.add_argument(
"-D",
"--debug",
action="store_true",
dest="debug_enabled",
help="Enable debug mode. Produces extra output."
)
sub.add_argument(
"-p",
action="store_true",
dest="preview_enabled",
help="Preview mode."
)
def _add_script_options(self, sub):
sub.add_argument(
"-C=",
"--context=",
action="append",
dest="variables",
help="Context variables for use in pre-parsing the config and templates. In the form of: name:value"
)
sub.add_argument(
"-i=",
"--input-file=",
default="commands.ini",
dest="command_file",
help="The path to the configuration file."
)
# sub.add_argument(
# "-f=",
# "--filter=",
# action="append",
# dest="filters",
# help="Filter the commands in the form of: attribute:value"
# )
# Capture filters.
# if args.filters:
# filters = dict()
# for token in args.filters:
# key, value = token.split(":")
# if key not in filters:
# filters[key] = list()
#
# filters[key].append(value)
sub.add_argument(
"-O=",
"--option=",
action="append",
dest="options",
help="Common command options in the form of: name:value"
)
sub.add_argument(
"-P=",
"--profile=",
choices=["centos", "ubuntu"],
default="ubuntu",
dest="profile",
help="The OS profile to use."
)
sub.add_argument(
"-T=",
"--template-path=",
action="append",
dest="template_locations",
help="The location of template files that may be used with the template command."
)
sub.add_argument(
"-w=",
"--write=",
dest="output_file",
help="Write the output to disk."
)
sub.add_argument(
"-V=",
"--variables-file=",
dest="variables_file",
help="Load variables from a file."
)
# # Imports
#
# from commonkit import smart_cast
# from configparser import ConfigParser
# import logging
# import os
# from ..constants import LOGGER_NAME
#
# log = logging.getLogger(LOGGER_NAME)
#
# # Exports
#
# __all__ = (
# "context_from_cli",
# "filters_from_cli",
# "options_from_cli",
# "variables_from_file",
# )
#
# # Functions
#
#
# def context_from_cli(variables):
# """Takes a list of variables given in the form of ``name:value`` and converts them to a dictionary.
#
# :param variables: A list of strings of ``name:value`` pairs.
# :type variables: list[str]
#
# :rtype: dict
#
# The ``value`` of the pair passes through "smart casting" to convert it to the appropriate Python data type.
#
# """
# context = dict()
# for i in variables:
# key, value = i.split(":")
# context[key] = smart_cast(value)
#
# return context
#
#
# def filters_from_cli(filters):
# """Takes a list of filters given in the form of ``name:value`` and converts them to a dictionary.
#
# :param filters: A list of strings of ``attribute:value`` pairs.
# :type filters: list[str]
#
# :rtype: dict
#
# """
# _filters = dict()
# for i in filters:
# key, value = i.split(":")
# if key not in filters:
# _filters[key] = list()
#
# _filters[key].append(value)
#
# return _filters
#
#
# def options_from_cli(options):
# """Takes a list of variables given in the form of ``name:value`` and converts them to a dictionary.
#
# :param options: A list of strings of ``name:value`` pairs.
# :type options: list[str]
#
# :rtype: dict
#
# The ``value`` of the pair passes through "smart casting" to convert it to the appropriate Python data type.
#
# """
# _options = dict()
# for i in options:
# key, value = i.split(":")
# _options[key] = smart_cast(value)
#
# return _options
#
#
# def variables_from_file(path):
# """Loads variables from a given INI file.
#
# :param path: The path to the INI file.
# :type path: str
#
# :rtype: dict | None
#
# The resulting dictionary flattens the sections and values. For example:
#
# .. code-block:: ini
#
# [copyright]
# name = ACME, Inc.
# year = 2020
#
# [domain]
# name = example.com
# tld = example_com
#
# The dictionary would contain:
#
# .. code-block:: python
#
# {
# 'copyright_name': "ACME, Inc.",
# 'copyright_year': 2020,
# 'domain_name': "example.com",
# 'domain_tld': "example_com",
# }
#
# """
# if not os.path.exists(path):
# log.warning("Variables file does not exist: %s" % path)
# return None
#
# ini = ConfigParser()
# ini.read(path)
#
# variables = dict()
# for section in ini.sections():
# for key, value in ini.items(section):
# key = "%s_%s" % (section, key)
# variables[key] = smart_cast(value)
#
# return variables

@ -1,153 +1,181 @@
# Imports # Imports
from commonkit import highlight_code from commonkit import copy_tree, highlight_code, indent, write_file
from commonkit.shell import EXIT from commonkit.shell import EXIT
from ..parsers import load_commands, load_config from markdown import markdown
import os
from ..lib.factories import command_factory
from ..constants import PROFILE
from ..variables import PATH_TO_SCRIPT_TEASE
# Exports # Exports
__all__ = ( __all__ = (
"output_commands", "copy_inventory",
"output_docs", "generate_docs",
"output_script", "generate_script",
) )
# Functions # Functions
def output_commands(path, color_enabled=False, context=None, filters=None, locations=None, options=None): def copy_inventory(name, to_path=None):
"""Output commands found in a given configuration file. """Copy an inventory item to a path.
:param path: The path to the configuration file. :param name: The name of the inventory item. ``?`` will list available items.
:type path: str :type name: str
:param color_enabled: Indicates the output should be colorized. :param to_path: The path to where the item should be copied. Defaults to the current working directory.
:type color_enabled: bool :type to_path: str
:param context: The context to be applied to the file before parsing it as configuration.
:type context: dict
:param filters: Output only those commands which match the given filters.
:type filters: dict
:param locations: The locations (paths) of additional resources.
:type locations: list[str]
:param options: Options to be applied to all commands.
:type options: dict
:rtype: int :rtype: int
:returns: An exit code. :returns: An exit code.
""" """
commands = load_commands( path = os.path.join(PATH_TO_SCRIPT_TEASE, "data", "inventory")
path, if name == "?":
context=context, for d in os.listdir(path):
filters=filters, print(d)
locations=locations,
options=options
)
if commands is None:
return EXIT.ERROR
output = list() return EXIT.OK
for command in commands:
statement = command.get_statement(cd=True)
if statement is None:
continue
output.append(statement) from_path = os.path.join(path, name)
output.append("")
if color_enabled: if to_path is None:
print(highlight_code("\n".join(output), language="bash")) to_path = os.path.join(os.getcwd(), name)
else: os.makedirs(to_path)
print("\n".join(output))
if copy_tree(from_path, to_path):
return EXIT.OK return EXIT.OK
return EXIT.ERROR
def output_docs(path, context=None, filters=None, locations=None, options=None):
"""Output documentation for commands found in a given configuration file.
:param path: The path to the configuration file. def generate_docs(loader, output_file=None, output_format="md", profile=PROFILE.UBUNTU):
:type path: str """Generate documentation from a commands file.
:param context: The context to be applied to the file before parsing it as configuration. :param loader: The loader instance.
:type context: dict :type loader: BaseType[scripttease.lib.loaders.BaseLoader]
:param filters: Output only those commands which match the given filters. :param output_file: The path to the output file.
:type filters: dict :type output_file: str
:param locations: The locations (paths) of additional resources. :param output_format: The output format; ``html``, ``md`` (Markdown, the default), ``plain`` (text), ``rst``
:type locations: list[str] (ReStructuredText).
:type output_format: str
:param options: Options to be applied to all commands. :param profile: The operating system profile to use.
:type options: dict :type profile: str
:rtype: int :rtype: int
:returns: An exit code. :returns: An exit code.
""" """
commands = load_commands( commands = command_factory(loader, profile=profile)
path,
context=context,
filters=filters,
locations=locations,
options=options
)
if commands is None: if commands is None:
return EXIT.ERROR return EXIT.ERROR
count = 1
output = list() output = list()
for command in commands: for command in commands:
output.append("%s. %s" % (count, command.comment))
count += 1
print("\n".join(output)) if command.name in ("explain", "screenshot"):
output.append(command.get_output(output_format))
elif command.name == "template":
if output_format == "plain":
output.append("+++")
output.append(command.get_content())
output.append("+++")
output.append("")
elif output_format == "rst":
output.append(".. code-block:: %s" % command.get_target_language())
output.append("")
output.append(indent(command.get_content()))
output.append("")
else:
output.append("```%s" % command.get_target_language())
output.append(command.get_content())
output.append("```")
output.append("")
else:
statement = command.get_statement(include_comment=False, include_register=False, include_stop=False)
if statement is not None:
line = command.comment.replace("#", "")
output.append("%s:" % line.capitalize())
output.append("")
if output_format == "plain":
output.append("---")
output.append(statement)
output.append("---")
output.append("")
elif output_format == "rst":
output.append(".. code-block:: bash")
output.append("")
output.append(indent(statement))
output.append("")
else:
output.append("```bash")
output.append(statement)
output.append("```")
output.append("")
if output_format == "html":
_output = markdown("\n".join(output), extensions=['fenced_code'])
else:
_output = "\n".join(output)
print(_output)
if output_file:
write_file(output_file, _output)
return EXIT.OK return EXIT.OK
def output_script(path, color_enabled=False, context=None, filters=None, locations=None, options=None): def generate_script(loader, color_enabled=False, include_shebang=False, output_file=None, profile=PROFILE.UBUNTU):
"""Output a script of commands found in a given configuration file. """Generate statements from a commands file.
:param path: The path to the configuration file. :param loader: The loader instance.
:type path: str :type loader: BaseType[scripttease.lib.loaders.BaseLoader]
:param color_enabled: Indicates the output should be colorized. :param color_enabled: Colorize the output.
:type color_enabled: bool :type color_enabled: bool
:param context: The context to be applied to the file before parsing it as configuration. :param include_shebang: Add the shebang to the beginning of the output.
:type context: dict :type include_shebang: bool
:param filters: Output only those commands which match the given filters. NOT IMPLEMENTED.
:type filters: dict
:param locations: The locations (paths) of additional resources. :param output_file: The path to the output file.
:type locations: list[str] :type output_file: str
:param options: Options to be applied to all commands. :param profile: The operating system profile to use.
:type options: dict :type profile: str
:rtype: int :rtype: int
:returns: An exit code. :returns: An exit code.
""" """
config = load_config( commands = command_factory(loader, profile=profile)
path, if commands is None:
context=context,
locations=locations,
options=options
)
if config is None:
return EXIT.ERROR return EXIT.ERROR
script = config.as_script() output = list()
if include_shebang:
output.append("#! /usr/bin/env bash")
for command in commands:
if command.name in ("explain", "screenshot"):
continue
statement = command.get_statement(include_comment=True)
if statement is not None:
output.append(statement)
output.append("")
if color_enabled: if color_enabled:
print(highlight_code(script.to_string(), language="bash")) print(highlight_code("\n".join(output), language="bash"))
else: else:
print(script) print("\n".join(output))
if output_file:
write_file(output_file, "\n".join(output))
return EXIT.OK return EXIT.OK

@ -0,0 +1,6 @@
[package]
description = Install Nextcloud.
docs = https://nextcloud.com
tags = collaboration
title = Nextcloud
version = 0.1.0-d

@ -0,0 +1,6 @@
[package]
description = Install PostgreSQL.
docs = https://postgresql.org
tags = database, postgres
title = PostgreSQL
version = 0.1.0-d

@ -3,7 +3,7 @@ explain: In this tutorial, we are going to install Radicale.
header: yes header: yes
[make sure a maintenance root exists] [make sure a maintenance root exists]
mkdir: /var/www/maint/www dir: /var/www/maint/www
group: www-data group: www-data
owner: www-data owner: www-data
recursive: yes recursive: yes
@ -12,14 +12,14 @@ recursive: yes
explain: The maintenance root is used to register an SSL certificate (below) before the site is completed and (later) after the site is live. explain: The maintenance root is used to register an SSL certificate (below) before the site is completed and (later) after the site is live.
[install radicale] [install radicale]
pip3: radicale pip: radicale
[install radicale screenshot] [install radicale screenshot]
screenshot: images/install.png screenshot: images/install.png
caption: Radical Installed caption: Radical Installed
[create radicale configuration directory] [create radicale configuration directory]
mkdir: /etc/radicale/config dir: /etc/radicale/config
owner: radicale owner: radicale
recursive: yes recursive: yes
@ -27,7 +27,7 @@ recursive: yes
template: config.ini /etc/radicale/config/config.ini template: config.ini /etc/radicale/config/config.ini
[create the radicale user] [create the radicale user]
user.add: radicale user: radicale
home: / home: /
login: /sbin/nologin login: /sbin/nologin
system: yes system: yes

@ -0,0 +1,6 @@
[package]
description = Set up an Ubuntu server.
docs = https://ubuntu.com
tags = operating system
title = Ubuntu
version = 0.1.0-d

@ -228,12 +228,16 @@ class Content(object):
if self.width is not None: if self.width is not None:
a.append(indent(":width: %s" % self.width, 8)) a.append(indent(":width: %s" % self.width, 8))
a.append("")
else: else:
if self.caption: if self.caption:
a.append("%s: %s" % (self.caption, self.image)) a.append("%s: %s" % (self.caption, self.image))
else: else:
a.append(self.image) a.append(self.image)
a.append("")
return "\n".join(a) return "\n".join(a)
def _get_message_output(self, output_format): def _get_message_output(self, output_format):
@ -566,12 +570,19 @@ class Template(object):
def __init__(self, source, target, backup=True, parser=PARSER_JINJA, **kwargs): def __init__(self, source, target, backup=True, parser=PARSER_JINJA, **kwargs):
self.backup_enabled = backup self.backup_enabled = backup
self.context = kwargs.pop("context", dict()) self.cd = kwargs.pop("cd", None)
self.comment = kwargs.pop("comment", "create template %s" % target)
self.condition = kwargs.pop("condition", None)
# self.context = kwargs.pop("context", dict())
self.name = "template" self.name = "template"
self.parser = parser self.parser = parser
self.prefix = kwargs.pop("prefix", None)
self.language = kwargs.pop("lang", None) self.language = kwargs.pop("lang", None)
self.locations = kwargs.pop("locations", list()) self.locations = kwargs.pop("locations", list())
self.register = kwargs.pop("register", None)
self.source = os.path.expanduser(source) self.source = os.path.expanduser(source)
self.stop = kwargs.pop("stop", False)
self.tags = kwargs.pop("tags", list())
self.target = target self.target = target
sudo = kwargs.pop("sudo", None) sudo = kwargs.pop("sudo", None)
@ -584,10 +595,10 @@ class Template(object):
else: else:
self.sudo = Sudo() self.sudo = Sudo()
self.kwargs = kwargs self.context = kwargs
def __getattr__(self, item): def __getattr__(self, item):
return self.kwargs.get(item) return self.context.get(item)
# def __str__(self): # def __str__(self):
# return "template" # return "template"

@ -23,6 +23,27 @@ from .base import Command
def django(management_command, *args, excluded_kwargs=None, **kwargs): def django(management_command, *args, excluded_kwargs=None, **kwargs):
"""Common function for assembling Django management commands.
:param management_command: The name of the management command.
:type management_command: str
:param excluded_kwargs: A dictionary of kwargs that should be excluded from the management command parameters.
:param excluded_kwargs: dict
:rtype: scripttease.lib.commands.base.Command
If provided, args are passed directly to the command as positional parameters.
Any provided kwargs are converted to long form parameters. For example, database="alternative_db" becomes
``--database="alternative_db"``.
A kwarg with a ``True`` value becomes a long form parameter with no value. For example, natural_foreign=True becomes
``--natural-foreign``.
Finally, any kwarg that is not a string is passed without quotes. For example, testing=1 becomes ``--testing=1``.
"""
# The excluded parameters (filtered below) may vary based on implementation. We do, however, need a default. # The excluded parameters (filtered below) may vary based on implementation. We do, however, need a default.
excluded_kwargs = excluded_kwargs or EXCLUDED_KWARGS excluded_kwargs = excluded_kwargs or EXCLUDED_KWARGS
@ -61,37 +82,92 @@ def django(management_command, *args, excluded_kwargs=None, **kwargs):
def django_check(**kwargs): def django_check(**kwargs):
"""Run Django checks."""
kwargs.setdefault("comment", "run django checks") kwargs.setdefault("comment", "run django checks")
kwargs.setdefault("register", "django_checks_out") kwargs.setdefault("register", "django_checks_out")
return django("check", **kwargs) return django("check", **kwargs)
def django_createsuperuser(username, email=None, **kwargs):
"""Create a superuser account.
:param username: The name for the user account.
:type username: str
:param email: The user's email address. Optional, but recommended because the account must be created without a
password.
:type email: str
"""
kwargs.setdefault("comment", "create the %s superuser" % username)
kwargs['username'] = username
kwargs['noinput'] = True
if email is not None:
kwargs['email'] = email
return django("createsuperuser", **kwargs)
def django_dump(target, path=None, **kwargs): def django_dump(target, path=None, **kwargs):
"""Dump data fixtures.
:param target: The app name or ``app.ModelName``.
:type target: str
:param path: The path to the fixture file.
:type path: str
"""
kwargs.setdefault("comment", "dump app/model data for %s" % target) kwargs.setdefault("comment", "dump app/model data for %s" % target)
kwargs.setdefault("format", "json") kwargs.setdefault("format", "json")
kwargs.setdefault("indent", 4) kwargs.setdefault("indent", 4)
app = target
file_name = "%s/initial.%s" % (app, kwargs['format'])
if "." in target:
app, model = target.split(".")
file_name = "%s/%s.%s" % (app, model.lower(), kwargs['format'])
if path is None: if path is None:
path = "../fixtures/%s.%s" % (target, kwargs['format']) path = "../fixtures/%s" % file_name
return django("dumpdata", target, "> %s" % path, **kwargs) return django("dumpdata", target, "> %s" % path, **kwargs)
def django_load(target, path=None, **kwargs): def django_load(target, path=None, **kwargs):
"""Load data fixtures.
:param target: The app name or ``app.ModelName``.
:type target: str
:param path: The path to the fixture file.
:type path: str
"""
kwargs.setdefault("comment", "load app/model data from %s" % target) kwargs.setdefault("comment", "load app/model data from %s" % target)
input_format = kwargs.pop("format", "json") input_format = kwargs.pop("format", "json")
app = target
file_name = "%s/initial.%s" % (app, input_format)
if "." in target:
app, model = target.split(".")
file_name = "%s/%s.%s" % (app, model.lower(), input_format)
if path is None: if path is None:
path = "../fixtures/%s.%s" % (target, input_format) path = "../fixtures/%s" % file_name
return django("loaddata", path, **kwargs) return django("loaddata", path, **kwargs)
def django_migrate(**kwargs): def django_migrate(**kwargs):
"""Apply database migrations."""
kwargs.setdefault("comment", "apply database migrations") kwargs.setdefault("comment", "apply database migrations")
return django("migrate", **kwargs) return django("migrate", **kwargs)
def django_static(**kwargs): def django_static(**kwargs):
"""Collect static files."""
kwargs.setdefault("comment", "collect static files") kwargs.setdefault("comment", "collect static files")
kwargs.setdefault("noinput", True) kwargs.setdefault("noinput", True)
return django("collectstatic", **kwargs) return django("collectstatic", **kwargs)
@ -100,6 +176,8 @@ def django_static(**kwargs):
DJANGO_MAPPINGS = { DJANGO_MAPPINGS = {
'django': django, 'django': django,
'django.check': django_check, 'django.check': django_check,
'django.collectstatic': django_static,
'django.createsuperuser': django_createsuperuser,
'django.dump': django_dump, 'django.dump': django_dump,
'django.dumpdata': django_dump, 'django.dumpdata': django_dump,
'django.load': django_load, 'django.load': django_load,

@ -610,6 +610,7 @@ POSIX_MAPPINGS = {
'run': run, 'run': run,
'rsync': rsync, 'rsync': rsync,
'scopy': scopy, 'scopy': scopy,
'ssl': certbot,
'sync': sync, 'sync': sync,
'touch': touch, 'touch': touch,
'wait': wait, 'wait': wait,

@ -1,7 +1,7 @@
# Imports # Imports
from commonkit import split_csv from commonkit import split_csv
from .base import Command, Template from .base import Command
from .django import DJANGO_MAPPINGS from .django import DJANGO_MAPPINGS
from .messages import MESSAGE_MAPPINGS from .messages import MESSAGE_MAPPINGS
from .mysql import MYSQL_MAPPINGS from .mysql import MYSQL_MAPPINGS

@ -66,7 +66,7 @@ def command_factory(loader, excluded_kwargs=None, mappings=None, profile=PROFILE
command.number = number command.number = number
if command.name == "template": if command.name == "template":
command.context = loader.get_context() command.context.update(loader.get_context())
commands.append(command) commands.append(command)

@ -61,6 +61,7 @@ class INILoader(BaseLoader):
# by double quotes. # by double quotes.
if command_name in ("explain", "screenshot"): if command_name in ("explain", "screenshot"):
args.append(value) args.append(value)
count += 1
continue continue
# Arguments surrounded by quotes are considered to be one argument. All others are split into a # Arguments surrounded by quotes are considered to be one argument. All others are split into a

@ -20,11 +20,11 @@ setup(
author='Shawn Davis', author='Shawn Davis',
author_email='shawn@develmaycare.com', author_email='shawn@develmaycare.com',
url='https://develmaycare.com/products/python/scripttease/', url='https://develmaycare.com/products/python/scripttease/',
download_url='https://github.com/develmaycare/python-scripttease', download_url='https://gittraction.com/diff6/python-scripttease',
project_urls={ project_urls={
'Documentation': "https://docs.develmaycare.com/en/python-scripttease/latest/", 'Documentation': "https://docs.develmaycare.com/en/python-scripttease/latest/",
'Source': "https://github.com/develmaycare/python-scripttease", 'Source': "https://gittraction.com/diff6/python-scripttease",
'Tracker': "https://github.com/develmaycare/python-scripttease/issues/" 'Tracker': "https://gittraction.com/diff6/python-scripttease/issues"
}, },
packages=find_packages(exclude=["tests", "tests.*"]), packages=find_packages(exclude=["tests", "tests.*"]),
include_package_data=True, include_package_data=True,

@ -0,0 +1,9 @@
[create the deploy user]
user: deploy
groups: sudo, www-data
home: /var/www
sudo: yes
[remove a user]
user: bob
op: remove

@ -0,0 +1,9 @@
from scripttease.lib.factories import command_factory
from scripttease.lib.loaders import INILoader
def test_user_commands():
ini = INILoader("tests/examples/users.ini")
ini.load()
commands = command_factory(ini)
print(commands)

@ -255,6 +255,22 @@ class TestSudo(object):
class TestTemplate(object): class TestTemplate(object):
def test_getattr(self):
context = {
'testing': "yes",
'times': 123,
}
t = Template(
"tests/examples/templates/simple.txt",
"tests/tmp/simple.txt",
backup=False,
# context=context,
parser=Template.PARSER_SIMPLE,
**context
)
assert t.testing == "yes"
assert t.times == 123
def test_get_content(self): def test_get_content(self):
context = { context = {
'testing': "yes", 'testing': "yes",
@ -264,8 +280,9 @@ class TestTemplate(object):
"tests/examples/templates/simple.txt", "tests/examples/templates/simple.txt",
"tests/tmp/simple.txt", "tests/tmp/simple.txt",
backup=False, backup=False,
context=context, # context=context,
parser=Template.PARSER_SIMPLE parser=Template.PARSER_SIMPLE,
**context
) )
content = t.get_content() content = t.get_content()
assert "I am testing? yes" in content assert "I am testing? yes" in content
@ -279,8 +296,9 @@ class TestTemplate(object):
"tests/examples/templates/simple.sh.txt", "tests/examples/templates/simple.sh.txt",
"tests/tmp/simple.sh", "tests/tmp/simple.sh",
backup=False, backup=False,
context=context, # context=context,
parser=Template.PARSER_SIMPLE parser=Template.PARSER_SIMPLE,
**context
) )
content = t.get_content() content = t.get_content()
assert "I am testing? yes" in content assert "I am testing? yes" in content
@ -294,7 +312,8 @@ class TestTemplate(object):
"tests/examples/templates/good.j2.txt", "tests/examples/templates/good.j2.txt",
"tests/tmp/good.txt", "tests/tmp/good.txt",
backup=False, backup=False,
context=context # context=context
**context
) )
content = t.get_content() content = t.get_content()
assert "I am testing? yes" in content assert "I am testing? yes" in content
@ -310,7 +329,13 @@ class TestTemplate(object):
'testing': True, 'testing': True,
'times': 3, 'times': 3,
} }
t = Template("tests/examples/templates/settings.py", "test/tmp/settings.py", context=context, parser=Template.PARSER_PYTHON) t = Template(
"tests/examples/templates/settings.py",
"test/tmp/settings.py",
# context=context,
parser=Template.PARSER_PYTHON,
**context
)
content = t.get_content() content = t.get_content()
assert "TESTING = True" in content assert "TESTING = True" in content
assert "TOTAL_TIMES = 3" in content assert "TOTAL_TIMES = 3" in content
@ -323,12 +348,13 @@ class TestTemplate(object):
t = Template( t = Template(
"tests/examples/templates/simple.txt", "tests/examples/templates/simple.txt",
"tests/tmp/simple.txt", "tests/tmp/simple.txt",
context=context, # context=context,
comment="A simple parser example.", comment="A simple parser example.",
parser=Template.PARSER_SIMPLE, parser=Template.PARSER_SIMPLE,
register="template_created", register="template_created",
stop=True, stop=True,
sudo=Sudo(user="root") sudo=Sudo(user="root"),
**context
) )
s = t.get_statement() s = t.get_statement()
assert "I am testing? yes" in s assert "I am testing? yes" in s
@ -341,10 +367,11 @@ class TestTemplate(object):
t = Template( t = Template(
"tests/examples/templates/simple.sh.txt", "tests/examples/templates/simple.sh.txt",
"tests/tmp/simple.txt", "tests/tmp/simple.txt",
context=context, # context=context,
parser=Template.PARSER_SIMPLE, parser=Template.PARSER_SIMPLE,
stop=True, stop=True,
sudo="root" sudo="root",
**context
) )
s = t.get_statement() s = t.get_statement()
assert "I am testing? yes" in s assert "I am testing? yes" in s
@ -357,8 +384,9 @@ class TestTemplate(object):
t = Template( t = Template(
"tests/examples/templates/good.j2.txt", "tests/examples/templates/good.j2.txt",
"tests/tmp/good.txt", "tests/tmp/good.txt",
context=context, # context=context,
sudo=True sudo=True,
**context
) )
s = t.get_statement() s = t.get_statement()
assert "I am testing? yes" in s assert "I am testing? yes" in s

@ -35,6 +35,15 @@ def test_django_collect_static():
assert "source python/bin/activate" in s assert "source python/bin/activate" in s
def test_django_createsuperuser():
c = django_createsuperuser("root", email="root@example.com")
s = c.get_statement()
assert "./manage.py createsuperuser" in s
assert '--username="root"' in s
assert '--email="root@example.com"' in s
assert '--noinput' in s
def test_django_dumpdata(): def test_django_dumpdata():
c = django_dump("projects") c = django_dump("projects")
s = c.get_statement() s = c.get_statement()
@ -42,15 +51,27 @@ def test_django_dumpdata():
assert "projects >" in s assert "projects >" in s
assert '--format="json"' in s assert '--format="json"' in s
assert "--indent=4" in s assert "--indent=4" in s
assert "../fixtures/projects.json" in s assert "../fixtures/projects/initial.json" in s
c = django_dump("projects.Category")
s = c.get_statement()
assert "./manage.py dumpdata" in s
assert "projects.Category >" in s
assert '--format="json"' in s
assert "--indent=4" in s
assert "../fixtures/projects/category.json" in s
def test_django_loaddata(): def test_django_loaddata():
c = django_load("projects") c = django_load("projects")
s = c.get_statement() s = c.get_statement()
print(s)
assert "./manage.py loaddata" in s assert "./manage.py loaddata" in s
assert "../fixtures/projects.json" in s assert "../fixtures/projects/initial.json" in s
c = django_load("projects.Category")
s = c.get_statement()
assert "./manage.py loaddata" in s
assert "../fixtures/projects/category.json" in s
def test_django_migrate(): def test_django_migrate():

Loading…
Cancel
Save