#!/usr/bin/env python3
"""
Levo: push your API files into Levo from CI.

Reads files your repository already has -- a Kong export, an OpenAPI spec, a Postman collection --
and sends them to Levo. It never contacts your gateway.

    pip install requests
    export LEVO_AUTH_KEY=...        # from <your Levo URL>/settings/keys
    export LEVO_ORG_ID=...

    python levo_push.py --file "kong/*.json" --env-name NonProd
    python levo_push.py --file specs/openapi.yaml --app payments-api --env-name NonProd

What each file is gets read from its contents, so it does not matter what you name it. A file Levo
cannot ingest is reported and skipped. Exit code is 0 only when every file landed.

Generated from levoai_e7s.pusher by standalone.py -- edit the module, not this file.
"""
from __future__ import annotations

import glob
import json
import logging
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple

import requests

logger = logging.getLogger(__name__)

DEFAULT_BASE_URL = "https://api.levo.ai"
DEFAULT_TIMEOUT = 60.0

KIND_KONG = "kong"
KIND_OPENAPI = "openapi"
KIND_POSTMAN = "postman"

SOURCE_KIND_KONG = "KONG"

#: Spec extensions Levo accepts, mapped to what the file input's `extension` field expects.
SPEC_EXTENSIONS = {".json": "json", ".yaml": "yaml", ".yml": "yaml"}

_OPENAPI_TEXT = re.compile(r"^\s*[\"']?(openapi|swagger)[\"']?\s*:", re.MULTILINE)


class LevoPushError(Exception):
    """Raised when a push cannot proceed. Carries a message meant for a CI log."""


@dataclass
class PushResult:
    """One file's outcome, for the summary a CI step prints."""

    path: Path
    kind: Optional[str]
    ok: bool
    detail: str


# --------------------------------------------------------------------------------------
# Identifying what a file is
# --------------------------------------------------------------------------------------


def parse_json(text: str) -> Optional[Any]:
    """The parsed document, or None when the text is not JSON (a YAML spec, most often)."""
    try:
        return json.loads(text)
    except ValueError:
        return None


def detect_kind(text: str) -> Optional[str]:
    """
    Which kind of file this is, or None when it is not one Levo can ingest.

    Deliberately conservative. An unrecognised file is skipped and reported; guessing would push a
    customer's unrelated JSON into their inventory, and nothing in the result would say so.
    """
    document = parse_json(text)
    if isinstance(document, dict):
        source = document.get("source")
        if document.get("format_version") is not None and isinstance(source, dict):
            if str(source.get("kind", "")).upper() == SOURCE_KIND_KONG:
                return KIND_KONG
        if document.get("openapi") or document.get("swagger"):
            return KIND_OPENAPI
        info = document.get("info")
        if isinstance(info, dict) and info.get("_postman_id"):
            return KIND_POSTMAN
        return None

    # Not JSON: only a YAML OpenAPI spec is expected here.
    return KIND_OPENAPI if _OPENAPI_TEXT.search(text) else None


def spec_extension(path: Path) -> str:
    """The `extension` value for a spec file, rejecting anything Levo will not parse."""
    extension = SPEC_EXTENSIONS.get(path.suffix.lower())
    if not extension:
        raise LevoPushError(
            f"{path.name}: unsupported spec extension {path.suffix or '(none)'};"
            f" expected one of {', '.join(sorted(SPEC_EXTENSIONS))}"
        )
    return extension


def expand_files(patterns: List[str]) -> List[Path]:
    """
    Files matching the given paths or globs, de-duplicated and ordered.

    A pattern that matches nothing raises: in CI a typo in a path would otherwise look exactly
    like a successful run that had nothing to do.
    """
    found: List[Path] = []
    seen = set()
    for pattern in patterns:
        matches = [Path(match) for match in sorted(glob.glob(pattern, recursive=True))]
        direct = Path(pattern)
        if not matches and direct.is_file():
            matches = [direct]
        if not matches:
            raise LevoPushError(f"no file matched {pattern!r}")
        for match in matches:
            if not match.is_file():
                continue
            resolved = match.resolve()
            if resolved not in seen:
                seen.add(resolved)
                found.append(match)
    if not found:
        raise LevoPushError("no files to push")
    return found


# --------------------------------------------------------------------------------------
# Talking to Levo
# --------------------------------------------------------------------------------------


