Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add option to load templates from remote git sources #717

Merged
merged 9 commits into from
Jun 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 6 additions & 8 deletions ansibledoctor/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,11 @@ def __init__(self):
self.config = SingleConfig()
self.config.load(args=self._parse_args())
self.log.register_hanlers(json=self.config.config.logging.json)
except ansibledoctor.exception.ConfigError as e:
self._execute()
except ansibledoctor.exception.DoctorError as e:
self.log.sysexit_with_message(e)

self._execute()
except KeyboardInterrupt:
self.log.sysexit_with_message("Aborted...")

def _parse_args(self):
"""
Expand Down Expand Up @@ -123,11 +124,8 @@ def _execute(self):
for item in walkdirs:
os.chdir(item)

try:
self.config.load(root_path=os.getcwd())
self.log.register_hanlers(json=self.config.config.logging.json)
except ansibledoctor.exception.ConfigError as e:
self.log.sysexit_with_message(e)
self.config.load(root_path=os.getcwd())
self.log.register_hanlers(json=self.config.config.logging.json)

try:
self.log.set_level(self.config.config.logging.level)
Expand Down
20 changes: 9 additions & 11 deletions ansibledoctor/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"""Global settings definition."""

import os
import re

from appdirs import AppDirs
from dynaconf import Dynaconf, ValidationError, Validator
Expand Down Expand Up @@ -58,6 +59,9 @@ def __init__(self):
self.load()

def load(self, root_path=None, args=None):
tmpl_src = os.path.join(os.path.dirname(os.path.realpath(__file__)), "templates")
tmpl_provider = ["local", "git"]

if args:
if args.get("config_file"):
self.config_merge = False
Expand Down Expand Up @@ -137,8 +141,12 @@ def load(self, root_path=None, args=None):
),
Validator(
"template.src",
default=os.path.join(os.path.dirname(os.path.realpath(__file__)), "templates"),
default=f"local>{tmpl_src}",
is_type_of=str,
condition=lambda x: re.match(r"^(local|git)\s*>\s*", x),
messages={
"condition": f"Template provider must be one of {tmpl_provider}.",
},
),
Validator(
"template.name",
Expand Down Expand Up @@ -218,16 +226,6 @@ def get_annotations_names(self, automatic=True):
annotations.append(k)
return annotations

def get_template(self):
"""
Get the base dir for the template to use.

:return: str abs path
"""
template_base = self.config.get("template.src")
template_name = self.config.get("template.name")
return os.path.realpath(os.path.join(template_base, template_name))


class SingleConfig(Config, metaclass=Singleton):
"""Singleton config class."""
Expand Down
57 changes: 17 additions & 40 deletions ansibledoctor/doc_generator.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
#!/usr/bin/env python3
"""Prepare output and write compiled jinja2 templates."""

import glob
import ntpath
import os
import re
from functools import reduce
Expand All @@ -12,43 +10,23 @@
from jinja2 import Environment, FileSystemLoader
from jinja2.filters import pass_eval_context

import ansibledoctor.exception
from ansibledoctor.config import SingleConfig
from ansibledoctor.template import Template
from ansibledoctor.utils import FileUtils, SingleLog


class Generator:
"""Generate documentation from jinja2 templates."""

def __init__(self, doc_parser):
self.template_files = []
self.extension = "j2"
self._parser = None
self.config = SingleConfig()
self.log = SingleLog()
self.logger = self.log.logger
self.config = SingleConfig()
self.template = Template(
self.config.config.get("template.name"),
self.config.config.get("template.src"),
)
self._parser = doc_parser
self._scan_template()

def _scan_template(self):
"""
Search for Jinja2 (.j2) files to apply to the destination.

