From b372991a7c99aa027eebfe0df1158a5429780230 Mon Sep 17 00:00:00 2001 From: Agustin Arce Date: Fri, 4 Oct 2024 10:26:38 -0400 Subject: [PATCH 1/2] chore (__init__): add type annotations --- nikola/__init__.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/nikola/__init__.py b/nikola/__init__.py index 12e660adb7..390fec86b7 100644 --- a/nikola/__init__.py +++ b/nikola/__init__.py @@ -28,13 +28,14 @@ import os import sys +from typing import Final -__version__ = '8.3.1' -DEBUG = bool(os.getenv('NIKOLA_DEBUG')) -SHOW_TRACEBACKS = bool(os.getenv('NIKOLA_SHOW_TRACEBACKS')) +__version__: Final[str] = "8.3.1" +DEBUG: bool = bool(os.getenv("NIKOLA_DEBUG")) +SHOW_TRACEBACKS: bool = bool(os.getenv("NIKOLA_SHOW_TRACEBACKS")) if sys.version_info[0] == 2: raise Exception("Nikola does not support Python 2.") -from .nikola import Nikola # NOQA from . import plugins # NOQA +from .nikola import Nikola # NOQA From 334ffb7631ead81b95e8f95a482b26c9c3df6b1a Mon Sep 17 00:00:00 2001 From: Agustin Arce Date: Fri, 4 Oct 2024 21:09:57 -0400 Subject: [PATCH 2/2] refactor (typing): add types to __main__.py --- Makefile | 56 ++++++++ mypy.ini | 6 + nikola/__main__.py | 341 ++++++++++++++++++++++++++------------------- py.typed | 0 4 files changed, 257 insertions(+), 146 deletions(-) create mode 100644 Makefile create mode 100644 mypy.ini create mode 100644 py.typed diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000..48abc28061 --- /dev/null +++ b/Makefile @@ -0,0 +1,56 @@ +# Makefile for running mypy type checks, tests, flake8 checks, and ruff formatting + +# Variables +PYTHON = python3 +MYPY = mypy +MYPY_CONFIG = mypy.ini +SOURCE_DIR = ./nikola +TEST_DIR = ./tests +TEST_RUNNER = pytest +FLAKE8 = flake8 +RUFF = ruff # Ruff linter/formatter + +# Default target +.PHONY: all +all: check test lint format + +# Check types with mypy +.PHONY: check +check: + @echo "Running mypy type checks..." + $(MYPY) --config-file $(MYPY_CONFIG) $(SOURCE_DIR) + +# Run tests with pytest +.PHONY: test +test: + @echo "Running tests..." + $(TEST_RUNNER) $(TEST_DIR) + +# Run flake8 checks +.PHONY: lint +lint: + @echo "Running flake8 checks..." + $(FLAKE8) $(SOURCE_DIR) $(TEST_DIR) + +# Run ruff checks and formatting +.PHONY: format +format: + @echo "Running Ruff for formatting and linting..." + $(RUFF) check $(SOURCE_DIR) $(TEST_DIR) # Change 'check' to 'fix' if you want to auto-fix issues + +# Clean up (optional) +.PHONY: clean +clean: + @echo "Cleaning up..." + # You can add commands to remove unwanted files if needed + +# Help +.PHONY: help +help: + @echo "Makefile commands:" + @echo " check - Run mypy type checks" + @echo " test - Run tests using pytest" + @echo " lint - Run flake8 checks on source and tests" + @echo " format - Run Ruff for formatting and linting" + @echo " clean - Clean up (optional)" + @echo " help - Show this help message" diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000000..af978fa063 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,6 @@ +[mypy] +ignore_missing_imports = True +strict = True +disallow_untyped_calls = True +disallow_untyped_defs = True +disable_error_code = no-untyped-call, misc diff --git a/nikola/__main__.py b/nikola/__main__.py index 4ac28e456c..cbf6a3250b 100644 --- a/nikola/__main__.py +++ b/nikola/__main__.py @@ -25,11 +25,21 @@ # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """The main function of Nikola.""" +from __future__ import annotations -from blinker import signal +import importlib.util +import os +import shutil +import sys +import textwrap +import traceback from collections import defaultdict +from importlib.machinery import ModuleSpec +from types import ModuleType +from typing import Any import doit.cmd_base +from blinker import signal from doit.cmd_base import TaskLoader2, _wrap from doit.cmd_clean import Clean as DoitClean from doit.cmd_completion import TabCompletion @@ -37,19 +47,14 @@ from doit.cmd_run import Run as DoitRun from doit.doit_cmd import DoitMain from doit.loader import generate_tasks +from doit.plugin import PluginDict from doit.reporter import ExecutedOnlyReporter - -import importlib.util -import os -import shutil -import sys -import textwrap -import traceback +from doit.task import Task from . import __version__ +from .log import LOGGER, ColorfulFormatter, LoggingMode, configure_logging from .nikola import Nikola from .plugin_categories import Command -from .log import configure_logging, LOGGER, ColorfulFormatter, LoggingMode from .utils import get_root_dir, req_missing, sys_decode try: @@ -57,16 +62,21 @@ except ImportError: pass # This is only so raw_input/input does nicer things if it's available -config = {} +config: dict[str, Any] = {} # DO NOT USE unless you know what you are doing! -_RETURN_DOITNIKOLA = False +_RETURN_DOITNIKOLA: bool = False -def main(args=None): +def main(args: list[str] | None = None) -> DoitNikola | int: """Run Nikola.""" - colorful = False - if sys.stderr.isatty() and os.name != 'nt' and os.getenv('NIKOLA_MONO') is None and os.getenv('TERM') != 'dumb': + colorful: bool = False + if ( + sys.stderr.isatty() + and os.name != "nt" + and os.getenv("NIKOLA_MONO") is None + and os.getenv("TERM") != "dumb" + ): colorful = True ColorfulFormatter._colorful = colorful @@ -74,24 +84,24 @@ def main(args=None): if args is None: args = sys.argv[1:] - oargs = args - args = [sys_decode(arg) for arg in args] + oargs: list[str] = args + args = [sys_decode(arg) for arg in args] # type: ignore - conf_filename = 'conf.py' - conf_filename_changed = False + conf_filename: str = "conf.py" + conf_filename_changed: bool = False for index, arg in enumerate(args): - if arg[:7] == '--conf=': + if arg[:7] == "--conf=": del args[index] del oargs[index] conf_filename = arg[7:] conf_filename_changed = True break - quiet = False - if len(args) > 0 and args[0] == 'build' and '--strict' in args: - LOGGER.info('Running in strict mode') + quiet: bool = False + if len(args) > 0 and args[0] == "build" and "--strict" in args: + LOGGER.info("Running in strict mode") configure_logging(LoggingMode.STRICT) - elif len(args) > 0 and args[0] == 'build' and '-q' in args or '--quiet' in args: + elif len(args) > 0 and args[0] == "build" and "-q" in args or "--quiet" in args: configure_logging(LoggingMode.QUIET) quiet = True else: @@ -99,35 +109,43 @@ def main(args=None): global config - original_cwd = os.getcwd() + original_cwd: str = os.getcwd() # Those commands do not require a `conf.py`. (Issue #1132) # Moreover, actually having one somewhere in the tree can be bad, putting # the output of that command (the new site) in an unknown directory that is # not the current working directory. (does not apply to `version`) - argname = args[0] if len(args) > 0 else None - if argname and argname not in ['init', 'version'] and not argname.startswith('import_'): - root = get_root_dir() + argname: str | None = args[0] if len(args) > 0 else None + if ( + argname + and argname not in ["init", "version"] + and not argname.startswith("import_") + ): + root: str | None = get_root_dir() # type: ignore if root: os.chdir(root) # Help and imports don't require config, but can use one if it exists - needs_config_file = (argname != 'help') and not argname.startswith('import_') + needs_config_file: bool = (argname != "help") and not argname.startswith( + "import_" + ) LOGGER.debug("Website root: %r", root) else: needs_config_file = False sys.path.insert(0, os.path.dirname(conf_filename)) try: - spec = importlib.util.spec_from_file_location("conf", conf_filename) - conf = importlib.util.module_from_spec(spec) + spec: ModuleSpec | None = importlib.util.spec_from_file_location( + "conf", conf_filename + ) + conf: ModuleType = importlib.util.module_from_spec(spec) # type: ignore # Preserve caching behavior of `import conf` if the filename matches if os.path.splitext(os.path.basename(conf_filename))[0] == "conf": sys.modules["conf"] = conf - spec.loader.exec_module(conf) + spec.loader.exec_module(conf) # type: ignore config = conf.__dict__ except Exception: if os.path.exists(conf_filename): - msg = traceback.format_exc() + msg: str = traceback.format_exc() LOGGER.error('"{0}" cannot be parsed.\n{1}'.format(conf_filename, msg)) return 1 elif needs_config_file and conf_filename_changed: @@ -138,32 +156,33 @@ def main(args=None): if conf_filename_changed: LOGGER.info("Using config file '{0}'".format(conf_filename)) - invariant = False + invariant: bool = False - if len(args) > 0 and args[0] == 'build' and '--invariant' in args: + if len(args) > 0 and args[0] == "build" and "--invariant" in args: try: import freezegun + freeze = freezegun.freeze_time("2038-01-01") freeze.start() invariant = True except ImportError: - req_missing(['freezegun'], 'perform invariant builds') + req_missing(["freezegun"], "perform invariant builds") if config: - if os.path.isdir('plugins') and not os.path.exists('plugins/__init__.py'): - with open('plugins/__init__.py', 'w') as fh: - fh.write('# Plugin modules go here.') - - config['__colorful__'] = colorful - config['__invariant__'] = invariant - config['__quiet__'] = quiet - config['__configuration_filename__'] = conf_filename - config['__cwd__'] = original_cwd - site = Nikola(**config) - DN = DoitNikola(site, quiet) + if os.path.isdir("plugins") and not os.path.exists("plugins/__init__.py"): + with open("plugins/__init__.py", "w") as fh: + fh.write("# Plugin modules go here.") + + config["__colorful__"] = colorful + config["__invariant__"] = invariant + config["__quiet__"] = quiet + config["__configuration_filename__"] = conf_filename + config["__cwd__"] = original_cwd + site: Nikola = Nikola(**config) + DN: DoitNikola = DoitNikola(site, quiet) if _RETURN_DOITNIKOLA: return DN - _ = DN.run(oargs) + _: Any | int = DN.run(oargs) if site.invariant: freeze.stop() @@ -174,15 +193,17 @@ class Help(DoitHelp): """Show Nikola usage.""" @staticmethod - def print_usage(cmds): + def print_usage(cmds: dict[str, Any]) -> None: """Print nikola "usage" (basic help) instructions.""" # Remove 'run'. Nikola uses 'build', though we support 'run' for # people used to it (eg. doit users). # WARNING: 'run' is the vanilla doit command, without support for # --strict, --invariant and --quiet. - del cmds['run'] + del cmds["run"] - print("Nikola is a tool to create static websites and blogs. For full documentation and more information, please visit https://getnikola.com/\n\n") + print( + "Nikola is a tool to create static websites and blogs. For full documentation and more information, please visit https://getnikola.com/\n\n" + ) print("Available commands:") for cmd_name in sorted(cmds.keys()): cmd = cmds[cmd_name] @@ -196,38 +217,38 @@ def print_usage(cmds): class Build(DoitRun): """Expose "run" command as "build" for backwards compatibility.""" - def __init__(self, *args, **kw): + def __init__(self, *args: tuple[Any], **kw: dict[str, Any]) -> None: """Initialize Build.""" - opts = list(self.cmd_options) + opts: list[dict[str, Any]] = list(self.cmd_options) opts.append( { - 'name': 'strict', - 'long': 'strict', - 'default': False, - 'type': bool, - 'help': "Fail on things that would normally be warnings.", + "name": "strict", + "long": "strict", + "default": False, + "type": bool, + "help": "Fail on things that would normally be warnings.", } ) opts.append( { - 'name': 'invariant', - 'long': 'invariant', - 'default': False, - 'type': bool, - 'help': "Generate invariant output (for testing only!).", + "name": "invariant", + "long": "invariant", + "default": False, + "type": bool, + "help": "Generate invariant output (for testing only!).", } ) opts.append( { - 'name': 'quiet', - 'long': 'quiet', - 'short': 'q', - 'default': False, - 'type': bool, - 'help': "Run quietly.", + "name": "quiet", + "long": "quiet", + "short": "q", + "default": False, + "type": bool, + "help": "Run quietly.", } ) - self.cmd_options = tuple(opts) + self.cmd_options: tuple[dict[str, Any], ...] = tuple(opts) super().__init__(*args, **kw) @@ -235,10 +256,10 @@ class Clean(DoitClean): """Clean site, including the cache directory.""" # The unseemly *a is because this API changed between doit 0.30.1 and 0.31 - def clean_tasks(self, tasks, dryrun, *a): + def clean_tasks(self, tasks: list[Task], dryrun: bool, *a: tuple[Any]) -> Any: """Clean tasks.""" if not dryrun and config: - cache_folder = config.get('CACHE_FOLDER', 'cache') + cache_folder = config.get("CACHE_FOLDER", "cache") if os.path.exists(cache_folder): shutil.rmtree(cache_folder) return super(Clean, self).clean_tasks(tasks, dryrun, *a) @@ -249,7 +270,8 @@ def clean_tasks(self, tasks, dryrun, *a): # doit_auto is not available with doit>=0.36.0. try: from doit.cmd_auto import Auto as DoitAuto - DoitAuto.name = 'doit_auto' + + DoitAuto.name = "doit_auto" except ImportError: DoitAuto = None @@ -257,40 +279,48 @@ def clean_tasks(self, tasks, dryrun, *a): class NikolaTaskLoader(TaskLoader2): """Nikola-specific task loader.""" - def __init__(self, nikola, quiet=False): + def __init__(self, nikola: Nikola, quiet: bool = False) -> None: """Initialize the loader.""" super().__init__() - self.nikola = nikola - self.quiet = quiet + self.nikola: Nikola = nikola + self.quiet: bool = quiet - def load_doit_config(self): + def load_doit_config(self) -> dict[str, Any]: """Load doit configuration.""" if self.quiet: doit_config = { - 'verbosity': 0, - 'reporter': 'zero', + "verbosity": 0, + "reporter": "zero", } else: doit_config = { - 'reporter': ExecutedOnlyReporter, - 'outfile': sys.stderr, + "reporter": ExecutedOnlyReporter, + "outfile": sys.stderr, } - doit_config['default_tasks'] = ['render_site', 'post_render'] + doit_config["default_tasks"] = ["render_site", "post_render"] doit_config.update(self.nikola._doit_config) return doit_config - def load_tasks(self, cmd, pos_args): + def load_tasks(self) -> list[Task]: """Load Nikola tasks.""" try: - tasks = generate_tasks( - 'render_site', - self.nikola.gen_tasks('render_site', "Task", 'Group of tasks to render the site.')) - latetasks = generate_tasks( - 'post_render', - self.nikola.gen_tasks('post_render', "LateTask", 'Group of tasks to be executed after site is rendered.')) - signal('initialized').send(self.nikola) + tasks: list[Task] = generate_tasks( + "render_site", + self.nikola.gen_tasks( + "render_site", "Task", "Group of tasks to render the site." + ), + ) + latetasks: list[Task] = generate_tasks( + "post_render", + self.nikola.gen_tasks( + "post_render", + "LateTask", + "Group of tasks to be executed after site is rendered.", + ), + ) + signal("initialized").send(self.nikola) except Exception: - LOGGER.error('Error loading tasks. An unhandled exception occurred.') + LOGGER.error("Error loading tasks. An unhandled exception occurred.") if self.nikola.debug or self.nikola.show_tracebacks: raise _print_exception() @@ -302,93 +332,102 @@ class DoitNikola(DoitMain): """Nikola-specific implementation of DoitMain.""" # overwite help command - DOIT_CMDS = list(DoitMain.DOIT_CMDS) + [Help, Build, Clean] - TASK_LOADER = NikolaTaskLoader + DOIT_CMDS: list[object] = list(DoitMain.DOIT_CMDS) + [Help, Build, Clean] + TASK_LOADER: type = NikolaTaskLoader if DoitAuto is not None: DOIT_CMDS.append(DoitAuto) - def __init__(self, nikola, quiet=False): + def __init__(self, nikola: Nikola, quiet: bool = False) -> None: """Initialzie DoitNikola.""" super().__init__() - self.nikola = nikola - nikola.doit = self - self.task_loader = self.TASK_LOADER(nikola, quiet) + self.nikola: Nikola = nikola + nikola.doit: DoitNikola = self # type: ignore + self.task_loader: NikolaTaskLoader = self.TASK_LOADER(nikola, quiet) - def get_cmds(self): + def get_cmds(self) -> PluginDict: """Get commands.""" # core doit commands - cmds = DoitMain.get_cmds(self) + cmds: PluginDict = DoitMain.get_cmds(self) # load nikola commands for name, cmd in self.nikola._commands.items(): cmds[name] = cmd return cmds - def run(self, cmd_args): + def run(self, cmd_args: list[str]) -> Any | int: """Run Nikola.""" - args = self.process_args(cmd_args) + args: list[str] = self.process_args(cmd_args) args = [sys_decode(arg) for arg in args] if len(args) == 0: - cmd_args = ['help'] - args = ['help'] + cmd_args = ["help"] + args = ["help"] - if '--help' in args or '-h' in args: - new_cmd_args = ['help'] + cmd_args - new_args = ['help'] + args + if "--help" in args or "-h" in args: + new_cmd_args: list[str] = ["help"] + cmd_args + new_args: list[str] = ["help"] + args cmd_args = [] args = [] for arg in new_cmd_args: - if arg not in ('--help', '-h'): + if arg not in ("--help", "-h"): cmd_args.append(arg) for arg in new_args: - if arg not in ('--help', '-h'): + if arg not in ("--help", "-h"): args.append(arg) - if args[0] == 'help': + if args[0] == "help": self.nikola.init_plugins(commands_only=True) - elif args[0] == 'plugin': + elif args[0] == "plugin": self.nikola.init_plugins(load_all=True) else: self.nikola.init_plugins() - sub_cmds = self.get_cmds() + sub_cmds: PluginDict = self.get_cmds() - if any(arg in ("--version", '-V') for arg in args): - cmd_args = ['version'] - args = ['version'] + if any(arg in ("--version", "-V") for arg in args): + cmd_args = ["version"] + args = ["version"] if args[0] not in sub_cmds.keys(): LOGGER.error("Unknown command {0}".format(args[0])) - sugg = defaultdict(list) - sub_filtered = (i for i in sub_cmds.keys() if i != 'run') + sugg: defaultdict[int, Any | list[Any | int]] = defaultdict(list) + sub_filtered: tuple[str, ...] = tuple( + i for i in sub_cmds.keys() if i != "run" + ) for c in sub_filtered: - d = levenshtein(c, args[0]) + d: Any | int = levenshtein(c, args[0]) sugg[d].append(c) if sugg.keys(): - best_sugg = sugg[min(sugg.keys())] + best_sugg: Any = sugg[min(sugg.keys())] if len(best_sugg) == 1: LOGGER.info('Did you mean "{}"?'.format(best_sugg[0])) else: - LOGGER.info('Did you mean "{}" or "{}"?'.format('", "'.join(best_sugg[:-1]), best_sugg[-1])) + LOGGER.info( + 'Did you mean "{}" or "{}"?'.format( + '", "'.join(best_sugg[:-1]), best_sugg[-1] + ) + ) return 3 - if not sub_cmds[args[0]] in (Help, TabCompletion) and not isinstance(sub_cmds[args[0]], Command): + if sub_cmds[args[0]] not in (Help, TabCompletion) and not isinstance( + sub_cmds[args[0]], Command + ): if not self.nikola.configured: - LOGGER.error("This command needs to run inside an " - "existing Nikola site.") + LOGGER.error( + "This command needs to run inside an " "existing Nikola site." + ) return 3 try: return super().run(cmd_args) except Exception: - LOGGER.error('An unhandled exception occurred.') + LOGGER.error("An unhandled exception occurred.") if self.nikola.debug or self.nikola.show_tracebacks: raise _print_exception() return 1 @staticmethod - def print_version(): + def print_version() -> None: """Print Nikola version.""" print("Nikola v" + __version__) @@ -396,40 +435,44 @@ def print_version(): # Override Command.help() to make it more readable and to remove # some doit-specific stuff. Based on doit's implementation. # (see Issue #3342) -def _command_help(self: Command): +def _command_help(self: Command) -> str: """Return help text for a command.""" text = [] - usage = "{} {} {}".format(self.bin_name, self.name, self.doc_usage) - text.extend(textwrap.wrap(usage, subsequent_indent=' ')) + usage: str = "{} {} {}".format(self.bin_name, self.name, self.doc_usage) + text.extend(textwrap.wrap(usage, subsequent_indent=" ")) text.extend(_wrap(self.doc_purpose, 4)) text.append("\nOptions:") - options = defaultdict(list) + options: defaultdict[str, Any] = defaultdict(list) for opt in self.cmdparser.options: options[opt.section].append(opt) for section, opts in sorted(options.items()): if section: - section_name = '\n{}'.format(section) + section_name: str = "\n{}".format(section) text.extend(_wrap(section_name, 2)) for opt in opts: # ignore option that cant be modified on cmd line if not (opt.short or opt.long): continue text.extend(_wrap(opt.help_param(), 4)) - opt_help = opt.help - if '%(default)s' in opt_help: - opt_help = opt.help % {'default': opt.default} - elif opt.default != '' and opt.default is not False and opt.default is not None: - opt_help += ' [default: {}]'.format(opt.default) - opt_choices = opt.help_choices() - desc = '{} {}'.format(opt_help, opt_choices) + opt_help: str = opt.help + if "%(default)s" in opt_help: + opt_help = opt.help % {"default": opt.default} + elif ( + opt.default != "" + and opt.default is not False + and opt.default is not None + ): + opt_help += " [default: {}]".format(opt.default) + opt_choices: str = opt.help_choices() + desc: str = "{} {}".format(opt_help, opt_choices) text.extend(_wrap(desc, 8)) # print bool inverse option if opt.inverse: - text.extend(_wrap('--{}'.format(opt.inverse), 4)) - text.extend(_wrap('opposite of --{}'.format(opt.long), 8)) + text.extend(_wrap("--{}".format(opt.inverse), 4)) + text.extend(_wrap("opposite of --{}".format(opt.long), 8)) if self.doc_description is not None: text.append("\n\nDescription:") @@ -440,8 +483,8 @@ def _command_help(self: Command): doit.cmd_base.Command.help = _command_help -def levenshtein(s1, s2): - u"""Calculate the Levenshtein distance of two strings. +def levenshtein(s1: str, s2: str) -> int: + """Calculate the Levenshtein distance of two strings. Implementation from Wikibooks: https://en.wikibooks.org/w/index.php?title=Algorithm_Implementation/Strings/Levenshtein_distance&oldid=2974448#Python @@ -454,25 +497,31 @@ def levenshtein(s1, s2): if len(s2) == 0: return len(s1) - previous_row = range(len(s2) + 1) + previous_row: list[int] = list(range(len(s2) + 1)) for i, c1 in enumerate(s1): current_row = [i + 1] for j, c2 in enumerate(s2): # j+1 instead of j since previous_row and current_row are one character longer than s2 - insertions = previous_row[j + 1] + 1 - deletions = current_row[j] + 1 - substitutions = previous_row[j] + (c1 != c2) + insertions: int = previous_row[j + 1] + 1 + deletions: int = current_row[j] + 1 + substitutions: int = previous_row[j] + (c1 != c2) current_row.append(min(insertions, deletions, substitutions)) previous_row = current_row return previous_row[-1] -def _print_exception(): +def _print_exception() -> None: """Print an exception in a friendlier, shorter style.""" etype, evalue, _ = sys.exc_info() - LOGGER.error(''.join(traceback.format_exception(etype, evalue, None, limit=0, chain=False)).strip()) - LOGGER.warning("To see more details, run Nikola in debug mode (set environment variable NIKOLA_DEBUG=1) or use NIKOLA_SHOW_TRACEBACKS=1") + LOGGER.error( + "".join( + traceback.format_exception(etype, evalue, None, limit=0, chain=False) + ).strip() + ) + LOGGER.warning( + "To see more details, run Nikola in debug mode (set environment variable NIKOLA_DEBUG=1) or use NIKOLA_SHOW_TRACEBACKS=1" + ) if __name__ == "__main__": diff --git a/py.typed b/py.typed new file mode 100644 index 0000000000..e69de29bb2