@dataclass
class LevoClient:
    """
    Authenticated Levo SaaS client: renew a token, then run GraphQL.

    Same handshake the code scanner performs, so an auth key that works for `levo-code-scan`
    works here with no extra setup: POST /auth/renew, then org and workspace headers on every
    call.
    """

    auth_key: str
    base_url: str = DEFAULT_BASE_URL
    org_id: Optional[str] = None
    workspace_id: Optional[str] = None
    timeout: float = DEFAULT_TIMEOUT
    session: requests.Session = field(default_factory=requests.Session)
    _access_token: Optional[str] = None
    _environment_ids: Dict[str, str] = field(default_factory=dict)

    def __post_init__(self) -> None:
        if not (self.auth_key or "").strip():
            raise LevoPushError(
                "no Levo authorization key; set LEVO_AUTH_KEY or pass --auth-key."
                " Create one at <your Levo URL>/settings/keys"
            )
        self.base_url = self.base_url.rstrip("/")

    # -- authentication ------------------------------------------------------------------

    def login(self) -> None:
        """Exchanges the authorization key for an access token."""
        url = f"{self.base_url}/auth/renew?artifacts=true"
        try:
            response = self.session.post(
                url,
                headers={"content-type": "application/json"},
                json={"refresh_token": self.auth_key},
                timeout=self.timeout,
            )
        except requests.RequestException as error:
            raise LevoPushError(f"could not reach Levo at {self.base_url}: {error}") from error

        if response.status_code in (401, 403):
            raise LevoPushError(
                f"Levo rejected the authorization key (HTTP {response.status_code})."
                " Check LEVO_AUTH_KEY, and that it belongs to this Levo URL."
            )
        if not response.ok:
            raise LevoPushError(
                f"could not authenticate with Levo (HTTP {response.status_code}):"
                f" {_short(response.text)}"
            )

        body = response.json() if response.content else {}
        token = (body or {}).get("access_token")
        if not token:
            raise LevoPushError("Levo returned no access token for that authorization key")
        self._access_token = token

    def _headers(self) -> Dict[str, str]:
        if not self._access_token:
            self.login()
        headers = {
            "Authorization": f"Bearer {self._access_token}",
            "Content-Type": "application/json",
        }
        if self.org_id:
            headers["x-levo-organization-id"] = self.org_id
        if self.workspace_id:
            headers["x-levo-workspace-id"] = self.workspace_id
        return headers

    # -- GraphQL -------------------------------------------------------------------------

    def graphql(self, query: str, variables: Dict[str, Any], what: str) -> Dict[str, Any]:
        """
        Runs one query or mutation and returns its `data`.

        GraphQL answers 200 with an `errors` array, so a failure here is invisible to a plain
        status check -- which is how a red CI step would silently go green.
        """
        url = f"{self.base_url}/graphql"
        try:
            response = self.session.post(
                url,
                headers=self._headers(),
                json={"query": query, "variables": variables},
                timeout=self.timeout,
            )
        except requests.RequestException as error:
            raise LevoPushError(f"{what}: could not reach {url}: {error}") from error

        if response.status_code in (401, 403):
            raise LevoPushError(
                f"{what}: Levo rejected the request (HTTP {response.status_code})."
                " The authorization key may lack access to this organization or workspace."
            )
        if not response.ok:
            raise LevoPushError(
                f"{what}: HTTP {response.status_code} from Levo: {_short(response.text)}"
            )

        payload = response.json() if response.content else {}
        errors = (payload or {}).get("errors")
        if errors:
            raise LevoPushError(f"{what}: {errors[0].get('message', 'GraphQL error')}")
        data = (payload or {}).get("data")
        if not data:
            raise LevoPushError(f"{what}: Levo returned no data")
        return dict(data)

    # -- tenancy -------------------------------------------------------------------------

    def resolve_org(self) -> str:
        """The organization to push into, asking Levo only when it was not supplied."""
        if self.org_id:
            return self.org_id
        try:
            response = self.session.get(
                f"{self.base_url}/organizations",
                headers={"Authorization": f"Bearer {self._access_token or self._token()}"},
                timeout=self.timeout,
            )
            response.raise_for_status()
            organizations = response.json() or []
        except requests.RequestException as error:
            raise LevoPushError(f"could not list organizations: {error}") from error

        if len(organizations) != 1:
            names = ", ".join(
                f"{org.get('organizationName')} ({org.get('organizationId')})"
                for org in organizations
            )
            raise LevoPushError(
                "this key can see "
                f"{len(organizations)} organizations, so --org-id (or LEVO_ORG_ID) is required."
                f" Choices: {names or 'none'}"
            )
        self.org_id = str(organizations[0]["organizationId"])
        return self.org_id

    def _token(self) -> str:
        self.login()
        return str(self._access_token)

    def resolve_workspace(self) -> str:
        """The default workspace, unless one was supplied."""
        if self.workspace_id:
            return self.workspace_id
        data = self.graphql(
            """query GetWorkspace {
                aiLevoEntityServiceV1EntityServiceGetDefaultWorkspace {
                    workspaceId
                    workspaceName
                }
            }""",
            {},
            "resolving the workspace",
        )
        workspace = data.get("aiLevoEntityServiceV1EntityServiceGetDefaultWorkspace") or {}
        workspace_id = workspace.get("workspaceId")
        if not workspace_id:
            raise LevoPushError("Levo returned no default workspace for this organization")
        self.workspace_id = str(workspace_id)
        logger.info("workspace %s", workspace.get("workspaceName") or self.workspace_id)
        return self.workspace_id

    def resolve_environment_id(self, env_name: str) -> str:
        """
        The id of an existing environment, by name.

        Kong and Postman imports address the environment by id while a spec addresses it by name,
        so this exists to keep one `--env-name` flag on the outside. Cached: several files in one
        run share the environment.
        """
        if env_name in self._environment_ids:
            return self._environment_ids[env_name]
        data = self.graphql(
            """query GetEnvironmentByName($input: AiLevoEntityServiceV1GetEnvironmentByNameRequestInput) {
                aiLevoEntityServiceV1EntityServiceGetEnvironmentByName(input: $input) {
                    id
                    name
                }
            }""",
            {"input": {"envName": env_name}},
            f"resolving environment {env_name!r}",
        )
        environment = data.get("aiLevoEntityServiceV1EntityServiceGetEnvironmentByName") or {}
        environment_id = environment.get("id")
        if not environment_id:
            raise LevoPushError(
                f"no environment named {env_name!r} in this workspace."
                " Create it in Levo first, or pass an --env-name that exists."
            )
        self._environment_ids[env_name] = str(environment_id)
        return self._environment_ids[env_name]