:return: None
"""
template = self.config.get_template()
if os.path.isdir(template):
self.logger.info(f"Using template: {os.path.relpath(template, self.log.ctx)}")
else:
self.log.sysexit_with_message(f"Can not open template directory {template}")

for file in glob.iglob(template + "/**/*." + self.extension, recursive=True):
relative_file = file[len(template) + 1 :]
if ntpath.basename(file)[:1] != "_":
self.logger.debug(f"Found template file: {relative_file}")
self.template_files.append(relative_file)
else:
self.logger.debug(f"Ignoring template file: {relative_file}")

def _create_dir(self, directory):
if not self.config.config["dry_run"] and not os.path.isdir(directory):
Expand All @@ -61,9 +39,9 @@ def _create_dir(self, directory):
def _write_doc(self):
files_to_overwite = []

for file in self.template_files:
for tf in self.template.files:
doc_file = os.path.join(
self.config.config.get("renderer.dest"), os.path.splitext(file)[0]
self.config.config.get("renderer.dest"), os.path.splitext(tf)[0]
)
if os.path.isfile(doc_file):
files_to_overwite.append(doc_file)
Expand Down Expand Up @@ -92,31 +70,30 @@ def _write_doc(self):
try:
if not FileUtils.query_yes_no(f"{prompt}\nDo you want to continue?"):
self.log.sysexit_with_message("Aborted...")
except ansibledoctor.exception.InputError as e:
self.logger.debug(str(e))
except KeyboardInterrupt:
self.log.sysexit_with_message("Aborted...")

for file in self.template_files:
for tf in self.template.files:
doc_file = os.path.join(
self.config.config.get("renderer.dest"), os.path.splitext(file)[0]
self.config.config.get("renderer.dest"), os.path.splitext(tf)[0]
)
source_file = self.config.get_template() + "/" + file
template = os.path.join(self.template.path, tf)

self.logger.debug(
f"Writing renderer output to: {os.path.relpath(doc_file, self.log.ctx)} "
f"from: {os.path.dirname(os.path.relpath(source_file, self.log.ctx))}"
f"from: {os.path.dirname(template)}"
)

# make sure the directory exists
self._create_dir(os.path.dirname(doc_file))

if os.path.exists(source_file) and os.path.isfile(source_file):
with open(source_file) as template:
if os.path.exists(template) and os.path.isfile(template):
with open(template) as template:
data = template.read()
if data is not None:
try:
jenv = Environment( # nosec
loader=FileSystemLoader(self.config.get_template()),
loader=FileSystemLoader(self.template.path),
lstrip_blocks=True,
trim_blocks=True,
autoescape=jinja2.select_autoescape(),
Expand All @@ -143,7 +120,7 @@ def _write_doc(self):
jinja2.exceptions.TemplateRuntimeError,
) as e:
self.log.sysexit_with_message(
f"Jinja2 templating error while loading file: '{file}'\n{e!s}"
f"Jinja2 templating error while loading file: {tf}\n{e!s}"
)
except UnicodeEncodeError as e:
self.log.sysexit_with_message(
Expand Down
6 changes: 2 additions & 4 deletions ansibledoctor/exception.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,5 @@ class ConfigError(DoctorError):
pass


class InputError(DoctorError):
"""Errors related to config file handling."""

pass
class TemplateError(DoctorError):
"""Errors related to template file handling."""
2 changes: 1 addition & 1 deletion ansibledoctor/file_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def _scan_for_yamls(self):
for filename in glob.iglob(pattern, recursive=True):
if not excludespec.match_file(filename):
self.log.debug(
f"Adding file to role '{role_name}': {os.path.relpath(filename, base_dir)}"
f"Adding file to role: {role_name}: {os.path.relpath(filename, base_dir)}"
)
self._doc.append(filename)
else:
Expand Down
113 changes: 113 additions & 0 deletions ansibledoctor/template.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"""Module for handling templates."""

import atexit
import glob
import ntpath
import os
import shutil
import tempfile

from git import GitCommandError, Repo

import ansibledoctor.exception
from ansibledoctor.utils import SingleLog


class Template:
"""
Represents a template that can be used to generate content.

Templates can besourced from a local file or a Git repository. The `Template` class handles
the initialization and setup of a template, including cloning a Git repository if necessary.

Args:
----
name (str): The name of the template.
src (str): The source of the template, in the format `<provider>><path>`.
Supported providers are `local` and `git`.

Raises:
------
ansibledoctor.exception.TemplateError

