Skip to content

Commit

Permalink
preparing 0.1.5 release
Browse files Browse the repository at this point in the history
  • Loading branch information
arthexis committed Feb 7, 2023
1 parent e27deec commit 9323ec4
Show file tree
Hide file tree
Showing 9 changed files with 141 additions and 42 deletions.
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"restructuredtext.confPath": "",
"python.linting.enabled": false
"python.linting.enabled": false,
"python.analysis.typeCheckingMode": "basic"
}
22 changes: 20 additions & 2 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ They can be useful for generating forms, URLs, SQL, etc. specially for rapid pro

This library includes tools to extract, resolve, replace and define sigils.

It works with Django OOTB, but it can be used in any Python project.


.. _Documentation:

Expand Down Expand Up @@ -198,6 +200,22 @@ This may also be useful for debugging and logging. For example:
assert sigils == ["[USER]"]
Environment Variables
---------------------

The *resolve* function can also replace environment variables by using
the SYS.ENV sigil. For example:

.. code-block:: python
import os
from sigils import resolve
os.environ["MY_VAR"] = "value"
assert resolve("[SYS.ENV.MY_VAR]") == "value"
Django Integration
------------------

Expand Down Expand Up @@ -262,8 +280,8 @@ Features Roadmap
- [X] Add 'sigil' project script to pyproject.toml.
- [ ] Improved built-in support for Django models.
- [X] Improved access to environment variables within SYS context.
- [ ] Support for custom gobal-level context functions (probably via a decorator).
- [ ] Support for list indexing and slicing.
- [ ] Support for custom global-level context functions (probably via a decorator).
- [X] Support for list indexing and slicing.
- [ ] Ability to monkey-patch sigil functionality into existing classes.
- [ ] Ability to load context from a JSON, YAML, or TOML file.
- [ ] Consider additional OOTB operations: XPATH, REGEX, etc.
Expand Down
6 changes: 4 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "sigils"
version = "0.1.4"
version = "0.1.5"
authors = [
{name = "Rafael Jesús Guillén Osorio", email = "arthexis@gmail.com"},
]
Expand All @@ -31,8 +31,10 @@ dependencies = [
'lru-dict'
]

[urls]
[project.urls]
Source = "https://github.com/arthexis/sigils"
Bug-Tracker = "https://github.com/arthexis/sigils/issues"


[project.optional-dependencies]
django = ["Django>=3.2"]
Expand Down
3 changes: 0 additions & 3 deletions sigils/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,6 @@ def main():
if sys.argv[2:]:
kwargs = dict(arg.split("=") for arg in sys.argv[2:] if "=" in arg)
kwargs = {key.upper(): value for key, value in kwargs.items()}
args = [arg for arg in sys.argv[2:] if "=" not in arg]
if args:
kwargs["ARGS"] = args
else:
kwargs = {}

Expand Down
33 changes: 26 additions & 7 deletions sigils/contexts.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import collections
import contextlib
import threading
from typing import Generator
from typing import Generator, Optional, Any

from lru import LRU

Expand Down Expand Up @@ -35,9 +35,6 @@ def env(self): return System._env
@property
def now(self): return datetime.now()

@property
def today(self): return datetime.today()

@property
def uuid(self): return str(uuid.uuid4()).replace('-', '')

Expand All @@ -53,6 +50,12 @@ def os(self): return os.name
@property
def python(self): return sys.executable

@property
def argv(self): return sys.argv

@property
def version(self): return sys.version