def _short(text: str, limit: int = 300) -> str:
    """Trims a server body for a log line."""
    collapsed = " ".join((text or "").split())
    return collapsed[:limit] + ("..." if len(collapsed) > limit else "")


# --------------------------------------------------------------------------------------
# One pusher per kind of file
# --------------------------------------------------------------------------------------

IMPORT_GATEWAY_METADATA = """
mutation ImportGatewayMetadata($input: AiLevoEntityServiceV1ImportGatewayMetadataRequestInput!) {
  aiLevoEntityServiceV1GatewayMetadataServiceImportGatewayMetadata(input: $input) {
    gatewayId
    routesReceived
    routesUsable
    labelsApplied
    labelsRemoved
    endpointsLabelled
    endpointsCreated
    createdInApplication
    skippedNoMethodPaths
  }
}
"""

VALIDATE_OPENAPI_SPEC = """
mutation ValidateOpenApiSpec($file: AiLevoEntityServiceV1FileInput) {
  aiLevoEntityServiceV1ApiCatalogServiceValidateOpenApiSpec(input: {file: $file}) {
    valid
    errorMessages {
      errorCode
      errorMessage
    }
  }
}
"""

UPSERT_API_SCHEMA = """
mutation UpsertApiSchema($input: AiLevoEntityServiceV1UpsertApiSchemaRequestInput) {
  aiLevoEntityServiceV1ApiCatalogServiceUpsertApiSchema(input: $input) {
    appId
    schemaId
  }
}
"""

IMPORT_POSTMAN_COLLECTION = """
mutation ImportPostmanCollection($input: AiLevoApiSpecServiceV1ImportPostmanCollectionRequestInput) {
  aiLevoApiSpecServiceV1ApiSpecServiceImportPostmanCollection(input: $input) {
    success
    message
    endpointsImported
    errorMessages {
      errorCode
      errorMessage
    }
  }
}
"""


def file_input(path: Path, text: str, extension: str) -> Dict[str, Any]:
    """The FileInput shape every file-taking mutation uses."""
    return {"name": path.name, "extension": extension, "content": {"stringContent": text}}


