blob: 38a97a187065abb1e926063dc013aaa302908889 [file] [log] [blame]
#!/usr/bin/env python3
#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
""" process bash scripts and generate documentation from them """
# Do this immediately to prevent compiled forms
import logging
import os
import pathlib
import re
import sys
from argparse import ArgumentParser
sys.dont_write_bytecode = True
ASFLICENSE = '''
<!---
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
-->
'''
class ShellFunction: # pylint: disable=too-many-instance-attributes
"""a shell function"""
def __init__(self, filename='Unknown'):
'''Initializer'''
self.audience = ''
self.description = []
self.filename = filename
self.linenum = 0
self.name = ''
self.params = []
self.replacebool = False
self.replacerawtext = ''
self.replacetext = 'Not Replaceable'
self.returnt = []
self.stability = ''
def __lt__(self, other):
'''comparison'''
if self.audience == other.audience:
if self.stability == other.stability:
if self.replacebool == other.replacebool:
return self.name < other.name
if self.replacebool:
return True
else:
if self.stability == "Stable":
return True
else:
if self.audience == "Public":
return True
return False
def header(self):
'''get the header for this function'''
return f"{self.audience}/{self.stability}/{self.replacetext}"
def getdocpage(self):
'''get the built document page for this function'''
params = " ".join(self.params)
usage = f"{self.name} {params}"
description = "\n".join(self.description)
if not self.returnt:
returntext = 'Nothing'
else:
returntext = "\n".join(self.returnt)
return (f"### `{self.name}`\n\n"
"* Synopsis\n\n"
f"```\n{usage}\n"
"```\n\n"
"* Description\n\n"
f"{description}\n\n"
"* Returns\n\n"
f"{returntext}\n\n"
"| Classification | Level |\n"
"| :--- | :--- |\n"
f"| Audience | {self.audience} |\n"
f"| Stability | {self.stability} |\n"
f"| Replaceable | {self.replacebool} |\n\n")
def isprivateandnotreplaceable(self):
''' is this function Private and not replaceable? '''
return self.audience == "Private" and not self.replacebool
def lint(self):
'''Lint this function'''
validvalues = {
"audience": ("Public", "Private"),
"stability": ("Stable", "Evolving"),
"replacerawtext": ("yes", "no"),
}
for attribute, attrvalues in validvalues.items():
value = getattr(self, attribute)
if (not value or value == '') and attribute != 'replacerawtext':
logging.error("%s:%u:ERROR: function %s has no @%s",
self.filename, self.linenum, self.name,
attribute.lower())
elif value not in attrvalues:
if attribute == 'replacerawtext' and value == '':
continue
validvalue = "|".join(v.lower() for v in attrvalues)
logging.error(
"%s:%d:ERROR: function %s has invalid value (%s) for @%s (%s)",
self.filename, self.linenum, self.name, value.lower(),
attribute.lower().replace('rawtext', 'able'), validvalue)
def __str__(self):
'''Generate a string for this function'''
return f"{{{self.name} {self.audience} {self.stability} {self.replacebool}}}"
class ProcessFile:
''' shell file processor '''
FUNCTIONRE = re.compile(r"^(\w+) *\(\) *{")
def __init__(self, filename=None, skipsuperprivate=False):
self.filename = filename
self.functions = []
self.skipsuperprivate = skipsuperprivate
def isignored(self):
"""Checks for the presence of the marker(SHELLDOC-IGNORE) to ignore the file.
Marker needs to be in a line of its own and can not
be an inline comment.
A leading '#' and white-spaces(leading or trailing)
are trimmed before checking equality.
Comparison is case sensitive and the comment must be in
UPPERCASE.
"""
with open(self.filename) as input_file: #pylint: disable=unspecified-encoding
for line in input_file:
if line.startswith(
"#") and line[1:].strip() == "SHELLDOC-IGNORE":
return True
return False
@staticmethod
def _docstrip(key, dstr):
'''remove extra spaces from shelldoc phrase'''
dstr = re.sub(f"^## @{key} ", "", dstr)
dstr = dstr.strip()
return dstr
def _process_description(self, funcdef, text=None):
if not text:
funcdef.description = []
return
funcdef.description.append(self._docstrip('description', text))
def _process_audience(self, funcdef, text=None):
'''set the audience of the function'''
if not text:
return
funcdef.audience = self._docstrip('audience', text)
funcdef.audience = funcdef.audience.capitalize()
def _process_stability(self, funcdef, text=None):
'''set the stability of the function'''
if not text:
return
funcdef.stability = self._docstrip('stability', text)
funcdef.stability = funcdef.stability.capitalize()
def _process_replaceable(self, funcdef, text=None):
'''set the replacement state'''
if not text:
return
funcdef.replacerawtext = self._docstrip("replaceable", text)
if funcdef.replacerawtext in ['yes', 'Yes', 'true', 'True']:
funcdef.replacebool = True
else:
funcdef.replacebool = False
if funcdef.replacebool:
funcdef.replacetext = 'Replaceable'
else:
funcdef.replacetext = 'Not Replaceable'
def _process_param(self, funcdef, text=None):
'''add a parameter'''
if not text:
funcdef.params = []
return
funcdef.params.append(self._docstrip('param', text))
def _process_return(self, funcdef, text=None):
'''add a return value'''
if not text:
funcdef.returnt = []
return
funcdef.returnt.append(self._docstrip('return', text))
@staticmethod
def _process_function(funcdef, text=None, linenum=1):
'''set the name of the function'''
if ProcessFile.FUNCTIONRE.match(text):
definition = ProcessFile.FUNCTIONRE.match(text).groups()[0]
else:
definition = text.split()[1]
funcdef.name = definition.replace("(", "").replace(")", "")
funcdef.linenum = linenum
def process_file(self):
""" stuff all of the functions into an array """
self.functions = []
mapping = {
'## @description': '_process_description',
'## @audience': '_process_audience',
'## @stability': '_process_stability',
'## @replaceable': '_process_replaceable',
'## @param': '_process_param',
'## @return': '_process_return',
}
if self.isignored():
return
try:
with open(self.filename, "r") as shellcode: #pylint: disable=unspecified-encoding
# if the file contains a comment containing
# only "SHELLDOC-IGNORE" then skip that file
funcdef = ShellFunction(self.filename)
linenum = 0
for line in shellcode:
linenum = linenum + 1
for text, method in mapping.items():
if line.startswith(text):
getattr(self, method)(funcdef, text=line)
if line.startswith(
'function') or ProcessFile.FUNCTIONRE.match(line):
self._process_function(funcdef,
text=line,
linenum=linenum)
if self.skipsuperprivate and funcdef.isprivateandnotreplaceable(
):
pass
else:
self.functions.append(funcdef)
funcdef = ShellFunction(self.filename)
except OSError as err:
logging.error("ERROR: Failed to read from file: %s. Skipping.",
err.filename)
self.functions = []
class MarkdownReport:
''' generate a markdown report '''
def __init__(self, functions, filename=None):
self.filename = filename
self.filepath = pathlib.Path(self.filename)
if functions:
self.functions = sorted(functions)
else:
self.functions = None
def write_tableofcontents(self, fhout):
'''build a table of contents'''
header = None
for function in self.functions:
if header != function.header():
header = function.header()
fhout.write(f" * {header}\n")
markdownsafename = function.name.replace("_", r"\_")
fhout.write(f" * [{markdownsafename}](#{function.name})\n")
def write_output(self):
""" write the markdown file """
self.filepath.parent.mkdir(parents=True, exist_ok=True)
with open(self.filename, "w", encoding='utf-8') as outfile:
outfile.write(ASFLICENSE)
self.write_tableofcontents(outfile)
outfile.write("\n------\n\n")
header = []
for function in self.functions:
if header != function.header():
header = function.header()
outfile.write(f"## {header}\n")
outfile.write(function.getdocpage())
def process_input(inputlist, skipprnorep):
""" take the input and loop around it """
def call_process_file(filename, skipsuperprivate):
''' handle building a ProcessFile '''
fileprocessor = ProcessFile(filename=filename,
skipsuperprivate=skipsuperprivate)
fileprocessor.process_file()
return fileprocessor.functions
allfuncs = []
for inputname in inputlist:
if pathlib.Path(inputname).is_dir():
for dirpath, dirnames, filenames in os.walk(inputname): #pylint: disable=unused-variable
for fname in filenames:
if fname.endswith('sh'):
allfuncs = allfuncs + call_process_file(
filename=pathlib.Path(dirpath).joinpath(fname),
skipsuperprivate=skipprnorep)
else:
allfuncs = allfuncs + call_process_file(
filename=inputname, skipsuperprivate=skipprnorep)
if allfuncs is None:
logging.error("ERROR: no functions found.")
sys.exit(1)
allfuncs = sorted(allfuncs)
return allfuncs
def getversion():
""" print the version file"""
basepath = pathlib.Path(__file__).parent.resolve()
for versionfile in [
basepath.resolve().joinpath('VERSION'),
basepath.parent.resolve().joinpath('VERSION')
]:
if versionfile.exists():
with open(versionfile, encoding='utf-8') as ver_file:
version = ver_file.read()
return version
mvnversion = basepath.parent.parent.parent.parent.resolve().joinpath(
'.mvn', 'maven.config')
if mvnversion.exists():
with open(mvnversion, encoding='utf-8') as ver_file:
return ver_file.read().split('=')[1].strip()
return 'Unknown'
def process_arguments():
''' deal with parameters '''
parser = ArgumentParser(
prog='shelldocs',
epilog="You can mark a file to be ignored by shelldocs by adding"
" 'SHELLDOC-IGNORE' as comment in its own line. " +
"--input may be given multiple times.")
parser.add_argument("-o",
"--output",
dest="outfile",
action="store",
type=str,
help="file to create",
metavar="OUTFILE")
parser.add_argument("-i",
"--input",
dest="infile",
action="append",
type=str,
help="file to read",
metavar="INFILE")
parser.add_argument("--skipprnorep",
dest="skipprnorep",
action="store_true",
help="Skip Private & Not Replaceable")
parser.add_argument("--lint",
dest="lint",
action="store_true",
help="Enable lint mode")
parser.add_argument(
"-V",
"--version",
dest="release_version",
action="store_true",
default=False,
help="display version information for shelldocs and exit.")
options = parser.parse_args()
if options.release_version:
print(getversion())
sys.exit(0)
if options.infile is None:
parser.error("At least one input file needs to be supplied")
elif options.outfile is None and options.lint is None:
parser.error(
"At least one of output file and lint mode needs to be specified")
return options
def main():
'''main entry point'''
logging.basicConfig(format='%(message)s')
options = process_arguments()
allfuncs = process_input(options.infile, options.skipprnorep)
if options.lint:
for funcs in allfuncs:
funcs.lint()
if options.outfile:
mdreport = MarkdownReport(allfuncs, filename=options.outfile)
mdreport.write_output()
if __name__ == "__main__":
main()