tq-query 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,29 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_dispatch:
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ test:
14
+ name: Python ${{ matrix.python-version }}
15
+ runs-on: ubuntu-latest
16
+ strategy:
17
+ fail-fast: false
18
+ matrix:
19
+ python-version: ["3.11", "3.12", "3.13"]
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+ - uses: astral-sh/setup-uv@v5
23
+ with:
24
+ python-version: ${{ matrix.python-version }}
25
+ enable-cache: true
26
+ - run: uv sync --locked --extra cli
27
+ - run: uv run ruff check .
28
+ - run: uv run ruff format --check .
29
+ - run: uv run pytest -q
@@ -0,0 +1,57 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ build:
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: astral-sh/setup-uv@v5
17
+ with:
18
+ python-version: "3.12"
19
+ enable-cache: true
20
+ - run: uv sync --locked --extra cli
21
+ - name: Check tag matches package version
22
+ run: |
23
+ uv run python - <<'PY'
24
+ import os
25
+ import tomllib
26
+ from pathlib import Path
27
+
28
+ version = tomllib.loads(Path("pyproject.toml").read_text())["project"]["version"]
29
+ expected = f"v{version}"
30
+ actual = os.environ["GITHUB_REF_NAME"]
31
+ if actual != expected:
32
+ raise SystemExit(f"tag {actual!r} does not match package version {expected!r}")
33
+ PY
34
+ - run: uv run ruff check .
35
+ - run: uv run ruff format --check .
36
+ - run: uv run pytest -q
37
+ - run: uv build
38
+ - uses: actions/upload-artifact@v4
39
+ with:
40
+ name: python-package-distributions
41
+ path: dist/
42
+ if-no-files-found: error
43
+
44
+ publish:
45
+ needs: build
46
+ runs-on: ubuntu-latest
47
+ environment:
48
+ name: pypi
49
+ url: https://pypi.org/p/tq-query
50
+ permissions:
51
+ id-token: write
52
+ steps:
53
+ - uses: actions/download-artifact@v4
54
+ with:
55
+ name: python-package-distributions
56
+ path: dist/
57
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ build/
7
+ dist/
8
+ *.egg-info/
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.5
2
+ Name: tq-query
3
+ Version: 0.1.0
4
+ Summary: Query arrays of structured data
5
+ License: Apache-2.0
6
+ Keywords: arrays,json,query
7
+ Requires-Python: >=3.11
8
+ Provides-Extra: cli
9
+ Requires-Dist: rich<15,>=13.7; extra == 'cli'
10
+ Description-Content-Type: text/markdown
11
+
12
+ # tq
13
+
14
+ `tq` is a small Python package and CLI for querying JSON records.
15
+
16
+ It reads JSON Lines, JSON arrays, multiline objects, and consecutive JSON objects from a file or stdin, and writes matching records as JSON.
17
+
18
+ ## Get started
19
+
20
+ Install the Python library only:
21
+
22
+ ```sh
23
+ pip install tq-query
24
+ ```
25
+
26
+ Install the CLI and its optional dependencies:
27
+
28
+ ```sh
29
+ pip install "tq-query[cli]"
30
+ # or: uv tool install "tq-query[cli]"
31
+ ```
32
+
33
+ The `tq` command requires the `cli` extra; without it, the command prints an install hint.
34
+
35
+ To try `tq` with OpenRouter model data from models.dev, download the catalog and convert it to `models.jsonl`:
36
+
37
+ ```sh
38
+ curl -fsSL https://models.dev/catalog.json \
39
+ | jq '.providers.openrouter.models[] | {id, modalities, limit, reasoning, tool_call}' -c \
40
+ > models.jsonl
41
+ ```
42
+
43
+ Each line is one model record. Query by identity or fields:
44
+
45
+ ```sh
46
+ tq 'openai/*[reasoning;tool_call]' models.jsonl
47
+ tq '[limit.context>=200000]' models.jsonl
48
+ tq '[modalities.input has image]' models.jsonl
49
+ tq '[modalities.input has all (text,image)]' models.jsonl
50
+ ```
51
+
52
+ ## CLI
53
+
54
+ ```text
55
+ usage: tq [OPTIONS] QUERY [FILE]
56
+
57
+ Arguments:
58
+ query match expression
59
+ file JSON input file; defaults to stdin
60
+
61
+ Options:
62
+ -h, --help show this help message and exit
63
+ -k KEY identity field (default: ref, then id)
64
+ -r, --reorder order matches by query branch, then input position
65
+ -C, --color force colored output
66
+ -M, --no-color force uncolored output
67
+ -c, --compact compact JSON output, one record per line
68
+ ```
69
+
70
+ Color is enabled for terminals and disabled for pipes. `-c` controls formatting independently.
71
+
72
+ ## Query syntax
73
+
74
+ A query is a comma-separated list of branches. Each branch may combine an identity pattern with predicates:
75
+
76
+ ```text
77
+ IDENTITY[PREDICATE;PREDICATE],IDENTITY[PREDICATE]
78
+ ```
79
+
80
+ Omit the identity to match any record. Branches are OR-ed; predicates within a branch are AND-ed and may be separated by commas or semicolons.
81
+
82
+ | Syntax | Meaning |
83
+ | --- | --- |
84
+ | `foo*` / `foo?` | Identity glob: `*` matches any sequence; `?` matches one character |
85
+ | `"foo*"` | Exact identity; wildcards are literal |
86
+ | `f` / `!f` | `f = true` / `f = false` |
87
+ | `f = v` / `f != v` | Equal / not equal |
88
+ | `f < v`, `f <= v`, `f > v`, `f >= v` | Ordered comparison |
89
+ | `f has v` / `f has no v` | Array contains / does not contain `v` |
90
+ | `f has any (a,b)` | Array contains at least one candidate |
91
+ | `f has all (a,b)` | Array contains every candidate |
92
+ | `f has none (a,b)` | Array contains no candidate |
93
+ | `v in f` / `v not in f` | Aliases for `f has v` / `f has no v` |
94
+ | `a.b.c` | Access a nested field |
95
+
96
+ `has any`, `has all`, and `has none` require at least two candidates.
97
+
98
+ Quote values when needed; for example, `"true"` is the text value `true`. Missing fields never match a predicate, including negative predicates. Explicit `null` is distinct from a missing field.
99
+
100
+ ## Python API
101
+
102
+ ```python
103
+ from tq import Query
104
+
105
+ schema = {"key": "id", "filter": ["reasoning", "tool_call"]}
106
+ query = Query.parse("openai/*[reasoning;tool_call]").validate(schema)
107
+
108
+ record = {
109
+ "id": "openai/gpt-5",
110
+ "reasoning": True,
111
+ "tool_call": True,
112
+ }
113
+
114
+ branch = query.match(record)
115
+ ```
116
+
117
+ ## Publishing
118
+
119
+ See [PUBLISHING.md](PUBLISHING.md) for PyPI Trusted Publishing setup and release instructions.
@@ -0,0 +1,27 @@
1
+ # Publishing
2
+
3
+ The `publish.yml` workflow publishes `tq-query` to PyPI when a `v*` version tag is pushed. It verifies that the tag matches the version in `pyproject.toml`, runs checks and tests, builds the wheel and source distribution, then publishes them using PyPI Trusted Publishing (OIDC). No PyPI token is stored in GitHub.
4
+
5
+ ## One-time PyPI setup
6
+
7
+ Configure a Trusted Publisher for `tq-query` with:
8
+
9
+ - Owner: `briceyan`
10
+ - Repository: `tq`
11
+ - Workflow: `publish.yml`
12
+ - Environment: `pypi`
13
+
14
+ If the project has not been published before, configure it as a pending publisher when creating the PyPI project.
15
+
16
+ ## Release a version
17
+
18
+ 1. Update `project.version` in `pyproject.toml` and refresh `uv.lock` with `uv lock`.
19
+ 2. Run the CI checks and commit the changes to `main`.
20
+ 3. Create and push a matching version tag, for example:
21
+
22
+ ```sh
23
+ git tag v0.1.1
24
+ git push origin v0.1.1
25
+ ```
26
+
27
+ The tag push starts the publish workflow. A GitHub Release is not required.
@@ -0,0 +1,108 @@
1
+ # tq
2
+
3
+ `tq` is a small Python package and CLI for querying JSON records.
4
+
5
+ It reads JSON Lines, JSON arrays, multiline objects, and consecutive JSON objects from a file or stdin, and writes matching records as JSON.
6
+
7
+ ## Get started
8
+
9
+ Install the Python library only:
10
+
11
+ ```sh
12
+ pip install tq-query
13
+ ```
14
+
15
+ Install the CLI and its optional dependencies:
16
+
17
+ ```sh
18
+ pip install "tq-query[cli]"
19
+ # or: uv tool install "tq-query[cli]"
20
+ ```
21
+
22
+ The `tq` command requires the `cli` extra; without it, the command prints an install hint.
23
+
24
+ To try `tq` with OpenRouter model data from models.dev, download the catalog and convert it to `models.jsonl`:
25
+
26
+ ```sh
27
+ curl -fsSL https://models.dev/catalog.json \
28
+ | jq '.providers.openrouter.models[] | {id, modalities, limit, reasoning, tool_call}' -c \
29
+ > models.jsonl
30
+ ```
31
+
32
+ Each line is one model record. Query by identity or fields:
33
+
34
+ ```sh
35
+ tq 'openai/*[reasoning;tool_call]' models.jsonl
36
+ tq '[limit.context>=200000]' models.jsonl
37
+ tq '[modalities.input has image]' models.jsonl
38
+ tq '[modalities.input has all (text,image)]' models.jsonl
39
+ ```
40
+
41
+ ## CLI
42
+
43
+ ```text
44
+ usage: tq [OPTIONS] QUERY [FILE]
45
+
46
+ Arguments:
47
+ query match expression
48
+ file JSON input file; defaults to stdin
49
+
50
+ Options:
51
+ -h, --help show this help message and exit
52
+ -k KEY identity field (default: ref, then id)
53
+ -r, --reorder order matches by query branch, then input position
54
+ -C, --color force colored output
55
+ -M, --no-color force uncolored output
56
+ -c, --compact compact JSON output, one record per line
57
+ ```
58
+
59
+ Color is enabled for terminals and disabled for pipes. `-c` controls formatting independently.
60
+
61
+ ## Query syntax
62
+
63
+ A query is a comma-separated list of branches. Each branch may combine an identity pattern with predicates:
64
+
65
+ ```text
66
+ IDENTITY[PREDICATE;PREDICATE],IDENTITY[PREDICATE]
67
+ ```
68
+
69
+ Omit the identity to match any record. Branches are OR-ed; predicates within a branch are AND-ed and may be separated by commas or semicolons.
70
+
71
+ | Syntax | Meaning |
72
+ | --- | --- |
73
+ | `foo*` / `foo?` | Identity glob: `*` matches any sequence; `?` matches one character |
74
+ | `"foo*"` | Exact identity; wildcards are literal |
75
+ | `f` / `!f` | `f = true` / `f = false` |
76
+ | `f = v` / `f != v` | Equal / not equal |
77
+ | `f < v`, `f <= v`, `f > v`, `f >= v` | Ordered comparison |
78
+ | `f has v` / `f has no v` | Array contains / does not contain `v` |
79
+ | `f has any (a,b)` | Array contains at least one candidate |
80
+ | `f has all (a,b)` | Array contains every candidate |
81
+ | `f has none (a,b)` | Array contains no candidate |
82
+ | `v in f` / `v not in f` | Aliases for `f has v` / `f has no v` |
83
+ | `a.b.c` | Access a nested field |
84
+
85
+ `has any`, `has all`, and `has none` require at least two candidates.
86
+
87
+ Quote values when needed; for example, `"true"` is the text value `true`. Missing fields never match a predicate, including negative predicates. Explicit `null` is distinct from a missing field.
88
+
89
+ ## Python API
90
+
91
+ ```python
92
+ from tq import Query
93
+
94
+ schema = {"key": "id", "filter": ["reasoning", "tool_call"]}
95
+ query = Query.parse("openai/*[reasoning;tool_call]").validate(schema)
96
+
97
+ record = {
98
+ "id": "openai/gpt-5",
99
+ "reasoning": True,
100
+ "tool_call": True,
101
+ }
102
+
103
+ branch = query.match(record)
104
+ ```
105
+
106
+ ## Publishing
107
+
108
+ See [PUBLISHING.md](PUBLISHING.md) for PyPI Trusted Publishing setup and release instructions.
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "tq-query"
7
+ version = "0.1.0"
8
+ description = "Query arrays of structured data"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "Apache-2.0" }
12
+ keywords = ["query", "json", "arrays"]
13
+ dependencies = []
14
+
15
+ [project.optional-dependencies]
16
+ cli = ["rich>=13.7,<15"]
17
+
18
+ [project.scripts]
19
+ tq = "tq.cli:main"
20
+
21
+ [tool.hatch.build.targets.wheel]
22
+ packages = ["src/tq"]
23
+
24
+ [tool.pytest.ini_options]
25
+ pythonpath = ["src"]
26
+ testpaths = ["tests"]
27
+
28
+ [tool.ruff]
29
+ target-version = "py311"
30
+
31
+ [dependency-groups]
32
+ dev = [
33
+ "pytest>=9.1.1",
34
+ "ruff>=0.16.9",
35
+ ]
@@ -0,0 +1,5 @@
1
+ """Public API for parsing, validating, and matching tq queries."""
2
+
3
+ from .query import Field, Match, Predicate, Query, QueryError
4
+
5
+ __all__ = ["Field", "Match", "Predicate", "Query", "QueryError"]
@@ -0,0 +1,5 @@
1
+ """Run tq as ``python -m tq``."""
2
+
3
+ from .cli import main
4
+
5
+ raise SystemExit(main())
@@ -0,0 +1,135 @@
1
+ """CLI for matching JSON records and rendering results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import sys
9
+ from collections.abc import Mapping, Sequence
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from .query import Query, QueryError
14
+
15
+
16
+ def _read_records(source: str) -> list[Mapping[str, Any]]:
17
+ decoder = json.JSONDecoder()
18
+ records: list[Mapping[str, Any]] = []
19
+ position = 0
20
+ while position < len(source):
21
+ while position < len(source) and source[position].isspace():
22
+ position += 1
23
+ if position == len(source):
24
+ break
25
+ try:
26
+ value, position = decoder.raw_decode(source, position)
27
+ except json.JSONDecodeError as error:
28
+ line = source.count("\n", 0, error.pos) + 1
29
+ raise QueryError(f"invalid JSON near line {line}: {error.msg}") from error
30
+ for record in value if isinstance(value, list) else (value,):
31
+ if not isinstance(record, Mapping):
32
+ raise QueryError("input records must be JSON objects")
33
+ records.append(record)
34
+ return records
35
+
36
+
37
+ def _infer_key(records: Sequence[Mapping[str, Any]], explicit: str | None) -> str:
38
+ if explicit:
39
+ return explicit
40
+ for candidate in ("ref", "id"):
41
+ if any(candidate in record for record in records):
42
+ return candidate
43
+ raise QueryError("cannot infer key field; provide -k KEY")
44
+
45
+
46
+ def _build_parser() -> argparse.ArgumentParser:
47
+ parser = argparse.ArgumentParser(
48
+ description="Filter JSON records with tq match union syntax",
49
+ usage="tq [-h] [-k KEY] [-r] [-C | -M] [-c] query [file]",
50
+ )
51
+ parser.add_argument("query", metavar="query", help="match union expression")
52
+ parser.add_argument(
53
+ "file", nargs="?", metavar="file", help="JSON input file; defaults to stdin"
54
+ )
55
+ parser.add_argument(
56
+ "-k",
57
+ "--key",
58
+ metavar="KEY",
59
+ dest="key",
60
+ help="record key field (default: ref, then id)",
61
+ )
62
+ parser.add_argument(
63
+ "-r",
64
+ "--reorder",
65
+ action="store_true",
66
+ help="order matches by query branch, then input position",
67
+ )
68
+ parser.add_argument(
69
+ "-c",
70
+ "--compact",
71
+ action="store_true",
72
+ help="compact output to one line per record",
73
+ )
74
+ colors = parser.add_mutually_exclusive_group()
75
+ colors.add_argument(
76
+ "-C",
77
+ "--color",
78
+ action="store_true",
79
+ help="force color even when output is piped",
80
+ )
81
+ colors.add_argument(
82
+ "-M", "--no-color", action="store_true", help="force plain output"
83
+ )
84
+ return parser
85
+
86
+
87
+ def main(argv: Sequence[str] | None = None) -> int:
88
+ """Filter records and render each result as JSON."""
89
+ parser = _build_parser()
90
+ args = parser.parse_args(argv)
91
+ try:
92
+ from rich.console import Console
93
+ from rich.json import JSON
94
+ except ModuleNotFoundError:
95
+ parser.error("the CLI requires the optional extra; install `tq-query[cli]`")
96
+ try:
97
+ source = (
98
+ Path(args.file).read_text(encoding="utf-8")
99
+ if args.file
100
+ else sys.stdin.read()
101
+ )
102
+ records = _read_records(source)
103
+ query = Query.parse(args.query)
104
+ key = _infer_key(records, args.key)
105
+ matched = [
106
+ (index, branch)
107
+ for index, item in enumerate(records)
108
+ if (branch := query.match(item, key=key)) is not None
109
+ ]
110
+ if args.reorder:
111
+ matched.sort(key=lambda result: (result[1], result[0]))
112
+
113
+ is_terminal = bool(getattr(sys.stdout, "isatty", lambda: False)())
114
+ color_enabled = args.color or (
115
+ is_terminal and not args.no_color and "NO_COLOR" not in os.environ
116
+ )
117
+ console = Console(
118
+ file=sys.stdout,
119
+ force_terminal=color_enabled,
120
+ no_color=not color_enabled,
121
+ color_system="standard" if color_enabled else None,
122
+ width=100_000,
123
+ )
124
+ for item_index, _branch in matched:
125
+ record = records[item_index]
126
+ if args.compact:
127
+ compact = json.dumps(record, ensure_ascii=False, separators=(",", ":"))
128
+ console.print(
129
+ JSON(compact, indent=None, ensure_ascii=False), soft_wrap=True
130
+ )
131
+ else:
132
+ console.print(JSON.from_data(record, indent=2, ensure_ascii=False))
133
+ except (OSError, QueryError, TypeError, ValueError) as error:
134
+ parser.error(str(error))
135
+ return 0