Source code for pyscaffold.structure

"""Functionality to generate and work with the directory structure of a project.

.. versionchanged:: 4.0
   ``Callable[[dict], str]`` and :obj:`string.Template` objects can also be used as file
   contents. They will be called with PyScaffold's ``opts`` (:obj:`string.Template` via
   :obj:`~string.Template.safe_substitute`)
"""
from copy import deepcopy
from pathlib import Path
from string import Template
from typing import Callable, Dict, Optional, Tuple, Union, cast

from . import templates
from .file_system import PathLike, create_directory
from .operations import (
    FileContents,
    FileOp,
    ScaffoldOpts,
    create,
    no_overwrite,
    skip_on_update,
)
from .templates import get_template

NO_OVERWRITE = no_overwrite()
SKIP_ON_UPDATE = skip_on_update()


# Sphinx is bad at documenting aliases for the time being... so we repeat the definition

AbstractContent = Union[FileContents, Callable[[ScaffoldOpts], FileContents], Template]
"""*Recipe* for obtaining file contents
::

    Union[FileContents, Callable[[ScaffoldOpts], FileContents], Template]
"""

ResolvedLeaf = Tuple[AbstractContent, FileOp]
"""Complete *recipe* for manipulating a file in disk (not only its contents but also the
file operation::

    Tuple[AbstractContent, FileOp]
"""

ReifiedLeaf = Tuple[FileContents, FileOp]
"""Similar to :obj:`ResolvedLeaf` but with file contents "reified", i.e. an actual
string instead of a "lazy object" (such as a function or template).
"""

Leaf = Union[AbstractContent, ResolvedLeaf]
"""Just the content of the file OR a tuple of content + file operation
::

    Union[AbstractContent, ResolvedLeaf]
"""

# TODO: Replace `dict` when recursive types are processed by mypy
Node = Union[Leaf, dict]
"""Representation of a *file system node* in the project structure (e.g. files,
directories::

    Union[Leaf, Structure]
"""


Structure = Dict[str, Node]
"""The directory tree represented as a (possibly nested) dictionary::

    Structure = Dict[str, Node]
    Node = Union[Leaf, Structure]

The keys indicate the path where a file will be written, while the
value indicates the content.

A nested dictionary represent a nested directory, while :obj:`str`,
:obj:`string.Template` and :obj:`callable` values represent a file to be created.
:obj:`tuple` values are also allowed, and in that case, the first element of the tuple
represents the file content while the second element is a :mod:`pyscaffold.operations
<file operation>` (which can be seen as a recipe on how to create a file with the given
content). :obj:`Callable <callable>` file contents are transformed into strings by
calling them with :obj:`PyScaffold's option dict as argument
<pyscaffold.api.create_structure>`. Similarly, :obj:`string.Template.safe_substitute`
are called with PyScaffold's opts.

The top level keys in the dict are file/dir names relative to the project root, while
keys in nested dicts are relative to the parent's key/location.

For example::

    from pyscaffold.operations import no_overwrite
    struct: Structure = {
        'namespace': {
            'module.py': ('print("Hello World!")', no_overwrite())
        }
    }

represents a ``namespace/module.py`` file inside the project folder
with content ``print("Hello World!")``, that will be created only if not
present.

Note:
    :obj:`None` file contents are ignored and not created in disk.
"""

ActionParams = Tuple[Structure, ScaffoldOpts]
"""See :obj:`pyscaffold.actions.ActionParams`"""


# -------- PyScaffold Actions --------