class ThreadLocal(threading.local):
def __init__(self):
Expand Down Expand Up @@ -136,10 +139,26 @@ def local_context(*args, **kwargs) -> Generator[collections.ChainMap, None, None
_local.lru.clear()


def global_context() -> collections.ChainMap:
"""Return the existing global context."""
def global_context(key: Optional[str] = None, value: Any = None) -> Any:
"""Get or set a global context value.
:param key: The key to get or set.
:param value: The value to set.
:return: The value of the key or the entire context.
>>> # Get the entire context
>>> global_context()
>>> # Get a value from the context
>>> global_context("TEXT")
>>> # Set a value in the context
>>> global_context("TEXT", "hello world")
"""
global _local
return _local.ctx._local.ctx
if key and value is None:
return _local.ctx[key]
elif key and value is not None:
_local.ctx[key] = value
return _local.ctx


__all__ = ["local_context", "global_context"]
14 changes: 5 additions & 9 deletions sigils/parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,26 +153,22 @@ def sigil(self, *nodes):
elif param and( value := _try_get_item(target, param)):
# logger.debug(f"Lookup param '{param}' found.")
target = value
else:
logger.debug(f"No param, no lookup. Root: {name=}.")
# Process additional nodes after the first (root)
logger.debug(f"Target: {target} {stack=}.")
while stack:
# TODO: Use a match statement?
name, param = stack.pop()
if isinstance(param, lark.Token): param = param.value
# logger.debug(f"Consume {name=} {param=}.")
if field := _try_get_item(target, name, param):
# This finds items only (not attributes)
logger.debug(f"Field (exact) {name} found in {target}.")
# logger.debug(f"Field (exact) {name} found in {target}.")
target = _try_call(field, param) or field
elif field := getattr(target, name.casefold(), None):
# Casefold is used to find Model fields, properties and methods
logger.debug(f"Field (casefold) {name} found in {target}.")
# logger.debug(f"Field (casefold) {name} found in {target}.")
target = _try_call(field, param) or field
elif _filter := self._ctx_lookup(name):
# We don't casefold filters (they are not Model fields)
logger.debug(f"Filter {name} found in context.")
# logger.debug(f"Filter {name} found in context.")
target = _try_call(_filter, target, param)
else:
logger.debug("Unable to consume full sigil {name} for {target}.")
Expand All @@ -194,9 +190,9 @@ def arg(self, value=None):

@lark.v_args(inline=True)
def integer(self, value=None):
return int(value)
return int(str(value))

null: bool = lambda self, _: ""
null = lambda self, _: ""


__all__ = ["extract", "SigilContextTransformer"]
25 changes: 19 additions & 6 deletions sigils/tests/test_contexts.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from ..transforms import * # Module under test
from ..errors import SigilError
from ..sigils import Sigil
from ..contexts import local_context
from ..contexts import local_context, global_context


def test_sigil_with_simple_context():
Expand Down Expand Up @@ -81,7 +81,7 @@ def test_call_lambda_error():
resolve("[DIVIDE_BY_ZERO=1]", on_error=OnError.RAISE)


def test_item_subscript():
def test_subitem_subscript():
with local_context(A={"B": "C"}):
assert resolve("[A.B]") == "C"

Expand All @@ -99,10 +99,11 @@ def test_required_key_not_in_context():


def test_replace_duplicated():
text = "User: [USER], Manager: [USER], Company: [ORG]"
text, sigils = replace(text, "%s")
assert text == "User: %s, Manager: %s, Company: %s"
assert sigils == ("[USER]", "[USER]", "[ORG]")
# TODO: Fix this test
text = "User: [U], Manager: [U], Company: [ORG]"
text, sigils = replace(text, "X")
assert sigils == ("[U]", "[U]", "[ORG]")
assert text == "User: X, Manager: X, Company: X"


def test_cache_value_is_used():
Expand Down Expand Up @@ -181,3 +182,15 @@ def test_get_python():
import sys
assert resolve("[SYS.PYTHON]") == sys.executable


# Test global_context
def test_global_context():
global_context()["USER"] = "arthex1s"
assert resolve("[USER]") == "arthex1s"


# Test global_context
def test_global_context_set_key():
global_context("USERA", "arthexe4s")
assert resolve("[USERA]") == "arthexe4s"

27 changes: 27 additions & 0 deletions sigils/tests/test_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,30 @@ def test_arithmetic():
assert resolve("[A.MUL=[B]]") == "2"
assert resolve("[A.DIV=[B]]") == "0.5"
assert resolve("[A.MOD=[B]]") == "1"


# Test setting and reading environment variables
def test_env():
import os
os.environ["FOO"] = "BAR"
assert resolve("[SYS.ENV.FOO]") == "BAR"


# Test executing a block of python code
def test_execute_python_code():
code = """
def func():
print("Hello [USERPARAM]")
return "Hello World"
func()
"""
import io
import contextlib
# Capture stdout
stdout = io.StringIO()
with contextlib.redirect_stdout(stdout):
with local_context(USERPARAM="World"):
execute(code)
assert stdout.getvalue() == "Hello World\n"


50 changes: 38 additions & 12 deletions sigils/transforms.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import ast
from enum import Enum
from typing import Union, Tuple, Text, Iterator, Callable, Any, Optional, TextIO

Expand All @@ -20,7 +21,7 @@ def resolve(
serializer: Callable[[Any], str] = str,
on_error: str = OnError.DEFAULT,
default: Optional[str] = "",
recursion_limit: int = 20,
recursion_limit: int = 1,
cache: bool = True,
) -> str:
"""
Expand All @@ -37,7 +38,7 @@ def resolve(
:param default: Value to use when the sigils resolves to None, defaults to "".
:param recursion_limit: If greater than zero, and the output of a resolved sigil
contains other sigils, resolve them as well until no sigils remain or
until the recursion limit is reached (default 20).
until the recursion limit is reached (default 1).
:param cache: Use an LRU cache to store resolved sigils (default True).
>>> # Resolving sigils using context:
Expand All @@ -51,11 +52,11 @@ def resolve(
sigils = set(parsing.extract(text))

if not sigils:
logger.debug(f"No more sigils in '{text}'.")
# logger.debug(f"No more sigils in '{text}'.")
return text # Not an error, just do nothing

results = []
logger.debug(f"Extracted sigils: {sigils}.")
# logger.debug(f"Extracted sigils: {sigils}.")
for sigil in sigils:
try:
# By using a lark transformer, we parse and resolve
Expand All @@ -80,18 +81,17 @@ def resolve(
default,
recursion_limit=(recursion_limit - 1)
)
text = text.replace(sigil, fragment)
text = text.replace(sigil, fragment)
except Exception as ex:
if on_error == OnError.RAISE:
raise errors.SigilError(sigil) from ex
elif on_error == OnError.REMOVE:
text = text.replace(sigil, "")
elif on_error == OnError.DEFAULT:
text = text.replace(sigil, default)
logger.debug(f"Sigil '{sigil}' could not be resolved:\n{ex}")

text = text.replace(sigil, str(default))
# logger.debug(f"Sigil '{sigil}' could not be resolved:\n{ex}")
if results:
return results if len(results) > 1 else results[0]
return str(results) if len(results) > 1 else str(results[0])
return text


Expand All @@ -114,10 +114,36 @@ def replace(
"""

sigils = list(parsing.extract(text))
_iter = (iter(pattern) if isinstance(pattern, Iterator)
else iter(pattern * len(sigils)))
for sigil in set(sigils):
text = (text.replace(sigil, str(next(pattern))
if hasattr(pattern, "__next__") else pattern))
text = (text.replace(sigil, str(next(_iter)) or sigil))
return text, tuple(sigils)


__all__ = ["resolve", "replace", "OnError"]
def execute(
code: str,
on_error: str = OnError.DEFAULT,
default: Optional[str] = "",
recursion_limit: int = 1,
cache: bool = True,
_locals: Optional[dict[str, Any]] = None,
_globals: Optional[dict[str, Any]] = None,
) -> Union[str, None]:
"""Execute a Python code block after resolving any sigils appearing in
it's strings. Sigils appearing outside of strings are not resolved.
"""
tree = ast.parse(code)
for node in ast.walk(tree):
if isinstance(node, ast.Str):
node.s = resolve(
node.s,
on_error=on_error,
default=default,
recursion_limit=recursion_limit,
cache=cache
)
exec(compile(tree, "<string>", "exec"), _globals, _locals)


__all__ = ["resolve", "replace", "execute", "OnError"]

0 comments on commit 9323ec4

Please sign in to comment.