Source code for pyscaffold.operations

"""
Collection of functions responsible for the "reification" of the file representation in
the project structure into a file written to the disk.

A function that "reifies" a file (manifests the programmatic representation of the file
in the project structure dictionary to the disk) is called here a **file operation** or
simply a **file op**. Actually file ops don't even need to be functions strictly
speaking, they can be any :obj:`callable` object. The only restriction is that file ops
**MUST** respect the :obj:`FileOp` signature. The :obj:`create` function is a good
example of how to write a new file op.

A function (or callable) that modifies the behaviour of an existing file op, by wrapping
it with another function/callable is called here **modifier**. Modifiers work similarly
to Python `decorators`_ and allow extending/refining the existing file ops.
A modifier should receive a file op as argument and return another file op.
:obj:`no_overwrite` and :obj:`skip_on_update` are good examples on how to write new file
op modifiers.

While modifiers don't have a specific signature (the number of parameters might vary,
but they always return a single file op value), the following conventions **SHOULD** be
observed when creating new modifiers:

- Modifiers should accept at least one argument: the **file op** they modify
  (you don't have to be very creative, go ahead and name this parameter ``file_op``, it
  is a good convention).
  This parameter should be made **optional** (the default value of :obj:`create` usually
  works, unless there is a better default value for the main use case of the modifier).
- Modifiers can accept arguments other then the file op they modify. These arguments
  should **precede** the file op in the list of arguments (the file op should be the
  last argument, which interoperates well with :obj:`~functools.partial`).
- When writing a modifier that happens to be a function (instead of a callable class),
  please name the inner function with the same name of the modifier but preceded by an
  ``_`` (underscore) char. This allows better inspection/debugging.


.. versionchanged:: 4.0
   Previously, file operations where simply indicated as a numeric flag
   (the members of ``pyscaffold.structure.FileOp``) in the project structure.
   Starting from PyScaffold 4, file operation are functions with signature given by
   :obj:`FileOp`.

.. _decorators: https://en.wikipedia.org/wiki/Python_syntax_and_semantics#Decorators
.. _shebang: https://en.wikipedia.org/wiki/Shebang_(Unix)
.. _File access permissions: https://en.wikipedia.org/wiki/File_system_permissions
"""

from pathlib import Path
from typing import Any, Callable, Dict, Union

from . import file_system as fs
from .log import logger

# Signatures for the documentation purposes

ScaffoldOpts = Dict[str, Any]
"""Dictionary with PyScaffold's options, see :obj:`pyscaffold.api.create_project`.
Should be treated as immutable (if required, copy before changing).

Please notice some behaviours given by the options **SHOULD** be observed. For example,
files should be overwritten when the **force** option is ``True``. Similarly when
**pretend** is ``True``, no operation should be really performed, but any action should
be logged as if realized.
"""

FileContents = Union[str, None]
"""When the file content is ``None``, the file should not be written to
disk (empty files are represented by an empty string ``""`` as content).
"""

FileOp = Callable[[Path, FileContents, ScaffoldOpts], Union[Path, None]]
"""Signature of functions considered file operations::

    Callable[[Path, FileContents, ScaffoldOpts], Union[Path, None]]

Args:
    path (pathlib.Path): file path potentially to be written to/changed in the disk.
    contents (:obj:`FileContents`): usually a string that represents a text
        content of the file. :obj:`None` indicates the file should not be written.
    opts (:obj:`ScaffoldOpts`): a dict with PyScaffold's options.

Returns:
    If the file is written (or more generally changed, such as new access permissions),
    by convention they should return the :obj:`file path <pathlib.Path>`.
    If no file was touched, :obj:`None` should be returned. Please notice a **FileOp**
    might return :obj:`None` if a pre-existing file in the disk is not modified.

Note:
    A **FileOp** usually has side effects (e.g. write a file to the disk), see
    :obj:`FileContents` and :obj:`ScaffoldOpts` for other conventions.
"""


# FileOps and FileOp modifiers (a.k.a. factories/decorators/wrappers)


[docs]def create(path: Path, contents: FileContents, opts: ScaffoldOpts) -> Union[Path, None]: """ Default :obj:`FileOp`: always create/write the file even during (forced) updates. """ if contents is None: return None if not path.parent.is_dir(): fs.create_directory(path.parent, pretend=opts.get("pretend")) return fs.create_file(path, contents, pretend=opts.get("pretend"))
[docs]def remove(path: Path, _content: FileContents, opts: ScaffoldOpts) -> Union[Path, None]: """Remove the file if it exists in the disk""" if not path.exists(): return None return fs.rm_rf(path, pretend=opts.get("pretend"))
[docs]def no_overwrite(file_op: FileOp = create) -> FileOp: """File op modifier. Returns a :obj:`FileOp` that does not overwrite an existing file during update (still created if not exists). Args: file_op: a :obj:`FileOp` that will be "decorated", i.e. will be called if the ``no_overwrite`` conditions are met. Default: :obj:`create`. """ def _no_overwrite(path: Path, contents: FileContents, opts: ScaffoldOpts): """See ``pyscaffold.operations.no_overwrite``""" if opts.get("force") or not path.exists(): return file_op(path, contents, opts) logger.report("skip", path) return None return _no_overwrite
[docs]def skip_on_update(file_op: FileOp = create) -> FileOp: """File op modifier. Returns a :obj:`FileOp` that will skip the file during a project update (the file will just be created for brand new projects). Args: file_op: a :obj:`FileOp` that will be "decorated", i.e. will be called if the ``skip_on_update`` conditions are met. Default: :obj:`create`. """ def _skip_on_update(path: Path, contents: FileContents, opts: ScaffoldOpts): """See ``pyscaffold.operations.skip_on_update``""" if opts.get("force") or not opts.get("update"): return file_op(path, contents, opts) logger.report("skip", path) return None return _skip_on_update
[docs]def add_permissions(permissions: int, file_op: FileOp = create) -> FileOp: """File op modifier. Returns a :obj:`FileOp` that will **add** access permissions to the file (on top of the ones given by default by the OS). Args: permissions (int): permissions to be added to file:: updated file mode = old mode | permissions (bitwise OR) Preferably the values should be a combination of :obj:`stat.S_* <stat.S_IRUSR>` values (see :obj:`os.chmod`). file_op: a :obj:`FileOp` that will be "decorated". If the file exists in disk after ``file_op`` is called (either created or pre-existing), ``permissions`` will be added to it. Default: :obj:`create`. Warning: This is an **experimental** file op and might be subject to incompatible changes (or complete removal) even in minor/patch releases. Note: `File access permissions`_ work in a completely different way depending on the operating system. This file op is straightforward on POSIX systems, but a bit tricky on Windows. It should be safe for desirable but not required effects (e.g. making a file with a `shebang`_ executable, but that can also run via ``python FILE``), or ensuring files are readable/writable. """ def _add_permissions(path: Path, contents: FileContents, opts: ScaffoldOpts): """See ``pyscaffold.operations.add_permissions``""" return_value = file_op(path, contents, opts) if path.exists(): mode = path.stat().st_mode | permissions return fs.chmod(path, mode, pretend=opts.get("pretend")) return return_value return _add_permissions