Source code for domdf_python_tools.paths

#!/usr/bin/env python
#
#  paths.py
"""
Functions for paths and files.

.. versionchanged:: 1.0.0

	Removed ``relpath2``.
	Use :func:`domdf_python_tools.paths.relpath` instead.
"""
#
#  Copyright © 2018-2020 Dominic Davis-Foster <dominic@davis-foster.co.uk>
#
#  Parts of the docstrings and the PathPlus class based on the Python 3.8.2 Documentation
#  Licensed under the Python Software Foundation License Version 2.
#  Copyright © 2001-2020 Python Software Foundation. All rights reserved.
#  Copyright © 2000 BeOpen.com . All rights reserved.
#  Copyright © 1995-2000 Corporation for National Research Initiatives. All rights reserved.
#  Copyright © 1991-1995 Stichting Mathematisch Centrum. All rights reserved.
#
#  copytree based on https://stackoverflow.com/a/12514470/3092681
#      Copyright © 2012 atzz
#      Licensed under CC-BY-SA
#
#  This program is free software; you can redistribute it and/or modify
#  it under the terms of the GNU Lesser General Public License as published by
#  the Free Software Foundation; either version 3 of the License, or
#  (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU Lesser General Public License for more details.
#
#  You should have received a copy of the GNU Lesser General Public License
#  along with this program; if not, write to the Free Software
#  Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
#  MA 02110-1301, USA.
#

# stdlib
import contextlib
import json
import os
import pathlib
import shutil
import stat
import sys
from typing import IO, Any, Callable, Iterable, List, Optional, TypeVar, Union

# this package
from domdf_python_tools.typing import JsonLibrary, PathLike

__all__ = [
		"append",
		"copytree",
		"delete",
		"maybe_make",
		"parent_path",
		"read",
		"relpath",
		"write",
		"clean_writer",
		"make_executable",
		"PathPlus",
		"PosixPathPlus",
		"WindowsPathPlus",
		"in_directory",
		"_P",
		]

newline_default = object()
_P = TypeVar("_P", bound=pathlib.PurePath)
"""
.. versionadded:: 0.11.0
"""


def append(var: str, filename: PathLike, **kwargs) -> int:
	"""
	Append ``var`` to the file ``filename`` in the current directory.

	.. TODO:: make this the file in the given directory, by default the current directory

	:param var: The value to append to the file
	:param filename: The file to append to
	"""

	with open(os.path.join(os.getcwd(), filename), 'a', **kwargs) as f:
		return f.write(var)


def copytree(
		src: PathLike,
		dst: PathLike,
		symlinks: bool = False,
		ignore: Optional[Callable] = None,
		) -> PathLike:
	"""
	Alternative to :func:`shutil.copytree` to support copying to a directory that already exists.

	Based on https://stackoverflow.com/a/12514470/3092681 by https://stackoverflow.com/users/23252/atzz

	In Python 3.8 and above :func:`shutil.copytree` takes a ``dirs_exist_ok`` argument,
	which has the same result.

	:param src: Source file to copy
	:param dst: Destination to copy file to
	:param symlinks: Whether to represent symbolic links in the source as symbolic
		links in the destination. If false or omitted, the contents and metadata
		of the linked files are copied to the new tree. When symlinks is false,
		if the file pointed by the symlink doesn't exist, an exception will be
		added in the list of errors raised in an Error exception at the end of
		the copy process. You can set the optional ignore_dangling_symlinks
		flag to true if you want to silence this exception. Notice that this
		option has no effect on platforms that don’t support :func:`os.symlink`.
	:param ignore: A callable that will receive as its arguments the source
		directory, and a list of its contents. The ignore callable will be
		called once for each directory that is copied. The callable must return
		a sequence of directory and file names relative to the current
		directory (i.e. a subset of the items in its second argument); these
		names will then be ignored in the copy process.
		:func:`shutil.ignore_patterns` can be used to create such a callable
		that ignores names based on
		glob-style patterns.
	"""

	for item in os.listdir(src):
		s = os.path.join(src, item)
		d = os.path.join(dst, item)
		if os.path.isdir(s):
			shutil.copytree(s, d, symlinks, ignore)
		else:
			shutil.copy2(s, d)

	return dst


