Repository: samuelcolvin/pydantic
Pull request #2037 (2020-10-26 05:43:25)

Bump devtools from 0.6.0 to 0.6.1
Bumps [devtools](https://github.com/samuelcolvin/python-devtools) from 0.6.0 to 0.6.1.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a href="https://github.com/samuelcolvin/python-devtools/releases">devtools's releases</a>.</em></p>
<blockquote>
<h2>v0.6.1</h2>
<p>compatibility with python 3.8.6</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/samuelcolvin/python-devtools/commit/4bef4c3d46f6d8ca7d6d3919b451bda0acde41e8"><code>4bef4c3</code></a> compatibility with python 3.8.6 (<a href="https://github-redirect.dependabot.com/samuelcolvin/python-devtools/issues/74">#74</a>)</li>
<li><a href="https://github.com/samuelcolvin/python-devtools/commit/cd9bdeec9a234ff56227e0365782dd6d4853be73"><code>cd9bdee</code></a> fix title in docs</li>
<li>See full diff in <a href="https://github.com/samuelcolvin/python-devtools/compare/v0.6...v0.6.1">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=devtools&package-manager=pip&previous-version=0.6.0&new-version=0.6.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/configuring-github-dependabot-security-updates)

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)


</details>

codecov[bot]: # [Codecov](https://codecov.io/gh/samuelcolvin/pydantic/pull/2037?src=pr&el=h1) Report
> Merging [#2037](https://codecov.io/gh/samuelcolvin/pydantic/pull/2037?src=pr&el=desc) into [master](https://codecov.io/gh/samuelcolvin/pydantic/commit/2f7e40476f1f91ecc579a0885156eec5be9b5d18?el=desc) will **decrease** coverage by `0.37%`.
> The diff coverage is `n/a`.


```diff
@@            Coverage Diff             @@
##           master    #2037      +/-   ##
==========================================
- Coverage   99.90%   99.52%   -0.38%     
==========================================
  Files          21       21              
  Lines        4035     4035              
  Branches      805      805              
==========================================
- Hits         4031     4016      -15     
- Misses          3       15      +12     
- Partials        1        4       +3     
```


| [Impacted Files](https://codecov.io/gh/samuelcolvin/pydantic/pull/2037?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [pydantic/version.py](https://codecov.io/gh/samuelcolvin/pydantic/pull/2037/diff?src=pr&el=tree#diff-cHlkYW50aWMvdmVyc2lvbi5weQ==) | `88.23% <0.00%> (-11.77%)` | :arrow_down: |
| [pydantic/typing.py](https://codecov.io/gh/samuelcolvin/pydantic/pull/2037/diff?src=pr&el=tree#diff-cHlkYW50aWMvdHlwaW5nLnB5) | `91.33% <0.00%> (-5.52%)` | :arrow_down: |
| [pydantic/env\_settings.py](https://codecov.io/gh/samuelcolvin/pydantic/pull/2037/diff?src=pr&el=tree#diff-cHlkYW50aWMvZW52X3NldHRpbmdzLnB5) | `97.43% <0.00%> (-2.57%)` | :arrow_down: |
| [pydantic/networks.py](https://codecov.io/gh/samuelcolvin/pydantic/pull/2037/diff?src=pr&el=tree#diff-cHlkYW50aWMvbmV0d29ya3MucHk=) | `99.22% <0.00%> (-0.78%)` | :arrow_down: |
| [pydantic/utils.py](https://codecov.io/gh/samuelcolvin/pydantic/pull/2037/diff?src=pr&el=tree#diff-cHlkYW50aWMvdXRpbHMucHk=) | `99.25% <0.00%> (-0.75%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/samuelcolvin/pydantic/pull/2037?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/samuelcolvin/pydantic/pull/2037?src=pr&el=footer). Last update [2f7e404...1358e59](https://codecov.io/gh/samuelcolvin/pydantic/pull/2037?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 67d4ad8d9..b411771f7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -38,7 +38,7 @@ jobs:
         run: make check-dist
 
   test-linux:
-    name: test py${{ matrix.python-version }} on ubuntu
+    name: test py${{ matrix.python-version }} on linux
     runs-on: ubuntu-latest
     strategy:
       fail-fast: false
@@ -195,15 +195,8 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        os:
-        - ubuntu
-        - macos
-        - windows
-        python-version:
-        - '6'
-        - '7'
-        - '8'
-        - '9'
+        os: [ubuntu , macos , windows]
+        python-version: ['6', '7', '8', '9']
         include:
         - os: ubuntu
           platform: linux
diff --git a/.gitignore b/.gitignore
index b653170b2..262563382 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,7 @@ venv/
 env36/
 env37/
 env38/
+env39/
 Pipfile
 *.lock
 *.py[cod]
@@ -32,4 +33,4 @@ pydantic/*.so
 /sandbox/
 /.ghtopdep_cache/
 /fastapi/
-/codecov.sh
\ No newline at end of file
+/codecov.sh
diff --git a/Makefile b/Makefile
index 69cad5f9a..8aa686f4b 100644
--- a/Makefile
+++ b/Makefile
@@ -25,7 +25,7 @@ install-benchmarks: install-pydantic
 	pip install -U -r benchmarks/requirements.txt
 
 .PHONY: install
-install: install-linting install-testing
+install: install-testing install-linting
 	@echo 'installed development requirements'
 
 .PHONY: build-trace
diff --git a/benchmarks/requirements.txt b/benchmarks/requirements.txt
index 9033b1762..6f3bf9a09 100644
--- a/benchmarks/requirements.txt
+++ b/benchmarks/requirements.txt
@@ -1,5 +1,5 @@
 # no versions since benchmarks should always run with the most recent version of each package
-devtools
+devtools==0.6.1
 
 python-dateutil
 trafaret
diff --git a/changes/1679-MrMrRobat.txt b/changes/1679-MrMrRobat.txt
new file mode 100644
index 000000000..8c6a2dc2e
--- /dev/null
+++ b/changes/1679-MrMrRobat.txt
@@ -0,0 +1,1 @@
+Add private attributes support
\ No newline at end of file
diff --git a/docs/examples/private_attributes.py b/docs/examples/private_attributes.py
new file mode 100644
index 000000000..51da4456e
--- /dev/null
+++ b/docs/examples/private_attributes.py
@@ -0,0 +1,19 @@
+from datetime import datetime
+from random import randint
+
+from pydantic import BaseModel, PrivateAttr
+
+
+class TimeAwareModel(BaseModel):
+    _processed_at: datetime = PrivateAttr(default_factory=datetime.now)
+    _secret_value: str = PrivateAttr()
+
+    def __init__(self, **data):
+        super().__init__(**data)
+        # this could also be done with default_factory
+        self._secret_value = randint(1, 5)
+
+
+m = TimeAwareModel()
+print(m._processed_at)
+print(m._secret_value)
diff --git a/docs/examples/private_attributes_underscore_attrs_are_private.py b/docs/examples/private_attributes_underscore_attrs_are_private.py
new file mode 100644
index 000000000..f24db5f1c
--- /dev/null
+++ b/docs/examples/private_attributes_underscore_attrs_are_private.py
@@ -0,0 +1,16 @@
+from typing import ClassVar
+
+from pydantic import BaseModel
+
+
+class Model(BaseModel):
+    _class_var: ClassVar[str] = 'class var value'
+    _private_attr: str = 'private attr value'
+
+    class Config:
+        underscore_attrs_are_private = True
+
+
+print(Model._class_var)
+print(Model._private_attr)
+print(Model()._private_attr)
diff --git a/docs/requirements.txt b/docs/requirements.txt
index 1ce891d0b..8f3b0d73d 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -2,11 +2,9 @@ ansi2html==1.5.2
 flake8==3.8.4
 flake8-quotes==3.2.0
 mkdocs==1.1.2
-markdown==3.3.1
 mkdocs-exclude==1.0.2
 mkdocs-material==6.1.0
 markdown-include==0.6.0
-pygments==2.7.1
-sqlalchemy   # pyup: ignore
-orjson       # pyup: ignore
-ujson        # pyup: ignore
+sqlalchemy
+orjson
+ujson
diff --git a/docs/usage/model_config.md b/docs/usage/model_config.md
index ade95eac1..f847ad8c0 100644
--- a/docs/usage/model_config.md
+++ b/docs/usage/model_config.md
@@ -93,6 +93,11 @@ Similarly, if using the `@dataclass` decorator:
 ```
 _(This script is complete, it should run "as is")_
 
+
+**`underscore_attrs_are_private`**
+: whether to treat any underscore non-class var attrs as private, or leave them as is; See [Private model attributes](models.md#private-model-attributes)
+
+
 ## Alias Generator
 
 If data source field names do not match your code style (e. g. CamelCase fields),
diff --git a/docs/usage/models.md b/docs/usage/models.md
index 013a05e6e..87312469d 100644
--- a/docs/usage/models.md
+++ b/docs/usage/models.md
@@ -531,6 +531,26 @@ Where `Field` refers to the [field function](schema.md#field-customisation).
     Moreover if you want to validate default values with `validate_all`,
     *pydantic* will need to call the `default_factory`, which could lead to side effects!
 
+## Private model attributes
+
+If you need to use internal attributes excluded from model fields, you can declare them using `PrivateAttr`:
+
+```py
+{!.tmp_examples/private_attributes.py!}
+```
+_(This script is complete, it should run "as is")_
+
+Private attribute names must start with underscore to prevent conflicts with model fields: both `_attr` and `__attr__` 
+are supported.
+
+If `Config.underscore_attrs_are_private` is `True`, any non-ClassVar underscore attribute will be treated as private:
+```py
+{!.tmp_examples/private_attributes_underscore_attrs_are_private.py!}
+```
+_(This script is complete, it should run "as is")_
+
+Upon class creation pydantic constructs `__slots__` filled with private attributes.
+
 ## Parsing data into a specified type
 
 Pydantic includes a standalone utility function `parse_obj_as` that can be used to apply the parsing
diff --git a/pydantic/__init__.py b/pydantic/__init__.py
index 1a3cabb8c..5cfc0583f 100644
--- a/pydantic/__init__.py
+++ b/pydantic/__init__.py
@@ -5,7 +5,7 @@ from .decorator import validate_arguments
 from .env_settings import BaseSettings
 from .error_wrappers import ValidationError
 from .errors import *
-from .fields import Field, Required, Schema
+from .fields import Field, PrivateAttr, Required, Schema
 from .main import *
 from .networks import *
 from .parse import Protocol
@@ -96,6 +96,7 @@ __all__ = [
     'StrictInt',
     'StrictFloat',
     'PaymentCardNumber',
+    'PrivateAttr',
     'ByteSize',
     # version
     'VERSION',
diff --git a/pydantic/class_validators.py b/pydantic/class_validators.py
index c697ff52d..9cd951a58 100644
--- a/pydantic/class_validators.py
+++ b/pydantic/class_validators.py
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Optional,
 
 from .errors import ConfigError
 from .typing import AnyCallable
-from .utils import in_ipython
+from .utils import ROOT_KEY, in_ipython
 
 
 class Validator:
@@ -42,7 +42,6 @@ if TYPE_CHECKING:
     ValidatorListDict = Dict[str, List[Validator]]
 
 _FUNCS: Set[str] = set()
-ROOT_KEY = '__root__'
 VALIDATOR_CONFIG_KEY = '__validator_config__'
 ROOT_VALIDATOR_CONFIG_KEY = '__root_validator_config__'
 
diff --git a/pydantic/fields.py b/pydantic/fields.py
index f2c9ba050..c451770c0 100644
--- a/pydantic/fields.py
+++ b/pydantic/fields.py
@@ -797,3 +797,31 @@ class ModelField(Representation):
         if self.alt_alias:
             args.append(('alias', self.alias))
         return args
+
+
+class PrivateAttr(Representation):
+    """
+    Indicates that attribute is only used internally and never mixed with regular fields.
+
+    Types or values of private attrs are not checked by pydantic and it's up to you to keep them relevant.
+
+    Private attrs are stored in model __slots__.
+    """
+
+    __slots__ = ('default', 'default_factory')
+
+    def __init__(self, default: Any = Undefined, *, default_factory: Optional[NoArgAnyCallable] = None) -> None:
+        if default is not Undefined and default_factory is not None:
+            raise TypeError('default and default_factory args can not be used together')
+
+        self.default = default
+        self.default_factory = default_factory
+
+    def get_default(self) -> Any:
+        return smart_deepcopy(self.default) if self.default_factory is None else self.default_factory()
+
+    def __eq__(self, other: Any) -> bool:
+        return isinstance(other, self.__class__) and (self.default, self.default_factory) == (
+            other.default,
+            other.default_factory,
+        )
diff --git a/pydantic/main.py b/pydantic/main.py
index 02e27d106..bc5444a70 100644
--- a/pydantic/main.py
+++ b/pydantic/main.py
@@ -16,6 +16,7 @@ from typing import (
     List,
     Mapping,
     Optional,
+    Set,
     Tuple,
     Type,
     TypeVar,
@@ -25,21 +26,24 @@ from typing import (
     overload,
 )
 
-from .class_validators import ROOT_KEY, ValidatorGroup, extract_root_validators, extract_validators, inherit_validators
+from .class_validators import ValidatorGroup, extract_root_validators, extract_validators, inherit_validators
 from .error_wrappers import ErrorWrapper, ValidationError
 from .errors import ConfigError, DictError, ExtraError, MissingError
-from .fields import SHAPE_MAPPING, ModelField, Undefined
+from .fields import SHAPE_MAPPING, ModelField, PrivateAttr, Undefined
 from .json import custom_pydantic_encoder, pydantic_encoder
 from .parse import Protocol, load_file, load_str_bytes
 from .schema import default_ref_template, model_schema
 from .types import PyObject, StrBytes
 from .typing import AnyCallable, ForwardRef, get_origin, is_classvar, resolve_annotations, update_field_forward_refs
 from .utils import (
+    ROOT_KEY,
     ClassAttribute,
     GetterDict,
     Representation,
     ValueItems,
     generate_model_signature,
+    is_valid_field,
+    is_valid_private_name,
     lenient_issubclass,
     sequence_like,
     smart_deepcopy,
@@ -81,7 +85,6 @@ if TYPE_CHECKING:
 else:
     SchemaExtraCallable = Callable[..., None]
 
-
 try:
     import cython  # type: ignore
 except ImportError:
@@ -123,6 +126,7 @@ class BaseConfig:
     json_loads: Callable[[str], Any] = json.loads
     json_dumps: Callable[..., str] = json.dumps
     json_encoders: Dict[Type[Any], AnyCallable] = {}
+    underscore_attrs_are_private: bool = False
 
     @classmethod
     def get_field_info(cls, name: str) -> Dict[str, Any]:
@@ -189,12 +193,6 @@ def prepare_config(config: Type[BaseConfig], cls_name: str) -> None:
         config.case_sensitive = not config.case_insensitive  # type: ignore
 
 
-def is_valid_field(name: str) -> bool:
-    if not name.startswith('_'):
-        return True
-    return ROOT_KEY == name
-
-
 def validate_custom_root_type(fields: Dict[str, ModelField]) -> None:
     if len(fields) > 1:
         raise ValueError('__root__ cannot be mixed with other fields')
@@ -217,6 +215,10 @@ class ModelMetaclass(ABCMeta):
         validators: 'ValidatorListDict' = {}
 
         pre_root_validators, post_root_validators = [], []
+        private_attributes: Dict[str, PrivateAttr] = {}
+        slots: Set[str] = namespace.get('__slots__', ())
+        slots = {slots} if isinstance(slots, str) else set(slots)
+
         for base in reversed(bases):
             if _is_base_model_class_defined and issubclass(base, BaseModel) and base != BaseModel:
                 fields.update(smart_deepcopy(base.__fields__))
@@ -224,6 +226,7 @@ class ModelMetaclass(ABCMeta):
                 validators = inherit_validators(base.__validators__, validators)
                 pre_root_validators += base.__pre_root_validators__
                 post_root_validators += base.__post_root_validators__
+                private_attributes.update(base.__private_attributes__)
 
         config = inherit_config(namespace.get('Config'), config)
         validators = inherit_validators(extract_validators(namespace), validators)
@@ -263,14 +266,21 @@ class ModelMetaclass(ABCMeta):
                         class_validators=vg.get_validators(ann_name),
                         config=config,
                     )
+                elif ann_name not in namespace and config.underscore_attrs_are_private:
+                    private_attributes[ann_name] = PrivateAttr()
 
             for var_name, value in namespace.items():
-                if (
-                    var_name not in annotations
-                    and is_valid_field(var_name)
-                    and not isinstance(value, untouched_types)
-                    and var_name not in class_vars
-                ):
+                can_be_changed = var_name not in class_vars and not isinstance(value, untouched_types)
+                if isinstance(value, PrivateAttr):
+                    if not is_valid_private_name(var_name):
+                        raise NameError(
+                            f'Private attributes "{var_name}" must not be a valid field name; '
+                            f'Use sunder or dunder names, e. g. "_{var_name}" or "__{var_name}__"'
+                        )
+                    private_attributes[var_name] = value
+                elif config.underscore_attrs_are_private and is_valid_private_name(var_name) and can_be_changed:
+                    private_attributes[var_name] = PrivateAttr(default=value)
+                elif is_valid_field(var_name) and var_name not in annotations and can_be_changed:
                     validate_field_name(bases, var_name)
                     inferred = ModelField.infer(
                         name=var_name,
@@ -296,6 +306,7 @@ class ModelMetaclass(ABCMeta):
             json_encoder = pydantic_encoder
         pre_rv_new, post_rv_new = extract_root_validators(namespace)
 
+        exclude_from_namespace = fields | private_attributes.keys() | {'__slots__'}
         new_namespace = {
             '__config__': config,
             '__fields__': fields,
@@ -305,7 +316,9 @@ class ModelMetaclass(ABCMeta):
             '__schema_cache__': {},
             '__json_encoder__': staticmethod(json_encoder),
             '__custom_root_type__': _custom_root_type,
-            **{n: v for n, v in namespace.items() if n not in fields},
+            '__private_attributes__': private_attributes,
+            '__slots__': slots | private_attributes.keys(),
+            **{n: v for n, v in namespace.items() if n not in exclude_from_namespace},
         }
 
         cls = super().__new__(mcs, name, bases, new_namespace, **kwargs)
@@ -314,6 +327,9 @@ class ModelMetaclass(ABCMeta):
         return cls
 
 
+object_setattr = object.__setattr__
+
+
 class BaseModel(Representation, metaclass=ModelMetaclass):
     if TYPE_CHECKING:
         # populated by the metaclass, defined here to help IDEs only
@@ -327,6 +343,8 @@ class BaseModel(Representation, metaclass=ModelMetaclass):
         __schema_cache__: 'DictAny' = {}
         __custom_root_type__: bool = False
         __signature__: 'Signature'
+        __private_attributes__: Dict[str, Any]
+        __fields_set__: SetStr = set()
 
     Config = BaseConfig
     __slots__ = ('__dict__', '__fields_set__')
@@ -339,17 +357,18 @@ class BaseModel(Representation, metaclass=ModelMetaclass):
         Raises ValidationError if the input data cannot be parsed to form a valid model.
         """
         # Uses something other than `self` the first arg to allow "self" as a settable attribute
-        if TYPE_CHECKING:
-            __pydantic_self__.__dict__: Dict[str, Any] = {}
-            __pydantic_self__.__fields_set__: 'SetStr' = set()
         values, fields_set, validation_error = validate_model(__pydantic_self__.__class__, data)
         if validation_error:
             raise validation_error
-        object.__setattr__(__pydantic_self__, '__dict__', values)
-        object.__setattr__(__pydantic_self__, '__fields_set__', fields_set)
+        object_setattr(__pydantic_self__, '__dict__', values)
+        object_setattr(__pydantic_self__, '__fields_set__', fields_set)
+        __pydantic_self__._init_private_attributes()
 
     @no_type_check
-    def __setattr__(self, name, value):
+    def __setattr__(self, name, value):  # noqa: C901 (ignore complexity)
+        if name in self.__private_attributes__:
+            return object_setattr(self, name, value)
+
         if self.__config__.extra is not Extra.allow and name not in self.__fields__:
             raise ValueError(f'"{self.__class__.__name__}" object has no field "{name}"')
         elif not self.__config__.allow_mutation:
@@ -387,11 +406,24 @@ class BaseModel(Representation, metaclass=ModelMetaclass):
         self.__fields_set__.add(name)
 
     def __getstate__(self) -> 'DictAny':
-        return {'__dict__': self.__dict__, '__fields_set__': self.__fields_set__}
+        return {
+            '__dict__': self.__dict__,
+            '__fields_set__': self.__fields_set__,
+            '__private_attribute_values__': {k: getattr(self, k, Undefined) for k in self.__private_attributes__},
+        }
 
     def __setstate__(self, state: 'DictAny') -> None:
-        object.__setattr__(self, '__dict__', state['__dict__'])
-        object.__setattr__(self, '__fields_set__', state['__fields_set__'])
+        object_setattr(self, '__dict__', state['__dict__'])
+        object_setattr(self, '__fields_set__', state['__fields_set__'])
+        for name, value in state.get('__private_attribute_values__', {}).items():
+            if value is not Undefined:
+                object_setattr(self, name, value)
+
+    def _init_private_attributes(self) -> None:
+        for name, private_attr in self.__private_attributes__.items():
+            default = private_attr.get_default()
+            if default is not Undefined:
+                object_setattr(self, name, default)
 
     def dict(
         self,
@@ -530,8 +562,9 @@ class BaseModel(Representation, metaclass=ModelMetaclass):
         values, fields_set, validation_error = validate_model(cls, obj)
         if validation_error:
             raise validation_error
-        object.__setattr__(m, '__dict__', values)
-        object.__setattr__(m, '__fields_set__', fields_set)
+        object_setattr(m, '__dict__', values)
+        object_setattr(m, '__fields_set__', fields_set)
+        m._init_private_attributes()
         return m
 
     @classmethod
@@ -544,10 +577,11 @@ class BaseModel(Representation, metaclass=ModelMetaclass):
         # default field values
         fields_values = {name: field.get_default() for name, field in cls.__fields__.items() if not field.required}
         fields_values.update(values)
-        object.__setattr__(m, '__dict__', fields_values)
+        object_setattr(m, '__dict__', fields_values)
         if _fields_set is None:
             _fields_set = set(values.keys())
-        object.__setattr__(m, '__fields_set__', _fields_set)
+        object_setattr(m, '__fields_set__', _fields_set)
+        m._init_private_attributes()
         return m
 
     def copy(
@@ -580,8 +614,15 @@ class BaseModel(Representation, metaclass=ModelMetaclass):
 
         cls = self.__class__
         m = cls.__new__(cls)
-        object.__setattr__(m, '__dict__', v)
-        object.__setattr__(m, '__fields_set__', self.__fields_set__.copy())
+        object_setattr(m, '__dict__', v)
+        object_setattr(m, '__fields_set__', self.__fields_set__.copy())
+        for name in self.__private_attributes__:
+            value = getattr(self, name, Undefined)
+            if value is not Undefined:
+                if deep:
+                    value = deepcopy(value)
+                object_setattr(m, name, value)
+
         return m
 
     @classmethod
diff --git a/pydantic/schema.py b/pydantic/schema.py
index 079e08c19..be2b454ce 100644
--- a/pydantic/schema.py
+++ b/pydantic/schema.py
@@ -25,7 +25,6 @@ from typing import (
 )
 from uuid import UUID
 
-from .class_validators import ROOT_KEY
 from .fields import (
     SHAPE_FROZENSET,
     SHAPE_ITERABLE,
@@ -59,7 +58,7 @@ from .types import (
     constr,
 )
 from .typing import ForwardRef, Literal, get_args, get_origin, is_callable_type, is_literal_type, literal_values
-from .utils import get_model, lenient_issubclass, sequence_like
+from .utils import ROOT_KEY, get_model, lenient_issubclass, sequence_like
 
 if TYPE_CHECKING:
     from .dataclasses import DataclassType  # noqa: F401
diff --git a/pydantic/utils.py b/pydantic/utils.py
index e67f336c1..c9b0f3c92 100644
--- a/pydantic/utils.py
+++ b/pydantic/utils.py
@@ -45,14 +45,18 @@ __all__ = (
     'almost_equal_floats',
     'get_model',
     'to_camel',
+    'is_valid_field',
+    'smart_deepcopy',
     'PyObjectStr',
     'Representation',
     'GetterDict',
     'ValueItems',
     'version_info',  # required here to match behaviour in v1.3
     'ClassAttribute',
+    'ROOT_KEY',
 )
 
+ROOT_KEY = '__root__'
 # these are types that are returned unchanged by deepcopy
 IMMUTABLE_NON_COLLECTIONS_TYPES: Set[Type[Any]] = {
     int,
@@ -591,3 +595,13 @@ def smart_deepcopy(obj: Obj) -> Obj:
         # faster way for empty collections, no need to copy its members
         return obj if obj_type is tuple else obj.copy()  # type: ignore  # tuple doesn't have copy method
     return deepcopy(obj)  # slowest way when we actually might need a deepcopy
+
+
+def is_valid_field(name: str) -> bool:
+    if not name.startswith('_'):
+        return True
+    return ROOT_KEY == name
+
+
+def is_valid_private_name(name: str) -> bool:
+    return not is_valid_field(name) and name not in {'__annotations__', '__module__', '__annotations__', '__qualname__'}
diff --git a/requirements.txt b/requirements.txt
index ee9342558..80e8d9c1a 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,7 +1,7 @@
 # requirements for compilation and from setup.py so dependabot prompts us to test with latest version of these packages
 
 Cython==0.29.21;sys_platform!='win32'
-devtools==0.6.0
+devtools==0.6.1
 email-validator==1.1.1
 dataclasses==0.6; python_version < '3.7'
 typing-extensions==3.7.4.1; python_version < '3.8'


Test patch:
diff --git a/tests/test_construction.py b/tests/test_construction.py
index 93abb6e03..00657153c 100644
--- a/tests/test_construction.py
+++ b/tests/test_construction.py
@@ -3,7 +3,7 @@ from typing import Any, List
 
 import pytest
 
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, PrivateAttr
 
 
 class Model(BaseModel):
@@ -58,6 +58,8 @@ def test_simple_copy():
 
 
 class ModelTwo(BaseModel):
+    __foo__ = PrivateAttr({'private'})
+
     a: float
     b: int = 10
     c: str = 'foobar'
@@ -66,6 +68,7 @@ class ModelTwo(BaseModel):
 
 def test_deep_copy():
     m = ModelTwo(a=24, d=Model(a='12'))
+    m.__foo__ = {'new value'}
     m2 = m.copy(deep=True)
 
     assert m.a == m2.a == 24
@@ -74,6 +77,8 @@ def test_deep_copy():
     assert m.d is not m2.d
     assert m == m2
     assert m.__fields__ == m2.__fields__
+    assert m.__foo__ == m2.__foo__
+    assert m.__foo__ is not m2.__foo__
 
 
 def test_copy_exclude():
@@ -215,6 +220,7 @@ def test_recursive_pickle():
     assert m.d.a == 123.45
     assert m2.d.a == 123.45
     assert m.__fields__ == m2.__fields__
+    assert m.__foo__ == m2.__foo__
 
 
 def test_immutable_copy():
diff --git a/tests/test_private_attributes.py b/tests/test_private_attributes.py
new file mode 100644
index 000000000..07f702c7f
--- /dev/null
+++ b/tests/test_private_attributes.py
@@ -0,0 +1,162 @@
+from typing import ClassVar
+
+import pytest
+
+from pydantic import BaseModel, Extra, PrivateAttr
+from pydantic.fields import Undefined
+
+
+def test_private_attribute():
+    default = {'a': {}}
+
+    class Model(BaseModel):
+        __foo__ = PrivateAttr(default)
+
+    assert Model.__slots__ == {'__foo__'}
+    assert repr(Model.__foo__) == "<member '__foo__' of 'Model' objects>"
+    assert Model.__private_attributes__ == {'__foo__': PrivateAttr(default)}
+
+    m = Model()
+    assert m.__foo__ == default
+    assert m.__foo__ is not default
+    assert m.__foo__['a'] is not default['a']
+
+    m.__foo__ = None
+    assert m.__foo__ is None
+
+    assert m.dict() == {}
+    assert m.__dict__ == {}
+
+
+def test_private_attribute_factory():
+    default = {'a': {}}
+
+    def factory():
+        return default
+
+    class Model(BaseModel):
+        __foo__ = PrivateAttr(default_factory=factory)
+
+    assert Model.__slots__ == {'__foo__'}
+    assert repr(Model.__foo__) == "<member '__foo__' of 'Model' objects>"
+    assert Model.__private_attributes__ == {'__foo__': PrivateAttr(default_factory=factory)}
+
+    m = Model()
+    assert m.__foo__ == default
+    assert m.__foo__ is default
+    assert m.__foo__['a'] is default['a']
+
+    m.__foo__ = None
+    assert m.__foo__ is None
+
+    assert m.dict() == {}
+    assert m.__dict__ == {}
+
+
+def test_private_attribute_annotation():
+    class Model(BaseModel):
+        __foo__: str
+
+        class Config:
+            underscore_attrs_are_private = True
+
+    assert Model.__slots__ == {'__foo__'}
+    assert repr(Model.__foo__) == "<member '__foo__' of 'Model' objects>"
+    assert Model.__private_attributes__ == {'__foo__': PrivateAttr(Undefined)}
+
+    m = Model()
+    with pytest.raises(AttributeError):
+        m.__foo__
+
+    m.__foo__ = '123'
+    assert m.__foo__ == '123'
+
+    m.__foo__ = None
+    assert m.__foo__ is None
+
+    del m.__foo__
+
+    with pytest.raises(AttributeError):
+        m.__foo__
+
+    m.__foo__ = '123'
+    assert m.__foo__ == '123'
+
+    assert m.dict() == {}
+    assert m.__dict__ == {}
+
+
+def test_underscore_attrs_are_private():
+    class Model(BaseModel):
+        __foo__: str = 'abc'
+        __bar__: ClassVar[str] = 'cba'
+
+        class Config:
+            underscore_attrs_are_private = True
+
+    assert Model.__slots__ == {'__foo__'}
+    assert repr(Model.__foo__) == "<member '__foo__' of 'Model' objects>"
+    assert Model.__bar__ == 'cba'
+    assert Model.__private_attributes__ == {'__foo__': PrivateAttr('abc')}
+
+    m = Model()
+    assert m.__foo__ == 'abc'
+    m.__foo__ = None
+    assert m.__foo__ is None
+
+    with pytest.raises(ValueError, match='"Model" object has no field "__bar__"'):
+        m.__bar__ = 1
+
+
+def test_private_attribute_intersection_with_extra_field():
+    class Model(BaseModel):
+        __foo__ = PrivateAttr('private_attribute')
+
+        class Config:
+            extra = Extra.allow
+
+    assert Model.__slots__ == {'__foo__'}
+    m = Model(__foo__='field')
+    assert m.__foo__ == 'private_attribute'
+    assert m.__dict__ == m.dict() == {'__foo__': 'field'}
+
+    m.__foo__ = 'still_private'
+    assert m.__foo__ == 'still_private'
+    assert m.__dict__ == m.dict() == {'__foo__': 'field'}
+
+
+def test_private_attribute_invalid_name():
+    with pytest.raises(
+        NameError,
+        match='Private attributes "foo" must not be a valid field name; '
+        'Use sunder or dunder names, e. g. "_foo" or "__foo__"',
+    ):
+
+        class Model(BaseModel):
+            foo = PrivateAttr()
+
+
+def test_slots_are_ignored():
+    class Model(BaseModel):
+        __slots__ = (
+            'foo',
+            '_bar',
+        )
+
+        def __init__(self):
+            super().__init__()
+            for attr_ in self.__slots__:
+                object.__setattr__(self, attr_, 'spam')
+
+    assert Model.__private_attributes__ == {}
+    assert set(Model.__slots__) == {'foo', '_bar'}
+    m = Model()
+    for attr in Model.__slots__:
+        assert object.__getattribute__(m, attr) == 'spam'
+        with pytest.raises(ValueError, match=f'"Model" object has no field "{attr}"'):
+            setattr(m, attr, 'not spam')
+
+
+def test_default_and_default_factory_used_error():
+    with pytest.raises(TypeError, match='default and default_factory args can not be used together'):
+        PrivateAttr(default=123, default_factory=lambda: 321)