diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6dadb29..76916c9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,12 +8,12 @@ on: jobs: unittest: - runs-on: ubuntu-latest strategy: matrix: python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] # format: 3.7, 3.8, 3.9 platform: [ubuntu-latest, macos-latest, windows-latest] fail-fast: false + runs-on: ${{ matrix.platform }} steps: - uses: actions/checkout@v2 - name: Set up Python diff --git a/.readthedocs.yml b/.readthedocs.yml index 4e28887..a429bc8 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -5,20 +5,17 @@ build: tools: python: "3.12" jobs: + post_create_environment: + # Install poetry + # https://python-poetry.org/docs/#installing-manually + - pip install poetry post_install: - - pip install poetry==1.7.1 - - poetry config virtualenvs.create false - - poetry install + # Install dependencies with all dependencies + # VIRTUAL_ENV needs to be set manually for now. + # See https://github.com/readthedocs/readthedocs.org/pull/11152/ + - VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH poetry install --only docs sphinx: configuration: docs/conf.py -formats: all - -python: - install: - - method: pip - path: "." - -submodules: - include: all \ No newline at end of file +formats: all \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index d60389f..dc9b6b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,14 @@ ## NEXT ### Deprecated * this is the last release to support python 3.7 +### Changed +* `BoolParser` is now a subclass of `LookupParser` +### Fixed +* environment sys-hooks can now handle invalid arguments gracefully ### Internal * update formatter to ruff 0.3.0 +* unittests now automatically run on all supported platforms +* using sluth for documentation ## 1.3.0 ### Added * single-environment variable can now be given additional arguments, that are passed to the parser. diff --git a/docs/conf.py b/docs/conf.py index 5bcba53..ed1567a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -10,6 +10,7 @@ # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. +from ast import Index import os from enum import EnumMeta from importlib import import_module @@ -28,7 +29,7 @@ # 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.intersphinx", "sphinx.ext.linkcode", "sphinx.ext.autosectionlabel"] +extensions = ["sphinx.ext.intersphinx", "sphinx.ext.autosectionlabel"] intersphinx_mapping = { "python": ("https://docs.python.org/3/", None), @@ -39,93 +40,58 @@ add_module_names = False autosectionlabel_prefix_document = True -import ast -import os +extensions = ["sphinx.ext.intersphinx", "sphinx.ext.autosectionlabel"] + +intersphinx_mapping = { + "python": ("https://docs.python.org/3/", None), +} + +python_use_unqualified_type_names = True +add_module_names = False +autosectionlabel_prefix_document = True -import envolved +extensions.append("sphinx.ext.linkcode") +import os +import subprocess +from importlib.util import find_spec +from pathlib import Path -release = envolved.__version__ or "master" +from sluth import NodeWalk +release = "main" +if rtd_version := os.environ.get("READTHEDOCS_GIT_IDENTIFIER"): + release = rtd_version +else: + # try to get the current branch name + try: + release = subprocess.check_output(["git", "rev-parse", "--abbrev-ref", "HEAD"]).decode("utf-8").strip() + except Exception: + pass -# Resolve function for the linkcode extension. -def linkcode_resolve(domain, info): - def is_assignment_node(node: ast.AST, var_name: str) -> bool: - if isinstance(node, ast.Assign): - for target in node.targets: - if isinstance(target, ast.Name) and target.id == var_name: - return True - elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) and node.target.id == var_name: - return True - return False - - def get_assignment_node(node: ast.AST, var_name: str): - if is_assignment_node(node, var_name): - return node - if isinstance(node, (ast.Module, ast.If, ast.For, ast.While, ast.With)): - for child in node.body: - result = get_assignment_node(child, var_name) - if result: - return result - return None +base_url = "https://github.com/bentheiii/envolved" # The base url of the repository +root_dir = Path(find_spec(project).submodule_search_locations[0]) - def find_var_lines(parent_source, parent_start_lineno, var_name): - root = ast.parse("".join(parent_source)) - node = get_assignment_node(root, var_name) - if node: - lineno = node.lineno - end_lineno = node.end_lineno - return parent_source[lineno : end_lineno + 1], lineno + parent_start_lineno - return parent_source, parent_start_lineno - - def find_source(): - if info["module"]: - obj = import_module("envolved." + info["module"]) - else: - obj = envolved - parts = info["fullname"].split(".") - for part in parts[:-1]: - obj = getattr(obj, part) - try: - item = getattr(obj, parts[-1]) - except AttributeError: - item_name = parts[-1] - else: - if ( - isinstance(item, (str, int, float, bool, bytes, type(None), Mock)) - or isinstance(type(item), EnumMeta) - or type(item) in (object,) - ): - # the object is a variable, we search for it's declaration manually - item_name = parts[-1] - else: - while hasattr(item, "fget"): # for properties - item = item.fget - while hasattr(item, "func"): # for cached properties - item = item.func - while hasattr(item, "__func__"): # for wrappers - item = item.__func__ - while hasattr(item, "__wrapped__"): # for wrappers - item = item.__wrapped__ - obj = item - item_name = None - - fn = getsourcefile(obj) - fn = os.path.relpath(fn, start=os.path.dirname(envolved.__file__)) - source, lineno = getsourcelines(obj) - if item_name: - source, lineno = find_var_lines(source, lineno, item_name) - return fn, lineno, lineno + len(source) - 1 +def linkcode_resolve(domain, info): if domain != "py": return None try: - fn, lineno, endno = find_source() - filename = f"envolved/{fn}#L{lineno}-L{endno}" + package_file = root_dir / (info["module"].replace(".", "/") + ".py") + if not package_file.exists(): + package_file = root_dir / info["module"].replace(".", "/") / "__init__.py" + if not package_file.exists(): + raise FileNotFoundError + blob = project / Path(package_file).relative_to(root_dir) + walk = NodeWalk.from_file(package_file) + try: + decl = walk.get_last(info["fullname"]) + except KeyError: + return None except Exception as e: print(f"error getting link code {info}") print_exc() raise - return "https://github.com/bentheiii/envolved/blob/%s/%s" % (release, filename) + return f"{base_url}/blob/{release}/{blob}#L{decl.lineno}-L{decl.end_lineno}" # Add any paths that contain templates here, relative to this directory. @@ -144,7 +110,7 @@ def find_source(): html_theme = "furo" html_theme_options = { - "source_repository": "https://github.com/biocatchltd/yellowbox", + "source_repository": "https://github.com/biocatchltd/envolved", "source_branch": "master", "source_directory": "docs/", } diff --git a/docs/describing.rst b/docs/describing.rst index fac141c..fe20ebc 100644 --- a/docs/describing.rst +++ b/docs/describing.rst @@ -85,6 +85,8 @@ In some cases it is useful to exclude some EnvVars from the description. This ca Returns a nested description of the EnvVars. +.. module:: describe.flat + .. class:: FlatEnvVarsDescription A flat representation of the EnvVars description. Only single-environment variable EnvVars (or single-environment variable children of envars) will be described. @@ -104,6 +106,8 @@ In some cases it is useful to exclude some EnvVars from the description. This ca :param kwargs: Keyword arguments to pass to :func:`textwrap.wrap`. :return: A list of string lines that describe the EnvVars. +.. module:: describe.nested + .. class:: NestedEnvVarsDescription A nested representation of the EnvVars description. All EnvVars will be described. diff --git a/docs/envvar.rst b/docs/envvar.rst index 98afd22..c3f7c87 100644 --- a/docs/envvar.rst +++ b/docs/envvar.rst @@ -26,7 +26,7 @@ EnvVars .. function:: env_var(key: str, *, type: collections.abc.Callable[..., T], \ default: T | missing | discard | Factory[T] = missing, \ args: dict[str, envvar.EnvVar | InferEnvVar] = ..., \ - pos_args: collections.base.Sequence[envvar.EnvVar | InferEnvVar] = ..., \ + pos_args: collections.abc.Sequence[envvar.EnvVar | InferEnvVar] = ..., \ description: str | collections.abc.Sequence[str] | None = None,\ validators: collections.abc.Iterable[collections.abc.Callable[[T], T]] = (), \ on_partial: T | missing | as_default | discard = missing) -> envvar.SchemaEnvVar[T] diff --git a/docs/introduction.rst b/docs/introduction.rst index 0a36de1..6b571f4 100644 --- a/docs/introduction.rst +++ b/docs/introduction.rst @@ -20,15 +20,16 @@ Envolved is a python library that makes reading and parsing environment variable api_token: str use_ssl: bool - # specify an environment variable that automatically converts to a ConnectionInfo, by drawing from multiple - # environment variables - connection_info_env_var = env_var('CONNECTION_INFO', type=ConnectionInfo, args=dict( - hostname=env_var('_HOSTNAME', type=str), # note the prefix, we will look for the host name under the - # environment variable CONNECTION_INFO_HOSTNAME - port=inferred_env_var('_PORT'), # you can omit the type of the argument for many classes - api_token=env_var('_API_TOKEN', type=str, default=None), - use_ssl=env_var('_USE_SSL', type=bool, default=False) - )) + # specify an environment variable that automatically converts to a ConnectionInfo, by drawing + # from multiple environment variables + connection_info_env_var = env_var('CONNECTION_INFO_', type=ConnectionInfo, args={ + 'hostname': env_var('HOSTNAME', type=str), # note the prefix, we will look for the host + # name under the environment variable + # CONNECTION_INFO_HOSTNAME + 'port': inferred_env_var('PORT'), # you can omit the type of the argument for many classes + 'api_token': env_var('API_TOKEN', type=str, default=None), + 'use_ssl': env_var('USE_SSL', type=bool, default=False) + }) # to retrieve its value we just perform: connection_info: ConnectionInfo = connection_info_env_var.get() diff --git a/envolved/envparser.py b/envolved/envparser.py index b9a124b..b357030 100644 --- a/envolved/envparser.py +++ b/envolved/envparser.py @@ -67,24 +67,32 @@ def __init__(self): def audit_hook(self, event: str, args: Tuple[Any, ...]): # pragma: no cover if event == "os.putenv": - key, _value = args + if not args: + return + key = args[0] if isinstance(key, bytes): try: key = key.decode("ascii") except UnicodeDecodeError: return + elif not isinstance(key, str): + return lower = key.lower() with self.lock: if lower not in self.environ_case_insensitive: self.environ_case_insensitive[lower] = set() self.environ_case_insensitive[lower].add(key) elif event == "os.unsetenv": - (key,) = args + if not args: + return + key = args[0] if isinstance(key, bytes): try: key = key.decode("ascii") except UnicodeDecodeError: return + elif not isinstance(key, str): + return lower = key.lower() with self.lock: if lower in self.environ_case_insensitive: diff --git a/envolved/parsers.py b/envolved/parsers.py index 8b5574a..801f1ba 100644 --- a/envolved/parsers.py +++ b/envolved/parsers.py @@ -105,51 +105,6 @@ def parser(t: ParserInput[T]) -> Parser[T]: raise TypeError(f"cannot coerce type {t!r} to a parser") -class BoolParser: - """ - A helper to parse boolean values from text - """ - - def __init__( - self, - maps_to_true: Iterable[str] = (), - maps_to_false: Iterable[str] = (), - *, - default: Optional[bool] = None, - case_sensitive: bool = False, - ): - """ - :param maps_to_true: An iterable of string values that should evaluate to True - :param maps_to_false: An iterable of string values that should evaluate to True - :param default: The behaviour for when the value is vacant from both the true iterable and the falsish iterable. - :param case_sensitive: Whether the string values should match exactly or case-insensitivity. - """ - if not case_sensitive: - maps_to_true = map(str.lower, maps_to_true) - maps_to_false = map(str.lower, maps_to_false) - - self.truth_set = frozenset(maps_to_true) - self.false_set = frozenset(maps_to_false) - - self.case_sensitive = case_sensitive - self.default = default - - def __call__(self, x: str) -> bool: - if not self.case_sensitive: - x = x.lower() - if x in self.truth_set: - return True - if x in self.false_set: - return False - if self.default is None: - raise ValueError( - f"must evaluate to either true ({', '.join(self.truth_set)}) or" f" false ({', '.join(self.false_set)})" - ) - return self.default - - -special_parser_inputs[bool] = BoolParser(["true"], ["false"]) - E = TypeVar("E") G = TypeVar("G") @@ -388,3 +343,35 @@ def __call__(self, x: str) -> T: parser_special_superclasses[Enum] = LookupParser.case_insensitive # type: ignore[assignment] + + +class BoolParser(LookupParser[bool]): + """ + A helper to parse boolean values from text + """ + + def __init__( + self, + maps_to_true: Iterable[str] = (), + maps_to_false: Iterable[str] = (), + *, + default: Optional[bool] = None, + case_sensitive: bool = False, + ): + """ + :param maps_to_true: An iterable of string values that should evaluate to True + :param maps_to_false: An iterable of string values that should evaluate to True + :param default: The behaviour for when the value is vacant from both the true iterable and the falsish iterable. + :param case_sensitive: Whether the string values should match exactly or case-insensitivity. + """ + super().__init__( + chain( + ((x, True) for x in maps_to_true), + ((x, False) for x in maps_to_false), + ), + fallback=default if default is not None else no_fallback, + _case_sensitive=case_sensitive, + ) + + +special_parser_inputs[bool] = BoolParser(["true"], ["false"]) diff --git a/pyproject.toml b/pyproject.toml index 54db4f2..d7c5fd1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,13 +20,19 @@ typing-extensions = [ [tool.poetry.group.dev.dependencies] pytest = "*" -sphinx = {version="^7", python = ">=3.12"} -furo = {version="*", python = ">=3.12"} mypy = {version="*", python=">=3.8"} pytest-cov = "^4.1.0" ruff = {version="*", python=">=3.8"} pydantic = "^2.5.2" +[tool.poetry.group.docs] +optional = true + +[tool.poetry.group.docs.dependencies] +sphinx = {version="^7", python = ">=3.12"} +furo = {version="*", python = ">=3.12"} +sluth = {version="*", python = ">=3.12"} + [build-system] requires = ["poetry>=0.12"] build-backend = "poetry.masonry.api" diff --git a/scripts/install.sh b/scripts/install.sh index 0cc8086..20a61f8 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,4 +1,4 @@ # install poetry and the dev-dependencies of the project -python -m pip install poetry==1.5.1 +python -m pip install poetry python -m poetry update --lock python -m poetry install \ No newline at end of file