Skip to main content

Type-safe property path navigation for Python

Project description

keyof

PyPI version Python versions License CI

Type-safe property paths for Python, inspired by TypeScript's keyof.

What it does

from dataclasses import dataclass
from keyof import KeyOf

@dataclass
class User:
    name: str
    email: str

# Define a path, your IDE autocompletes, your type checker validates
path = KeyOf(lambda u: u.name)

# Extract the value
user = User(name="Alice", email="alice@example.com")
path.from_(user)  # → "Alice"

# Serialize for APIs, configs, logs...
str(path)  # → "name"

Installation

pip install py-keyof

Why? 🤔

String-based paths like "user.address.city" are convenient, but they break silently when you rename a field. Your IDE can't autocomplete them, and typos only surface at runtime (usually in production, at 3am).

keyof uses a lambda to capture the path. Pylance/Pyright sees the lambda and validates every attribute access, so you get squiggly red lines in your editor instead of surprises in production.

How it works (and Python's limitations)

Python doesn't have TypeScript's keyof operator, so we get a bit creative:

  1. At runtime, the lambda receives a proxy object that records attribute accesses
  2. At type-check time, the lambda is analyzed normally by Pylance/Pyright

The path gets validated statically, but the mechanism is admittedly a bit of a hack. The lambda never actually runs on real data, it only runs once during KeyOf construction to capture the path.

Handling optional fields 🔗

When a field is T | None, accessing attributes on it is a type error. Use nn() to tell the type checker "trust me, this isn't None":

from keyof import KeyOf, nn

@dataclass
class User:
    address: Address | None

# nn() strips None from the type (no-op at runtime)
path = KeyOf(lambda u: nn(u.address).city)

# Or with pipe syntax, if that's more your style
path = KeyOf(lambda u: (u.address | nn).city)

This is similar to TypeScript's ! or Kotlin's !!. The path will still fail at runtime if the value is actually None. Use path.from_(obj, default=...) to handle that gracefully.

Working with dicts and lists

Bracket access works too:

data = {"users": [{"name": "Alice"}]}

path = KeyOf(lambda d: d["users"][0]["name"])
path.from_(data)  # → "Alice"

Path introspection 🔍

path = KeyOf(lambda u: u.address.city)

path.parts   # → ("address", "city")
path.depth   # → 2
path.root    # → "address"
path.leaf    # → "city"
path.parent()  # → KeyOf for "address"

Serialization

Need your path in a different format? keyof got you covered:

path = KeyOf(lambda u: u.address.city)

str(path)          # → "address.city"
path.to_jsonpath() # → "$.address.city"
path.to_bracket()  # → "['address']['city']"
path.to_posix()    # → "address/city"
path.to_xpath()    # → "/address/city"

Equality and hashing

Paths are immutable and can be compared, hashed, and used in sets/dicts:

p1 = KeyOf(lambda u: u.name)
p2 = KeyOf(lambda u: u.name)

p1 == p2      # True
p1 == "name"  # True (compares string representation)
{p1, p2}      # Set with one element

Limitations ⚠️

  • The lambda trick only works for attribute and item access. Method calls, arithmetic, or other expressions won't work.
  • Pipe syntax (x | nn) needs parentheses because . binds tighter than |.
  • There's no way to express "this path might not exist" at the type level. That's a runtime concern, handled by the default parameter.

AI disclaimer 🤖

Some of the files in this project were generated with the help of AI, especially the config files and project scaffolding. Github Copilot suggestions were used for some of the implementation, tests, and documentation. While I strive to ensure its correctness and quality, please be aware that it may contain errors or suboptimal patterns. Always review and test the code before using it in production.

License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

py_keyof-1.0.0.tar.gz (12.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

py_keyof-1.0.0-py3-none-any.whl (8.6 kB view details)

Uploaded Python 3

File details

Details for the file py_keyof-1.0.0.tar.gz.

File metadata

  • Download URL: py_keyof-1.0.0.tar.gz
  • Upload date:
  • Size: 12.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for py_keyof-1.0.0.tar.gz
Algorithm Hash digest
SHA256 c716b018c83c445f3ef963002865260b07824ae801ca17cba7458060d693a0c3
MD5 dea4b32bb680df7844ed8790be7c2050
BLAKE2b-256 b2721dd77f207007dcf8ba51de1df38520d2ea216a24d07e9fdf9bdc390c30f7

See more details on using hashes here.

Provenance

The following attestation bundles were made for py_keyof-1.0.0.tar.gz:

Publisher: ci.yml on eyusd/keyof

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file py_keyof-1.0.0-py3-none-any.whl.

File metadata

  • Download URL: py_keyof-1.0.0-py3-none-any.whl
  • Upload date:
  • Size: 8.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for py_keyof-1.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 716b2d303f6a6e0cd6d63110fee4c91b7ccaee7c74d3c682c2b0dc0dd8982dd5
MD5 86cfa7927b6e4228d279e7ece6536a4c
BLAKE2b-256 addb6e9cbb7ad0655befd4fbb7e9c2a074273f473eca2dc64885dc764a01e689

See more details on using hashes here.

Provenance

The following attestation bundles were made for py_keyof-1.0.0-py3-none-any.whl:

Publisher: ci.yml on eyusd/keyof

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page