"""

def __init__(self, name, src):
self.log = SingleLog()
self.logger = self.log.logger
self.name = name
self.src = src

try:
provider, path = self.src.split(">", 1)
except ValueError as e:
raise ansibledoctor.exception.TemplateError(
"Error reading template src", str(e)
) from e

self.provider = provider.strip().lower()
self.path = path.strip()

if self.provider == "local":
self.path = os.path.realpath(os.path.join(self.path, self.name))
elif self.provider == "git":
repo_url, branch_or_tag = (
self.path.split("#", 1) if "#" in self.path else (self.path, None)
)
temp_dir = self._clone_repo(repo_url, branch_or_tag)
self.path = os.path.join(temp_dir, self.name)
else:
raise ansibledoctor.exception.TemplateError(
f"Unsupported template provider: {provider}"
)

self.files = self._scan_files()

def _clone_repo(self, repo_url, branch_or_tag=None):
temp_dir = tempfile.mkdtemp(prefix="ansibledoctor-")
atexit.register(self._cleanup_temp_dir, temp_dir)

try:
self.logger.debug(f"Cloning template repo: {repo_url}")
repo = Repo.clone_from(repo_url, temp_dir)
if branch_or_tag:
self.logger.debug(f"Checking out branch or tag: {branch_or_tag}")
try:
repo.git.checkout(branch_or_tag)
except GitCommandError as e:
raise ansibledoctor.exception.TemplateError(
f"Error checking out branch or tag: {branch_or_tag}: {e}"
) from e

return temp_dir
except GitCommandError as e:
msg = e.stderr.strip("'").strip()
msg = msg.removeprefix("stderr: ")

raise ansibledoctor.exception.TemplateError(
f"Error cloning Git repository: {msg}"
) from e

def _scan_files(self):
"""Search for Jinja2 (.j2) files to apply to the destination."""
template_files = []

if os.path.isdir(self.path):
self.logger.info(f"Using template src: {self.src} name: {self.name}")
else:
self.log.sysexit_with_message(f"Can not open template directory {self.path}")

for file in glob.iglob(self.path + "/**/*.j2", recursive=True):
relative_file = file[len(self.path) + 1 :]
if ntpath.basename(file)[:1] != "_":
self.logger.debug(f"Found template file: {relative_file}")
template_files.append(relative_file)
else:
self.logger.debug(f"Ignoring template file: {relative_file}")

return template_files

@staticmethod
def _cleanup_temp_dir(temp_dir):
if temp_dir and os.path.exists(temp_dir):
shutil.rmtree(temp_dir)
17 changes: 9 additions & 8 deletions ansibledoctor/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@
import colorama
from pythonjsonlogger import jsonlogger

import ansibledoctor.exception

CONSOLE_FORMAT = "{}{}[%(levelname)s]{} %(message)s"
JSON_FORMAT = "%(asctime)s %(levelname)s %(message)s"

Expand Down Expand Up @@ -331,9 +329,12 @@ def query_yes_no(question, default=True):
"""
prompt = "[Y/n]" if default else "[N/y]"

try:
# input method is safe in python3
choice = input(f"{question} {prompt} ") or default # nosec
return to_bool(choice)
except (KeyboardInterrupt, ValueError) as e:
raise ansibledoctor.exception.InputError("Error while reading input", e) from e
while True:
try:
# input method is safe in python3
choice = input(f"{question} {prompt} ") or default # nosec
return to_bool(choice)
except ValueError:
print("Invalid input. Please enter 'y' or 'n'.") # noqa: T201
except KeyboardInterrupt as e:
raise e
28 changes: 27 additions & 1 deletion docs/content/usage/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,34 @@ logging:
json: False

template:
# Name of the template to be used. In most cases, this is the name of a directory that is attached to the
# the `src` path or Git repo (see example below).
name: readme
# Default is built-in templates directory.

# Template provider source. Currently supported providers are `local|git`.
# The `local` provider loads templates from the local file system. This provider
# is used by default and uses the built-in templates.
#
# Examples:
# template:
# name: readme
# src: local>/tmp/custom_templates/
#
# The `git` provider allows templates to be loaded from a git repository. At the moment
# the functions of this provider are limited and only public repositories are supported.
#
# Examples:
# template:
# src: git>https://github.com/thegeeklab/ansible-doctor
# name: ansibledoctor/templates/readme
#
# template:
# src: git>[email protected]:thegeeklab/ansible-doctor.git
# name: ansibledoctor/templates/readme
#
# template:
# src: git>[email protected]:thegeeklab/ansible-doctor.git#branch-or-tag
# name: ansibledoctor/templates/readme
src:

options:
Expand Down
Loading