Repository: samuelcolvin/pydantic
Pull request #3349 (2021-10-24 00:19:29)

fix(PostgresDsn): Add support for multiple hosts in PostgresDsn #3337
## Change Summary

Add support for multiple hosts validation in `PostgresDsn` class.

Postgres supports multiple hosts in the DSN like: `postgres://user:pass@host1.db.net:4321,host2.db.net:6432/app`.
The change keeps old PostgreDsn working for single host as it was before and introduces new attribute `hosts` to keep track of all added hosts with validation.
I have also separated some regex to be able to reuse them in the new matching regexes. 

## Related issue number

Fixes: #3337

## Checklist

* [x] Unit tests for the changes exist
* [x] Tests pass on CI and coverage remains at 100%
* [x] Documentation reflects the changes where applicable
* [x] `changes/<pull request or issue id>-<github username>.md` file added describing change
  (see [changes/README.md](https://github.com/samuelcolvin/pydantic/blob/master/changes/README.md) for details)
* [x] My PR is ready to review, **please add a comment including the phrase "please review" to assign reviewers**


samuelcolvin: please update, also conflicts.
rglsk: Thank you for code review, please review
samuelcolvin: please update.

also, you need to run mypy before committing.
rglsk: please review

Patch:
diff --git a/.github/ISSUE_TEMPLATE/bug.md b/.github/ISSUE_TEMPLATE/bug.md
deleted file mode 100644
index b87a10e70..000000000
--- a/.github/ISSUE_TEMPLATE/bug.md
+++ /dev/null
@@ -1,32 +0,0 @@
----
-name: Bug
-about: Create a bug report to help us improve pydantic
-labels: bug
----
-
-### Checks
-
-* [ ] I added a descriptive title to this issue
-* [ ] I have searched (google, github) for similar issues and couldn't find anything
-* [ ] I have read and followed [the docs](https://pydantic-docs.helpmanual.io/) and still think this is a bug
-
-<!-- Sorry to sound so draconian, but every second saved replying to issues is time spend improving pydantic :-) -->
-
-# Bug
-
-Output of `python -c "import pydantic.utils; print(pydantic.utils.version_info())"`:
-```
-...
-```
-<!-- or if you're using pydantic prior to v1.3, manually include: OS, python version and pydantic version -->
-
-<!-- Please read the [docs](https://pydantic-docs.helpmanual.io/) and search through issues to
-confirm your bug hasn't already been reported. -->
-
-<!-- Where possible please include a self-contained code snippet describing your bug: -->
-
-```py
-import pydantic
-
-...
-```
diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml
new file mode 100644
index 000000000..a5e9350a0
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug.yml
@@ -0,0 +1,92 @@
+name: 🐛 Bug
+description: Report a bug or unexpected behavior in pydantic
+labels: [bug, unconfirmed]
+
+body:
+  - type: markdown
+    attributes:
+      value:  Thank you for contributing to pydantic! ✊
+
+  - type: checkboxes
+    id: checks
+    attributes:
+      label: Initial Checks
+      description: |
+        Just a few checks to make sure you need to create a bug report.
+        
+        _Sorry to sound so draconian 👿; but every second spent replying to issues is time not spent improving pydantic 🙇._
+      options:
+        - label: I have searched GitHub for a duplicate issue and I'm sure this is something new
+          required: true
+        - label: I have searched Google & StackOverflow for a solution and couldn't find anything
+          required: true
+        - label: I have read and followed [the docs](https://pydantic-docs.helpmanual.io) and still think this is a bug
+          required: true
+        - label: >
+            I am confident that the issue is with pydantic 
+            (not my code, or another library in the ecosystem like [FastAPI](https://fastapi.tiangolo.com) or
+            [mypy](https://mypy.readthedocs.io/en/stable))
+          required: true
+
+  - type: textarea
+    id: description
+    attributes:
+      label: Description
+      description: |
+        Please explain what you're seeing and what you would expect to see.
+
+        Please provide as much detail as possible to make understanding and solving your problem as quick as possible. 🙏
+    validations:
+      required: true
+
+  - type: textarea
+    id: example
+    attributes:
+      label: Example Code
+      description: >
+        If applicable, please add a self-contained,
+        [minimal, reproducible, example](https://stackoverflow.com/help/minimal-reproducible-example)
+        demonstrating the bug.
+
+      placeholder: |
+        import pydantic
+
+        ...
+      render: Python
+
+  - type: textarea
+    id: version
+    attributes:
+      label: Python, Pydantic & OS Version
+      description: |
+        Which version of Python & Pydantic are you using, and which Operating System?
+        
+        Please run the following command and copy the output below:
+
+        ```bash
+        python -c "import pydantic.utils; print(pydantic.utils.version_info())"
+        ```
+
+      render: Text
+    validations:
+      required: true
+
+  - type: checkboxes
+    id: affected-components
+    attributes:
+      label: Affected Components
+      description: Which of the following parts of pydantic does this bug affect?
+      # keep this lis in sync with feature_request.yml
+      options:
+        - label: '[Compatibility between releases](https://pydantic-docs.helpmanual.io/changelog/)'
+        - label: '[Data validation/parsing](https://pydantic-docs.helpmanual.io/usage/models/#basic-model-usage)'
+        - label: '[Data serialization](https://pydantic-docs.helpmanual.io/usage/exporting_models/) - `.dict()` and `.json()`'
+        - label: '[JSON Schema](https://pydantic-docs.helpmanual.io/usage/schema/)'
+        - label: '[Dataclasses](https://pydantic-docs.helpmanual.io/usage/dataclasses/)'
+        - label: '[Model Config](https://pydantic-docs.helpmanual.io/usage/model_config/)'
+        - label: '[Field Types](https://pydantic-docs.helpmanual.io/usage/types/) - adding or changing a particular data type'
+        - label: '[Function validation decorator](https://pydantic-docs.helpmanual.io/usage/validation_decorator/)'
+        - label: '[Generic Models](https://pydantic-docs.helpmanual.io/usage/models/#generic-models)'
+        - label: '[Other Model behaviour](https://pydantic-docs.helpmanual.io/usage/models/) - `construct()`, pickling, private attributes, ORM mode'
+        - label: '[Settings Management](https://pydantic-docs.helpmanual.io/usage/settings/)'
+        - label: '[Plugins](https://pydantic-docs.helpmanual.io/) and integration with other tools - mypy, FastAPI, python-devtools, Hypothesis, VS Code, PyCharm, etc.'
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
index bf1982bfa..f498e4e2b 100644
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -1,11 +1,5 @@
 blank_issues_enabled: true
 contact_links:
-- name: Question
-  url: 'https://github.com/samuelcolvin/pydantic/discussions/new?category=question'
-  about: Ask a question about how to use pydantic using github discussions
-
-- name: Feature Request
-  url: 'https://github.com/samuelcolvin/pydantic/discussions/new?category=feature-request'
-  about: >
-    If you think we should add a new feature to pydantic, please start a discussion, once it attracts
-    wider support, it can be migrated to an issue
+  - name: 🤔 Ask a Question
+    url: 'https://github.com/samuelcolvin/pydantic/discussions/new?category=question'
+    about: Ask a question about how to use pydantic using github discussions
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644
index 000000000..26273cbf4
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -0,0 +1,56 @@
+name: 🚀 Feature request
+description: Suggest a new feature or change to pydantic
+labels: [feature request]
+
+body:
+  - type: markdown
+    attributes:
+      value: Thank you for contributing to pydantic! ✊
+
+  - type: checkboxes
+    id: searched
+    attributes:
+      label: Initial Checks
+      description: |
+        Just a few checks to make sure you need to create a feature request.
+        
+        _Sorry to sound so draconian 👿; but every second spent replying to issues is time not spent improving pydantic 🙇._
+      options:
+        - label: I have searched Google & GitHub for similar requests and couldn't find anything
+          required: true
+        - label: I have read and followed [the docs](https://pydantic-docs.helpmanual.io) and still think this feature is missing
+          required: true
+
+  - type: textarea
+    id: description
+    attributes:
+      label: Description
+      description: |
+        Please give as much detail as possible about the feature you would like to suggest. 🙏
+        
+        You might like to add:
+        * A demo of how code might look when using the feature
+        * Your use case(s) for the feature
+        * Why the feature should be added to pydantic (as opposed to another library or just implemented in your code)
+    validations:
+      required: true
+
+  - type: checkboxes
+    id: affected-components
+    attributes:
+      label: Affected Components
+      description: Which of the following parts of pydantic does this feature affect?
+      # keep this lis in sync with bug.yml
+      options:
+        - label: '[Compatibility between releases](https://pydantic-docs.helpmanual.io/changelog/)'
+        - label: '[Data validation/parsing](https://pydantic-docs.helpmanual.io/usage/models/#basic-model-usage)'
+        - label: '[Data serialization](https://pydantic-docs.helpmanual.io/usage/exporting_models/) - `.dict()` and `.json()`'
+        - label: '[JSON Schema](https://pydantic-docs.helpmanual.io/usage/schema/)'
+        - label: '[Dataclasses](https://pydantic-docs.helpmanual.io/usage/dataclasses/)'
+        - label: '[Model Config](https://pydantic-docs.helpmanual.io/usage/model_config/)'
+        - label: '[Field Types](https://pydantic-docs.helpmanual.io/usage/types/) - adding or changing a particular data type'
+        - label: '[Function validation decorator](https://pydantic-docs.helpmanual.io/usage/validation_decorator/)'
+        - label: '[Generic Models](https://pydantic-docs.helpmanual.io/usage/models/#generic-models)'
+        - label: '[Other Model behaviour](https://pydantic-docs.helpmanual.io/usage/models/) - `construct()`, pickling, private attributes, ORM mode'
+        - label: '[Settings Management](https://pydantic-docs.helpmanual.io/usage/settings/)'
+        - label: '[Plugins](https://pydantic-docs.helpmanual.io/) and integration with other tools - mypy, FastAPI, python-devtools, Hypothesis, VS Code, PyCharm, etc.'
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 998af3f76..508efd759 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -1,6 +1,7 @@
 <!-- Thank you for your contribution! -->
 <!-- Unless your change is trivial, please create an issue to discuss the change before creating a PR -->
 <!-- See https://pydantic-docs.helpmanual.io/contributing/ for help on Contributing -->
+<!-- PLEASE DO **NOT** put issue ids in the PR title! Instead, add a descriptive title and put ids in the body -->
 
 ## Change Summary
 
diff --git a/.gitignore b/.gitignore
index 55c223952..766bf1e03 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,3 +32,4 @@ pydantic/*.so
 /.ghtopdep_cache/
 /fastapi/
 /codecov.sh
+/worktrees/
diff --git a/changes/3112-ojii.md b/changes/3112-ojii.md
new file mode 100644
index 000000000..a4c6e144d
--- /dev/null
+++ b/changes/3112-ojii.md
@@ -0,0 +1,1 @@
+Catch overflow errors in `int_validator`
\ No newline at end of file
diff --git a/changes/3315-samuelcolvin.md b/changes/3315-samuelcolvin.md
new file mode 100644
index 000000000..47f38c5b6
--- /dev/null
+++ b/changes/3315-samuelcolvin.md
@@ -0,0 +1,1 @@
+Fix parsing of very small numeric timedelta values
diff --git a/changes/3337-rglsk.md b/changes/3337-rglsk.md
new file mode 100644
index 000000000..c0f2da372
--- /dev/null
+++ b/changes/3337-rglsk.md
@@ -0,0 +1,1 @@
+Support multi hosts validation in `PostgresDsn`.
\ No newline at end of file
diff --git a/docs/usage/types.md b/docs/usage/types.md
index 15bb9f9cb..4e6ad2068 100644
--- a/docs/usage/types.md
+++ b/docs/usage/types.md
@@ -641,7 +641,10 @@ For URI/URL validation the following types are available:
 - `AnyHttpUrl`: scheme `http` or `https`, TLD not required, host required
 - `HttpUrl`: scheme `http` or `https`, TLD required, host required, max length 2083
 - `FileUrl`: scheme `file`, host not required
-- `PostgresDsn`: scheme `postgres`, `postgresql`, user info required, TLD not required, host required. Also, its supported DBAPI dialects:
+- `PostgresDsn`: user info required, TLD not required, host required,
+  as of V.10 `PostgresDsn` supports multiple hosts. The following schemes are supported:
+  - `postgres`
+  - `postgresql`
   - `postgresql+asyncpg`
   - `postgresql+pg8000`
   - `postgresql+psycopg2`
@@ -650,7 +653,7 @@ For URI/URL validation the following types are available:
   - `postgresql+pygresql`
 - `AmqpDsn`: schema `amqp` or `amqps`, user info not required, TLD not required, host not required
 - `RedisDsn`: scheme `redis` or `rediss`, user info not required, tld not required, host not required (CHANGED: user info
-- `MongoDsn` : scheme `mongodb`, user info not required, database name not required, port not required  
+- `MongoDsn` : scheme `mongodb`, user info not required, database name not required, port
   not required from **v1.6** onwards), user info may be passed without user part (e.g., `rediss://:pass@localhost`)
 - `stricturl`: method with the following keyword arguments:
     - `strip_whitespace: bool = True`
diff --git a/pydantic/datetime_parse.py b/pydantic/datetime_parse.py
index 024a899d0..b642f17bb 100644
--- a/pydantic/datetime_parse.py
+++ b/pydantic/datetime_parse.py
@@ -223,7 +223,7 @@ def parse_duration(value: StrBytesIntFloat) -> timedelta:
 
     if isinstance(value, (int, float)):
         # below code requires a string
-        value = str(value)
+        value = f'{value:f}'
     elif isinstance(value, bytes):
         value = value.decode()
 
diff --git a/pydantic/networks.py b/pydantic/networks.py
index 71396f852..4bab18856 100644
--- a/pydantic/networks.py
+++ b/pydantic/networks.py
@@ -15,6 +15,8 @@ from typing import (
     Collection,
     Dict,
     Generator,
+    List,
+    Match,
     Optional,
     Pattern,
     Set,
@@ -51,6 +53,13 @@ if TYPE_CHECKING:
         query: Optional[str]
         fragment: Optional[str]
 
+    class HostParts(TypedDict, total=False):
+        host: str
+        tld: Optional[str]
+        host_type: Optional[str]
+        port: Optional[str]
+        rebuild: bool
+
 else:
     email_validator = None
 
@@ -80,30 +89,54 @@ __all__ = [
 ]
 
 _url_regex_cache = None
+_multi_host_url_regex_cache = None
 _ascii_domain_regex_cache = None
 _int_domain_regex_cache = None
+_host_regex_cache = None
+
+_host_regex = (
+    r'(?:'
+    r'(?P<ipv4>(?:\d{1,3}\.){3}\d{1,3})(?=$|[/:#?])|'  # ipv4
+    r'(?P<ipv6>\[[A-F0-9]*:[A-F0-9:]+\])(?=$|[/:#?])|'  # ipv6
+    r'(?P<domain>[^\s/:?#]+)'  # domain, validation occurs later
+    r')?'
+    r'(?::(?P<port>\d+))?'  # port
+)
+_scheme_regex = r'(?:(?P<scheme>[a-z][a-z0-9+\-.]+)://)?'  # scheme https://tools.ietf.org/html/rfc3986#appendix-A
+_user_info_regex = r'(?:(?P<user>[^\s:/]*)(?::(?P<password>[^\s/]*))?@)?'
+_path_regex = r'(?P<path>/[^\s?#]*)?'
+_query_regex = r'(?:\?(?P<query>[^\s#]*))?'
+_fragment_regex = r'(?:#(?P<fragment>[^\s#]*))?'
 
 
 def url_regex() -> Pattern[str]:
     global _url_regex_cache
     if _url_regex_cache is None:
         _url_regex_cache = re.compile(
-            r'(?:(?P<scheme>[a-z][a-z0-9+\-.]+)://)?'  # scheme https://tools.ietf.org/html/rfc3986#appendix-A
-            r'(?:(?P<user>[^\s:/]*)(?::(?P<password>[^\s/]*))?@)?'  # user info
-            r'(?:'
-            r'(?P<ipv4>(?:\d{1,3}\.){3}\d{1,3})(?=$|[/:#?])|'  # ipv4
-            r'(?P<ipv6>\[[A-F0-9]*:[A-F0-9:]+\])(?=$|[/:#?])|'  # ipv6
-            r'(?P<domain>[^\s/:?#]+)'  # domain, validation occurs later
-            r')?'
-            r'(?::(?P<port>\d+))?'  # port
-            r'(?P<path>/[^\s?#]*)?'  # path
-            r'(?:\?(?P<query>[^\s#]*))?'  # query
-            r'(?:#(?P<fragment>[^\s#]*))?',  # fragment
+            rf'{_scheme_regex}{_user_info_regex}{_host_regex}{_path_regex}{_query_regex}{_fragment_regex}',
             re.IGNORECASE,
         )
     return _url_regex_cache
 
 
+def multi_host_url_regex() -> Pattern[str]:
+    """
+    Compiled multi host url regex.
+
+    Additionally to `url_regex` it allows to match multiple hosts.
+    E.g. host1.db.net,host2.db.net
+    """
+    global _multi_host_url_regex_cache
+    if _multi_host_url_regex_cache is None:
+        _multi_host_url_regex_cache = re.compile(
+            rf'{_scheme_regex}{_user_info_regex}'
+            r'(?P<hosts>([^/]*))'  # validation occurs later
+            rf'{_path_regex}{_query_regex}{_fragment_regex}',
+            re.IGNORECASE,
+        )
+    return _multi_host_url_regex_cache
+
+
 def ascii_domain_regex() -> Pattern[str]:
     global _ascii_domain_regex_cache
     if _ascii_domain_regex_cache is None:
@@ -124,6 +157,16 @@ def int_domain_regex() -> Pattern[str]:
     return _int_domain_regex_cache
 
 
+def host_regex() -> Pattern[str]:
+    global _host_regex_cache
+    if _host_regex_cache is None:
+        _host_regex_cache = re.compile(
+            _host_regex,
+            re.IGNORECASE,
+        )
+    return _host_regex_cache
+
+
 class AnyUrl(str):
     strip_whitespace = True
     min_length = 1
@@ -228,7 +271,7 @@ class AnyUrl(str):
             value = value.strip()
         url: str = cast(str, constr_length_validator(value, field, config))
 
-        m = url_regex().match(url)
+        m = cls._match_url(url)
         # the regex should always match, if it doesn't please report with details of the URL tried
         assert m, 'URL regex failed unexpectedly'
 
@@ -236,11 +279,19 @@ class AnyUrl(str):
         parts = cls.apply_default_parts(original_parts)
         parts = cls.validate_parts(parts)
 
-        host, tld, host_type, rebuild = cls.validate_host(parts)
-
         if m.end() != len(url):
             raise errors.UrlExtraError(extra=url[m.end() :])
 
+        return cls._build_url(m, url, parts)
+
+    @classmethod
+    def _build_url(cls, m: Match[str], url: str, parts: 'Parts') -> 'AnyUrl':
+        """
+        Validate hosts and build the AnyUrl object. Split from `validate` so this method
+        can be altered in `MultiHostDsn`.
+        """
+        host, tld, host_type, rebuild = cls.validate_host(parts)
+
         return cls(
             None if rebuild else url,
             scheme=parts['scheme'],
@@ -255,10 +306,19 @@ class AnyUrl(str):
             fragment=parts['fragment'],
         )
 
+    @staticmethod
+    def _match_url(url: str) -> Optional[Match[str]]:
+        return url_regex().match(url)
+
+    @staticmethod
+    def _validate_port(port: Optional[str]) -> None:
+        if port is not None and int(port) > 65_535:
+            raise errors.UrlPortError()
+
     @classmethod
-    def validate_parts(cls, parts: 'Parts') -> 'Parts':
+    def validate_parts(cls, parts: 'Parts', validate_port: bool = True) -> 'Parts':
         """
-        A method used to validate parts of an URL.
+        A method used to validate parts of a URL.
         Could be overridden to set default values for parts if missing
         """
         scheme = parts['scheme']
@@ -268,9 +328,8 @@ class AnyUrl(str):
         if cls.allowed_schemes and scheme.lower() not in cls.allowed_schemes:
             raise errors.UrlSchemePermittedError(set(cls.allowed_schemes))
 
-        port = parts['port']
-        if port is not None and int(port) > 65_535:
-            raise errors.UrlPortError()
+        if validate_port:
+            cls._validate_port(parts['port'])
 
         user = parts['user']
         if cls.user_required and user is None:
@@ -280,7 +339,7 @@ class AnyUrl(str):
 
     @classmethod
     def validate_host(cls, parts: 'Parts') -> Tuple[str, Optional[str], str, bool]:
-        host, tld, host_type, rebuild = None, None, None, False
+        tld, host_type, rebuild = None, None, False
         for f in ('domain', 'ipv4', 'ipv6'):
             host = parts[f]  # type: ignore[literal-required]
             if host:
@@ -360,7 +419,71 @@ class FileUrl(AnyUrl):
     __slots__ = ()
 
 
-class PostgresDsn(AnyUrl):
+class MultiHostDsn(AnyUrl):
+    __slots__ = AnyUrl.__slots__ + ('hosts',)
+
+    def __init__(self, *args: Any, hosts: Optional[List['HostParts']] = None, **kwargs: Any):
+        super().__init__(*args, **kwargs)
+        self.hosts = hosts
+
+    @staticmethod
+    def _match_url(url: str) -> Optional[Match[str]]:
+        return multi_host_url_regex().match(url)
+
+    @classmethod
+    def validate_parts(cls, parts: 'Parts', validate_port: bool = True) -> 'Parts':
+        return super().validate_parts(parts, validate_port=False)
+
+    @classmethod
+    def _build_url(cls, m: Match[str], url: str, parts: 'Parts') -> 'MultiHostDsn':
+        hosts_parts: List['HostParts'] = []
+        host_re = host_regex()
+        for host in m.groupdict()['hosts'].split(','):
+            d: Parts = host_re.match(host).groupdict()  # type: ignore
+            host, tld, host_type, rebuild = cls.validate_host(d)
+            port = d.get('port')
+            cls._validate_port(port)
+            hosts_parts.append(
+                {
+                    'host': host,
+                    'host_type': host_type,
+                    'tld': tld,
+                    'rebuild': rebuild,
+                    'port': port,
+                }
+            )
+
+        if len(hosts_parts) > 1:
+            return cls(
+                None if any([hp['rebuild'] for hp in hosts_parts]) else url,
+                scheme=parts['scheme'],
+                user=parts['user'],
+                password=parts['password'],
+                path=parts['path'],
+                query=parts['query'],
+                fragment=parts['fragment'],
+                host_type=None,
+                hosts=hosts_parts,
+            )
+        else:
+            # backwards compatibility with single host
+            host_part = hosts_parts[0]
+            return cls(
+                None if host_part['rebuild'] else url,
+                scheme=parts['scheme'],
+                user=parts['user'],
+                password=parts['password'],
+                host=host_part['host'],
+                tld=host_part['tld'],
+                host_type=host_part['host_type'],
+                port=host_part.get('port'),
+                path=parts['path'],
+                query=parts['query'],
+                fragment=parts['fragment'],
+            )
+
+
+class PostgresDsn(MultiHostDsn):
     allowed_schemes = {
         'postgres',
         'postgresql',
diff --git a/pydantic/validators.py b/pydantic/validators.py
index 2c6565802..897d1bb4a 100644
--- a/pydantic/validators.py
+++ b/pydantic/validators.py
@@ -125,7 +125,7 @@ def int_validator(v: Any) -> int:
 
     try:
         return int(v)
-    except (TypeError, ValueError):
+    except (TypeError, ValueError, OverflowError):
         raise errors.IntegerError()
 
 


Test patch:
diff --git a/tests/test_datetime_parse.py b/tests/test_datetime_parse.py
index f714d6667..004b161c0 100644
--- a/tests/test_datetime_parse.py
+++ b/tests/test_datetime_parse.py
@@ -175,6 +175,7 @@ def test_parse_python_format(delta):
         ('30', timedelta(seconds=30)),
         (30, timedelta(seconds=30)),
         (30.1, timedelta(seconds=30, milliseconds=100)),
+        (9.9e-05, timedelta(microseconds=99)),
         # minutes seconds
         ('15:30', timedelta(minutes=15, seconds=30)),
         ('5:30', timedelta(minutes=5, seconds=30)),
diff --git a/tests/test_networks.py b/tests/test_networks.py
index a1b1702e9..851d39b45 100644
--- a/tests/test_networks.py
+++ b/tests/test_networks.py
@@ -419,31 +419,92 @@ def test_http_urls_default_port(url, port):
     assert m.v == url
 
 
-def test_postgres_dsns():
+@pytest.mark.parametrize(
+    'dsn',
+    [
+        'postgres://user:pass@localhost:5432/app',
+        'postgresql://user:pass@localhost:5432/app',
+        'postgresql+asyncpg://user:pass@localhost:5432/app',
+        'postgres://user:pass@host1.db.net,host2.db.net:6432/app',
+    ],
+)
+def test_postgres_dsns(dsn):
     class Model(BaseModel):
         a: PostgresDsn
 
-    assert Model(a='postgres://user:pass@localhost:5432/app').a == 'postgres://user:pass@localhost:5432/app'
-    assert Model(a='postgresql://user:pass@localhost:5432/app').a == 'postgresql://user:pass@localhost:5432/app'
-    assert (
-        Model(a='postgresql+asyncpg://user:pass@localhost:5432/app').a
-        == 'postgresql+asyncpg://user:pass@localhost:5432/app'
-    )
+    assert Model(a=dsn).a == dsn
 
-    with pytest.raises(ValidationError) as exc_info:
-        Model(a='http://example.org')
-    assert exc_info.value.errors()[0]['type'] == 'value_error.url.scheme'
-    assert exc_info.value.json().startswith('[')
 
-    with pytest.raises(ValidationError) as exc_info:
-        Model(a='postgres://localhost:5432/app')
-    error = exc_info.value.errors()[0]
-    assert error == {'loc': ('a',), 'msg': 'userinfo required in URL but missing', 'type': 'value_error.url.userinfo'}
+@pytest.mark.parametrize(
+    'dsn,error_message',
+    (
+        (
+            'postgres://user:pass@host1.db.net:4321,/foo/bar:5432/app',
+            {'loc': ('a',), 'msg': 'URL host invalid', 'type': 'value_error.url.host'},
+        ),
+        (
+            'postgres://user:pass@host1.db.net,/app',
+            {'loc': ('a',), 'msg': 'URL host invalid', 'type': 'value_error.url.host'},
+        ),
+        (
+            'postgres://user:pass@/foo/bar:5432,host1.db.net:4321/app',
+            {'loc': ('a',), 'msg': 'URL host invalid', 'type': 'value_error.url.host'},
+        ),
+        (
+            'postgres://localhost:5432/app',
+            {'loc': ('a',), 'msg': 'userinfo required in URL but missing', 'type': 'value_error.url.userinfo'},
+        ),
+        (
+            'postgres://user@/foo/bar:5432/app',
+            {'loc': ('a',), 'msg': 'URL host invalid', 'type': 'value_error.url.host'},
+        ),
+        (
+            'http://example.org',
+            {
+                'loc': ('a',),
+                'msg': 'URL scheme not permitted',
+                'type': 'value_error.url.scheme',
+                'ctx': {'allowed_schemes': PostgresDsn.allowed_schemes},
+            },
+        ),
+    ),
+)
+def test_postgres_dsns_validation_error(dsn, error_message):
+    class Model(BaseModel):
+        a: PostgresDsn
 
     with pytest.raises(ValidationError) as exc_info:
-        Model(a='postgres://user@/foo/bar:5432/app')
+        Model(a=dsn)
     error = exc_info.value.errors()[0]
-    assert error == {'loc': ('a',), 'msg': 'URL host invalid', 'type': 'value_error.url.host'}
+    assert error == error_message
+
+
+def test_multihost_postgres_dsns():
+    class Model(BaseModel):
+        a: PostgresDsn
+
+    any_multihost_url = Model(a='postgres://user:pass@host1.db.net:4321,host2.db.net:6432/app').a
+    assert any_multihost_url == 'postgres://user:pass@host1.db.net:4321,host2.db.net:6432/app'
+    assert any_multihost_url.scheme == 'postgres'
+    assert any_multihost_url.host is None
+    assert any_multihost_url.host_type is None
+    assert any_multihost_url.tld is None
+    assert any_multihost_url.port is None
+    assert any_multihost_url.path == '/app'
+    assert any_multihost_url.hosts == [
+        {'host': 'host1.db.net', 'port': '4321', 'tld': 'net', 'host_type': 'domain', 'rebuild': False},
+        {'host': 'host2.db.net', 'port': '6432', 'tld': 'net', 'host_type': 'domain', 'rebuild': False},
+    ]
+
+    any_multihost_url = Model(a='postgres://user:pass@host.db.net:4321/app').a
+    assert any_multihost_url.scheme == 'postgres'
+    assert any_multihost_url == 'postgres://user:pass@host.db.net:4321/app'
+    assert any_multihost_url.host == 'host.db.net'
+    assert any_multihost_url.host_type == 'domain'
+    assert any_multihost_url.tld == 'net'
+    assert any_multihost_url.port == '4321'
+    assert any_multihost_url.path == '/app'
+    assert any_multihost_url.hosts is None
 
 
 def test_amqp_dsns():
diff --git a/tests/test_validators.py b/tests/test_validators.py
index f7d7016f1..e7007ad2a 100644
--- a/tests/test_validators.py
+++ b/tests/test_validators.py
@@ -43,6 +43,18 @@ def test_int_validation():
     assert Model(a=4.5).a == 4
 
 
+@pytest.mark.parametrize('value', [2.2250738585072011e308, float('nan'), float('inf')])
+def test_int_overflow_validation(value):
+    class Model(BaseModel):
+        a: int
+
+    with pytest.raises(ValidationError) as exc_info:
+        Model(a=value)
+    assert exc_info.value.errors() == [
+        {'loc': ('a',), 'msg': 'value is not a valid integer', 'type': 'type_error.integer'}
+    ]
+
+
 def test_frozenset_validation():
     class Model(BaseModel):
         a: frozenset