def delete(filename: PathLike, **kwargs):
	"""
	Delete the file in the current directory.

	.. TODO:: make this the file in the given directory, by default the current directory

	:param filename: The file to delete
	"""

	os.remove(os.path.join(os.getcwd(), filename), **kwargs)


def maybe_make(directory: PathLike, mode: int = 0o777, parents: bool = False, exist_ok: bool = False):
	"""
	Create a directory at the given path, but only if the directory does not already exist.

	.. attention::

		This will fail silently if a file with the same name already exists.
		This appears to be due to the behaviour of :func:`os.mkdir`.

	:param directory: Directory to create
	:param mode: Combined with the process’ umask value to determine the file mode and access flags
	:param parents: If :py:obj:`False` (the default), a missing parent raises a :class:`FileNotFoundError`.
		If :py:obj:`True`, any missing parents of this path are created as needed; they are created with the
		default permissions without taking mode into account (mimicking the POSIX mkdir -p command).
	:no-default parents:
	:param exist_ok: If :py:obj:`False` (the default), a :class:`FileExistsError` is raised if the
		target directory already exists. If :py:obj:`True`, :class:`FileExistsError` exceptions
		will be ignored (same behavior as the POSIX mkdir -p command), but only if the last path
		component is not an existing non-directory file.
	:no-default exist_ok:
	"""

	if not isinstance(directory, pathlib.Path):
		directory = pathlib.Path(directory)

	if not directory.exists():
		directory.mkdir(mode, parents, exist_ok)


def parent_path(path: PathLike) -> pathlib.Path:
	"""
	Returns the path of the parent directory for the given file or directory.

	:param path: Path to find the parent for

	:return: The parent directory
	"""

	if not isinstance(path, pathlib.Path):
		path = pathlib.Path(path)

	return path.parent


def read(filename: PathLike, **kwargs) -> str:
	"""
	Read a file in the current directory (in text mode).

	.. TODO:: make this the file in the given directory, by default the current directory

	:param filename: The file to read from.

	:return: The contents of the file.
	"""

	with open(os.path.join(os.getcwd(), filename), **kwargs) as f:
		return f.read()


def relpath(path: PathLike, relative_to: Optional[PathLike] = None) -> pathlib.Path:
	"""
	Returns the path for the given file or directory relative to the given
	directory or, if that would require path traversal, returns the absolute path.

	:param path: Path to find the relative path for
	:param relative_to: The directory to find the path relative to.
		Defaults to the current directory.
	:no-default relative_to:
	"""  # noqa D400

	if not isinstance(path, pathlib.Path):
		path = pathlib.Path(path)

	abs_path = path.absolute()

	if relative_to is None:
		relative_to = pathlib.Path().absolute()

	if not isinstance(relative_to, pathlib.Path):
		relative_to = pathlib.Path(relative_to)

	relative_to = relative_to.absolute()

	try:
		return abs_path.relative_to(relative_to)
	except ValueError:
		return abs_path


def write(var: str, filename: PathLike, **kwargs) -> None:
	"""
	Write a variable to file in the current directory.

	.. TODO:: make this the file in the given directory, by default the current directory

	:param var: The value to write to the file.
	:param filename: The file to write to.
	"""

	with open(os.path.join(os.getcwd(), filename), 'w', **kwargs) as f:
		f.write(var)


def clean_writer(string: str, fp: IO) -> None:
	"""
	Write string to ``fp`` without trailing spaces.

	:param string:
	:param fp:
	"""

	# this package
	from domdf_python_tools.stringlist import StringList

	buffer = StringList(string)
	buffer.blankline(ensure_single=True)
	fp.write(str(buffer))


def make_executable(filename: PathLike) -> None:
	"""
	Make the given file executable.

	:param filename: Filename of the file to make executable
	"""

	if not isinstance(filename, pathlib.Path):
		filename = pathlib.Path(filename)

	st = os.stat(str(filename))
	os.chmod(str(filename), st.st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)


@contextlib.contextmanager
def in_directory(directory: PathLike):
	"""
	Context manager to change into the given directory for the
	duration of the ``with`` block.

	:param directory:
	"""  # noqa: D400

	oldwd = os.getcwd()
	try:
		os.chdir(str(directory))
		yield
	finally:
		os.chdir(oldwd)