def gateway_input(document: Dict[str, Any], environment_id: str) -> Dict[str, Any]:
    """
    Maps an exported gateway bundle (snake_case, per the file contract) onto the GraphQL input.

    Route and service counts are re-derived from the routes actually sent rather than copied, so
    the server's completeness check cannot fail on a discrepancy introduced here. `complete` must
    be an explicit true before the server is allowed to remove anything.
    """
    source = document.get("source") or {}
    snapshot = document.get("snapshot") or {}
    routes = document.get("routes") or []
    gateway_id = str(source.get("gateway_id") or "").strip()
    if not gateway_id:
        raise LevoPushError("the file has no source.gateway_id, so Levo cannot own its labels")

    return {
        "formatVersion": document.get("format_version"),
        "createInEnvironmentId": environment_id,
        "source": {
            "kind": str(source.get("kind", SOURCE_KIND_KONG)).upper(),
            "collectedAt": source.get("collected_at") or "",
            "collector": source.get("collector") or "",
            "gatewayVersion": source.get("gateway_version") or "",
            "gatewayId": gateway_id,
        },
        "snapshot": {
            "complete": snapshot.get("complete") is True,
            "routeCount": len(routes),
            "serviceCount": snapshot.get("service_count") or 0,
        },
        "routes": [
            {
                "routeId": route.get("route_id") or "",
                "routeName": route.get("route_name") or "",
                "serviceId": route.get("service_id") or "",
                "serviceName": route.get("service_name") or "",
                "paths": route.get("paths") or [],
                "methods": route.get("methods") or [],
                "routeTags": route.get("route_tags") or [],
                "serviceTags": route.get("service_tags") or [],
                "hosts": route.get("hosts") or [],
            }
            for route in routes
        ],
    }


def push_kong(client: LevoClient, path: Path, text: str, app_name: Optional[str],
              env_name: str) -> str:
    """Sends a gateway export. Labels land on endpoints Levo already knows; unseen routes become
    endpoints of their own, in one application per gateway."""
    document = parse_json(text)
    if not isinstance(document, dict):
        raise LevoPushError(f"{path.name}: not a JSON object")
    if app_name:
        logger.info("%s: --app is ignored for a gateway export; Levo decides where labels land",
                    path.name)

    variables = {"input": gateway_input(document, client.resolve_environment_id(env_name))}
    data = client.graphql(IMPORT_GATEWAY_METADATA, variables, f"importing {path.name}")
    outcome = data["aiLevoEntityServiceV1GatewayMetadataServiceImportGatewayMetadata"] or {}

    parts = [
        f"{outcome.get('labelsApplied') or 0} labels applied to"
        f" {outcome.get('endpointsLabelled') or 0} endpoints",
        f"{outcome.get('routesUsable') or 0}/{outcome.get('routesReceived') or 0} routes usable",
    ]
    if outcome.get("labelsRemoved"):
        parts.append(f"{outcome['labelsRemoved']} labels withdrawn")
    if outcome.get("endpointsCreated"):
        parts.append(
            f"{outcome['endpointsCreated']} endpoints created in"
            f" {outcome.get('createdInApplication') or 'a new application'}"
        )
    skipped = outcome.get("skippedNoMethodPaths") or []
    if skipped:
        parts.append(f"{len(skipped)} routes had no method ({', '.join(skipped[:5])})")
    return f"gateway {outcome.get('gatewayId') or '?'}: " + "; ".join(parts)


def push_openapi(client: LevoClient, path: Path, text: str, app_name: Optional[str],
                 env_name: str) -> str:
    """Validates a spec, then imports it into `app_name`."""
    if not app_name:
        raise LevoPushError(f"{path.name}: --app is required for an OpenAPI spec")
    extension = spec_extension(path)
    payload = file_input(path, text, extension)

    validation = client.graphql(
        VALIDATE_OPENAPI_SPEC, {"file": payload}, f"validating {path.name}"
    )["aiLevoEntityServiceV1ApiCatalogServiceValidateOpenApiSpec"] or {}
    if not validation.get("valid"):
        messages = [
            str(error.get("errorMessage")) for error in (validation.get("errorMessages") or [])
        ]
        raise LevoPushError(
            f"{path.name}: Levo rejected the spec"
            + (f": {'; '.join(messages[:3])}" if messages else "")
        )

    data = client.graphql(
        UPSERT_API_SCHEMA,
        {"input": {"appName": app_name, "envName": env_name, "file": payload}},
        f"importing {path.name}",
    )
    result = data["aiLevoEntityServiceV1ApiCatalogServiceUpsertApiSchema"] or {}
    return f"spec imported into {app_name} (schema {result.get('schemaId') or '?'})"


