Pretty attrs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f1d30bbf9..76ea275ae 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - Added syntax for call, i.e. "Foo(bar)"
 - Fixed initial blank lines removed from Syntax https://github.com/willmcgugan/rich/issues/1214
 - Added Console.measure as a convenient alias for Measurement.get
+- Added support for pretty printing attrs objects
 
 ## [10.1.0] - 2020-04-03
 
diff --git a/examples/attrs.py b/examples/attrs.py
new file mode 100644
index 000000000..4928405f7
--- /dev/null
+++ b/examples/attrs.py
@@ -0,0 +1,58 @@
+from typing import List
+
+try:
+    import attr
+except ImportError:
+    print("This example requires attrs library")
+    print("pip install attrs")
+    raise
+
+
+@attr.define
+class Point3D:
+    x: float
+    y: float
+    z: float = 0
+
+
+@attr.define
+class Triangle:
+    point1: Point3D
+    point2: Point3D
+    point3: Point3D
+
+
+@attr.define
+class Model:
+    name: str
+    triangles: List[Triangle] = attr.Factory(list)
+
+
+if __name__ == "__main__":
+    model = Model(
+        name="Alien#1",
+        triangles=[
+            Triangle(
+                Point3D(x=20, y=50),
+                Point3D(x=50, y=15, z=-45.34),
+                Point3D(3.1426, 83.2323, -16),
+            )
+        ],
+    )
+
+    from rich.console import Console
+
+    console = Console()
+
+    console.print(
+        "\nRich can pretty print [b]attrs[/b] objects ( https://www.attrs.org/en/stable/ )\n",
+        justify="center",
+    )
+
+    console.rule("attrs without Rich")
+
+    print(model)
+
+    console.rule("attrs with Rich")
+
+    console.print(model)
diff --git a/poetry.lock b/poetry.lock
index 9a94b5f6e..00992afec 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -49,17 +49,17 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 
 [[package]]
 name = "attrs"
-version = "21.1.0"
+version = "20.3.0"
 description = "Classes Without Boilerplate"
 category = "main"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 
 [package.extras]
-dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit"]
-docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"]
-tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"]
-tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"]
+dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "furo", "sphinx", "pre-commit"]
+docs = ["furo", "sphinx", "zope.interface"]
+tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"]
+tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"]
 
 [[package]]
 name = "backcall"
@@ -891,7 +891,7 @@ jupyter = ["ipywidgets"]
 [metadata]
 lock-version = "1.1"
 python-versions = "^3.6"
