From 95609085557a518da07eea2b0ac96f8873cba5bf Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Tue, 27 Jan 2015 17:53:17 +0100 Subject: [PATCH] Initial commit Docs aren't there yet --- .gitignore | 5 + .travis.yml | 23 +++ AUTHORS.rst | 15 ++ CONTRIBUTING.rst | 38 +++++ LICENSE | 21 +++ MANIFEST.in | 5 + README.rst | 53 +++++++ attr/__init__.py | 22 +++ attr/_dunders.py | 207 ++++++++++++++++++++++++++ attr/_funcs.py | 33 +++++ attr/_make.py | 113 +++++++++++++++ docs/Makefile | 177 ++++++++++++++++++++++ docs/api.rst | 101 +++++++++++++ docs/changelog.rst | 19 +++ docs/conf.py | 306 +++++++++++++++++++++++++++++++++++++++ docs/contributing.rst | 3 + docs/examples.rst | 40 +++++ docs/index.rst | 62 ++++++++ docs/license.rst | 9 ++ docs/why.rst | 52 +++++++ setup.py | 91 ++++++++++++ tests/__init__.py | 0 tests/test_dark_magic.py | 46 ++++++ tests/test_dunders.py | 219 ++++++++++++++++++++++++++++ tests/test_funcs.py | 66 +++++++++ tests/test_make.py | 90 ++++++++++++ tox.ini | 31 ++++ 27 files changed, 1847 insertions(+) create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 AUTHORS.rst create mode 100644 CONTRIBUTING.rst create mode 100644 LICENSE create mode 100644 MANIFEST.in create mode 100644 README.rst create mode 100644 attr/__init__.py create mode 100644 attr/_dunders.py create mode 100644 attr/_funcs.py create mode 100644 attr/_make.py create mode 100644 docs/Makefile create mode 100644 docs/api.rst create mode 100644 docs/changelog.rst create mode 100644 docs/conf.py create mode 100644 docs/contributing.rst create mode 100644 docs/examples.rst create mode 100644 docs/index.rst create mode 100644 docs/license.rst create mode 100644 docs/why.rst create mode 100644 setup.py create mode 100644 tests/__init__.py create mode 100644 tests/test_dark_magic.py create mode 100644 tests/test_dunders.py create mode 100644 tests/test_funcs.py create mode 100644 tests/test_make.py create mode 100644 tox.ini diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..dfa9476cb --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.tox +.coverage +*.pyc +*.egg-info +docs/_build/ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 000000000..8c4f28c23 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,23 @@ +language: python +python: 2.7 +env: + - TOX_ENV=py26 + - TOX_ENV=py27 + - TOX_ENV=py33 + - TOX_ENV=py34 + - TOX_ENV=pypy + - TOX_ENV=docs + - TOX_ENV=flake8 + - TOX_ENV=manifest + +install: + - pip install tox coveralls + +script: + - tox --hashseed 0 -e $TOX_ENV + +after_success: + - coveralls + +notifications: + email: false diff --git a/AUTHORS.rst b/AUTHORS.rst new file mode 100644 index 000000000..ec7a353a0 --- /dev/null +++ b/AUTHORS.rst @@ -0,0 +1,15 @@ +Authors +------- + +``attrs`` is written and maintained by `Hynek Schlawack `_. + +The development is kindly supported by `Variomedia AG `_. + +It’s the spiritual successor of `characteristic `_ and aspires to fix some of it clunkiness and unfortunate decisions. Both were inspired by Twisted’s `FancyEqMixin `_ but both are implemented using class decorators because `sub-classing is bad for you `_, m’kay? + + +The following folks helped forming ``attrs`` into what it is now: + +- `Glyph `_ + +Of course ``characteristic``\ ’s `hall of fame `_ applies as well since they share a lot of code. diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst new file mode 100644 index 000000000..3d9cc770a --- /dev/null +++ b/CONTRIBUTING.rst @@ -0,0 +1,38 @@ +How To Contribute +================= + +Every open source project lives from the generous help by contributors that sacrifice their time and ``attrs`` is no different. + +To make participation as pleasant as possible, this project adheres to the `Code of Conduct`_ by the Python Software Foundation. + +Here are a few guidelines to get you started: + +- Add yourself to the AUTHORS.rst_ file in an alphabetical fashion. + Every contribution is valuable and shall be credited. +- If your change is noteworthy, add an entry to the changelog_. +- No contribution is too small; please submit as many fixes for typos and grammar bloopers as you can! +- Don’t *ever* break backward compatibility. + If it ever *has* to happen for higher reasons, ``attrs`` will follow the proven procedures_ of the Twisted project. +- *Always* add tests and docs for your code. + This is a hard rule; patches with missing tests or documentation won’t be merged. + If a feature is not tested or documented, it doesn’t exist. +- Obey `PEP 8`_ and `PEP 257`_. +- Write `good commit messages`_. + +.. note:: + If you have something great but aren’t sure whether it adheres -- or even can adhere -- to the rules above: **please submit a pull request anyway**! + + In the best case, we can mold it into something, in the worst case the pull request gets politely closed. + There’s absolutely nothing to fear. + +Thank you for considering to contribute to ``attrs``! +If you have any question or concerns, feel free to reach out to me. + + +.. _`PEP 8`: http://legacy.python.org/dev/peps/pep-0008/ +.. _`PEP 257`: http://legacy.python.org/dev/peps/pep-0257/ +.. _`good commit messages`: http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html +.. _`Code of Conduct`: https://www.python.org/psf/codeofconduct/ +.. _changelog: https://github.com/hynek/attrs/blob/master/docs/changelog.rst +.. _AUTHORS.rst: https://github.com/hynek/attrs/blob/master/AUTHORS.rst +.. _procedures: http://twistedmatrix.com/trac/wiki/CompatibilityPolicy diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..27acfefca --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 Hynek Schlawack + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 000000000..c853a214f --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,5 @@ +include *.rst *.txt LICENSE tox.ini .travis.yml docs/Makefile .coveragerc +recursive-include tests *.py +recursive-include docs *.rst +recursive-include docs *.py +prune docs/_build diff --git a/README.rst b/README.rst new file mode 100644 index 000000000..9ea02b5ce --- /dev/null +++ b/README.rst @@ -0,0 +1,53 @@ +============================================= +attrs: Python attributes without boilerplate. +============================================= + +.. image:: https://pypip.in/version/attrs/badge.svg + :target: https://pypi.python.org/pypi/attrs/ + :alt: Latest Version + +.. image:: https://travis-ci.org/hynek/attrs.svg + :target: https://travis-ci.org/hynek/attrs + :alt: CI status + +.. image:: https://coveralls.io/repos/hynek/attrs/badge.png?branch=master + :target: https://coveralls.io/r/hynek/attrs?branch=master + :alt: Current coverage + +.. begin + +``attrs`` is an `MIT `_-licensed Python package with class decorators that ease the chores of implementing the most common attribute-related object protocols: + +.. code-block:: pycon + + >>> import attr + >>> @attr.s + ... class C(object): + ... x = attr.a(default_value=42) + ... y = attr.a(default_factory=list) + >>> i = C(x=1, y=2) + >>> i + + >>> i == C(1, 2) + True + >>> i != C(2, 1) + True + >>> attr.to_dict(i) + {'y': 2, 'x': 1} + >>> C() + + +You just specify the attributes to work with and ``attrs`` gives you: + +- a nice human-readable ``__repr__``, +- a complete set of comparison methods, +- and an initializer + +*without* writing dull boilerplate code again and again. + +This gives you the power to use actual classes with actual types in your code instead of confusing ``tuple``\ s or confusingly behaving ``namedtuple``\ s. + +So put down that type-less data structures and welcome some class into your life! + +``attrs``\ ’s documentation lives at `Read the Docs `_, the code on `GitHub `_. +It’s rigorously tested on Python 2.6, 2.7, 3.3+, and PyPy. diff --git a/attr/__init__.py b/attr/__init__.py new file mode 100644 index 000000000..71fcfd494 --- /dev/null +++ b/attr/__init__.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- + +from __future__ import absolute_import, division, print_function + +from ._funcs import ( + ls, + to_dict, +) +from ._make import ( + _make_attr as a, + s, +) + +__version__ = "0.0.0.dev0" +__author__ = "Hynek Schlawack" +__license__ = "MIT" +__copyright__ = "Copyright 2015 Hynek Schlawack" + + +__all__ = [ + "a", "s", "ls", "to_dict", +] diff --git a/attr/_dunders.py b/attr/_dunders.py new file mode 100644 index 000000000..a2b26bf53 --- /dev/null +++ b/attr/_dunders.py @@ -0,0 +1,207 @@ +# -*- coding: utf-8 -*- + +from __future__ import absolute_import, division, print_function + +import hashlib +import linecache +import sys + + +def _attrs_to_tuple(obj, attrs): + """ + Create a tuple of all values of *obj*'s *attrs*. + """ + return tuple(getattr(obj, a) for a in attrs) + + +def _add_hash(cl, attrs=None): + if attrs is None: + attrs = [a.name for a in cl.__attrs_attrs__] + + def hash_(self): + """ + Automatically created by attrs. + """ + return hash(_attrs_to_tuple(self, attrs)) + + cl.__hash__ = hash_ + return cl + + +def _add_cmp(cl, attrs=None): + if attrs is None: + attrs = [a.name for a in cl.__attrs_attrs__] + + def attrs_to_tuple(obj): + """ + Save us some typing. + """ + return _attrs_to_tuple(obj, attrs) + + def eq(self, other): + """ + Automatically created by attrs. + """ + if isinstance(other, self.__class__): + return attrs_to_tuple(self) == attrs_to_tuple(other) + else: + return NotImplemented + + def ne(self, other): + """ + Automatically created by attrs. + """ + result = eq(self, other) + if result is NotImplemented: + return NotImplemented + else: + return not result + + def lt(self, other): + """ + Automatically created by attrs. + """ + if isinstance(other, self.__class__): + return attrs_to_tuple(self) < attrs_to_tuple(other) + else: + return NotImplemented + + def le(self, other): + """ + Automatically created by attrs. + """ + if isinstance(other, self.__class__): + return attrs_to_tuple(self) <= attrs_to_tuple(other) + else: + return NotImplemented + + def gt(self, other): + """ + Automatically created by attrs. + """ + if isinstance(other, self.__class__): + return attrs_to_tuple(self) > attrs_to_tuple(other) + else: + return NotImplemented + + def ge(self, other): + """ + Automatically created by attrs. + """ + if isinstance(other, self.__class__): + return attrs_to_tuple(self) >= attrs_to_tuple(other) + else: + return NotImplemented + + cl.__eq__ = eq + cl.__ne__ = ne + cl.__lt__ = lt + cl.__le__ = le + cl.__gt__ = gt + cl.__ge__ = ge + + return cl + + +def _add_repr(cl, attrs=None): + if attrs is None: + attrs = [a.name for a in cl.__attrs_attrs__] + + def repr_(self): + """ + Automatically created by attrs. + """ + return "<{0}({1})>".format( + self.__class__.__name__, + ", ".join(a + "=" + repr(getattr(self, a)) for a in attrs) + ) + cl.__repr__ = repr_ + return cl + + +# I'm sorry. :( +if sys.version_info[0] == 2: + def exec_(code, locals_, globals_): + exec("exec code in locals_, globals_") +else: # pragma: no cover + def exec_(code, locals_, globals_): + exec(code, locals_, globals_) + + +class _Nothing(object): + """ + Sentinel class to indicate the lack of a value when ``None`` is ambiguous. + """ + def __repr__(self): + return "NOTHING" + + +NOTHING = _Nothing() +""" +Sentinel to indicate the lack of a value when ``None`` is ambiguous. +""" + + +def _add_init(cl): + attrs = cl.__attrs_attrs__ + + # We cache the generated init methods for the same kinds of attributes. + sha1 = hashlib.sha1() + sha1.update(repr(attrs).encode("utf-8")) + unique_filename = "".format( + sha1.hexdigest() + ) + + script = _attrs_to_script(attrs) + locs = {} + bytecode = compile(script, unique_filename, "exec") + attr_dict = dict((a.name, a) for a in attrs) + exec_(bytecode, {"NOTHING": NOTHING, "attr_dict": attr_dict}, locs) + init = locs["__init__"] + + # In order of debuggers like PDB being able to step through the code, + # we add a fake linecache entry. + linecache.cache[unique_filename] = ( + len(script), + None, + script.splitlines(True), + unique_filename + ) + cl.__init__ = init + return cl + + +def _attrs_to_script(attrs): + """ + Return a valid Python script of an initializer for *attrs*. + """ + lines = [] + args = [] + for a in attrs: + if a.default_value is not NOTHING: + args.append("{name}={default!r}".format(name=a.name, + default=a.default_value)) + lines.append("self.{name} = {name}".format(name=a.name)) + elif a.default_factory is not NOTHING: + args.append("{name}=NOTHING".format(name=a.name)) + lines.extend("""\ +if {name} is not NOTHING: + self.{name} = {name} +else: + self.{name} = attr_dict["{name}"].default_factory()""" + .format(name=a.name) + .split("\n")) + else: + args.append(a.name) + lines.append("self.{name} = {name}".format(name=a.name)) + + return """\ +def __init__(self, {args}): + ''' + Attribute initializer automatically created by attrs. + ''' + {setters} +""".format( + args=", ".join(args), + setters="\n ".join(lines), + ) diff --git a/attr/_funcs.py b/attr/_funcs.py new file mode 100644 index 000000000..07cb0d9e1 --- /dev/null +++ b/attr/_funcs.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- + +from __future__ import absolute_import, division, print_function + + +def ls(cl): + """ + Returns the list of `Attribute`s for a class or an instance. + """ + if not isinstance(cl, type): + cl = cl.__class__ + attrs = getattr(cl, "__attrs_attrs__", None) + if attrs is None: + raise TypeError("{cl!r} is not an attrs-decorated class.".format( + cl=cl + )) + return attrs + + +def to_dict(i, recurse=True): + """ + Return the values of *i* as a dict. Optionally recurse into classes that + are also decorated with attrs. + """ + attrs = ls(i) + rv = {} + for a in attrs: + v = getattr(i, a.name) + if recurse is True and getattr(v, "__attrs_attrs__", None) is not None: + rv[a.name] = to_dict(v, recurse=True) + else: + rv[a.name] = v + return rv diff --git a/attr/_make.py b/attr/_make.py new file mode 100644 index 000000000..7ef4afc42 --- /dev/null +++ b/attr/_make.py @@ -0,0 +1,113 @@ +# -*- coding: utf-8 -*- + +from __future__ import absolute_import, division, print_function + + +from ._dunders import ( + NOTHING, + _add_cmp, + _add_hash, + _add_init, + _add_repr, +) + + +class Attribute(object): + def __init__(self, name, default_value, default_factory): + self.name = name + self.default_value = default_value + self.default_factory = default_factory + + @classmethod + def from_counting_attr(cl, name, ca): + return cl( + name=name, + default_value=ca.default_value, + default_factory=ca.default_factory, + ) + + +_a = ["name", "default_value", "default_factory"] +Attribute = _add_cmp(_add_repr(Attribute, attrs=_a), attrs=_a) + + +class _CountingAttr(object): + __attrs_attrs__ = [ + Attribute(name=name, default_value=NOTHING, default_factory=NOTHING) + for name + in ("counter", "default_value", "default_factory",) + ] + counter = 0 + + def __init__(self, default_value=NOTHING, default_factory=NOTHING): + _CountingAttr.counter += 1 + self.counter = _CountingAttr.counter + self.default_value = default_value + self.default_factory = default_factory + + +_CountingAttr = _add_cmp(_add_repr(_CountingAttr)) + + +def _make_attr(default_value=NOTHING, default_factory=NOTHING): + """ + Create a new attribute on a class. + + Does nothing unless the class is also decorated with ``@attr.s``! + """ + if default_value is not NOTHING and default_factory is not NOTHING: + raise ValueError( + "Specifying both default_value and default_factory is " + "ambiguous." + ) + + return _CountingAttr( + default_value=default_value, + default_factory=default_factory, + ) + + +def _get_attrs(cl): + """ + Return list of tuples of `(name, _Attr)`. + """ + attrs = [] + + for name, instance in sorted(( + (n, i) for n, i in cl.__dict__.items() + if isinstance(i, _CountingAttr) + ), key=lambda e: e[1].counter): + attrs.append((name, instance)) + + return attrs + + +def s(maybe_cl=None, add_repr=True, add_cmp=True, add_hash=True, + add_init=True): + # attrs_or class type depends on the usage of the decorator. + # It's a class if it's used as `@s` but ``None`` (or a value + # passed) if used as `@s()`. + if isinstance(maybe_cl, type): + cl = maybe_cl + cl.__attrs_attrs__ = [ + Attribute.from_counting_attr(name=name, ca=ca) + for name, ca in _get_attrs(cl) + ] + return _add_init(_add_hash(_add_cmp(_add_repr(cl)))) + else: + def wrap(cl): + cl.__attrs_attrs__ = [ + Attribute.from_counting_attr(name=name, ca=ca) + for name, ca in _get_attrs(cl) + ] + if add_repr is True: + cl = _add_repr(cl) + if add_cmp is True: + cl = _add_cmp(cl) + if add_hash is True: + cl = _add_hash(cl) + if add_init is True: + cl = _add_init(cl) + return cl + + return wrap diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 000000000..7232ea5a1 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,177 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = _build + +# User-friendly check for sphinx-build +ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) +$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) +endif + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . +# the i18n builder cannot share the environment and doctrees with the others +I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . + +.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext + +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " texinfo to make Texinfo files" + @echo " info to make Texinfo files and run them through makeinfo" + @echo " gettext to make PO message catalogs" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " xml to make Docutils-native XML files" + @echo " pseudoxml to make pseudoxml-XML files for display purposes" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + +clean: + rm -rf $(BUILDDIR)/* + +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/characteristic.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/characteristic.qhc" + +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/characteristic" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/characteristic" + @echo "# devhelp" + +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make' in that directory to run these through (pdf)latex" \ + "(use \`make latexpdf' here to do that automatically)." + +latexpdf: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +latexpdfja: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through platex and dvipdfmx..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +texinfo: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo + @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." + @echo "Run \`make' in that directory to run these through makeinfo" \ + "(use \`make info' here to do that automatically)." + +info: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo "Running Texinfo files through makeinfo..." + make -C $(BUILDDIR)/texinfo info + @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." + +gettext: + $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale + @echo + @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." + +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." + +xml: + $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml + @echo + @echo "Build finished. The XML files are in $(BUILDDIR)/xml." + +pseudoxml: + $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml + @echo + @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." diff --git a/docs/api.rst b/docs/api.rst new file mode 100644 index 000000000..ab28c3402 --- /dev/null +++ b/docs/api.rst @@ -0,0 +1,101 @@ +.. _api: + +API +=== + +``characteristic`` consists of class decorators that add attribute-related features to your classes. + +.. currentmodule:: characteristic + +There are two approaches on how to define those attributes: + +#. By defining those attributes as class variables using instances of the :class:`Attribute` class. + This approach has been added as of version 14.0 to make ``characteristic`` future-proof by adding more flexibility. +#. Using a list of names which I henceforth refer to as the 'legacy way'. + As per our backward compatibility policy, support for this approach will *not* be removed before 15.0 (if ever), however no new features will be added so I strongly urge you to *not* use it. + +Both approaches usually entail the usage of the :func:`@attributes ` decorator which will automatically detect the desired approach and prevent mixing of them. + +.. autofunction:: attributes + +.. autoclass:: Attribute + + +Legacy +------ + +There are three that start with ``@with_`` that add *one* feature to your class based on a list of attributes. +Then there's the helper :func:`@attributes ` that combines them all into one decorator so you don't have to repeat the attribute list multiple times. + + +.. autofunction:: with_repr + + .. doctest:: + + >>> from characteristic import with_repr + >>> @with_repr(["a", "b"]) + ... class RClass(object): + ... def __init__(self, a, b): + ... self.a = a + ... self.b = b + >>> c = RClass(42, "abc") + >>> print c + + + +.. autofunction:: with_cmp + + .. doctest:: + + >>> from characteristic import with_cmp + >>> @with_cmp(["a", "b"]) + ... class CClass(object): + ... def __init__(self, a, b): + ... self.a = a + ... self.b = b + >>> o1 = CClass(1, "abc") + >>> o2 = CClass(1, "abc") + >>> o1 == o2 # o1.a == o2.a and o1.b == o2.b + True + >>> o1.c = 23 + >>> o2.c = 42 + >>> o1 == o2 # attributes that are not passed to with_cmp are ignored + True + >>> o3 = CClass(2, "abc") + >>> o1 < o3 # because 1 < 2 + True + >>> o4 = CClass(1, "bca") + >>> o1 < o4 # o1.a == o4.a, but o1.b < o4.b + True + + +.. autofunction:: with_init + + .. doctest:: + + >>> from characteristic import with_init + >>> @with_init(["a", "b"], defaults={"b": 2}) + ... class IClass(object): + ... def __init__(self): + ... if self.b != 2: + ... raise ValueError("'b' must be 2!") + >>> o1 = IClass(a=1, b=2) + >>> o2 = IClass(a=1) + >>> o1.a == o2.a + True + >>> o1.b == o2.b + True + >>> IClass() + Traceback (most recent call last): + ... + ValueError: Missing keyword value for 'a'. + >>> IClass(a=1, b=3) # the custom __init__ is called after the attributes are initialized + Traceback (most recent call last): + ... + ValueError: 'b' must be 2! + + .. note:: + + The generated initializer explicitly does *not* support positional + arguments. Those are *always* passed to the existing ``__init__`` + unaltered. diff --git a/docs/changelog.rst b/docs/changelog.rst new file mode 100644 index 000000000..46283b027 --- /dev/null +++ b/docs/changelog.rst @@ -0,0 +1,19 @@ +.. currentmodule:: attr + +.. :changelog: + +Changelog +========= + +Versions are year-based with a strict backwards-compatibility policy. +The third digit is only for regressions. + + +15.0.0 (UNRELEASED) +------------------- + + +Changes: +^^^^^^^^ + +- Initial release. diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 000000000..c1dc3fe9b --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,306 @@ +# -*- coding: utf-8 -*- +# +# attrs documentation build configuration file, created by +# sphinx-quickstart on Sun May 11 16:17:15 2014. +# +# This file is execfile()d with the current directory set to its +# containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +import codecs +import datetime +import os +import re + +try: + import sphinx_rtd_theme +except ImportError: + sphinx_rtd_theme = None + + +def read(*parts): + """ + Build an absolute path from *parts* and and return the contents of the + resulting file. Assume UTF-8 encoding. + """ + here = os.path.abspath(os.path.dirname(__file__)) + with codecs.open(os.path.join(here, *parts), "rb", "utf-8") as f: + return f.read() + + +def find_version(*file_paths): + """ + Build a path from *file_paths* and search for a ``__version__`` + string inside. + """ + version_file = read(*file_paths) + version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", + version_file, re.M) + if version_match: + return version_match.group(1) + raise RuntimeError("Unable to find version string.") + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +#sys.path.insert(0, os.path.abspath('.')) + +# -- General configuration ------------------------------------------------ + +# If your documentation needs a minimal Sphinx version, state it here. +#needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.doctest', + 'sphinx.ext.intersphinx', + 'sphinx.ext.todo', +] + + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix of source filenames. +source_suffix = '.rst' + +# The encoding of source files. +#source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = u'attrs' +year = datetime.date.today().year +copyright = u'2015{0}, Hynek Schlawack'.format( + u'-{0}'.format(year) if year != 2015 else u"" +) + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +release = find_version("../attr/__init__.py") +version = release.rsplit(u".", 1)[0] +# The full version, including alpha/beta/rc tags. + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +#language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +#today = '' +# Else, today_fmt is used as the format for a strftime call. +#today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = ['_build'] + +# The reST default role (used for this markup: `text`) to use for all +# documents. +#default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +#add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +#add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +#show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# A list of ignored prefixes for module index sorting. +#modindex_common_prefix = [] + +# If true, keep warnings as "system message" paragraphs in the built documents. +#keep_warnings = False + + +# -- Options for HTML output ---------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. + +if sphinx_rtd_theme: + html_theme = "sphinx_rtd_theme" + html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] +else: + html_theme = "default" + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +#html_theme_options = {} + +# Add any paths that contain custom themes here, relative to this directory. +#html_theme_path = [] + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +#html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +#html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +#html_logo = None + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +#html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +# html_static_path = ['_static'] + +# Add any extra paths that contain custom files (such as robots.txt or +# .htaccess) here, relative to this directory. These files are copied +# directly to the root of the documentation. +#html_extra_path = [] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +#html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +#html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +#html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +#html_additional_pages = {} + +# If false, no module index is generated. +#html_domain_indices = True + +# If false, no index is generated. +#html_use_index = True + +# If true, the index is split into individual pages for each letter. +#html_split_index = False + +# If true, links to the reST sources are added to the pages. +#html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +#html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +#html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +#html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +#html_file_suffix = None + +# Output file base name for HTML help builder. +htmlhelp_basename = 'attrsdoc' + + +# -- Options for LaTeX output --------------------------------------------- + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + #'papersize': 'letterpaper', + + # The font size ('10pt', '11pt' or '12pt'). + #'pointsize': '10pt', + + # Additional stuff for the LaTeX preamble. + #'preamble': '', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + ('index', 'attrs.tex', u'attrs Documentation', + u'Hynek Schlawack', 'manual'), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +#latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +#latex_use_parts = False + +# If true, show page references after internal links. +#latex_show_pagerefs = False + +# If true, show URL addresses after external links. +#latex_show_urls = False + +# Documents to append as an appendix to all manuals. +#latex_appendices = [] + +# If false, no module index is generated. +#latex_domain_indices = True + + +# -- Options for manual page output --------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + ('index', 'attrs', u'attrs Documentation', + [u'Hynek Schlawack'], 1) +] + +# If true, show URL addresses after external links. +#man_show_urls = False + + +# -- Options for Texinfo output ------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + ('index', 'attrs', u'attrs Documentation', + u'Hynek Schlawack', 'attrs', 'One line description of project.', + 'Miscellaneous'), +] + +# Documents to append as an appendix to all manuals. +#texinfo_appendices = [] + +# If false, no module index is generated. +#texinfo_domain_indices = True + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +#texinfo_show_urls = 'footnote' + +# If true, do not generate a @detailmenu in the "Top" node's menu. +#texinfo_no_detailmenu = False + + +# Example configuration for intersphinx: refer to the Python standard library. +intersphinx_mapping = {'https://docs.python.org/': None} diff --git a/docs/contributing.rst b/docs/contributing.rst new file mode 100644 index 000000000..462818222 --- /dev/null +++ b/docs/contributing.rst @@ -0,0 +1,3 @@ +.. _contributing: + +.. include:: ../CONTRIBUTING.rst diff --git a/docs/examples.rst b/docs/examples.rst new file mode 100644 index 000000000..9790c1894 --- /dev/null +++ b/docs/examples.rst @@ -0,0 +1,40 @@ +.. _examples: + +Examples +======== + + +:func:`@attributes ` together with the definition of the attributes using class attributes enhances your class by: + +- a nice ``__repr__``, +- comparison methods that compare instances as if they were tuples of their attributes, +- and – optionally but by default – an initializer that uses the keyword arguments to initialize the specified attributes before running the class’ own initializer (you just write the validator!). + + +.. doctest:: + + >>> from characteristic import Attribute, attributes + >>> @attributes + ... class C(object): + ... a = Attribute() + ... b = Attribute() + >>> obj1 = C(a=1, b="abc") + >>> obj1 + + >>> obj2 = C(a=2, b="abc") + >>> obj1 == obj2 + False + >>> obj1 < obj2 + True + >>> obj3 = C(a=1, b="bca") + >>> obj3 > obj1 + True + >>> @attributes + ... class CWithDefaults(object): + ... a = Attribute() + ... b = Attribute() + ... c = Attribute(default=3) + >>> obj4 = CWithDefaults(a=1, b=2) + >>> obj5 = CWithDefaults(a=1, b=2, c=3) + >>> obj4 == obj5 + True diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 000000000..8d2864ac6 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,62 @@ +characteristic: Say 'yes' to types but 'no' to typing! +====================================================== + +Release v\ |release| (:doc:`What's new? `). + + +.. include:: ../README.rst + :start-after: begin + + +Teaser +------ + +.. doctest:: + + >>> from characteristic import Attribute, attributes + >>> @attributes + ... class AClass(object): + ... a = Attribute() + ... b = Attribute() + >>> @attributes + ... class AnotherClass(object): + ... a = Attribute() + ... b = Attribute(default="abc") + >>> obj1 = AClass(a=1, b="abc") + >>> obj2 = AnotherClass(a=1, b="abc") + >>> obj3 = AnotherClass(a=1) + >>> print obj1, obj2, obj3 + + >>> obj1 == obj2 + False + >>> obj2 == obj3 + True + + +User's Guide +------------ + +.. toctree:: + :maxdepth: 1 + + why + api + examples + +Project Information +^^^^^^^^^^^^^^^^^^^ + +.. toctree:: + :maxdepth: 1 + + license + contributing + changelog + + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`search` diff --git a/docs/license.rst b/docs/license.rst new file mode 100644 index 000000000..7532d60b3 --- /dev/null +++ b/docs/license.rst @@ -0,0 +1,9 @@ +License and Hall of Fame +======================== + +``characteristic`` is licensed under the permissive `MIT `_ license. +The full license text can be also found in the `source code repository `_. + +.. _authors: + +.. include:: ../AUTHORS.rst diff --git a/docs/why.rst b/docs/why.rst new file mode 100644 index 000000000..6e6a7b213 --- /dev/null +++ b/docs/why.rst @@ -0,0 +1,52 @@ +.. _why: + +Why? +==== + +The difference between namedtuple_\ s and classes decorated by ``characteristic`` is that the latter are type-sensitive and less typing aside regular classes: + + +.. doctest:: + + >>> from characteristic import Attribute, attributes + >>> @attributes + ... class C1(object): + ... a = Attribute() + ... def __init__(self): + ... if not isinstance(self.a, int): + ... raise ValueError("'a' must be an integer.") + ... def print_a(self): + ... print self.a + >>> @attributes + ... class C2(object): + ... a = Attribute() + >>> c1 = C1(a=1) + >>> c2 = C2(a=1) + >>> c1 == c2 + False + >>> c1.print_a() + 1 + >>> C1(a="hello") + Traceback (most recent call last): + ... + ValueError: 'a' must be an integer. + + +…while namedtuple’s purpose is *explicitly* to behave like tuples: + + +.. doctest:: + + >>> from collections import namedtuple + >>> NT1 = namedtuple("NT1", "a") + >>> NT2 = namedtuple("NT2", "b") + >>> t1 = NT1._make([1,]) + >>> t2 = NT2._make([1,]) + >>> t1 == t2 == (1,) + True + + +This can easily lead to surprising and unintended behaviors. + +.. _namedtuple: https://docs.python.org/2/library/collections.html#collections.namedtuple +.. _tuple: https://docs.python.org/2/tutorial/datastructures.html#tuples-and-sequences diff --git a/setup.py b/setup.py new file mode 100644 index 000000000..a6205c0fe --- /dev/null +++ b/setup.py @@ -0,0 +1,91 @@ +import codecs +import os +import re +import sys + +from setuptools import setup, find_packages +from setuptools.command.test import test as TestCommand + + +def read(*parts): + """ + Build an absolute path from *parts* and and return the contents of the + resulting file. Assume UTF-8 encoding. + """ + here = os.path.abspath(os.path.dirname(__file__)) + with codecs.open(os.path.join(here, *parts), "rb", "utf-8") as f: + return f.read() + + +def find_version(*file_paths): + """ + Build a path from *file_paths* and search for a ``__version__`` + string inside. + """ + version_file = read(*file_paths) + version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", + version_file, re.M) + if version_match: + return version_match.group(1) + raise RuntimeError("Unable to find version string.") + + +class PyTest(TestCommand): + user_options = [('pytest-args=', 'a', "Arguments to pass to py.test")] + + def initialize_options(self): + TestCommand.initialize_options(self) + self.pytest_args = None + + def finalize_options(self): + TestCommand.finalize_options(self) + self.test_args = [] + self.test_suite = True + + def run_tests(self): + # import here, cause outside the eggs aren't loaded + import pytest + errno = pytest.main(self.pytest_args or [] + + ["test_attrs.py"]) + sys.exit(errno) + + +if __name__ == "__main__": + setup( + name="attrs", + version=find_version("attr/__init__.py"), + description="Python attributes without boilerplate.", + long_description=(read("README.rst") + "\n\n" + + read("AUTHORS.rst")), + url="https://attrs.readthedocs.org/", + license="MIT", + author="Hynek Schlawack", + author_email="hs@ox.cx", + packages=find_packages(exclude=['tests*']), + classifiers=[ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Natural Language :: English", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 2", + "Programming Language :: Python :: 2.6", + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.3", + "Programming Language :: Python :: 3.4", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", + "Topic :: Software Development :: Libraries :: Python Modules", + ], + install_requires=[ + ], + tests_require=[ + "pytest" + ], + cmdclass={ + "test": PyTest, + }, + zip_safe=False, + ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_dark_magic.py b/tests/test_dark_magic.py new file mode 100644 index 000000000..4fe0a82dd --- /dev/null +++ b/tests/test_dark_magic.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- + +from __future__ import absolute_import, division, print_function + + +import attr + +from attr._make import Attribute, NOTHING + + +@attr.s +class C1(object): + x = attr.a() + y = attr.a() + + +foo = None + + +@attr.s() +class C2(object): + x = attr.a(default_value=foo) + y = attr.a(default_factory=list) + + +class TestDarkMagic(object): + """ + Integration tests. + """ + def test_ls(self): + """ + `attr.ls` works. + """ + assert [ + Attribute(name="x", default_value=None, default_factory=NOTHING), + Attribute(name="y", default_value=NOTHING, default_factory=list), + ] == attr.ls(C2) + + def test_to_dict(self): + """ + `attr.to_dict` works. + """ + assert { + "x": 1, + "y": 2, + } == attr.to_dict(C1(x=1, y=2)) diff --git a/tests/test_dunders.py b/tests/test_dunders.py new file mode 100644 index 000000000..bdea9a7a1 --- /dev/null +++ b/tests/test_dunders.py @@ -0,0 +1,219 @@ +# -*- coding: utf-8 -*- + +from __future__ import absolute_import, division, print_function + +from attr._make import Attribute +from attr._dunders import ( + NOTHING, + _add_cmp, + _add_hash, + _add_init, + _add_repr, +) + + +def simple_attr(name): + return Attribute(name=name, default_value=NOTHING, default_factory=NOTHING) + + +def make_class(): + class C(object): + __attrs_attrs__ = [simple_attr("a"), simple_attr("b")] + + def __init__(self, a, b): + self.a = a + self.b = b + return C + +CmpC = _add_cmp(make_class()) +ReprC = _add_repr(make_class()) +HashC = _add_hash(make_class()) + + +class InitC(object): + __attrs_attrs__ = [simple_attr("a"), simple_attr("b")] + +InitC = _add_init(InitC) + + +class TestMakeClass(object): + """ + Tests for the testing helper function `make_class`. + """ + def test_returns_class(self): + """ + Returns a class object. + """ + assert type is make_class().__class__ + + def returns_distinct_classes(self): + """ + Each call returns a completely new class. + """ + assert make_class() is not make_class() + + +class TestAddCmp(object): + def test_equal(self): + """ + Equal objects are detected as equal. + """ + assert CmpC(1, 2) == CmpC(1, 2) + assert not (CmpC(1, 2) != CmpC(1, 2)) + + def test_unequal_same_class(self): + """ + Unequal objects of correct type are detected as unequal. + """ + assert CmpC(1, 2) != CmpC(2, 1) + assert not (CmpC(1, 2) == CmpC(2, 1)) + + def test_unequal_different_class(self): + """ + Unequal objects of differnt type are detected even if their attributes + match. + """ + class NotCmpC(object): + a = 1 + b = 2 + assert CmpC(1, 2) != NotCmpC() + assert not (CmpC(1, 2) == NotCmpC()) + + def test_lt(self): + """ + __lt__ compares objects as tuples of attribute values. + """ + for a, b in [ + ((1, 2), (2, 1)), + ((1, 2), (1, 3)), + (("a", "b"), ("b", "a")), + ]: + assert CmpC(*a) < CmpC(*b) + + def test_lt_unordable(self): + """ + __lt__ returns NotImplemented if classes differ. + """ + assert NotImplemented == (CmpC(1, 2).__lt__(42)) + + def test_le(self): + """ + __le__ compares objects as tuples of attribute values. + """ + for a, b in [ + ((1, 2), (2, 1)), + ((1, 2), (1, 3)), + ((1, 1), (1, 1)), + (("a", "b"), ("b", "a")), + (("a", "b"), ("a", "b")), + ]: + assert CmpC(*a) <= CmpC(*b) + + def test_le_unordable(self): + """ + __le__ returns NotImplemented if classes differ. + """ + assert NotImplemented == (CmpC(1, 2).__le__(42)) + + def test_gt(self): + """ + __gt__ compares objects as tuples of attribute values. + """ + for a, b in [ + ((2, 1), (1, 2)), + ((1, 3), (1, 2)), + (("b", "a"), ("a", "b")), + ]: + assert CmpC(*a) > CmpC(*b) + + def test_gt_unordable(self): + """ + __gt__ returns NotImplemented if classes differ. + """ + assert NotImplemented == (CmpC(1, 2).__gt__(42)) + + def test_ge(self): + """ + __ge__ compares objects as tuples of attribute values. + """ + for a, b in [ + ((2, 1), (1, 2)), + ((1, 3), (1, 2)), + ((1, 1), (1, 1)), + (("b", "a"), ("a", "b")), + (("a", "b"), ("a", "b")), + ]: + assert CmpC(*a) >= CmpC(*b) + + def test_ge_unordable(self): + """ + __ge__ returns NotImplemented if classes differ. + """ + assert NotImplemented == (CmpC(1, 2).__ge__(42)) + + +class TestAddRepr(object): + def test_repr(self): + """ + Test repr returns a sensible value. + """ + assert "" == repr(ReprC(1, 2)) + + +class TestAddHash(object): + def test_hash(self): + """ + __hash__ returns different hashes for different values. + """ + assert hash(HashC(1, 2)) != hash(HashC(1, 1)) + + +class TestAddInit(object): + def test_sets_attributes(self): + """ + The attributes are initialized using the passed keywords. + """ + obj = InitC(a=1, b=2) + assert 1 == obj.a + assert 2 == obj.b + + def test_default_value(self): + """ + If a default value is present, it's used as fallback. + """ + class C(object): + __attrs_attrs__ = [Attribute("a", + default_value=2, + default_factory=NOTHING), + Attribute("b", + default_value="hallo", + default_factory=NOTHING), + Attribute("c", + default_value=None, + default_factory=NOTHING), + ] + + C = _add_init(C) + i = C() + assert 2 == i.a + assert "hallo" == i.b + assert None is i.c + + def test_default_factory(self): + """ + If a default factory is present, it's used as fallback. + """ + class D(object): + pass + + class C(object): + __attrs_attrs__ = [Attribute("a", + default_value=NOTHING, + default_factory=list), + Attribute("b", + default_value=NOTHING, + default_factory=D)] + C = _add_init(C) + i = C() + assert [] == i.a + assert isinstance(i.b, D) diff --git a/tests/test_funcs.py b/tests/test_funcs.py new file mode 100644 index 000000000..cf38d242b --- /dev/null +++ b/tests/test_funcs.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- + +from __future__ import absolute_import, division, print_function + +import pytest + +from attr._funcs import ls, to_dict +from attr._make import ( + Attribute, + _make_attr, + s, +) + + +@s +class C(object): + x = _make_attr() + y = _make_attr() + + +class TestLs(object): + def test_instance(self): + """ + Works also on instances of classes. + """ + assert ls(C) == ls(C(1, 2)) + + def test_handler_non_attrs_class(self): + """ + Raises `TypeError` if passed a non-attrs instance. + """ + with pytest.raises(TypeError) as e: + ls(object) + assert ( + "{o!r} is not an attrs-decorated class.".format(o=object) + ) == e.value.args[0] + + def test_ls(self): + """ + Returns a list of `Attribute`. + """ + + assert all(isinstance(a, Attribute) for a in ls(C)) + + +class TestToDict(object): + def test_shallow(self): + """ + Shallow to_dict returns correct dict. + """ + assert { + "x": 1, + "y": 2, + } == to_dict(C(x=1, y=2), False) + + def test_recurse(self): + """ + Deep to_dict returns correct dict. + """ + assert { + "x": {"x": 1, "y": 2}, + "y": {"x": 3, "y": 4}, + } == to_dict(C( + C(1, 2), + C(3, 4), + )) diff --git a/tests/test_make.py b/tests/test_make.py new file mode 100644 index 000000000..c04336449 --- /dev/null +++ b/tests/test_make.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- + +from __future__ import absolute_import, division, print_function + +import pytest + +from attr._make import ( + Attribute, + _CountingAttr, + _get_attrs, + _make_attr, + s, +) + + +class TestMakeAttr(object): + """ + Tests for `_make_attr`. + """ + def test_returns_Attr(self): + """ + Returns an instance of _Attr. + """ + a = _make_attr() + assert isinstance(a, _CountingAttr) + + def test_catches_ambiguous_defaults(self): + """ + Raises ValueError if both default_value and default_factory are + specified. + """ + with pytest.raises(ValueError) as e: + _make_attr(default_value=42, default_factory=list) + + assert ( + "Specifying both default_value and default_factory is ambiguous." + == e.value.args[0] + ) + + +class TestGetAttrs(object): + """ + Tests for `_get_attrs`. + """ + def test_normal(self): + """ + Returns attributes in correct order. + """ + @s + class C(object): + z = _make_attr() + y = _make_attr() + x = _make_attr() + + assert ["z", "y", "x"] == [name for (name, _) in _get_attrs(C)] + + def test_empty(self): + """ + No attributes returns an empty list. + """ + @s + class C(object): + pass + + assert [] == _get_attrs(C) + + +class TestS(object): + """ + Tests for the `s` class decorator. + """ + def test_sets_attrs(self): + """ + Sets the `__attrs_attrs__` class attribute with a list of `Attribute`s. + """ + @s + class C(object): + x = _make_attr() + assert "x" == C.__attrs_attrs__[0].name + assert all(isinstance(a, Attribute) for a in C.__attrs_attrs__) + + def test_empty(self): + """ + No attributes, no problems. + """ + @s + class C3(object): + pass + assert "" == repr(C3()) + assert C3() == C3() diff --git a/tox.ini b/tox.ini new file mode 100644 index 000000000..caa3828f1 --- /dev/null +++ b/tox.ini @@ -0,0 +1,31 @@ +[tox] +envlist = py26, py27, py33, py34, pypy, flake8, manifest + +[testenv] +deps = + setuptools>=7.0 # to avoid .egg directories + pytest-cov +commands = + python setup.py test -a "--cov attr --cov-report term-missing" + +[testenv:flake8] +basepython = python2.7 +deps = + flake8 +commands = flake8 attr tests + +; [testenv:docs] +; basepython = python2.7 +; setenv = +; PYTHONHASHSEED = 0 +; deps = +; sphinx +; commands = +; sphinx-build -W -b html -d {envtmpdir}/doctrees docs docs/_build/html +; sphinx-build -W -b doctest -d {envtmpdir}/doctrees docs docs/_build/html + +[testenv:manifest] +deps = + check-manifest +commands = + check-manifest