Repository: sloria/environs

Issue:
IDE autocomplete support
HI!
Thanks for the package, looks amazing, but what about IDE autocomplete / IntelliSense / hints?
The IDE (I'm using PyCharm) does not prompt existing methods of the `Env` class instance (see picture below).

![Screenshot from 2019-09-20 11-15-20](https://user-images.githubusercontent.com/26823697/65311230-9dc5ab00-db98-11e9-9c5d-1bc372f71db6.png)

For me, this is an important drawback of this package.
Will it be decided?
Thanks!


Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 97876fd..dc28b52 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,31 @@
 # Changelog
 
+## 6.0.0
+
+Features:
+
+- Default parser methods are now defined as bound methods.
+  This enables static analysis features, e.g. autocomplete ([#103](https://github.com/sloria/environs/issues/103)).
+  Thanks [rugleb](https://github.com/rugleb) for the suggestion.
+  _Backwards-incompatible_: As a result of this change, adding a parser name that is the same as an existing method
+  will result in an error being raised.
+
+```python
+import environs
+
+env = environs.Env()
+
+# Below conflicts with built-in `url` method.
+# In <6.0.0, this would override the built-in method.
+# In >=6.0.0, this raises an error:
+#   environs.ParserConflictError: Env already has a method with name 'url'. Use a different name.
+@env.parser_for("url")
+def https_url(value):
+    return "https://" + value
+```
+
+- _Backwards-incompatible_: Rename `Env.__parser_map__` to `Env.__custom_parsers__`.
+
 ## 5.2.1 (2019-08-08)
 
 Bug fixes:
@@ -161,3 +187,7 @@ Bug fixes:
 ## 0.1.0 (2016-04-25)
 
 - First PyPI release.
+
+```
+
+```
diff --git a/environs.py b/environs.py
index 2c60439..da97c0c 100644
--- a/environs.py
+++ b/environs.py
@@ -19,16 +19,24 @@ __all__ = ["EnvError", "Env"]
 MARSHMALLOW_VERSION_INFO = tuple([int(part) for part in ma.__version__.split(".") if part.isdigit()])
 _PROXIED_PATTERN = re.compile(r"\s*{{\s*(\S*)\s*}}\s*")
 
-T = typing.TypeVar("T")
+_T = typing.TypeVar("_T")
+_StrType = str
+_BoolType = bool
+_IntType = int
+
 FieldFactory = typing.Callable[..., ma.fields.Field]
-Subcast = typing.Union[typing.Type, typing.Callable[..., T]]
+Subcast = typing.Union[typing.Type, typing.Callable[..., _T]]
 FieldType = typing.Type[ma.fields.Field]
 FieldOrFactory = typing.Union[FieldType, FieldFactory]
-ParserMethod = typing.Callable[..., T]
+ParserMethod = typing.Callable[..., _T]
 
 
 class EnvError(ValueError):
-    pass
+    """Raised when an environment variable or if a required environment variable is unset."""
+
+
+class ParserConflictError(ValueError):
+    """Raised when adding a custom parser that conflicts with a built-in parser method."""
 
 
 def _field2method(
@@ -36,7 +44,7 @@ def _field2method(
 ) -> ParserMethod:
     def method(
         self: "Env", name: str, default: typing.Any = ma.missing, subcast: Subcast = None, **kwargs
-    ) -> T:
+    ) -> _T:
         missing = kwargs.pop("missing", None) or default
         if isinstance(field_or_factory, type) and issubclass(field_or_factory, ma.fields.Field):
             field = typing.cast(typing.Type[ma.fields.Field], field_or_factory)(missing=missing, **kwargs)
@@ -106,8 +114,8 @@ def _preprocess_list(value: typing.Union[str, typing.Iterable], **kwargs) -> typ
 
 
 def _preprocess_dict(
-    value: typing.Union[str, typing.Mapping[str, T]], subcast: Subcast, **kwargs
-) -> typing.Mapping[str, T]:
+    value: typing.Union[str, typing.Mapping[str, _T]], subcast: Subcast, **kwargs
+) -> typing.Mapping[str, _T]:
     if isinstance(value, Mapping):
         return value
 
@@ -176,44 +184,42 @@ class Env:
 
     __call__ = _field2method(ma.fields.Field, "__call__")  # type: ParserMethod
 
-    default_parser_map = dict(
-        bool=_field2method(ma.fields.Bool, "bool"),
-        str=_field2method(ma.fields.Str, "str"),
-        int=_field2method(ma.fields.Int, "int"),
-        float=_field2method(ma.fields.Float, "float"),
-        decimal=_field2method(ma.fields.Decimal, "decimal"),
-        list=_field2method(_make_list_field, "list", preprocess=_preprocess_list),
-        dict=_field2method(ma.fields.Dict, "dict", preprocess=_preprocess_dict),
-        json=_field2method(ma.fields.Field, "json", preprocess=_preprocess_json),
-        datetime=_field2method(ma.fields.DateTime, "datetime"),
-        date=_field2method(ma.fields.Date, "date"),
-        path=_field2method(PathField, "path"),
-        log_level=_field2method(LogLevelField, "log_level"),
-        timedelta=_field2method(ma.fields.TimeDelta, "timedelta"),
-        uuid=_field2method(ma.fields.UUID, "uuid"),
-        url=_field2method(URLField, "url"),
-        dj_db_url=_func2method(_dj_db_url_parser, "dj_db_url"),
-        dj_email_url=_func2method(_dj_email_url_parser, "dj_email_url"),
-    )  # type: typing.Dict[str, ParserMethod]
+    int = _field2method(ma.fields.Int, "int")
+    bool = _field2method(ma.fields.Bool, "bool")
+    str = _field2method(ma.fields.Str, "str")
+    float = _field2method(ma.fields.Float, "float")
+    decimal = _field2method(ma.fields.Decimal, "decimal")
+    list = _field2method(_make_list_field, "list", preprocess=_preprocess_list)
+    dict = _field2method(ma.fields.Dict, "dict", preprocess=_preprocess_dict)
+    json = _field2method(ma.fields.Field, "json", preprocess=_preprocess_json)
+    datetime = _field2method(ma.fields.DateTime, "datetime")
+    date = _field2method(ma.fields.Date, "date")
+    path = _field2method(PathField, "path")
+    log_level = _field2method(LogLevelField, "log_level")
+    timedelta = _field2method(ma.fields.TimeDelta, "timedelta")
+    uuid = _field2method(ma.fields.UUID, "uuid")
+    url = _field2method(URLField, "url")
+    dj_db_url = _func2method(_dj_db_url_parser, "dj_db_url")
+    dj_email_url = _func2method(_dj_email_url_parser, "dj_email_url")
 
     def __init__(self):
-        self._fields = {}  # type: typing.Dict[str, ma.fields.Field]
-        self._values = {}  # type: typing.Dict[str, typing.Any]
-        self._prefix = None  # type: typing.Optional[str]
-        self.__parser_map__ = self.default_parser_map.copy()
+        self._fields = {}  # type: typing.Dict[_StrType, ma.fields.Field]
+        self._values = {}  # type: typing.Dict[_StrType, typing.Any]
+        self._prefix = None  # type: typing.Optional[_StrType]
+        self.__custom_parsers__ = {}
 
-    def __repr__(self) -> str:
+    def __repr__(self) -> _StrType:
         return "<{} {}>".format(self.__class__.__name__, self._values)
 
     __str__ = __repr__
 
     @staticmethod
     def read_env(
-        path: str = None,
-        recurse: bool = True,
-        stream: str = None,
-        verbose: bool = False,
-        override: bool = False,
+        path: _StrType = None,
+        recurse: _BoolType = True,
+        stream: _StrType = None,
+        verbose: _BoolType = False,
+        override: _BoolType = False,
     ) -> DotEnv:
         """Read a .env file into os.environ.
 
@@ -246,7 +252,7 @@ class Env:
             return load_dotenv(start, stream=stream, verbose=verbose, override=override)
 
     @contextlib.contextmanager
-    def prefixed(self, prefix: str) -> typing.Iterator["Env"]:
+    def prefixed(self, prefix: _StrType) -> typing.Iterator["Env"]:
         """Context manager for parsing envvars with a common prefix."""
         try:
             old_prefix = self._prefix
@@ -260,20 +266,24 @@ class Env:
             self._prefix = None
         self._prefix = old_prefix
 
-    def __getattr__(self, name: str, **kwargs):
+    def __getattr__(self, name: _StrType, **kwargs):
         try:
-            return functools.partial(self.__parser_map__[name], self)
+            return functools.partial(self.__custom_parsers__[name], self)
         except KeyError as error:
             raise AttributeError("{} has no attribute {}".format(self, name)) from error
 
-    def add_parser(self, name: str, func: typing.Callable) -> None:
+    def add_parser(self, name: _StrType, func: typing.Callable) -> None:
         """Register a new parser method with the name ``name``. ``func`` must
         receive the input value for an environment variable.
         """
-        self.__parser_map__[name] = _func2method(func, method_name=name)
+        if hasattr(self, name):
+            raise ParserConflictError(
+                "Env already has a method with name '{}'. Use a different name.".format(name)
+            )
+        self.__custom_parsers__[name] = _func2method(func, method_name=name)
         return None
 
-    def parser_for(self, name: str) -> typing.Callable[[typing.Callable], typing.Callable]:
+    def parser_for(self, name: _StrType) -> typing.Callable[[typing.Callable], typing.Callable]:
         """Decorator that registers a new parser method with the name ``name``.
         The decorated function must receive the input value for an environment variable.
         """
@@ -284,11 +294,11 @@ class Env:
 
         return decorator
 
-    def add_parser_from_field(self, name: str, field_cls: typing.Type[ma.fields.Field]):
+    def add_parser_from_field(self, name: _StrType, field_cls: typing.Type[ma.fields.Field]):
         """Register a new parser method with name ``name``, given a marshmallow ``Field``."""
-        self.__parser_map__[name] = _field2method(field_cls, method_name=name)
+        self.__custom_parsers__[name] = _field2method(field_cls, method_name=name)
 
-    def dump(self) -> typing.Mapping[str, typing.Any]:
+    def dump(self) -> typing.Mapping[_StrType, typing.Any]:
         """Dump parsed environment variables to a dictionary of simple data types (numbers
         and strings).
         """
@@ -297,8 +307,8 @@ class Env:
         return dump_result.data if MARSHMALLOW_VERSION_INFO[0] < 3 else dump_result
 
     def _get_from_environ(
-        self, key: str, default: typing.Any, *, proxied: bool = False
-    ) -> typing.Tuple[str, typing.Any, typing.Optional[str]]:
+        self, key: _StrType, default: typing.Any, *, proxied: _BoolType = False
+    ) -> typing.Tuple[_StrType, typing.Any, typing.Optional[_StrType]]:
         """Access a value from os.environ. Handles proxied variables, e.g. SMTP_LOGIN={{MAILGUN_LOGIN}}.
 
         Returns a tuple (envvar_key, envvar_value, proxied_key). The ``envvar_key`` will be different from
@@ -316,5 +326,5 @@ class Env:
                 return (key, self._get_from_environ(proxied_key, default, proxied=True)[1], proxied_key)
         return env_key, value, None
 
-    def _get_key(self, key: str, *, omit_prefix: bool = False) -> str:
+    def _get_key(self, key: _StrType, *, omit_prefix: _BoolType = False) -> _StrType:
         return self._prefix + key if self._prefix and not omit_prefix else key


Test patch:
diff --git a/tests/test_environs.py b/tests/test_environs.py
index 1bcf877..b57b179 100644
--- a/tests/test_environs.py
+++ b/tests/test_environs.py
@@ -282,31 +282,38 @@ class TestCustomTypes:
     def test_add_parser(self, set_env, env):
         set_env({"URL": "test.test/"})
 
-        def url(value):
+        def https_url(value):
             return "https://" + value
 
-        env.add_parser("url", url)
-        assert env.url("URL") == "https://test.test/"
+        env.add_parser("https_url", https_url)
+        assert env.https_url("URL") == "https://test.test/"
         with pytest.raises(environs.EnvError) as excinfo:
             env.url("NOT_SET")
         assert excinfo.value.args[0] == 'Environment variable "NOT_SET" not set'
 
-        assert env.url("NOT_SET", "default.test/") == "https://default.test/"
+        assert env.https_url("NOT_SET", "default.test/") == "https://default.test/"
+
+    def test_cannot_override_built_in_parser(self, set_env, env):
+        def https_url(value):
+            return "https://" + value
+
+        with pytest.raises(environs.ParserConflictError):
+            env.add_parser("url", https_url)
 
     def test_parser_for(self, set_env, env):
         set_env({"URL": "test.test/"})
 
-        @env.parser_for("url")
-        def url(value):
+        @env.parser_for("https_url")
+        def https_url(value):
             return "https://" + value
 
-        assert env.url("URL") == "https://test.test/"
+        assert env.https_url("URL") == "https://test.test/"
 
         with pytest.raises(environs.EnvError) as excinfo:
-            env.url("NOT_SET")
+            env.https_url("NOT_SET")
         assert excinfo.value.args[0] == 'Environment variable "NOT_SET" not set'
 
-        assert env.url("NOT_SET", "default.test/") == "https://default.test/"
+        assert env.https_url("NOT_SET", "default.test/") == "https://default.test/"
 
     def test_parser_function_can_take_extra_arguments(self, set_env, env):
         set_env({"ENV": "dev"})
@@ -324,17 +331,17 @@ class TestCustomTypes:
             env.enum("ENV", choices=["dev", "prod"])
 
     def test_add_parser_from_field(self, set_env, env):
-        class MyURL(fields.Field):
+        class HTTPSURL(fields.Field):
             def _deserialize(self, value, *args, **kwargs):
                 return "https://" + value
 
-        env.add_parser_from_field("url", MyURL)
+        env.add_parser_from_field("https_url", HTTPSURL)
 
         set_env({"URL": "test.test/"})
-        assert env.url("URL") == "https://test.test/"
+        assert env.https_url("URL") == "https://test.test/"
 
         with pytest.raises(environs.EnvError) as excinfo:
-            env.url("NOT_SET")
+            env.https_url("NOT_SET")
         assert excinfo.value.args[0] == 'Environment variable "NOT_SET" not set'
 
 
@@ -371,13 +378,13 @@ class TestDumping:
         assert result["LOG_LEVEL"] == logging.WARNING
 
     def test_env_with_custom_parser(self, set_env, env):
-        @env.parser_for("url")
-        def url(value):
+        @env.parser_for("https_url")
+        def https_url(value):
             return "https://" + value
 
         set_env({"URL": "test.test"})
 
-        env.url("URL")
+        env.https_url("URL")
 
         assert env.dump() == {"URL": "https://test.test"}