Source code for devops_utils.core.select

"""Project and render JSON-shaped results without a shell-side ``jq``.

The ``azdo`` CLI prints JSON; scripts used to pull values out with
``… | jq -r .id``, which is bash-flavoured and rarely installed on Windows.
These pure helpers let the CLI do that projection itself so one command line
works the same in bash, PowerShell, and ``cmd``:

- :func:`select` keeps only the requested paths. Paths are ``/``-separated so
  dotted Azure DevOps field names need no quoting:
  ``fields/Microsoft.VSTS.Scheduling.StartDate``.
- :func:`render` turns the (projected) result into text: pretty JSON, bare ids,
  or raw scalars.
"""

from __future__ import annotations

import json
from typing import Any

#: Output modes accepted by :func:`render`.
OUTPUT_MODES = ("json", "id", "raw")

_MISSING = object()


def _lookup(value: Any, path: str) -> Any:
    """Walk ``path`` (``/``-separated keys or list indexes) into ``value``."""
    current = value
    for part in path.split("/"):
        if isinstance(current, dict):
            current = current.get(part, _MISSING)
        elif isinstance(current, list) and part.lstrip("-").isdigit():
            index = int(part)
            current = (
                current[index] if -len(current) <= index < len(current) else _MISSING
            )
        else:
            current = _MISSING
        if current is _MISSING:
            return None
    return current


def _project(item: Any, paths: tuple[str, ...]) -> Any:
    if len(paths) == 1:
        return _lookup(item, paths[0])
    return {path.rsplit("/", 1)[-1]: _lookup(item, path) for path in paths}


[docs] def select(result: Any, paths: tuple[str, ...] | list[str]) -> Any: """Keep only ``paths`` from ``result``. One path yields the bare value; several yield an object keyed by each path's last segment. A list result is projected item by item. Missing paths come back as ``None`` rather than raising, so a sparse field doesn't abort a whole listing. """ paths = tuple(paths) if not paths: return result if isinstance(result, list): return [_project(item, paths) for item in result] return _project(result, paths)
def _scalar_text(value: Any) -> str: if value is None: return "" if isinstance(value, str): return value if isinstance(value, bool | int | float): return json.dumps(value) return json.dumps(value, ensure_ascii=False)
[docs] def render(result: Any, mode: str = "json") -> str: """Render ``result`` as text for stdout. Args: result: JSON-shaped value (usually already passed through :func:`select`). mode: ``json`` — pretty JSON (the default, unchanged CLI behaviour); ``id`` — the ``id`` of each object, one per line, nothing else; ``raw`` — scalars unquoted, one list element per line, anything nested as compact JSON. """ if mode == "json": return json.dumps(result, indent=2, ensure_ascii=False) items = result if isinstance(result, list) else [result] if mode == "id": ids = [item.get("id") if isinstance(item, dict) else item for item in items] return "\n".join(_scalar_text(i) for i in ids) if mode == "raw": return "\n".join(_scalar_text(item) for item in items) raise ValueError(f"unknown output mode: {mode!r}")