Commit ae5d8af9 authored by Dom Sekotill's avatar Dom Sekotill
Browse files

initial commit

Started with:
 * set of three utility modules
 * unittests for each of the above
 * setuptools based setup.py script
parents
Loading
Loading
Loading
Loading
Loading

.gitignore

0 → 100644
+6 −0
Original line number Diff line number Diff line
# python generated
*.py[cod]

# setuptool generated
*.egg-info
setuptools-*

.gitmodules

0 → 100644
+3 −0
Original line number Diff line number Diff line
[submodule "tests/extra"]
	path = py-unittest-extra
	url = https://code.kodo.org.uk/dom/py-unittest-extra.git
+21 −0
Original line number Diff line number Diff line
#
# This file is part of 'django-utils-extra', a set of extra utilities for 
# Django projects.
# Copyright (C) 2015  Dom Sekotill
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
#

__version_info__ = (0,1)
__version__ = '.'.join(str(n) for n in __version_info__)
+113 −0
Original line number Diff line number Diff line
#
# This file is part of 'django-utils-extra', a set of extra utilities for 
# Django projects.
# Copyright (C) 2015  Dom Sekotill
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
#

import sys
import six

from contextlib import contextmanager


class suppress:
	"""
	A more advanced form of contextlib.suppress

	This context manager stores the suppressed exception in the attribute
	`exc_info` and can be used as a boolean value to indicate whether a
	suppression occurred or not.

	It has two extra methods for re-raising the suppressed exception: `reraise`
	and `reraise_on_error`

	Example:
	```
		>>> with suppress(ValueError) as suppressed:
		...     raise ValueError("foo")
		... 
		>>> if suppressed:
		...    print("a ValueError was suppressed")
		... 
		A ValueError was suppressed
		>>> with suppressed.reraise_on_error():
		...     raise Exception("bar")
		... 
		ValueError: foo
	```
	"""


	def __init__(self, *exc_types):
		assert len(exc_types) > 0
		self.exc_types = exc_types
		self.exc_info = (None, None, None)

	def __enter__(self):
		return self

	def __exit__(self, *exc_info):
		if isinstance(exc_info[1], self.exc_types):
			self.exc_info = exc_info
			return True

	def __bool__(self):
		return self.exc_info[0] is not None

	@staticmethod
	def _append_args(args, extra):
		for arg in args:
			if isinstance(arg, six.string_types) and extra:
				yield '{0}: {1}'.format(arg, extra)
				extra = None
			else:
				yield arg

	def _exc_info(self, extra_msg):
		if self.exc_info[0] is None or extra_msg is None:
			return self.exc_info
		tp, val, tb = self.exc_info
		args = self._append_args(val.args, extra_msg)
		return tp, tp(*args), tb

	def reraise(self, extra_msg=None):
		"""
		Raise the suppressed exception with an optional addendum appended.
		
		In Python 3.x, if called inside an `except` clause, the suppressed
		exception is chained with the current exception.
		"""
		if self.exc_info[0] is None:
			return
		sys_exc_info = sys.exc_info()
		exc_info = self._exc_info(extra_msg)
		if sys_exc_info:
			six.raise_from(exc_info[1], sys_exc_info[1])
		six.reraise(*exc_info)

	@contextmanager
	def reraise_on_error(self, extra_msg=None):
		"""
		Raise the suppressed exception if another is raised out of the context.

		See `reraise`.
		"""
		try:
			yield
		except (Exception):
			if self.exc_info[0] is None:
				raise
			self.reraise(extra_msg)
+134 −0
Original line number Diff line number Diff line
#
# This file is part of 'django-utils-extra', a set of extra utilities for 
# Django projects.
# Copyright (C) 2015  Dom Sekotill
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
#

import os
import six
import json
import logging
from glob import glob
from os.path import join
from six.moves import configparser
from collections import Sequence, Mapping
from .context_managers import suppress


LOGGER = logging.getLogger(__name__)


def import_from(context, items):
	"""
	Process and add the name-value pairs in `items` into `context` (a dict)

	All names and values must be strings. Names should be valid identifiers.

	If a value starts with '+' it is appended, added or merged with the current
	value, depending on the types. If both new and current types are `Mapping`
	types, `update` is used. If both are `Sequence` types, `extend` is used.
	For any other variation the values are summed, so must support the
	`__add__` interface.

	The portion of the value after the optional leading '+' is parsed as a JSON
	string and if successful the returned object becomes the new value. This is
	to allow adding of non-string objects.

	In the event that a '+' prefixed value cannot be parsed as JSON and cannot
	be appended to the current value (as the current value cannot `__add__` a
	string) the original JSON parse exception will be raised, not the exception
	raised by the failed `__add__`. (Python 3: the `__add__` exception will be
	chained using the `raise X from Y` syntax.)
	"""
	for name, value in items:
		name = name.upper()
		cur = context.get(name)
		org_value = value = value.lstrip()
		add = (value and value[0] == '+')
		if add:
			value = value[1:]
		supp_msg = "\n{0} = {1}".format(name, value.replace('\n', '\n    '))

		with suppress(ValueError) as suppressed:
			value = json.loads(value)

		if cur is not None:
			_type = type(cur)
		else:
			_type = type(value)

		if not add or cur is None:
			cur = _type()

		if __debug__:
			if add:
				LOGGER.debug("adding %s to %s (= %s )", value, name, repr(cur))
			else:
				LOGGER.debug("setting %s to %s", name, value)

		with suppressed.reraise_on_error(supp_msg):
			if hasattr(cur, 'extend'):
				if not isinstance(value, list):
					raise ValueError(
						"{0} must be a JSON list type not {1} ({2})"
						.format(name, type(value), org_value))
				cur.extend(value)
			elif hasattr(cur, 'update'):
				if isinstance(value, six.string_types):
					raise ValueError(
						"{0} cannot be a string ({1})"
						.format(name, org_value))
				cur.update(value)
			elif isinstance(cur, str):
				if not isinstance(value, six.string_types):
					raise ValueError(
						"{0} must be a string. not {1} ({2})"
						.format(name, type(value), org_value))
				cur += value
			else:
				try:
					cur += value
				except (TypeError):
					cur += _type(value)

		context[name] = cur


def load_site(context, config_pattern):
	"""
	Parse a config file and add any section named 'django' to the context.

	This function used `import_from` to add the values found in the 'django'
	section to the context.

	`context` is a dict or dict-like object. Typically it is the return value of
	`globals()`.

	`config_pattern` is a glob style filename pattern which matches files to
	parse.
	"""
	config_pattern = join(os.getcwd(), config_pattern)
	conf = configparser.ConfigParser()
	read = conf.read(glob(config_pattern))
	if not read:
		LOGGER.warning("No configuration files found.")
		return
	try:
		import_from(context, conf.items('django'))
	except (configparser.NoSectionError):
		LOGGER.warning("No valid configurations found in %s.", read)
	else:
		LOGGER.info("Read configuration files %s.", read)