From 3351a3682c4bcc38d356210b8aad83021ef7ddab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20Andr=C3=A9?= Date: Mon, 8 Mar 2021 23:59:58 +0100 Subject: [PATCH] Initial commit --- .editorconfig | 24 ++++ .gitattributes | 12 ++ .gitignore | 11 ++ LICENSE | 24 ++++ docs/Makefile | 20 ++++ docs/make.bat | 35 ++++++ docs/source/Pydentic.rst | 8 ++ docs/source/conf.py | 56 ++++++++++ docs/source/index.rst | 16 +++ readme.md | 130 ++++++++++++++++++++++ setup.cfg | 86 +++++++++++++++ setup.py | 3 + src/pydentic/__init__.py | 1 + src/pydentic/core/__init__.py | 4 + src/pydentic/core/datatypes.py | 33 ++++++ src/pydentic/core/utils.py | 85 ++++++++++++++ src/pydentic/exceptions.py | 59 ++++++++++ src/pydentic/py.typed | 0 src/pydentic/strings/__init__.py | 108 ++++++++++++++++++ src/pydentic/strings/mime.py | 113 +++++++++++++++++++ src/pydentic/strings/postal_code.py | 18 +++ src/pydentic/strings/uri/__init__.py | 3 + src/pydentic/strings/uri/base.py | 158 +++++++++++++++++++++++++++ src/pydentic/strings/uri/grammar.py | 20 ++++ src/pydentic/strings/uri/network.py | 112 +++++++++++++++++++ src/pydentic/strings/uri/urn.py | 57 ++++++++++ src/pydentic/tools/__init__.py | 7 ++ src/pydentic/tools/db.py | 92 ++++++++++++++++ tests/conftest.py | 5 + tests/test_core.py | 23 ++++ tests/test_postal.py | 19 ++++ tests/test_strings.py | 21 ++++ tests/test_uris.py | 47 ++++++++ 33 files changed, 1410 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 docs/Makefile create mode 100644 docs/make.bat create mode 100644 docs/source/Pydentic.rst create mode 100644 docs/source/conf.py create mode 100644 docs/source/index.rst create mode 100644 readme.md create mode 100644 setup.cfg create mode 100644 setup.py create mode 100644 src/pydentic/__init__.py create mode 100644 src/pydentic/core/__init__.py create mode 100644 src/pydentic/core/datatypes.py create mode 100644 src/pydentic/core/utils.py create mode 100644 src/pydentic/exceptions.py create mode 100644 src/pydentic/py.typed create mode 100644 src/pydentic/strings/__init__.py create mode 100644 src/pydentic/strings/mime.py create mode 100644 src/pydentic/strings/postal_code.py create mode 100644 src/pydentic/strings/uri/__init__.py create mode 100644 src/pydentic/strings/uri/base.py create mode 100644 src/pydentic/strings/uri/grammar.py create mode 100644 src/pydentic/strings/uri/network.py create mode 100644 src/pydentic/strings/uri/urn.py create mode 100644 src/pydentic/tools/__init__.py create mode 100644 src/pydentic/tools/db.py create mode 100644 tests/conftest.py create mode 100644 tests/test_core.py create mode 100644 tests/test_postal.py create mode 100644 tests/test_strings.py create mode 100644 tests/test_uris.py diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..1e9d71f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,24 @@ +# https://editorconfig.org +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.bat] +charset = latin1 +end_of_line = crlf +indent_style = tab + +[Makefile] +indent_style = tab + +[LICENSE] +insert_final_newline = false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..45c491c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +* text=auto eol=lf + +*.py text diff=python +*.md text diff=markdown +*.bat text eol=crlf + + +tests export-ignore +.editorconfig export-ignore +.gitattributes export-ignore +.gitignore export-ignore +*.md export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a7e6464 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.pytest_cache/ +__pycache__/ +.mypy_cache/ +*.egg-info/ +**/models/ +.py[cod] +.eggs/ +build/ +*.log +data/ +dist/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7aa0e2b --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +Copyright (c) 2021, Nuno André Novo +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +* Neither the name of the nor the names of its contributors + may be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY DIRECT, +INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE +OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF +ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..9534b01 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/source/Pydentic.rst b/docs/source/Pydentic.rst new file mode 100644 index 0000000..03446be --- /dev/null +++ b/docs/source/Pydentic.rst @@ -0,0 +1,8 @@ +Pydentic +======== + +.. toctree:: + :maxdepth: 2 + +.. automodule:: pydentic + :members: diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..45541f6 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,56 @@ +# Configuration file for the Sphinx documentation builder. +# https://www.sphinx-doc.org/en/master/usage/configuration.html +import sphinx_automodapi + +# -- Path setup -------------------------------------------------------- + +# Add to sys.path the absolute paths of extensions (or modules to +# document with autodoc) that are in another directory. +import os +import sys +sys.path.insert(0, os.path.abspath('../../src')) + + +# -- Project information ----------------------------------------------- + +project = 'Pydentic' +copyright = '2021, Nuno André Novo' +author = 'Nuno André Novo' +release = '0.0.1' + +# -- General configuration --------------------------------------------- + +# 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_automodapi.automodapi'] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] +source_suffix = '.rst' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = [] + +# Cross-reference to Python objects marked up `like this` +default_role = 'py:obj' + +# For debugging: warns about not found ref targets. +# nitpicky = True + +# -- Options for HTML output ------------------------------------------- + +# The theme to use for HTML and HTML Help pages. +html_theme = 'alabaster' + +# Paths (relative to this directory) that contain custom static files. +# They are copied after the builtin static files, so a file named +# "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# -- ext.extlinks options ---------------------------------------------- + +extlinks = {'rfc': ('https://tools.ietf.org/html/rfc%s', 'rfc:')} diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..0c211b6 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,16 @@ +Welcome to Pydentic's documentation! +==================================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + Pydentic + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..8fcf199 --- /dev/null +++ b/readme.md @@ -0,0 +1,130 @@ +# Pydentic + +**_Pydentic_** is a thin wrapper over _[python-stdnum]_ to facilitate the use +of its extensive collection of validators and formatters in _[Pydantic]_ models. + +``` +pip install pydentic +``` + +## Features + +Automatic validation and formatting. + +```python +from pydentic.strings import Iban +from pydantic import BaseModel + +class User(BaseModel): + name: str + iban: Iban + +user = User(name='John Doe', iban='es1000750080110600658108') +print(user) + +#> name='John Doe' iban='ES10 0075 0080 1106 0065 8108' +``` + +```python +# note the extra last character +user = User(name='John Doe', iban='es1000750080110600658108Ñ') + +# raises +... +pydantic.error_wrappers.ValidationError: 1 validation error for User +iban + es1000750080110600658108Ñ (type=value_error.format; error=invalid literal for int() with base 36: 'Ñ') +``` + +Title and description in the JSON Schema. +```json +{ + "title": "User", + "type": "object", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "iban": { + "title": "IBAN", + "description": "International Bank Account Number", + "type": "string" + } + }, + "required": ["user", "iban"] +} +``` + +## Identifiers + +The list below contains some available common identifiers. There are around 200 +more included (see [the python-stdnum docs] for the complete list.) + +### Information and documentation + +| identifier | spec | description | +| ------------ | ---------- | ----------- | +| DOI | ISO 26324 | [Digital Object Identifier][DOI] +| GRid | | [Global Release Identifier][GRid] +| ISAN | ISO 15706 | [International Standard Audiovisual Number][ISAN] +| ISBN | ISO 2108 | [International Standard Book Number][ISBN] +| ISIL | ISO 15511 | [International Standard Identifier for Libraries][ISIL] +| ISMN | ISO 10957 | [International Standard Music Number][ISMN] for notated music +| ISSN | ISO 3297 | [International Standard Serial Number][ISSN] + +### Technology + +| identifier | spec | description | +| ------------ | ------------- | ----------- | +| IMEI | | International Mobile Equipment Identity +| IMSI | [ITU E.212] | [International Mobile Subscriber Identity][IMSI] +| MAC address | IEEE 802 | [Media Access Control address][MAC] +| MEID | 3GPP2 S.R0048 | Mobile Equipment Identifier + +### Other + +| identifier | spec | description | +| ------------ | ---------- | ----------- | +| BIC | [ISO 9362] | [Business Identifier Code][BIC] +| BIC-Code | ISO 6346 | [International standard for container identification][BIC-Code] +| Bitcoin address | | +| CAS RN | | [Chemical Abstracts Service Registry Number][CASRN] +| CUSIP number | | [financial security identification number ][CUSIP] +| EAN | | [International Article Number][EAN] +| FIGI | [OMG FIGI] | [Financial Instrument Global Identifier][FIGI] +| GS1-128 | | GS-1 (product information) using [Code 128 barcodes][C128] +| IBAN | ISO 13616 | [International Bank Account Number][IBAN] +| IMO number | | [International Maritime Organization number][IMO] +| ISIN | ISO 6166 | International Securities Identification Number +| LEI | ISO 17442 | [Legal Entity Identifier][LEI] +| | ISO 11649 | Structured Creditor Reference + +[Pydantic]: https://github.com/samuelcolvin/pydantic +[python-stdnum]: https://github.com/arthurdejong/python-stdnum +[the python-stdnum docs]: https://arthurdejong.org/python-stdnum/formats + +[BIC]: https://www.swift.com/standards/data-standards/bic-business-identifier-code "(SWIFT) Society for Worldwide Interbank Financial Telecommunication" +[BIC-Code]: https://www.bic-code.org/ "Bureau International des Containers et du Transport Intermodal" +[CASRN]: https://www.cas.org/support/documentation/chemical-substances/faqs "(CAS) Chemical Abstracts Service" +[CUSIP]: https://www.cusip.com/identifiers.html#/CUSIP "CUSIP Global Services" +[C128]: https://en.wikipedia.org/wiki/Code_128 +[DOI]: https://www.doi.org/hb.html "DOI handbook" +[EAN]: https://www.gs1.org/standards/barcodes/ean-upc "GS1 - EAN/UPC" +[FIGI]: https://www.openfigi.com/ "Open FIGI" +[GRid]: https://www.ifpi.org/resource/grid/ "(IFPI) International Federation of the Phonographic Industry. I've said \"pho-no-gra-phic\"" +[IBAN]: https://www.swift.com/standards/data-standards/iban-international-bank-account-number "(SWIFT) Society for Worldwide Interbank Financial Telecommunication" +[IMO]: https://www.imo.org/en/OurWork/MSAS/Pages/IMO-identification-number-scheme.aspx "(IMO) International Maritime Organization" +[IMSI]: https://imsiadmin.com/ +[ISAN]: https://www.isan.org/ "ISAN International Agency" +[ISBN]: https://www.isbn-international.org/content/what-isbn "International ISBN Agency" +[ISIL]: https://english.slks.dk/libraries/library-standards/isil/ "Danish Agency for Culture and Palaces (ISIL international authority)" +[ISMN]: https://www.ismn-international.org/ "International ISMN Agency" +[ISSN]: https://portal.issn.org/ "ISSN International Centre" +[LEI]: https://www.gleif.org/en/about-lei/introducing-the-legal-entity-identifier-lei "(GLEIF) Global Legal Entity Identifier Foundation" +[MAC]: https://standards.ieee.org/content/ieee-standards/en/products-services/regauth/index.html + + +[ISO 9362]: https://www.iso9362.org/isobic/overview.html +[ITU E.212]: https://www.itu.int/rec/T-REC-E.212 +[OMG FIGI]: https://www.omg.org/spec/FIGI/1.0 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..89f966e --- /dev/null +++ b/setup.cfg @@ -0,0 +1,86 @@ +[metadata] +name = pydentic +description = Pydantic Identifiers +version = attr: pydentic.__version__ +author = Nuno André +author_email = mail@nunoand.re +long_description = file: README.md +long_description_content_type = text/markdown +license = BSD-3-Clause +license_files = LICENSE +project_urls = + Source = https://github.com/nuno-andre/pydentic + Bug Tracker = https://github.com/nuno-andre/pydentic/issues +classifiers = + Development Status :: 2 - Pre-Alpha + Intended Audience :: Developers + Intended Audience :: Information Technology + Intended Audience :: System Administrators + License :: OSI Approved :: BSD License + Natural Language :: English + Operating System :: OS Independent + Programming Language :: Python + Programming Language :: Python :: 3 + Programming Language :: Python :: 3 :: Only + Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + Topic :: Software Development + Typing :: Typed +platforms = any + +[options] +zip_safe = false +python_requires = >= 3.6.1 +package_dir = + =src +packages = find: +setup_requires = + setuptools >= 40.9.0 + wheel >= 0.32 +install_requires = + python-stdnum >= 1.16 + typing-extensions >= 3.7.4.3; python_version < '3.8' +include_package_data = true + +[options.packages.find] +where = src +exclude = + tests* + +[options.extras_require] +dev = + flake8 +docs = + sphinx +test = + pytest + pydantic + +[options.package_data] +pydentic = + py.typed + +[flake8] +ignore = + E221, # multiple spaces before operator + E241, # multiple spaces after ':' +exclude = + .git, + __pycache__ +per-file-ignores = + **/_meta.py: F401, # '' imported but unused + **/__init__.py: F401 +max-complexity = 10 +max-line-length = 80 +inline-quotes = single +multiline-quotes = single +docstring-quotes = single + +[tool:pytest] +log_cli = true +log_cli_level = INFO +log_file = pytest.log +log_file_level = DEBUG +norecursedirs = src docs .git diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..0ca740b --- /dev/null +++ b/setup.py @@ -0,0 +1,3 @@ +#! /usr/bin/env python3 +from setuptools import setup +setup() diff --git a/src/pydentic/__init__.py b/src/pydentic/__init__.py new file mode 100644 index 0000000..8a978b4 --- /dev/null +++ b/src/pydentic/__init__.py @@ -0,0 +1 @@ +__version__ = 0, 0, 1, 'dev4' diff --git a/src/pydentic/core/__init__.py b/src/pydentic/core/__init__.py new file mode 100644 index 0000000..a425a7f --- /dev/null +++ b/src/pydentic/core/__init__.py @@ -0,0 +1,4 @@ +from .datatypes import RegExpString + + +__all__ = ['RegExpString'] diff --git a/src/pydentic/core/datatypes.py b/src/pydentic/core/datatypes.py new file mode 100644 index 0000000..b8f17df --- /dev/null +++ b/src/pydentic/core/datatypes.py @@ -0,0 +1,33 @@ +from typing import Type, TypeVar +import re + +from .utils import unname_groups + +T = TypeVar('T') + + +class RegExpString(str): + def __init_subclass__(cls, pattern: str): + cls._pattern = re.compile(pattern) + + def __new__(cls: Type[T], string: str, **kwargs) -> T: + try: + parsed = cls._pattern.match(string).groupdict() + except AttributeError: + raise ValueError(string) from None + + self = super().__new__(cls, string) + + # set pattern's named groups as instance attributes + for field in list(parsed): + setattr(self, field, parsed.pop(field)) + + return self + + @classmethod + def __modify_schema__(cls, field_schema) -> None: + pattern = unname_groups(cls._pattern.pattern) + field_schema.update(type='string', pattern=pattern) + + +__all__ = ['RegExpString'] diff --git a/src/pydentic/core/utils.py b/src/pydentic/core/utils.py new file mode 100644 index 0000000..ca92766 --- /dev/null +++ b/src/pydentic/core/utils.py @@ -0,0 +1,85 @@ +""" +String munging utils. +""" +from typing import Optional, List, Dict +from functools import partial +import re + + +#: Removes capture groups' name (for JSON Schema patterns) +unname_groups = partial(re.compile(r'\(\?P<\w+>').sub, '(') + + +def segment(string: str, *sizes: int, start: int = 0) -> List[str]: + '''Splits the string into the indicated number and size of pieces. + + >>> segment('0123456789ABCDEF', 3, 4, 5, 4) + ['012', '3456', '789AB', 'CDEF'] + ''' + from itertools import accumulate, tee + + a, b = tee(accumulate((start, *sizes))) + next(b, None) + return [string[slice(*ab)] for ab in zip(a, b)] + + +def parse_params( + string: Optional[str], + sep: str = ';', + whitespace: bool = False, +) -> Optional[Dict[str, str]]: + '''Parses a string of parameters and values, and normalizes + parameters to lowercase. + + Args: + whitespace: strips whitespace between params. + + >>> parse_params('a=b;C=D') + {'a': 'b', 'c': 'D'} + ''' + if not string or not string.strip(): + return None + + if whitespace: + params = filter(None, map(str.strip, string.split(sep))) + params = (p.split('=') for p in params) + else: + params = (p.split('=') for p in string.strip().split(sep)) + + return {k.lower(): v for k, v in params} + + +def slug(text: str, to_identifier: bool = False) -> str: + import unicodedata + + text = unicodedata.normalize('NFD', text) + text = text.encode('ascii', 'ignore') + text = text.decode('utf-8').casefold() + text = re.sub('[ ]+', '_', text) + text = re.sub('[^0-9a-z_-]', '', text) + + return str(text) + + +class SnakeCasing: + '''Formats text strings in snake case. + ''' + def __init__(self): + self.prep = re.compile('(.)([A-Z][a-z]+)') + self.ptrn = re.compile('([a-z0-9])([A-Z])') + + def __call__(self, text): + text = self.prep.sub(r'\1_\2', text) + return self.ptrn.sub(r'\1_\2', text).casefold() + + +to_snake = partial(SnakeCasing()) + + +def to_camel(string: str) -> str: + first, *rest = string.split('_') + return ''.join([first, *map(str.capitalize, rest)]) + + +def to_pascal(string: str) -> str: + return ''.join(map(str.capitalize, string.split('_'))) diff --git a/src/pydentic/exceptions.py b/src/pydentic/exceptions.py new file mode 100644 index 0000000..daed646 --- /dev/null +++ b/src/pydentic/exceptions.py @@ -0,0 +1,59 @@ +from typing import Optional, NoReturn + +from stdnum.exceptions import ( + InvalidChecksum, + InvalidFormat, + InvalidComponent, + InvalidLength as _InvalidLength, + ValidationError, +) + + +class PydenticError(ValueError): + '''Base validation error. + ''' + def __init__(self, value: str, error: Optional[str] = None): + if error: + self.error = error + super().__init__(value) + + +class FormatError(PydenticError): + code = 'format' + + +class InvalidLength(PydenticError): + code = 'length' + + +class ContentError(PydenticError): + code = 'content' + + +class ChecksumError(ContentError): + code = 'checksum' + + +EXCMAPPING = { + InvalidChecksum: ChecksumError, + InvalidFormat: FormatError, + InvalidComponent: ContentError, + _InvalidLength: InvalidLength, + ValidationError: PydenticError, + ValueError: PydenticError, + TypeError: PydenticError, +} + + +def reraise(e: Exception, v: str) -> NoReturn: + try: + exc = EXCMAPPING[type(e)] + except KeyError: + raise e + + try: + error = e.__context__.args[0] + except AttributeError: + error = None + + raise exc(value=v, error=error) diff --git a/src/pydentic/py.typed b/src/pydentic/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/pydentic/strings/__init__.py b/src/pydentic/strings/__init__.py new file mode 100644 index 0000000..533b3f5 --- /dev/null +++ b/src/pydentic/strings/__init__.py @@ -0,0 +1,108 @@ +from typing import Union, Optional, Callable, TypeVar, Type, Iterator, Tuple +from types import ModuleType, new_class +from importlib import import_module +from pkgutil import iter_modules +from functools import partial +from pathlib import Path +import sys +import re + +import stdnum + +from ..exceptions import reraise +# from .uri import AnyUrn + +T = TypeVar('T') + +DESCR = re.compile(r'^(.*?)\s*?\((.*?)\)') + + +class Stdnum(str): + + def __init_subclass__(cls, module: Union[str, ModuleType]): + if isinstance(module, str): + module = import_module(f'stdnum.{module}') + cls.__doc__ = module.__doc__.strip().splitlines()[0].rstrip('.') + cls._validate = lambda v: getattr(module, 'validate')(v) + cls._format = lambda v: getattr(module, 'format')(v) + # if cls.__name__ in {'Issn', 'Isbn', 'Isan'}: + # cls.urn = property(lambda s: str(AnyUrn(nid=cls.__name__.lower(), + # nss=module.compact(s)))) + + @classmethod + def __modify_schema__(cls, field_schema): + try: + title, description = DESCR.match(cls.__doc__).groups() + except AttributeError: + title, description = cls.__name__, cls.__doc__ + field_schema.update(title=title, description=description) + + @classmethod + def __get_validators__(cls) -> Iterator[Callable]: + yield cls.validate + yield cls.format + + @classmethod + def validate(cls, v: str) -> str: + try: + return cls._validate(v) + except Exception as e: + reraise(e, v) + + @classmethod + def format(cls: Type[T], v: str) -> T: + try: + return cls(cls._format(v)) + except AttributeError: + return cls(v) + except Exception as e: + reraise(e, v) + + +TERM = ('account', 'catastral', 'code', 'id', 'fiscale', + 'kimlik', 'note', 'numero', 'nummer') + +# capitalize the second term in compund words +capitalize = partial(re.compile('({})$'.format('|'.join(TERM))).sub, + lambda m: m.group(0).capitalize()) + + +# def traverse_modules(package: ModuleType) -> Tuple[Path, str, Optional[str]]: +# pkgdir, = map(Path, package.__path__) +# for _, mod, ispkg in iter_modules([pkgdir]): +# if ispkg: +# for _, modsub, _ in iter_modules([pkgdir / mod]): +# yield pkgdir, modsub, mod +# else: +# yield pkgdir, mod, None + +# for path, mod, subpkg in traverse_modules(stdnum): +# if mod in (): +# continue + + +# TODO: decouple with traverse_modules +pkgdir, = stdnum.__path__ +for _, mod, ispkg in iter_modules([pkgdir]): + if ispkg: + subpkgdir = Path(pkgdir, mod) + module = ModuleType(f'{__name__}.{mod}') + module.__path__ = [str(subpkgdir / mod)] + for _, modsub, _ in iter_modules([subpkgdir]): + name = capitalize(modsub.capitalize()) + cls = new_class(name, (Stdnum,), {'module': f'{mod}.{modsub}'}) + setattr(module, name, cls) + globals().update({mod: module}) + sys.modules[f'{__name__}.{mod}'] = module + + elif not mod.startswith('mod_') and mod not in ( + # iso9362 deprecated in favor of bic + 'exceptions', 'numdb', 'util', 'iso9362', + # algorithms + 'luhn', 'damn', 'verhoeff'): + name = mod.capitalize() + globals()[name] = new_class(name, (Stdnum,), {'module': mod}) + + +del (ispkg, iter_modules, mod, modsub, module, ModuleType, partial, Path, re, + stdnum, Stdnum, subpkgdir, capitalize, cls, name, pkgdir, TERM, new_class) diff --git a/src/pydentic/strings/mime.py b/src/pydentic/strings/mime.py new file mode 100644 index 0000000..0bcc8ca --- /dev/null +++ b/src/pydentic/strings/mime.py @@ -0,0 +1,113 @@ +from typing import NamedTuple, Optional, Dict +from enum import Enum +import re + +from ..core.utils import parse_params + +# https://tools.ietf.org/html/rfc2045#section-5.1 +# https://tools.ietf.org/html/rfc7231#section-3.1.1.1 + + +class MediaTypes(str, Enum): + application = 'application' + audio = 'audio' + chemical = 'chemical' + example = 'example' + font = 'font' + image = 'image' + message = 'message' + model = 'model' + multipart = 'multipart' + text = 'text' + video = 'video' + + +# rfc3025, rfc6839 +class MediaSuffixes(str, Enum): + ber = 'ber' + cbor = 'cbor' + cbor_seq = 'cbor-seq' + der = 'der' + fastinfoset = 'fastinfoset' + gzip = 'gzip' + json = 'json' + json_seq = 'json-seq' + jwt = 'jwt' + sqlite3 = 'sqlite3' + tlv = 'tlv' # https://en.wikipedia.org/wiki/Type-length-value + xml = 'xml' + wbxml = 'wbxml' + zip = 'zip' + + +def mime_pattern(type: Optional[str] = None) -> str: + TOKEN = r'[A-Z0-9-.]+' + + if not type: + types = '|'.join(m.value for m in MediaTypes) + type = f'{types}|x-{TOKEN}' + suffixes = '|'.join(m.value for m in MediaSuffixes) + pattern = (f'(?P{type})/' + f'(?P{TOKEN})' + fr'(\+(?P{suffixes}))?') + return f'(?i:{pattern})' + + +def content_type_pattern(type: Optional[str] = None) -> str: + OWS = r'[ \t]*' + TOKEN = r'[A-Z0-9!#$%&\'*+.^_`|~-]+' + QUOTED = r'\"(?:[^\"\\\\]|\\.)*\"' + PARAM = f';{OWS}{TOKEN}=({TOKEN}|{QUOTED})' + + return f'{mime_pattern(type)}(?P({PARAM})*)' + + +MIME = re.compile(mime_pattern()) + +CTYPE = re.compile(content_type_pattern(), re.I) + + +class MediaType(NamedTuple): + type: str + subtype: str + suffix: Optional[str] + + def __str__(self) -> str: + suffix = f'+{self.suffix}' if self.suffix else '' + return f'{self.type}/{self.subtype}{suffix}' + + @classmethod + def from_str(cls, string: str) -> 'MediaType': + try: + return cls(**MIME.match(string).groupdict()) + except AttributeError: + raise ValueError(string) + + +class ContentType(NamedTuple): + type: str + subtype: str + suffix: Optional[str] + params: Optional[Dict[str, str]] + + def __str__(self) -> str: + suffix = f'+{self.suffix}' if self.suffix else '' + m_type = f'{self.type}/{self.subtype}{suffix}' + params = [f'{k}={v}' for k, v in (self.params or {}).items()] + return ';'.join((m_type, *params)) + + @property + def media_type(self) -> MediaType: + return MediaType(type=self.type, subtype=self.subtype, suffix=self.suffix) + + @classmethod + def from_str(cls, string: str) -> 'ContentType': + try: + ctype = CTYPE.match(string).groupdict() + except AttributeError: + raise ValueError(string) + + return cls(type=ctype['type'].lower(), + subtype=ctype['subtype'].lower(), + suffix=ctype['suffix'].lower() if ctype['suffix'] else None, + params=parse_params(ctype['params'])) diff --git a/src/pydentic/strings/postal_code.py b/src/pydentic/strings/postal_code.py new file mode 100644 index 0000000..d0a714b --- /dev/null +++ b/src/pydentic/strings/postal_code.py @@ -0,0 +1,18 @@ +from ..core import RegExpString + + +class CnPostalCode( + RegExpString, + pattern=r'^(?P\d\d)(?P\d)(?P\d)(?P\d\d)$' +): + '''China Postal Code. + + Attrs: + province: province, province-equivalent municipality, or + autonomous region. + zone: postal zone within the province, municipality or + autonomous region. + prefecture: postal office within prefectures or prefecture-level + cities. + area: specific mailing area for delivery. + ''' diff --git a/src/pydentic/strings/uri/__init__.py b/src/pydentic/strings/uri/__init__.py new file mode 100644 index 0000000..00e1ac9 --- /dev/null +++ b/src/pydentic/strings/uri/__init__.py @@ -0,0 +1,3 @@ +from .base import GeoUri +from .urn import AnyUrn, IssnUrn, HandleUrn, DoiUri +from .network import StunUri, TurnUri, WebSocketUri diff --git a/src/pydentic/strings/uri/base.py b/src/pydentic/strings/uri/base.py new file mode 100644 index 0000000..76296d0 --- /dev/null +++ b/src/pydentic/strings/uri/base.py @@ -0,0 +1,158 @@ +from typing import Type, TypeVar, NamedTuple, Optional, Pattern, Dict +from os import PathLike +import logging +import re + +from ...exceptions import ContentError +from .grammar import Grammar, Rule +from ...core.utils import parse_params, unname_groups + +T = TypeVar('T') + +log = logging.getLogger(__name__) + + +def uri_pattern( + scheme: str = '[^:/?#]+', + authority: str = '[^/?#]*', + path: str = '[^?#]*', + query: str = '[^#]*', + fragment: str = '.*', +) -> str: + '''`URI syntax`__. + + __ https://tools.ietf.org/html/rfc3986#appendix-B + ''' + pattern = [f'((?P{scheme}):)?'] + if authority is not None: + pattern.append(f'(//(?P{authority}))?') + if path is not None: + pattern.append(f'(?P{path})') + if query is not None: + pattern.append(rf'(\?(?P{query}))?') + if fragment is not None: + pattern.append(f'(#(?P{fragment}))?') + return '^(?i:{})$'.format(''.join(pattern)) + + +URI = re.compile(uri_pattern()) + + +class ParsedUri(NamedTuple): + scheme: Optional[str] = None + authority: Optional[str] = None + path: str = '' + query: Optional[str] = None + fragment: Optional[str] = None + + @classmethod + def from_str(cls, string: str, pattern: Pattern = URI) -> 'ParsedUri': + try: + match = pattern.match(string).groupdict() + except AttributeError: + raise ValueError(string) from None + + if 'scheme' in match: + match['scheme'] = match['scheme'].lower() + + return cls(**match) + + def __str__(self) -> str: + # https://tools.ietf.org/html/rfc3986#section-5.3 + + # check None to make distinction between undefined components + # (missing separator) and empty components (separator is present). + result = [] + if self.scheme is not None: + result.extend([self.scheme, ':']) + if self.authority is not None: + result.extend(['//', self.authority]) + result.append(self.path) + if self.query is not None: + result.extend(['?', self.query]) + if self.fragment is not None: + result.extend(['#', self.fragment]) + return ''.join(result) + + +class AnyUri(str): + def __init_subclass__(cls, **kwargs: str): + cls._pattern = re.compile(uri_pattern(**kwargs)) + + def __new__(cls: Type[T], string: str, **kwargs) -> T: + try: + parsed = cls._pattern.match(string).groupdict() + except AttributeError: + raise ValueError(string) from None + + self = super().__new__(cls, string) + + # set pattern's non-URI named groups as instance attributes + # e.g. `nid` and `nss` in AnyUrn + for field in list(parsed): + if field not in ParsedUri._fields: + setattr(self, field, parsed.pop(field)) + if 'scheme' in parsed: + parsed['scheme'] = parsed['scheme'].lower() + self.uri = ParsedUri(**parsed) + + return self + + @classmethod + def __modify_schema__(cls, field_schema) -> None: + pattern = unname_groups(cls._pattern.pattern) + field_schema.update(type='string', pattern=pattern) + + +class SqliteUri(PathLike, AnyUri, scheme='sqlite', authority=''): + + # XXX: fspath protocol not working as SqliteUri subclasses str + # https://www.python.org/dev/peps/pep-0519/#standard-library-changes + def __fspath__(self) -> str: + if not self.uri.path: + raise OSError(':memory: database') + return self.uri.path + + +class GeoUri( + AnyUri, + scheme='geo', + authority=None, + path=(r'(?P-?\d{{1,2}}(\.\d+)?)' + r',(?P-?\d{{1,3}}(\.\d+)?)' + r'(,(?P{NUM}))?' + r'(;crs=(?-i:(?Pwsg84)))?' + r'(;u=(?P{PNUM}))?' + r'(;(?P.*?))?' + ).format_map(Grammar), + fragment=None, +): + '''`geo URI`__. + + Attributes: + lat: WGS84 latitude. Decimal degrees. + lon: WGS84 longitude. Decimal degrees. + alt: (optional) WGS84 altitude. Decimal meters above the local + reference ellipsoid. + unc: (optional) uncertainty in meters. + crs: `wgs84` is the default value and the only reference system + supported. + + __ https://tools.ietf.org/html/rfc5870 + ''' + + def __init__(self, string: str) -> None: + self.crs = self.crs or 'wsg84' + if self.params: + self.params = parse_params(self.params) + if 'crs' in self.params or 'u' in self.params: + err = ("'crs' and 'u' can only appear once, in that order, " + "and before other parameters") + raise ContentError(string, err) + + def __geo_interface__(self) -> Dict: + if self.alt is None: + coordinates = (self.lon, self.lat) + else: + coordinates = (self.lon, self.lat, self.alt) + return dict(type='Point', coordinates=coordinates) diff --git a/src/pydentic/strings/uri/grammar.py b/src/pydentic/strings/uri/grammar.py new file mode 100644 index 0000000..08b1b6a --- /dev/null +++ b/src/pydentic/strings/uri/grammar.py @@ -0,0 +1,20 @@ +from enum import Enum +import re + + +class Grammar(str, Enum): + ALPHANUM = '0-9a-zA-Z' + HEXDIG = '0-9a-fA-F' + PNUM = r'\d+(\.\d+)?' + NUM = f'-?{PNUM}' + MARK = re.compile("-_.!~*'/()") # https://tools.ietf.org/html/rfc5870#section-3.3 + PCT_ENCODED = f'%[{HEXDIG}][{HEXDIG}]' + + +class Rule(str, Enum): + HOSTPORT = r'(?P[^/?#:]+)(?:\:(?P\d+))?' + # FIXME: remove lookbehinds + # https://json-schema.org/understanding-json-schema/reference/regular_expressions.html + USERHOSTPORT = (r'(?:(?P[^@]*)@)' + r'\[?(?P(?<=\[).+(?=\])|([^:]+))\]?' + r'(?:\:(?P\d+))?') diff --git a/src/pydentic/strings/uri/network.py b/src/pydentic/strings/uri/network.py new file mode 100644 index 0000000..a36ee7f --- /dev/null +++ b/src/pydentic/strings/uri/network.py @@ -0,0 +1,112 @@ +from .base import AnyUri, Rule + + +class StunUri( + AnyUri, + scheme='stuns?', + authority=None, + path=Rule.HOSTPORT, + query=None, + fragment=None, +): + '''Session Traversal Utilities for NAT (STUN). + + Attributes: + host: STUN server. + port: + secure: + ''' + @property + def secure(self) -> bool: + return self.uri.scheme == 'stuns' + + def __init__(self, string): + if not self.port: + self.port = '5349' if self.secure else '3478' + + def __str__(self) -> str: + return f'{self.uri.scheme}:{self.host}:{self.port}' + + +class TurnUri( + AnyUri, + scheme='turns?', + authority=None, + path=Rule.HOSTPORT, + fragment=None, +): + '''Traversal Using Relays around NAT (TURN). + + Attributes: + host: + port: + transport: + secure: + ''' + @property + def secure(self) -> bool: + return self.uri.scheme == 'turns' + + def __init__(self, string): + if self.query: + self.query = {k.lower(): v for k, v in self.query.items()} + if list(self.keys()) != ['transport']: + # TODO: set in subclass params + raise ValueError("no param other than 'transport' is allowed") + self.transport = self.query['transport'] + + +# TODO +class SocksUri( + AnyUri, + scheme='socks(4|4a|5|5h)', +): + '''SOCKS protocol. + + Atributes: + remote_dns_resolution: if `True` (socks4a, socks5h) the hostname + is resolved by the SOCKS server. Otherwise (socks4, socks5), + it's locally resolved. + ''' + @property + def remote_dns_resolution(self): + return self.scheme in {'socks4a', 'socks5h'} + + +# TODO: raise ValueError if fragment in uri +class WebSocketUri( + AnyUri, + scheme='wss?', + authority=Rule.HOSTPORT, + fragment=None, +): + '''`WebSocket Protocol`__ + + Attributes: + host: + port: + secure: + resource: path and query (if any). Fragment is not allowed. + + __ https://tools.ietf.org/html/rfc6455 + ''' + def __init__(self, string): + if self.port is None: + self.port = '443' if self.secure else '80' + + @property + def secure(self) -> bool: + return self.uri.scheme == 'wss' + + @property + def resource(self) -> str: + result = self.uri.path or "/" + if self.uri.query: + result += '?' + self.uri.query + return result + + def __str__(self) -> str: + return f'{self.uri.scheme}://{self.host}:{self.port}{self.resource}' + + +__all__ = ['StunUri', 'TurnUri', 'WebSocketUri'] diff --git a/src/pydentic/strings/uri/urn.py b/src/pydentic/strings/uri/urn.py new file mode 100644 index 0000000..9cd1b46 --- /dev/null +++ b/src/pydentic/strings/uri/urn.py @@ -0,0 +1,57 @@ +from .base import AnyUri + + +def urn_path( + nid: str = '[A-Z0-9][A-Z0-9-]{1,31}', + nss: str = '[^:]*?', # TODO +): + '''`URN syntax`__. + + Args: + nid: Namespace identifier (IANA registered). + nss: Namespace-specific string. + + __ https://tools.ietf.org/html/rfc2141#section-2 + ''' + return (rf'(?i:(?P{nid})):' + rf'(?P{nss})$') + + +class AnyUrn(AnyUri, scheme='urn', authority=None, path=urn_path()): + + def __str__(self): + return f'urn:{self.nid}:{self.nss}' + + +class IssnUrn(AnyUrn, path=urn_path('issn')): + + def __url__(self): + return f'https://urn.issn.org/{self}' + + +# FIXME: this is DOI specific +HANDLEID = r'(?P10\.[1-9]\d{3}(\.\d+)?)/(?P.*)$' + + +class HandleUrn(AnyUrn, path=urn_path('hdl', HANDLEID)): + ''' + Attributes: + prefix: naming authority + suffix: unique local name + ''' + def __url__(self): + return f'http://hdl.handle.net/{self.prefix}/{self.suffix}' + + +class DoiUri(AnyUri, scheme='doi', authority=None, path=HANDLEID): + + def __str__(self): + return f'doi:{self.prefix}/{self.suffix}' + + def __url__(self): + return f'https://doi.org/{self.prefix}/{self.suffix}' + + +__all__ = ['AnyUrn', 'IssnUrn', 'HandleUrn', 'DoiUri'] + +del (urn_path, HANDLEID) diff --git a/src/pydentic/tools/__init__.py b/src/pydentic/tools/__init__.py new file mode 100644 index 0000000..1907a88 --- /dev/null +++ b/src/pydentic/tools/__init__.py @@ -0,0 +1,7 @@ +from .db import Etld + + +def update_etld_data(): + db = Etld() + db.create_tables() + db.populate() diff --git a/src/pydentic/tools/db.py b/src/pydentic/tools/db.py new file mode 100644 index 0000000..3361b43 --- /dev/null +++ b/src/pydentic/tools/db.py @@ -0,0 +1,92 @@ +from urllib.request import urlopen +from typing import Optional, Iterator, Tuple, List +from pathlib import Path +import logging +import sqlite3 + +log = logging.getLogger(__name__) + +DATADIR = Path(__file__).parents[1].joinpath('data') +DATADIR.mkdir(exist_ok=True) + + +class Db: + def __init__(self) -> None: + self.conn = sqlite3.connect(DATADIR / 'db.sqlite') + + def create_tables(self) -> None: + log.debug('Creating %s tables...', self.__class__.__name__) + self.conn.executescript(self._drop_tables) + self.conn.executescript(self._create_tables) + + def get_source( + self, + url: str, + encoding: Optional[str] = None, + ) -> Iterator[str]: + + with urlopen(url) as resp: + ctype = resp.headers.get_content_type() + if ctype == 'text/plain': + if encoding is None: + encoding = resp.headers.get_content_charset() + data = resp.read().decode(encoding or 'utf-8') + return iter(data.splitlines()) + else: + return resp.read() + + def populate(self) -> None: + log.debug('Populating %s tables...', self.__class__.__name__) + recs = self.parse_source() + self.conn.executemany(self._insert_item, recs) + self.conn.commit() + + +ETLD_URL = 'https://raw.githubusercontent.com/publicsuffix/list/master/public_suffix_list.dat' + + +class Etld(Db): + _create_tables = '''\ + create table etld ( + id integer primary key autoincrement, + tld text not null collate nocase, + etld text not null collate nocase, + registrar text + ); + create unique index etld_un on etld ( + tld, etld + ); + ''' + + _drop_tables = 'drop table if exists etld;' + + _insert_item = 'insert into etld (tld, etld, registrar) values (?, ?, ?);' + + def parse_source(self) -> List[Tuple[str, str, str]]: + recs = list() + data = self.get_source(ETLD_URL) + + for line in data: + if '===BEGIN ICANN DOMAINS===' in line: + break + + while True: + try: + line = next(data) + except StopIteration: + break + + if not line.strip(): + continue + + registrar = line.lstrip('/ ') + + for line in data: + if not line.strip(): + break + if line.startswith('//'): + continue + tld = line.split('.')[-1] + recs.append((tld, line, registrar)) + + return sorted(recs) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..833c5a5 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,5 @@ +from pathlib import Path +import sys + +basedir = Path(__file__).parents[1] +sys.path.insert(0, str(basedir / 'src')) diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..96e18c9 --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,23 @@ +import logging +import pytest + +log = logging.getLogger(__name__) +log.setLevel(logging.INFO) + + +def test_import_modules(): + import pydentic + from pydentic import strings + from pydentic.strings import ad + from pydentic.strings.ad import Nrt + + +def test_reraise(): + from pydentic.exceptions import EXCMAPPING, reraise + + for stdexc, pydexc in EXCMAPPING.items(): + with pytest.raises(pydexc): + try: + raise stdexc('error') + except Exception as e: + reraise(e, 'value') diff --git a/tests/test_postal.py b/tests/test_postal.py new file mode 100644 index 0000000..37170d7 --- /dev/null +++ b/tests/test_postal.py @@ -0,0 +1,19 @@ +import logging +import pytest + +log = logging.getLogger(__name__) + + +def test_cn(): + from pydentic.strings.postal_code import CnPostalCode + + code = CnPostalCode('025500') + assert code == '025500' + assert code.province == '02' + assert code.zone == '5' + assert code.prefecture == '5' + assert code.area == '00' + + for string in ('02550', '0255001'): + with pytest.raises(ValueError): + code = CnPostalCode(string) diff --git a/tests/test_strings.py b/tests/test_strings.py new file mode 100644 index 0000000..f0f420b --- /dev/null +++ b/tests/test_strings.py @@ -0,0 +1,21 @@ +import logging + +log = logging.getLogger(__name__) + + +# def test_urn(): +# from pydentic.strings import Isan, Isbn, Issn + +# assert Isbn('978-0-465-02656-2').urn == 'urn:isbn:9780465026562' +# isan = 'ISAN 0000-0000-3A8D-0000-Z-0000-0000-6' + + +def test_mac(): + from pydentic.strings import Mac + + for mac in ('00:11:22:dd:ee:ff', # standard format + '00-11-22-DD-EE-FF', + '1b:7749:54fd', # Cisco format + '001122:DDEEFF', # PostgreSQL format + '001.122.DDE.EFF'): + log.info(Mac(mac)) diff --git a/tests/test_uris.py b/tests/test_uris.py new file mode 100644 index 0000000..c0855f1 --- /dev/null +++ b/tests/test_uris.py @@ -0,0 +1,47 @@ +import pytest +import logging + +from pydentic.exceptions import ContentError + +log = logging.getLogger(__name__) + + +def test_geo(): + from pydentic.strings.uri import GeoUri + + geo = GeoUri('geo:48.2010,16.3695,183') + assert (geo.lat, geo.lon, geo.alt) == ('48.2010', '16.3695', '183') + assert geo.unc is None + assert geo.crs == 'wsg84' + + geo = GeoUri('geo:48.2010,16.3695,183;crs=wsg84;u=12;param=value') + + with pytest.raises(ContentError): + GeoUri('geo:48,16;crs=wsg84;u=1;crs=wsg84;u=1') + + +def test_stun(): + from pydentic.strings.uri import StunUri + + assert StunUri('stun:example.com:8000') == 'stun:example.com:8000' + assert StunUri('stun:example.com').port == '3478' + assert StunUri('stuns:example.com').port == '5349' + assert StunUri('stun:example.com').secure is False + assert StunUri('stuns:example.com').secure is True + + +def test_websocket(): + from pydentic.strings.uri import WebSocketUri + + # TODO: + # with pytest.raises(ValueError): + # # fragment not allowed + # WebSocketUri('ws://host:80/path?query#fragment') + + ws = WebSocketUri('ws://host/path?query') + assert ws.host == 'host' + assert ws.port == '80' + assert ws.resource == '/path?query' + + assert WebSocketUri('ws://host/path?query').secure is False + assert WebSocketUri('wss://host/path?query').secure is True