class PathPlus(pathlib.Path):
	"""
	Subclass of :class:`pathlib.Path` with additional methods and a default encoding of UTF-8.

	Path represents a filesystem path but unlike PurePath, also offers
	methods to do system calls on path objects. Depending on your system,
	instantiating a Path will return either a PosixPath or a WindowsPath
	object. You can also instantiate a PosixPath or WindowsPath directly,
	but cannot instantiate a WindowsPath on a POSIX system or vice versa.

	.. versionadded:: 0.3.8

	.. versionchanged:: 0.5.1

		Defaults to Unix line endings (``LF``) on all platforms.
	"""

	__slots__ = ("_accessor", )

	def __new__(cls, *args, **kwargs):  # noqa D102
		if cls is PathPlus:
			cls = WindowsPathPlus if os.name == "nt" else PosixPathPlus

		self = cls._from_parts(args, init=False)  # type: ignore
		if not self._flavour.is_supported:
			raise NotImplementedError(f"cannot instantiate {cls.__name__!r} on your system")

		self._init()
		return self

	def make_executable(self) -> None:
		"""
		Make the file executable.

		:rtype:

		.. versionadded:: 0.3.8
		"""

		make_executable(self)

	def write_clean(
			self,
			string: str,
			encoding: Optional[str] = "UTF-8",
			errors: Optional[str] = None,
			):
		"""
		Write to the file without trailing whitespace, and with a newline at the end of the file.

		:param string:
		:param encoding: The encoding to write to the file in.
		:param errors:

		:rtype:

		.. versionadded:: 0.3.8
		"""

		with self.open("w", encoding=encoding, errors=errors) as fp:
			clean_writer(string, fp)

	def maybe_make(
			self,
			mode: int = 0o777,
			parents: bool = False,
			exist_ok: bool = False,
			):
		"""
		Create a directory at this path, but only if the directory does not already exist.

		.. note::

			This will fail silently if a file with the same name already exists.
			This appears to be due to the behaviour of :func:`os.mkdir`.

		:param mode: Combined with the process’ umask value to determine the file mode and access flags
		:param parents: If :py:obj:`False` (the default), a missing parent raises a :class:`FileNotFoundError`.
			If :py:obj:`True`, any missing parents of this path are created as needed; they are created with the
			default permissions without taking mode into account (mimicking the POSIX mkdir -p command).
		:no-default parents:
		:param exist_ok: If :py:obj:`False` (the default), a :class:`FileExistsError` is raised if the
			target directory already exists. If :py:obj:`True`, :class:`FileExistsError` exceptions
			will be ignored (same behavior as the POSIX mkdir -p command), but only if the last path
			component is not an existing non-directory file.
		:no-default exist_ok:

		:rtype:

		.. versionadded:: 0.3.8
		"""

		maybe_make(self, mode=mode, parents=parents, exist_ok=exist_ok)

	def append_text(
			self,
			string: str,
			encoding: Optional[str] = "UTF-8",
			errors: Optional[str] = None,
			):
		"""
		Open the file in text mode, append the given string to it, and close the file.

		:param string:
		:param encoding: The encoding to write to the file in.
		:param errors:

		:rtype:

		.. versionadded:: 0.3.8
		"""

		with self.open("a", encoding=encoding, errors=errors) as fp:
			fp.write(string)

	def write_text(
			self,
			data: str,
			encoding: Optional[str] = "UTF-8",
			errors: Optional[str] = None,
			) -> int:
		"""
		Open the file in text mode, write to it, and close the file.

		:param data:
		:param encoding: The encoding to write to the file in.
		:param errors:

		:rtype:

		.. versionadded:: 0.3.8
		"""

		return super().write_text(data, encoding=encoding, errors=errors)

	def write_lines(
			self,
			data: Iterable[str],
			encoding: Optional[str] = "UTF-8",
			errors: Optional[str] = None,
			) -> None:
		"""
		Write the given list of lines to the file without trailing whitespace.

		:param data:
		:param encoding: The encoding to write to the file in.
		:param errors:

		.. versionadded:: 0.5.0
		"""  # noqa D400

		return self.write_clean("\n".join(data), encoding=encoding, errors=errors)

	def read_text(
			self,
			encoding: Optional[str] = "UTF-8",
			errors: Optional[str] = None,
			) -> str:
		"""
		Open the file in text mode, read it, and close the file.

		:param encoding: The encoding to write to the file in.
		:param errors:

		:return: The content of the file.

		.. versionadded:: 0.3.8
		"""

		return super().read_text(encoding=encoding, errors=errors)

	def read_lines(
			self,
			encoding: Optional[str] = "UTF-8",
			errors: Optional[str] = None,
			) -> List[str]:
		"""
		Open the file in text mode, return a list containing the lines in the file,
		and close the file.

		:param encoding: The encoding to write to the file in.
		:param errors:

		:return: The content of the file.

		.. versionadded:: 0.5.0
		"""  # noqa D400

		return self.read_text(encoding=encoding, errors=errors).split("\n")

	def open(  # type: ignore  # noqa A003
		self,
		mode: str = "r",
		buffering: int = -1,
		encoding: Optional[str] = "UTF-8",
		errors: Optional[str] = None,
		newline: Optional[str] = newline_default,  # type: ignore
		) -> IO[Any]:
		"""
		Open the file pointed by this path and return a file object, as
		the built-in :func:`open` function does.

		:param mode: The mode to open the file in.
		:default mode: ``'r'`` (read only)
		:param buffering:
		:param encoding:
		:param errors:
		:param newline:
		:default newline: `universal newlines <https://docs.python.org/3/glossary.html#term-universal-newlines>`__ for reading, Unix line endings (``LF``) for writing.

		.. versionadded:: 0.3.8

		.. versionchanged:: 0.5.1

			Defaults to Unix line endings (``LF``) on all platforms.
		"""  # noqa D400

		if 'b' in mode:
			encoding = None
			newline = None

		if newline is newline_default:
			if 'r' in mode:
				newline = None
			else:
				newline = "\n"

		return super().open(
				mode,
				buffering=buffering,
				encoding=encoding,
				errors=errors,
				newline=newline,
				)

	def dump_json(
			self,
			data: Any,
			encoding: Optional[str] = "UTF-8",
			errors: Optional[str] = None,
			json_library: JsonLibrary = json,  # type: ignore
			**kwargs,
			) -> None:
		"""
		Dump ``data`` to the file as JSON.

		:param data: The object to serialise to JSON.
		:param encoding: The encoding to write to the file in.
		:param errors:
		:param json_library: The JSON serialisation library to use.
		:default json_library: :mod:`json`
		:param kwargs: Keyword arguments to pass to the JSON serialisation function.

		.. versionadded:: 0.5.0

		.. versionchanged:: 1.0.0

			Now uses :meth:`PathPlus.write_clean <domdf_python_tools.paths.PathPlus.write_clean>`
			rather than :meth:`PathPlus.write_text <domdf_python_tools.paths.PathPlus.write_text>`,
			and returns :py:obj:`None` rather than :class:`int`.
		"""

		return self.write_clean(
				json_library.dumps(data, **kwargs),
				encoding=encoding,
				errors=errors,
				)

	def load_json(
			self,
			encoding: Optional[str] = "UTF-8",
			errors: Optional[str] = None,
			json_library: JsonLibrary = json,  # type: ignore
			**kwargs,
			) -> Any:
		"""
		Load JSON data from the file.

		:param encoding: The encoding to write to the file in.
		:param errors:
		:param json_library: The JSON serialisation library to use.
		:default json_library: :mod:`json`
		:param kwargs: Keyword arguments to pass to the JSON deserialisation function.

		:return: The deserialised JSON data.

		.. versionadded:: 0.5.0
		"""

		return json_library.loads(
				self.read_text(encoding=encoding, errors=errors),
				**kwargs,
				)

	if sys.version_info < (3, 7):

		def is_mount(self) -> bool:
			"""
			Check if this path is a POSIX mount point.

			:rtype:

			.. versionadded:: 0.3.8 for Python 3.7 and above
			.. versionadded:: 0.11.0 for Python 3.6
			"""

			# Need to exist and be a dir
			if not self.exists() or not self.is_dir():
				return False

			parent = pathlib.Path(self.parent)
			try:
				parent_dev = parent.stat().st_dev
			except OSError:
				return False

			dev = self.stat().st_dev
			if dev != parent_dev:
				return True
			ino = self.stat().st_ino
			parent_ino = parent.stat().st_ino
			return ino == parent_ino

	if sys.version_info < (3, 8):

		def rename(self: _P, target: Union[str, pathlib.PurePath]) -> _P:  # type: ignore
			"""
			Rename this path to the target path.

			The target path may be absolute or relative. Relative paths are
			interpreted relative to the current working directory, *not* the
			directory of the Path object.

			:param target:

			:returns: The new Path instance pointing to the target path.

			.. versionadded:: 0.3.8 for Python 3.8 and above
			.. versionadded:: 0.11.0 for Python 3.6 and Python 3.7
			"""

			self._accessor.rename(self, target)  # type: ignore
			return self.__class__(target)

		def replace(self: _P, target: Union[str, pathlib.PurePath]) -> _P:  # type: ignore
			"""
			Rename this path to the target path, overwriting if that path exists.

			The target path may be absolute or relative. Relative paths are
			interpreted relative to the current working directory, *not* the
			directory of the Path object.

			Returns the new Path instance pointing to the target path.

			:param target:

			:returns: The new Path instance pointing to the target path.

			.. versionadded:: 0.3.8 for Python 3.8 and above
			.. versionadded:: 0.11.0 for Python 3.6 and Python 3.7
			"""

			self._accessor.replace(self, target)  # type: ignore
			return self.__class__(target)

		def unlink(self, missing_ok: bool = False) -> None:
			"""
			Remove this file or link.

			If the path is a directory, use :meth:`~domdf_python_tools.paths.PathPlus.rmdir()` instead.

			.. versionadded:: 0.3.8 for Python 3.8 and above
			.. versionadded:: 0.11.0 for Python 3.6 and Python 3.7
			"""

			try:
				self._accessor.unlink(self)  # type: ignore
			except FileNotFoundError:
				if not missing_ok:
					raise

	if sys.version_info < (3, 9):

		def __enter__(self):
			return self

		def __exit__(self, t, v, tb):
			# https://bugs.python.org/issue39682
			# In previous versions of pathlib, this method marked this path as
			# closed; subsequent attempts to perform I/O would raise an IOError.
			# This functionality was never documented, and had the effect of
			# making Path objects mutable, contrary to PEP 428. In Python 3.9 the
			# _closed attribute was removed, and this method made a no-op.
			# This method and __enter__()/__exit__() should be deprecated and
			# removed in the future.
			pass

		def is_relative_to(self, *other: Union[str, os.PathLike]) -> bool:
			r"""
			Returns whether the path is relative to another path.

			:param \*other:

			:rtype:

			.. versionadded:: 0.3.8 for Python 3.9 and above
			.. versionadded:: 1.4.0 for Python 3.6 and Python 3.7
			"""

			try:
				self.relative_to(*other)
				return True
			except ValueError:
				return False

	def abspath(self) -> "PathPlus":
		"""
		Return the absolute version of the path.

		:rtype:

		.. versionadded:: 1.3.0
		"""

		return self.__class__(os.path.abspath(self))


class PosixPathPlus(PathPlus, pathlib.PurePosixPath):
	"""
	:class:`~.PathPlus` subclass for non-Windows systems.

	On a POSIX system, instantiating a PathPlus object should return an instance of this class.

	.. versionadded:: 0.3.8
	"""

	__slots__ = ()


class WindowsPathPlus(PathPlus, pathlib.PureWindowsPath):
	"""
	:class:`~.PathPlus` subclass for Windows systems.

	On a Windows system, instantiating a PathPlus object should return an instance of this class.

	.. versionadded:: 0.3.8
	"""

	__slots__ = ()

	def owner(self):  # pragma: no cover
		"""
		Unsupported on Windows.
		"""

		raise NotImplementedError("Path.owner() is unsupported on this system")

	def group(self):  # pragma: no cover
		"""
		Unsupported on Windows.
		"""

		raise NotImplementedError("Path.group() is unsupported on this system")

	def is_mount(self):  # pragma: no cover
		"""
		Unsupported on Windows.
		"""

		raise NotImplementedError("Path.is_mount() is unsupported on this system")