-content-hash = "9e43bf9c815b3ce6471d7cee0281c57b457375773358f2de76cdb1a5c8644730"
+content-hash = "3cacbff8606717bf2ba96796c8e29a386fa24c587199234626627027e2eb576d"
 
 [metadata.files]
 appdirs = [
@@ -931,8 +931,8 @@ atomicwrites = [
     {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"},
 ]
 attrs = [
-    {file = "attrs-21.1.0-py2.py3-none-any.whl", hash = "sha256:8ee1e5f5a1afc5b19bdfae4fdf0c35ed324074bdce3500c939842c8f818645d9"},
-    {file = "attrs-21.1.0.tar.gz", hash = "sha256:3901be1cb7c2a780f14668691474d9252c070a756be0a9ead98cfeabfa11aeb8"},
+    {file = "attrs-20.3.0-py2.py3-none-any.whl", hash = "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6"},
+    {file = "attrs-20.3.0.tar.gz", hash = "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"},
 ]
 backcall = [
     {file = "backcall-0.2.0-py2.py3-none-any.whl", hash = "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255"},
diff --git a/pyproject.toml b/pyproject.toml
index 09f7abd47..9e5951183 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -41,6 +41,7 @@ pytest = "^6.2.3"
 black = "^20.8b1"
 mypy = "^0.812"
 pytest-cov = "^2.11.1"
+attrs = "^20.1.0"
 
 [build-system]
 requires = ["poetry-core>=1.0.0"]
diff --git a/rich/pretty.py b/rich/pretty.py
index 1e2f532d4..49a782560 100644
--- a/rich/pretty.py
+++ b/rich/pretty.py
@@ -18,8 +18,23 @@
     Tuple,
 )
 
-from rich.highlighter import ReprHighlighter
+try:
+    import attr as _attr_module
+except ImportError:  # pragma: no cover
+    _attr_module = None  # type: ignore
 
+
+def _is_attr_object(obj: Any) -> bool:
+    """Check if an object was created with attrs module."""
+    return _attr_module is not None and _attr_module.has(type(obj))
+
+
+def _get_attr_fields(obj: Any) -> Iterable["_attr_module.Attribute"]:
+    """Get fields for an attrs object."""
+    return _attr_module.fields(type(obj)) if _attr_module is not None else []
+
+
+from .highlighter import ReprHighlighter
 from . import get_console
 from ._loop import loop_last
 from ._pick import pick_bool
@@ -264,6 +279,7 @@ def is_expandable(obj: Any) -> bool:
         isinstance(obj, _CONTAINERS)
         or (is_dataclass(obj) and not isinstance(obj, type))
         or hasattr(obj, "__rich_repr__")
+        or _is_attr_object(obj)
     )
 
 
@@ -489,7 +505,6 @@ def iter_rich_args(rich_args) -> Iterable[Union[Any, Tuple[str, Any]]]:
                         child_node = _traverse(child)
                         child_node.last = last
                         child_node.key_repr = key
-                        child_node.last = last
                         child_node.key_separator = "="
                         append(child_node)
                     else:
@@ -500,6 +515,50 @@ def iter_rich_args(rich_args) -> Iterable[Union[Any, Tuple[str, Any]]]:
                 node = Node(
                     value_repr=f"{obj.__class__.__name__}()", children=[], last=root
                 )
+        elif _is_attr_object(obj):
+            children = []
+            append = children.append
+
+            attr_fields = _get_attr_fields(obj)
+            if attr_fields:
+                node = Node(
+                    open_brace=f"{obj.__class__.__name__}(",
+                    close_brace=")",
+                    children=children,
+                    last=root,
+                )
+
+                def iter_attrs() -> Iterable[
+                    Tuple[str, Any, Optional[Callable[[Any], str]]]
+                ]:
+                    """Iterate over attr fields and values."""
+                    for attr in attr_fields:
+                        if attr.repr:
+                            try:
+                                value = getattr(obj, attr.name)
+                            except Exception as error:
+                                # Can happen, albeit rarely
+                                yield (attr.name, error, None)
+                            else:
+                                yield (
+                                    attr.name,
+                                    value,
+                                    attr.repr if callable(attr.repr) else None,
+                                )
+
+                for last, (name, value, repr_callable) in loop_last(iter_attrs()):
+                    if repr_callable:
+                        child_node = Node(value_repr=str(repr_callable(value)))
+                    else:
+                        child_node = _traverse(value)
+                    child_node.last = last
+                    child_node.key_repr = name
+                    child_node.key_separator = "="
+                    append(child_node)
+            else:
+                node = Node(
+                    value_repr=f"{obj.__class__.__name__}()", children=[], last=root
+                )
 
         elif (
             is_dataclass(obj)
@@ -629,7 +688,7 @@ def pprint(
     max_length: Optional[int] = None,
     max_string: Optional[int] = None,
     expand_all: bool = False,
-):
+) -> None:
     """A convenience function for pretty printing.
 
     Args:
diff --git a/tests/test_pretty.py b/tests/test_pretty.py
index d8c046c27..cb17e064c 100644
--- a/tests/test_pretty.py
+++ b/tests/test_pretty.py
@@ -5,10 +5,19 @@
 import sys
 from typing import List
 
+import attr
+import pytest
+
 from rich.console import Console
 from rich.pretty import install, Pretty, pprint, pretty_repr, Node
 
 
+skip_py36 = pytest.mark.skipif(
+    sys.version_info.minor == 6 and sys.version_info.major == 3,
+    reason="rendered differently on py3.6",
+)
+
+
 def test_install():
     console = Console(file=io.StringIO())
     dh = sys.displayhook
@@ -181,3 +190,42 @@ def __repr__(self):
             return ""
 
     assert pretty_repr(Foo()) == ""
+
+
+def test_attrs():
+    @attr.define
+    class Point:
+        x: int
+        y: int
+        foo: str = attr.field(repr=str.upper)
+        z: int = 0
+
+    result = pretty_repr(Point(1, 2, foo="bar"))
+    print(repr(result))
+    expected = "Point(x=1, y=2, foo=BAR, z=0)"
+    assert result == expected
+
+
+def test_attrs_empty():
+    @attr.define
+    class Nada:
+        pass
+
+    result = pretty_repr(Nada())
+    print(repr(result))
+    expected = "Nada()"
+    assert result == expected
+
+
+@skip_py36
+def test_attrs_broken():
+    @attr.define
+    class Foo:
+        bar: int
+
+    foo = Foo(1)
+    del foo.bar
+    result = pretty_repr(foo)
+    print(repr(result))
+    expected = "Foo(bar=AttributeError('bar'))"
+    assert result == expected

Adds pretty printing support for attrs module.
