Skip to content

Commit

Permalink
Fixes (#14)
Browse files Browse the repository at this point in the history
* rtd

* .

* maybe

* .

* .

---------

Co-authored-by: Ben Avrahami <[email protected]>
  • Loading branch information
bentheiii and Ben Avrahami authored Mar 2, 2024
1 parent 789b99d commit 1d5da8e
Show file tree
Hide file tree
Showing 11 changed files with 124 additions and 149 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 9 additions & 12 deletions .readthedocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
formats: all
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
118 changes: 42 additions & 76 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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),
Expand All @@ -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.
Expand All @@ -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/",
}
Expand Down
4 changes: 4 additions & 0 deletions docs/describing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/envvar.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
19 changes: 10 additions & 9 deletions docs/introduction.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
12 changes: 10 additions & 2 deletions envolved/envparser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
77 changes: 32 additions & 45 deletions envolved/parsers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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"])
Loading

0 comments on commit 1d5da8e

Please sign in to comment.