Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@
"Edit(.github/release-drafter.yml)"
]
}
}
}
10 changes: 5 additions & 5 deletions .github/workflows/build-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
- uses: pre-commit/action@v3.0.1

build-and-test:
Expand All @@ -23,13 +23,13 @@ jobs:
python-version: ["3.9", "3.14"]

steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Install uv and set the Python version
uses: astral-sh/setup-uv@v8.1.0
uses: astral-sh/setup-uv@v10.1.0
with:
python-version: ${{ matrix.python-version }}
- name: Enable caching
uses: astral-sh/setup-uv@v8.1.0
uses: astral-sh/setup-uv@v10.1.0
with:
enable-cache: true

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/draft-release-notes.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@ jobs:
steps:
- uses: release-drafter/release-drafter@v7
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ jobs:
environment:
name: pypi
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Install uv
uses: astral-sh/setup-uv@v8.1.0
uses: astral-sh/setup-uv@v10.1.0
- name: Install Python
run: uv python install
- name: Build
Expand Down
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -146,4 +146,4 @@ etc/solar-ecliptic-longitude/*
.vscode

# Local files not intended for inclusion public project
_local
_local
4 changes: 2 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ repos:
- id: check-yaml
- id: end-of-file-fixer
- id: trailing-whitespace
repo: https://github.com/astral-sh/ruff-pre-commit
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.15.11
rev: v0.16.8
hooks:
# Run the linter.
- id: ruff-check
Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ The source code consists of the single file @src/valimp/valimp.py which employs

### Formatting

- format to `ruff` (Black compatible).
- format to `ruff` (Black compatible).
- see @ruff.toml for configuration.

```bash
Expand Down Expand Up @@ -220,7 +220,7 @@ def my_func(param1: int, param2: str = "default", param3: None | str = None) ->

### Comments

- pay particular attention to comments starting with...:
- pay particular attention to comments starting with...:
- 'NOTE'
- 'TODO'
- 'AIDEV-NOTE' - these comments are specifically addressed to you.
Expand Down
2 changes: 1 addition & 1 deletion LICENSE.txt
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
SOFTWARE.
41 changes: 27 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<!-- UPDATE BADGE ADDRESSES! -->
[![PyPI](https://img.shields.io/pypi/v/valimp)](https://pypi.org/project/valimp/) ![Python Support](https://img.shields.io/pypi/pyversions/valimp) [![Code style: ruff](https://img.shields.io/badge/code%20style-ruff-D7FF64.svg)](https://github.com/astral-sh/ruff) [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/maread99/valimp/main.svg)](https://results.pre-commit.ci/latest/github/maread99/valimp/main)

In Python use type hints to validate, parse and coerce inputs to **public functions and dataclasses**.
In Python use type hints to validate, parse and coerce inputs to **public functions and dataclasses**.

This is the sole use of `valimp`. It's a single short module with no depenencies that does one thing and makes it simple to do.

Expand All @@ -13,6 +13,7 @@ Works like this:
from valimp import parse, Parser, Coerce
from typing import Annotated, Union, Optional, Any


@parse # add the `valimp.parse`` decorator to a public function or method
def public_function(
# validate against built-in or custom types
Expand All @@ -25,18 +26,15 @@ def public_function(
# coerce input to a specific type
d: Annotated[
int | float | str, # Union[int, float, str]
Coerce(int)
Coerce(int),
],
# parse input with reference to earlier inputs...
e: Annotated[
str,
Parser(lambda name, obj, params: obj + f"_{name}_{params['a']}")
],
e: Annotated[str, Parser(lambda name, obj, params: obj + f"_{name}_{params['a']}")],
# coerce and parse input...
f: Annotated[
str | int, # Union[str, int]
Coerce(str),
Parser(lambda name, obj, _: obj + f"_{name}")
Parser(lambda name, obj, _: obj + f"_{name}"),
],
# validate input is a class (rather than an instance)
g: type,
Expand All @@ -47,19 +45,33 @@ def public_function(
# support for packing extra arguments if required, can be optionally typed...
*args: Annotated[
int | float | str, # Union[int, float, str]
Coerce(int)
Coerce(int),
],
# support for optional types
j: str | None, # Optional[str]
# define default values dynamically with reference to earlier inputs
k: Annotated[
float | None, # Optional[float]
Parser(lambda _, obj, params: params["b"] if obj is None else obj)
Parser(lambda _, obj, params: params["b"] if obj is None else obj),
] = None,
# support for packing excess kwargs if required, can be optionally typed...
# **kwargs: int | float # Union[int, float]
) -> dict[str, Any]:
return {"a":a, "b":b, "c":c, "d":d, "e":e, "f":f, "g":g, "h":h, "i":i, "args":args, "j":j, "k":k}
return {
"a": a,
"b": b,
"c": c,
"d": d,
"e": e,
"f": f,
"g": g,
"h": h,
"i": i,
"args": args,
"j": j,
"k": k,
}


public_function(
# NB 'a' must be passed positionally, 'b' through 'i' can be passed positionally
Expand Down Expand Up @@ -99,7 +111,7 @@ public_function(
["not a string"], # INVALID
b="not an int or a float", # INVALID
c={2: "two"}, # INVALID, key not a str and value not an int or float
d=3.2, # valid input
d=3.2, # valid input
e="valid input",
f=5.0, # INVALID, not a str or an int
g=str, # valid input
Expand Down Expand Up @@ -165,17 +177,18 @@ Use all the same functionality to validate, parse and coerce the fields of a dat
from valimp import parse_cls
import dataclasses


@parse_cls # place valimp decorator above the dataclass decorator
@dataclasses.dataclass
class ADataclass:

a: str
b: Annotated[
str | int, # Union[str, int]
Coerce(str),
Parser(lambda name, obj, params: obj + f" {name} {params['a']}")
Parser(lambda name, obj, params: obj + f" {name} {params['a']}"),
]


rtrn = ADataclass("I'm a and will appear at the end of b", 33)
dataclasses.asdict(rtrn)
```
Expand All @@ -200,7 +213,7 @@ Further documentation can be found in the module docstring of [valimp.py](https:
### Why even validate input type?
Some may argue that validating the type of public inputs is not pythonic and we can 'duck' out of it and let the errors arise where they may. I'd argue that for the sake of adding a decorator I'd rather raise an intelligible error message than have to respond to an issue asking 'why am I getting this error...'.

> :information_source: `valimp` is only intended for handling inputs to **public functions and dataclasses**. For internal validation, consider using a type checker (for example, [mypy](https://github.com/python/mypy)).
> :information_source: `valimp` is only intended for handling inputs to **public functions and dataclasses**. For internal validation, consider using a type checker (for example, [mypy](https://github.com/python/mypy)).

Also, I like the option of abstracting away all parsing, coercion and validation of public inputs and just receiving the formal parameter as required. For example, public methods in [market-prices](https://github.com/maread99/market_prices) often include a 'date' parameter. I like to offer users the convenience to pass this as either a `str`, a `datetime.date` or a `pandas.Timestamp`, although internally I want it as a `pandas.Timestamp`. I can do this with Valimp by simply including `Coerce(pandas.Timestamp)` to the metadata of the type annotation of each 'date' parameter. I also need to validate that the input is timezone-naive and does indeed represent a date rather than a time. I can do this by defining a single `valimp.Parser` and similarly including it to the annotation metadata of the 'date' parameters. Everything's abstracted away. With a little understanding of type annotations the user can see what's going on by simple inspection of the function's signature (as included within the standard help).

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ dynamic = ["version"]
test = [
"pytest",
]

dev = [
{include-group = "test"},
"mypy",
Expand Down
2 changes: 1 addition & 1 deletion pytest.ini
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[pytest]
addopts = -rxXs --strict-markers --doctest-modules --capture=no

testpaths =
testpaths =
tests
src/valimp/
6 changes: 4 additions & 2 deletions ruff.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ indent-width = 4
# Assume Python 3.9
target-version = "py39"

[lint]
[lint]
# Allow fix for all enabled rules (when `--fix`) is provided.
fixable = ["ALL"]
unfixable = []
Expand All @@ -66,7 +66,9 @@ ignore = [
"FIX002", # line-contains-todo. Ignore here to pass CI tests.
"D105", # undocumented-magic-method
"PLR0913", # too-many-arguments
"PLR0917", # too-many-positional-arguments. Same rationale as PLR0913.
"PLR2044", # empty-comment. Happy to have an empty line as a comment, can serve as a eseful separator.
"CPY001", # missing-copyright-notice. Project does not use per-file copyright headers.
# Package-specific wilful ignores...
"PLR0915", # too-many-statements.
]
Expand Down Expand Up @@ -118,4 +120,4 @@ docstring-code-format = false
#
# This only has an effect when the `docstring-code-format` setting is
# enabled.
docstring-code-line-length = "dynamic"
docstring-code-line-length = "dynamic"
10 changes: 5 additions & 5 deletions tests/test_valimp.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ def func(
t: abc.Callable[[str, int], str],
u: abc.Callable[..., str],
v: abc.Callable[..., str],
w: Union[int, str, None, Literal["spam", "foo"]],
w: Union[int, str, Literal["spam", "foo"], None],
x: Annotated[Union[str, int, float], "spam meta", "foo meta"],
y: Annotated[
Optional[Union[str, int, float]], "foo meta", m.Parser(lambda _n, o, _p: o)
Expand Down Expand Up @@ -213,7 +213,7 @@ def func(
t: abc.Callable[[str, int], str],
u: abc.Callable[..., str],
v: abc.Callable[..., str],
w: Union[int, str, None, Literal["spam", "foo"]],
w: Union[int, str, Literal["spam", "foo"], None],
x: Annotated[Union[str, int, float], "spam meta", "foo meta"],
y: Annotated[
Optional[Union[str, int, float]],
Expand Down Expand Up @@ -324,7 +324,7 @@ class DataCls:
t: abc.Callable[[str, int], str]
u: abc.Callable[..., str]
v: abc.Callable[..., str]
w: Union[int, str, None, Literal["spam", "foo"]]
w: Union[int, str, Literal["spam", "foo"], None]
x: Annotated[Union[str, int, float], "spam meta", "foo meta"]
y: Annotated[
Optional[Union[str, int, float]],
Expand Down Expand Up @@ -402,7 +402,7 @@ def func(
t: abc.Callable[[str, int], str],
u: abc.Callable[..., str],
v: abc.Callable[..., str],
w: Union[int, str, None, Literal["spam", "foo"]],
w: Union[int, str, Literal["spam", "foo"], None],
x: Annotated[Union[str, int, float], "spam meta", "foo meta"],
y: Annotated[
Optional[Union[str, int, float]], "foo meta", m.Parser(lambda _n, o, _p: o)
Expand Down Expand Up @@ -844,7 +844,7 @@ def f(
Takes type <class 'collections.abc.Callable'> although received 'not callable' of type <class 'str'>.

w
Takes input that conforms with <(<class 'int'>, <class 'str'>, <class 'NoneType'>, typing.Literal['spam', 'foo'])> although received '['list not in union']' of type <class 'list'>.
Takes input that conforms with <(<class 'int'>, <class 'str'>, typing.Literal['spam', 'foo'], <class 'NoneType'>)> although received '['list not in union']' of type <class 'list'>.

x
Takes input that conforms with <(<class 'str'>, <class 'int'>, <class 'float'>)> although received '{'dict': 'not in annotated union'}' of type <class 'dict'>.
Expand Down
Loading
Loading