def push_postman(client: LevoClient, path: Path, text: str, app_name: Optional[str],
                 env_name: str) -> str:
    """Imports a Postman collection into `app_name`."""
    if not app_name:
        raise LevoPushError(f"{path.name}: --app is required for a Postman collection")
    data = client.graphql(
        IMPORT_POSTMAN_COLLECTION,
        {
            "input": {
                "appName": app_name,
                "envId": client.resolve_environment_id(env_name),
                "postmanCollectionFile": file_input(path, text, "json"),
            }
        },
        f"importing {path.name}",
    )
    outcome = data["aiLevoApiSpecServiceV1ApiSpecServiceImportPostmanCollection"] or {}
    if not outcome.get("success"):
        messages = [
            str(error.get("errorMessage")) for error in (outcome.get("errorMessages") or [])
        ]
        raise LevoPushError(
            f"{path.name}: Levo rejected the collection"
            + (f": {'; '.join(messages[:3])}" if messages else "")
            + (f" ({outcome['message']})" if outcome.get("message") else "")
        )
    return (
        f"collection imported into {app_name}"
        f" ({outcome.get('endpointsImported') or 0} endpoints)"
    )


#: The whole extension point. A new kind of file is one detection rule plus one entry here.
PUSHERS: Dict[str, Callable[[LevoClient, Path, str, Optional[str], str], str]] = {
    KIND_KONG: push_kong,
    KIND_OPENAPI: push_openapi,
    KIND_POSTMAN: push_postman,
}


# --------------------------------------------------------------------------------------
# Orchestration
# --------------------------------------------------------------------------------------


def read_text(path: Path) -> str:
    """The file's text, refusing an empty one."""
    try:
        text = path.read_text(encoding="utf-8")
    except OSError as error:
        raise LevoPushError(f"{path}: could not read the file: {error}") from error
    except UnicodeDecodeError as error:
        raise LevoPushError(f"{path}: not a text file ({error.reason})") from error
    if not text.strip():
        raise LevoPushError(f"{path}: the file is empty")
    return text


def push_file(client: LevoClient, path: Path, app_name: Optional[str], env_name: str,
              only_kind: Optional[str] = None) -> PushResult:
    """
    Pushes one file, returning its outcome rather than raising.

    A run over several files reports every one of them: stopping at the first failure would leave
    the customer guessing which of the rest had landed.
    """
    try:
        text = read_text(path)
        kind = detect_kind(text)
        if kind is None:
            return PushResult(path, None, False, "not a file Levo can ingest; skipped")
        if only_kind and kind != only_kind:
            return PushResult(
                path, kind, False,
                f"looks like {kind}, but --type {only_kind} was requested; skipped"
            )
        detail = PUSHERS[kind](client, path, text, app_name, env_name)
        return PushResult(path, kind, True, detail)
    except LevoPushError as error:
        return PushResult(path, None, False, str(error))


@dataclass
class PushSummary:
    """What a whole run did, for the CLI's exit code and closing lines."""

    results: List[PushResult]

    @property
    def pushed(self) -> List[PushResult]:
        return [result for result in self.results if result.ok]

    @property
    def failed(self) -> List[PushResult]:
        return [result for result in self.results if not result.ok]

    @property
    def ok(self) -> bool:
        return bool(self.results) and not self.failed


def push_files(client: LevoClient, patterns: List[str], app_name: Optional[str], env_name: str,
               only_kind: Optional[str] = None) -> PushSummary:
    """
    Resolves the patterns, authenticates once, and pushes every matched file.

    Tenancy is resolved before the first file so a missing organization or workspace fails the run
    immediately instead of once per file.
    """
    paths = expand_files(patterns)
    client.login()
    client.resolve_org()
    client.resolve_workspace()

    results: List[PushResult] = []
    for path in paths:
        logger.info("pushing %s", path)
        result = push_file(client, path, app_name, env_name, only_kind)
        level = logging.INFO if result.ok else logging.ERROR
        logger.log(level, "%s: %s", path.name, result.detail)
        results.append(result)
    return PushSummary(results)