[docs]def define_structure(struct: Structure, opts: ScaffoldOpts) -> ActionParams: """Creates the project structure as dictionary of dictionaries Args: struct : previous directory structure (usually and empty dict) opts: options of the project Returns: Project structure and PyScaffold's options .. versionchanged:: 4.0 :obj:`string.Template` and functions added directly to the file structure. """ files: Structure = { # Tools ".gitignore": (get_template("gitignore"), NO_OVERWRITE), ".coveragerc": (get_template("coveragerc"), NO_OVERWRITE), ".readthedocs.yml": (get_template("rtd_cfg"), NO_OVERWRITE), # Project configuration "pyproject.toml": (templates.pyproject_toml, NO_OVERWRITE), "setup.py": get_template("setup_py"), "setup.cfg": (templates.setup_cfg, NO_OVERWRITE), "tox.ini": (get_template("tox_ini"), NO_OVERWRITE), # Essential docs "README.rst": (get_template("readme"), NO_OVERWRITE), "AUTHORS.rst": (get_template("authors"), NO_OVERWRITE), "LICENSE.txt": (templates.license, NO_OVERWRITE), "CHANGELOG.rst": (get_template("changelog"), NO_OVERWRITE), "CONTRIBUTING.rst": (get_template("contributing"), NO_OVERWRITE), # Code "src": { opts["package"]: { "__init__.py": templates.init, "skeleton.py": (get_template("skeleton"), SKIP_ON_UPDATE), } }, # Tests "tests": { "conftest.py": (get_template("conftest_py"), NO_OVERWRITE), "test_skeleton.py": (get_template("test_skeleton"), SKIP_ON_UPDATE), }, # Remaining of the Documentation "docs": { "conf.py": get_template("sphinx_conf"), "authors.rst": get_template("sphinx_authors"), "contributing.rst": get_template("sphinx_contributing"), "index.rst": (get_template("sphinx_index"), NO_OVERWRITE), "readme.rst": get_template("sphinx_readme"), "license.rst": get_template("sphinx_license"), "changelog.rst": get_template("sphinx_changelog"), "Makefile": get_template("sphinx_makefile"), "_static": {".gitignore": get_template("gitignore_empty")}, "requirements.txt": (get_template("rtd_requirements"), NO_OVERWRITE), }, } return merge(struct, files), opts
[docs]def create_structure( struct: Structure, opts: ScaffoldOpts, prefix: Optional[Path] = None ) -> ActionParams: """Manifests/reifies a directory structure in the filesystem Args: struct: directory structure as dictionary of dictionaries opts: options of the project prefix: prefix path for the structure Returns: Directory structure as dictionary of dictionaries (similar to input, but only containing the files that actually changed) and input options Raises: TypeError: raised if content type in struct is unknown .. versionchanged:: 4.0 Also accepts :obj:`string.Template` and :obj:`callable` objects as file contents. """ update = opts.get("update") or opts.get("force") pretend = opts.get("pretend") if prefix is None: prefix = cast(Path, opts.get("project_path", ".")) create_directory(prefix, update, pretend) prefix = Path(prefix) changed: Structure = {} for name, node in struct.items(): path = prefix / name if isinstance(node, dict): create_directory(path, update, pretend) changed[name], _ = create_structure(node, opts, prefix=path) else: content, file_op = reify_leaf(node, opts) if file_op(path, content, opts): changed[name] = content return changed, opts
# -------- Auxiliary Functions --------
[docs]def resolve_leaf(contents: Leaf) -> ResolvedLeaf: """Normalize project structure leaf to be a Tuple[AbstractContent, FileOp]""" if isinstance(contents, tuple): return contents return (contents, create)
[docs]def reify_content(content: AbstractContent, opts: ScaffoldOpts) -> FileContents: """Make content string (via __call__ or safe_substitute with opts if necessary)""" if callable(content): return content(opts) if isinstance(content, Template): return content.safe_substitute(opts) return content
[docs]def reify_leaf(contents: Leaf, opts: ScaffoldOpts) -> ReifiedLeaf: """Similar to :obj:`resolve_leaf` but applies :obj:`reify_content` to the first element of the returned tuple. """ file_contents, action = resolve_leaf(contents) return (reify_content(file_contents, opts), action)
# -------- Structure Manipulation --------
[docs]def modify( struct: Structure, path: PathLike, modifier: Callable[[AbstractContent, FileOp], ResolvedLeaf], ) -> Structure: """Modify the contents of a file in the representation of the project tree. If the given path does not exist, the parent directories are automatically created. Args: struct: project representation as (possibly) nested dict. See :obj:`~.merge`. path: path-like string or object relative to the structure root. The following examples are equivalent:: from pathlib import Path 'docs/api/index.html' Path('docs', 'api', 'index.html') .. versionchanged:: 4.0 The function no longer accepts a list of strings of path parts. modifier: function (or callable object) that receives the old content and the old file operation as arguments and returns a tuple with the new content and new file operation. Note that, if the file does not exist in ``struct``, ``None`` will be passed as argument. Example:: modifier = lambda old, op: ((old or '') + 'APPENDED CONTENT'!, op) modifier = lambda old, op: ('PREPENDED CONTENT!' + (old or ''), op) .. versionchanged:: 4.0 ``modifier`` requires 2 arguments and now is a mandatory argument. .. versionchanged:: 4.0 ``update_rule`` is no longer an argument. Instead the arity ``modifier`` was changed to accept 2 arguments instead of only 1. This is more suitable to handling the new :obj:`pyscaffold.operations` API. Returns: Updated project tree representation Note: Use an empty string as content to ensure a file is created empty (``None`` contents will not be created). """ # Retrieve a list of parts from a path-like object path_parts = Path(path).parts # Walk the entire path, creating parents if necessary. root = deepcopy(struct) last_parent: dict = root name = path_parts[-1] for parent in path_parts[:-1]: last_parent = last_parent.setdefault(parent, {}) # Get the old value if existent. old_value = resolve_leaf(last_parent.get(name)) # Update the value. new_value = modifier(*old_value) last_parent[name] = _merge_leaf(old_value, new_value) return root
[docs]def ensure( struct: Structure, path: PathLike, content: AbstractContent = None, file_op: FileOp = create, ) -> Structure: """Ensure a file exists in the representation of the project tree with the provided content. All the parent directories are automatically created. Args: struct: project representation as (possibly) nested. path: path-like string or object relative to the structure root. See :obj:`~.modify`. .. versionchanged:: 4.0 The function no longer accepts a list of strings of path parts. content: file text contents, ``None`` by default. The old content is preserved if ``None``. file_op: see :obj:`pyscaffold.operations`, :obj:`~.create`` by default. .. versionchanged:: 4.0 Instead of a ``update_rule`` flag, the function now accepts a :obj:`file_op <pyscaffold.oprtations.FileOp>`. Returns: Updated project tree representation Note: Use an empty string as content to ensure a file is created empty. """ return modify( struct, path, lambda old, _: (old if content is None else content, file_op) )
[docs]def reject(struct: Structure, path: PathLike) -> Structure: """Remove a file from the project tree representation if existent. Args: struct: project representation as (possibly) nested. path: path-like string or object relative to the structure root. See :obj:`~.modify`. .. versionchanged:: 4.0 The function no longer accepts a list of strings of path parts. Returns: Modified project tree representation """ # Retrieve a list of parts from a path-like object path_parts = Path(path).parts # Walk the entire path, creating parents if necessary. root = deepcopy(struct) last_parent: dict = root name = path_parts[-1] for parent in path_parts[:-1]: if parent not in last_parent: return root # one ancestor already does not exist, do nothing last_parent = last_parent[parent] if name in last_parent: del last_parent[name] return root
[docs]def merge(old: Structure, new: Structure) -> Structure: """Merge two dict representations for the directory structure. Basically a deep dictionary merge, except from the leaf update method. Args: old: directory descriptor that takes low precedence during the merge. new: directory descriptor that takes high precedence during the merge. .. versionchanged:: 4.0 Project structure now considers everything **under** the top level project folder. Returns: Resulting merged directory representation Note: Use an empty string as content to ensure a file is created empty. (``None`` contents will not be created). """ return _inplace_merge(deepcopy(old), new)
def _inplace_merge(old: Structure, new: Structure) -> Structure: """Similar to :obj:`~.merge` but modifies the first dict.""" for key, value in new.items(): old_value = old.get(key, None) new_is_dict = isinstance(value, dict) old_is_dict = isinstance(old_value, dict) if new_is_dict and old_is_dict: old[key] = _inplace_merge( cast(Structure, old_value), cast(Structure, value) ) elif old_value is not None and not new_is_dict and not old_is_dict: # both are defined and final leaves old[key] = _merge_leaf(cast(Leaf, old_value), cast(Leaf, value)) else: old[key] = deepcopy(value) return old def _merge_leaf(old_value: Leaf, new_value: Leaf) -> Leaf: """Merge leaf values for the directory tree representation. The leaf value is expected to be a tuple ``(content, update_rule)``. When a string is passed, it is assumed to be the content and ``None`` is used for the update rule. Args: old_value: descriptor for the file that takes low precedence during the merge. new_value: descriptor for the file that takes high precedence during the merge. Note: ``None`` contents are ignored, use and empty string to force empty contents. Returns: Resulting value for the merged leaf """ old = old_value if isinstance(old_value, (list, tuple)) else (old_value, None) new = new_value if isinstance(new_value, (list, tuple)) else (new_value, None) content = old[0] if new[0] is None else new[0] file_op = old[1] if new[1] is None else new[1] if file_op is None: return content return (content, file_op)