def summarise(summary: PushSummary) -> Tuple[str, ...]:
    """The closing lines a CI log should end with."""
    lines = [f"{len(summary.pushed)} of {len(summary.results)} file(s) pushed"]
    for result in summary.failed:
        lines.append(f"  failed: {result.path} -- {result.detail}")
    return tuple(lines)


import argparse
import logging
import os
import sys
from typing import List, Optional

logger = logging.getLogger(__name__)

EXIT_OK = 0
EXIT_FAILED = 1
EXIT_USAGE = 2

KINDS = (KIND_KONG, KIND_OPENAPI, KIND_POSTMAN)


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="levo-push",
        description="Push a Kong export, an OpenAPI spec, or a Postman collection into Levo. "
        "Reads the files your repository already has; never contacts your gateway.",
        epilog="Every option can come from an environment variable instead: LEVO_AUTH_KEY, "
        "LEVO_ORG_ID, LEVO_WORKSPACE_ID, LEVO_BASE_URL, LEVO_APP_NAME, LEVO_ENV_NAME.",
    )
    parser.add_argument(
        "--file",
        dest="files",
        action="append",
        required=True,
        metavar="PATH_OR_GLOB",
        help="A file to push. Repeatable, and accepts globs: --file 'kong/*.json' "
        "--file specs/openapi.yaml. What each file is gets read from its contents, not its name.",
    )
    parser.add_argument(
        "--app",
        dest="app_name",
        default=os.getenv("LEVO_APP_NAME"),
        help="Application in Levo to import a spec or Postman collection into. Required for "
        "those; ignored for a gateway export, where Levo decides which endpoints the labels "
        "belong to.",
    )
    parser.add_argument(
        "--env-name",
        dest="env_name",
        default=os.getenv("LEVO_ENV_NAME", "default"),
        help="Environment in Levo to import into. Must already exist. Defaults to 'default'.",
    )
    parser.add_argument(
        "--type",
        dest="only_kind",
        choices=KINDS,
        default=None,
        help="Push only files of this kind and skip the rest. Without it every recognised file "
        "is pushed.",
    )
    parser.add_argument(
        "--auth-key",
        dest="auth_key",
        default=os.getenv("LEVO_AUTH_KEY"),
        help="Levo authorization key. Prefer the LEVO_AUTH_KEY environment variable so it does "
        "not land in shell history or CI logs.",
    )
    parser.add_argument(
        "--org-id",
        dest="org_id",
        default=os.getenv("LEVO_ORG_ID"),
        help="Levo organization id. Optional when the key can see exactly one organization.",
    )
    parser.add_argument(
        "--workspace-id",
        dest="workspace_id",
        default=os.getenv("LEVO_WORKSPACE_ID"),
        help="Levo workspace id. Defaults to the organization's default workspace.",
    )
    parser.add_argument(
        "--saas-url",
        dest="base_url",
        default=os.getenv("LEVO_BASE_URL", DEFAULT_BASE_URL),
        help=f"Levo API URL for your account. Defaults to {DEFAULT_BASE_URL}.",
    )
    parser.add_argument(
        "--timeout",
        type=float,
        default=60.0,
        help="Per-request timeout in seconds. Defaults to 60.",
    )
    parser.add_argument("--verbose", action="store_true", help="Log every request Levo is sent.")
    return parser


def main(argv: Optional[List[str]] = None) -> int:
    args = build_parser().parse_args(argv)
    logging.basicConfig(
        level=logging.DEBUG if args.verbose else logging.INFO,
        format="%(levelname)s %(message)s",
    )

    try:
        client = LevoClient(
            auth_key=args.auth_key or "",
            base_url=args.base_url,
            org_id=args.org_id,
            workspace_id=args.workspace_id,
            timeout=args.timeout,
        )
    except LevoPushError as error:
        logger.error("%s", error)
        return EXIT_USAGE

    try:
        summary = push_files(client, args.files, args.app_name, args.env_name, args.only_kind)
    except LevoPushError as error:
        # Nothing was pushed: a bad path, a rejected key, or a missing environment.
        logger.error("%s", error)
        return EXIT_USAGE

    for line in summarise(summary):
        logger.log(logging.INFO if summary.ok else logging.ERROR, "%s", line)
    return EXIT_OK if summary.ok else EXIT_FAILED


if __name__ == "__main__":
    sys.exit(main())
