diff --git a/README.md b/README.md index 06d6535..cac0983 100644 --- a/README.md +++ b/README.md @@ -25,15 +25,9 @@ This module is powered by [httpx](https://github.com/encode/httpx/tree/master/ht This module requires python >= 3.8 -=== "pip" - ```sh - pip install lightkube - ``` - -=== "uv" - ```sh - uv add lightkube - ``` +```sh +pip install lightkube +``` ## Usage @@ -41,238 +35,120 @@ This module requires python >= 3.8 Read a pod -=== "Sync" - ```python - from lightkube import Client - from lightkube.resources.core_v1 import Pod - - client = Client() - pod = client.get(Pod, name="my-pod", namespace="default") - print(pod.namespace.uid) - ``` - -=== "Async" - ```python - from lightkube import AsyncClient - from lightkube.resources.core_v1 import Pod +```python +from lightkube import Client +from lightkube.resources.core_v1 import Pod - async def example(): - client = AsyncClient() - pod = await client.get(Pod, name="my-pod", namespace="default") - print(pod.namespace.uid) - ``` +client = Client() +pod = client.get(Pod, name="my-pod", namespace="default") +print(pod.namespace.uid) +``` List nodes -=== "Sync" - ```python - from lightkube import Client - from lightkube.resources.core_v1 import Node +```python +from lightkube import Client +from lightkube.resources.core_v1 import Node - client = Client() - for node in client.list(Node): - print(node.metadata.name) - ``` - -=== "Async" - ```python - from lightkube import AsyncClient - from lightkube.resources.core_v1 import Node - - async def example(): - client = AsyncClient() - async for node in client.list(Node): - print(node.metadata.name) - ``` +client = Client() +for node in client.list(Node): + print(node.metadata.name) +``` ### Create Create a config map -=== "Sync" - ```python - from lightkube import Client - from lightkube.resources.core_v1 import ConfigMap - from lightkube.models.meta_v1 import ObjectMeta - - client = Client() - config = ConfigMap( - metadata=ObjectMeta(name='my-config', namespace='default'), - data={'key1': 'value1', 'key2': 'value2'} - ) - - client.create(config) - ``` - -=== "Async" - ```python - from lightkube import AsyncClient - from lightkube.resources.core_v1 import ConfigMap - from lightkube.models.meta_v1 import ObjectMeta - - async def example(): - client = AsyncClient() - config = ConfigMap( - metadata=ObjectMeta(name='my-config', namespace='default'), - data={'key1': 'value1', 'key2': 'value2'} - ) - await client.create(config) - ``` +```python +from lightkube import Client +from lightkube.resources.core_v1 import ConfigMap +from lightkube.models.meta_v1 import ObjectMeta -Create resources defined in a file +client = Client() +config = ConfigMap( + metadata=ObjectMeta(name='my-config', namespace='default'), + data={'key1': 'value1', 'key2': 'value2'} +) -=== "Sync" - ```python - from lightkube import Client, codecs +client.create(config) +``` - client = Client() - with open('deployment.yaml') as f: - for obj in codecs.load_all_yaml(f): - client.create(obj) - ``` +Create resources defined in a file -=== "Async" - ```python - from lightkube import AsyncClient, codecs +```python +from lightkube import Client, codecs - async def example(): - client = AsyncClient() - with open('deployment.yaml') as f: - for obj in codecs.load_all_yaml(f): - await client.create(obj) - ``` +client = Client() +with open('deployment.yaml') as f: + for obj in codecs.load_all_yaml(f): + client.create(obj) +``` ### Modify Replace the previous config with a different content -=== "Sync" - ```python - config.data['key1'] = 'new value' - client.replace(config) - ``` - -=== "Async" - ```python - config.data['key1'] = 'new value' - await client.replace(config) - ``` +```python +config.data['key1'] = 'new value' +client.replace(config) +``` Patch an existing config adding more data -=== "Sync" - ```python - patch = {"data": {"key3": "value3"}} - client.patch(ConfigMap, name="my-config", obj=patch) - ``` - -=== "Async" - ```python - patch = {"data": {"key3": "value3"}} - await client.patch(ConfigMap, name='my-config', obj=patch) - ``` +```python +patch = {"data": {"key3": "value3"}} +client.patch(ConfigMap, name="my-config", obj=patch) +``` Remove the just added data key `key3` -=== "Sync" - ```python - # When using PatchType.MERGE, setting a value of a key/value to None, will remove the current item - patch = {'metadata': {"key3": None}} - client.patch(ConfigMap, name='my-config', namespace='default', obj=patch, patch_type=PatchType.MERGE) - ``` - -=== "Async" - ```python - # When using PatchType.MERGE, setting a value of a key/value to None, will remove the current item - patch = {'metadata': {"key3": None}} - await client.patch(ConfigMap, name='my-config', namespace='default', obj=patch, patch_type=PatchType.MERGE) - ``` +```python +# When using PatchType.MERGE, setting a value of a key/value to None, will remove the current item +patch = {'metadata': {"key3": None}} +client.patch(ConfigMap, name='my-config', namespace='default', obj=patch, patch_type=PatchType.MERGE) +``` Add a label -=== "Sync" - ```python - client.set(ConfigMap, name="my-config", labels={'env': 'prod'}) - ``` - -=== "Async" - ```python - await client.set(ConfigMap, name="my-config", labels={'env': 'prod'}) - ``` +```python +client.set(ConfigMap, name="my-config", labels={'env': 'prod'}) +``` Remove a label -=== "Sync" - ```python - client.set(ConfigMap, name="my-config", labels={'env': None}) - ``` - -=== "Async" - ```python - await client.set(ConfigMap, name="my-config", labels={'env': None}) - ``` +```python +client.set(ConfigMap, name="my-config", labels={'env': None}) +``` Scale a deployment -=== "Sync" - ```python - from lightkube import Client - from lightkube.resources.apps_v1 import Deployment - from lightkube.models.meta_v1 import ObjectMeta - from lightkube.models.autoscaling_v1 import ScaleSpec - - client = Client() - obj = Deployment.Scale( - metadata=ObjectMeta(name='metrics-server', namespace='kube-system'), - spec=ScaleSpec(replicas=1) - ) - client.replace(obj) - ``` - -=== "Async" - ```python - from lightkube import AsyncClient - from lightkube.resources.apps_v1 import Deployment - from lightkube.models.meta_v1 import ObjectMeta - from lightkube.models.autoscaling_v1 import ScaleSpec - - async def example(): - client = AsyncClient() - obj = Deployment.Scale( - metadata=ObjectMeta(name='metrics-server', namespace='kube-system'), - spec=ScaleSpec(replicas=1) - ) - await client.replace(obj, 'metrics-server', namespace='kube-system') - ``` +```python +from lightkube import Client +from lightkube.resources.apps_v1 import Deployment +from lightkube.models.meta_v1 import ObjectMeta +from lightkube.models.autoscaling_v1 import ScaleSpec + +client = Client() +obj = Deployment.Scale( + metadata=ObjectMeta(name='metrics-server', namespace='kube-system'), + spec=ScaleSpec(replicas=1) +) +client.replace(obj) +``` Update Status of a deployment -=== "Sync" - ```python - from lightkube import Client - from lightkube.resources.apps_v1 import Deployment - from lightkube.models.apps_v1 import DeploymentStatus - - client = Client() - obj = Deployment.Status( - status=DeploymentStatus(observedGeneration=99) - ) - client.apply(obj, name='metrics-server', namespace='kube-system') - ``` - -=== "Async" - ```python - from lightkube import AsyncClient - from lightkube.resources.apps_v1 import Deployment - from lightkube.models.apps_v1 import DeploymentStatus - - async def example(): - client = AsyncClient() - obj = Deployment.Status( - status=DeploymentStatus(observedGeneration=99) - ) - await client.apply(obj, name='metrics-server', namespace='kube-system') - ``` +```python +from lightkube import Client +from lightkube.resources.apps_v1 import Deployment +from lightkube.models.apps_v1 import DeploymentStatus + +client = Client() +obj = Deployment.Status( + status=DeploymentStatus(observedGeneration=99) +) +client.apply(obj, name='metrics-server', namespace='kube-system') +``` Create and modify resources using [server side apply](https://kubernetes.io/docs/reference/using-api/server-side-apply/) @@ -280,155 +156,79 @@ Create and modify resources using [server side apply](https://kubernetes.io/docs or when calling `apply()`. Also `apiVersion` and `kind` need to be provided as part of the object definition. -=== "Sync" - ```python - from lightkube.resources.core_v1 import ConfigMap - from lightkube.models.meta_v1 import ObjectMeta - - client = Client(field_manager="my-manager") - config = ConfigMap( - # note apiVersion and kind need to be specified for server-side apply - apiVersion='v1', kind='ConfigMap', - metadata=ObjectMeta(name='my-config', namespace='default'), - data={'key1': 'value1', 'key2': 'value2'} - ) - - res = client.apply(config) - print(res.data) - # prints {'key1': 'value1', 'key2': 'value2'} - - del config.data['key1'] - config.data['key3'] = 'value3' - - res = client.apply(config) - print(res.data) - # prints {'key2': 'value2', 'key3': 'value3'} - ``` - -=== "Async" - ```python - from lightkube import AsyncClient - from lightkube.resources.core_v1 import ConfigMap - from lightkube.models.meta_v1 import ObjectMeta - - async def example(): - client = AsyncClient(field_manager="my-manager") - config = ConfigMap( - apiVersion='v1', kind='ConfigMap', - metadata=ObjectMeta(name='my-config', namespace='default'), - data={'key1': 'value1', 'key2': 'value2'} - ) - - res = await client.apply(config) - print(res.data) - # prints {'key1': 'value1', 'key2': 'value2'} - - del config.data['key1'] - config.data['key3'] = 'value3' - - res = await client.apply(config) - print(res.data) - # prints {'key2': 'value2', 'key3': 'value3'} - ``` +```python +from lightkube.resources.core_v1 import ConfigMap +from lightkube.models.meta_v1 import ObjectMeta + +client = Client(field_manager="my-manager") +config = ConfigMap( + # note apiVersion and kind need to be specified for server-side apply + apiVersion='v1', kind='ConfigMap', + metadata=ObjectMeta(name='my-config', namespace='default'), + data={'key1': 'value1', 'key2': 'value2'} +) + +res = client.apply(config) +print(res.data) +# prints {'key1': 'value1', 'key2': 'value2'} + +del config.data['key1'] +config.data['key3'] = 'value3' + +res = client.apply(config) +print(res.data) +# prints {'key2': 'value2', 'key3': 'value3'} +``` ### Delete Delete a namespaced resource -=== "Sync" - ```python - client.delete(ConfigMap, name='my-config', namespace='default') - ``` - -=== "Async" - ```python - await client.delete(ConfigMap, name='my-config', namespace='default') - ``` +```python +client.delete(ConfigMap, name='my-config', namespace='default') +``` ### Monitoring & Interaction Watch deployments -=== "Sync" - ```python - from lightkube import Client - from lightkube.resources.apps_v1 import Deployment - - client = Client() - for op, dep in client.watch(Deployment, namespace="default"): - print(f"{dep.namespace.name} {dep.spec.replicas}") - ``` - -=== "Async" - ```python - from lightkube import AsyncClient - from lightkube.resources.apps_v1 import Deployment +```python +from lightkube import Client +from lightkube.resources.apps_v1 import Deployment - async def example(): - client = AsyncClient() - async for op, dep in client.watch(Deployment, namespace="default"): - print(f"{dep.namespace.name} {dep.spec.replicas}") - ``` +client = Client() +for op, dep in client.watch(Deployment, namespace="default"): + print(f"{dep.namespace.name} {dep.spec.replicas}") +``` Stream pod logs -=== "Sync" - ```python - from lightkube import Client +```python +from lightkube import Client - client = Client() - for line in client.log('my-pod', follow=True): - print(line) - ``` +client = Client() +for line in client.log('my-pod', follow=True): + print(line) +``` -=== "Async" - ```python - from lightkube import AsyncClient +Execute a command inside a pod - async def example(): - client = AsyncClient() - async for line in client.log('my-pod', follow=True): - print(line) - ``` +```python +from lightkube import Client -Execute a command inside a pod +client = Client() + +# Capture stdout or raise ApiError if error code is != 0 +res = client.exec('my-pod', namespace='default', command=['ls', '-l', '/'], + stdout=True, raise_on_error=True) +print(res.stdout) -=== "Sync" - ```python - from lightkube import Client - - client = Client() - - # Capture stdout or raise ApiError if error code is != 0 - res = client.exec('my-pod', namespace='default', command=['ls', '-l', '/'], - stdout=True, raise_on_error=True) - print(res.stdout) - - # Send data to stdin and capture output - res = client.exec('my-pod', namespace='default', command=['cat'], - stdin='hello\n', stdout=True) - print(res.stdout) - print(res.exit_code) - ``` - -=== "Async" - ```python - from lightkube import AsyncClient - - async def example(): - client = AsyncClient() - - # List a directory - res = await client.exec('my-pod', namespace='default', command=['ls', '-l', '/'], - stdout=True, raise_on_error=True) - print(res.stdout) - - # Send data to stdin and capture output - res = await client.exec('my-pod', namespace='default', command=['cat'], - stdin='hello\\n', stdout=True) - print(res.stdout) - print(res.exit_code) - ``` +# Send data to stdin and capture output +res = client.exec('my-pod', namespace='default', command=['cat'], + stdin='hello\n', stdout=True) +print(res.stdout) +print(res.exit_code) +``` ## Unsupported features diff --git a/docs/index.md b/docs/index.md index 563ed56..06d6535 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1 +1,439 @@ -{!README.md!} +# lightkube + +![](https://img.shields.io/github/actions/workflow/status/gtsystem/lightkube/python-package.yml?branch=master) +[![Coverage Status](https://coveralls.io/repos/github/gtsystem/lightkube/badge.svg?branch=master)](https://coveralls.io/github/gtsystem/lightkube?branch=master) +[![pypi supported versions](https://img.shields.io/pypi/pyversions/lightkube.svg)](https://pypi.python.org/pypi/lightkube) + +Modern lightweight kubernetes module for python + + +## Highlights + +* *Simple* interface shared across all kubernetes APIs. +* Extensive *type hints* to avoid common mistakes and to support autocompletion. +* Models and resources generated from the swagger specifications using standard dataclasses. +* Load/Dump resource objects from YAML. +* Support for async/await +* Support for installing a specific version of the kubernetes models (1.20 to 1.35) +* Lazy instantiation of inner models. +* Fast startup and small memory footprint as only needed models and resources can be imported. +* Automatic handling of pagination when listing resources. + +This module is powered by [httpx](https://github.com/encode/httpx/tree/master/httpx). + +## Installation + +This module requires python >= 3.8 + +=== "pip" + ```sh + pip install lightkube + ``` + +=== "uv" + ```sh + uv add lightkube + ``` + +## Usage + +### List + +Read a pod + +=== "Sync" + ```python + from lightkube import Client + from lightkube.resources.core_v1 import Pod + + client = Client() + pod = client.get(Pod, name="my-pod", namespace="default") + print(pod.namespace.uid) + ``` + +=== "Async" + ```python + from lightkube import AsyncClient + from lightkube.resources.core_v1 import Pod + + async def example(): + client = AsyncClient() + pod = await client.get(Pod, name="my-pod", namespace="default") + print(pod.namespace.uid) + ``` + +List nodes + +=== "Sync" + ```python + from lightkube import Client + from lightkube.resources.core_v1 import Node + + client = Client() + for node in client.list(Node): + print(node.metadata.name) + ``` + +=== "Async" + ```python + from lightkube import AsyncClient + from lightkube.resources.core_v1 import Node + + async def example(): + client = AsyncClient() + async for node in client.list(Node): + print(node.metadata.name) + ``` + +### Create + +Create a config map + +=== "Sync" + ```python + from lightkube import Client + from lightkube.resources.core_v1 import ConfigMap + from lightkube.models.meta_v1 import ObjectMeta + + client = Client() + config = ConfigMap( + metadata=ObjectMeta(name='my-config', namespace='default'), + data={'key1': 'value1', 'key2': 'value2'} + ) + + client.create(config) + ``` + +=== "Async" + ```python + from lightkube import AsyncClient + from lightkube.resources.core_v1 import ConfigMap + from lightkube.models.meta_v1 import ObjectMeta + + async def example(): + client = AsyncClient() + config = ConfigMap( + metadata=ObjectMeta(name='my-config', namespace='default'), + data={'key1': 'value1', 'key2': 'value2'} + ) + await client.create(config) + ``` + +Create resources defined in a file + +=== "Sync" + ```python + from lightkube import Client, codecs + + client = Client() + with open('deployment.yaml') as f: + for obj in codecs.load_all_yaml(f): + client.create(obj) + ``` + +=== "Async" + ```python + from lightkube import AsyncClient, codecs + + async def example(): + client = AsyncClient() + with open('deployment.yaml') as f: + for obj in codecs.load_all_yaml(f): + await client.create(obj) + ``` + +### Modify + +Replace the previous config with a different content + +=== "Sync" + ```python + config.data['key1'] = 'new value' + client.replace(config) + ``` + +=== "Async" + ```python + config.data['key1'] = 'new value' + await client.replace(config) + ``` + +Patch an existing config adding more data + +=== "Sync" + ```python + patch = {"data": {"key3": "value3"}} + client.patch(ConfigMap, name="my-config", obj=patch) + ``` + +=== "Async" + ```python + patch = {"data": {"key3": "value3"}} + await client.patch(ConfigMap, name='my-config', obj=patch) + ``` + +Remove the just added data key `key3` + +=== "Sync" + ```python + # When using PatchType.MERGE, setting a value of a key/value to None, will remove the current item + patch = {'metadata': {"key3": None}} + client.patch(ConfigMap, name='my-config', namespace='default', obj=patch, patch_type=PatchType.MERGE) + ``` + +=== "Async" + ```python + # When using PatchType.MERGE, setting a value of a key/value to None, will remove the current item + patch = {'metadata': {"key3": None}} + await client.patch(ConfigMap, name='my-config', namespace='default', obj=patch, patch_type=PatchType.MERGE) + ``` + +Add a label + +=== "Sync" + ```python + client.set(ConfigMap, name="my-config", labels={'env': 'prod'}) + ``` + +=== "Async" + ```python + await client.set(ConfigMap, name="my-config", labels={'env': 'prod'}) + ``` + +Remove a label + +=== "Sync" + ```python + client.set(ConfigMap, name="my-config", labels={'env': None}) + ``` + +=== "Async" + ```python + await client.set(ConfigMap, name="my-config", labels={'env': None}) + ``` + +Scale a deployment + +=== "Sync" + ```python + from lightkube import Client + from lightkube.resources.apps_v1 import Deployment + from lightkube.models.meta_v1 import ObjectMeta + from lightkube.models.autoscaling_v1 import ScaleSpec + + client = Client() + obj = Deployment.Scale( + metadata=ObjectMeta(name='metrics-server', namespace='kube-system'), + spec=ScaleSpec(replicas=1) + ) + client.replace(obj) + ``` + +=== "Async" + ```python + from lightkube import AsyncClient + from lightkube.resources.apps_v1 import Deployment + from lightkube.models.meta_v1 import ObjectMeta + from lightkube.models.autoscaling_v1 import ScaleSpec + + async def example(): + client = AsyncClient() + obj = Deployment.Scale( + metadata=ObjectMeta(name='metrics-server', namespace='kube-system'), + spec=ScaleSpec(replicas=1) + ) + await client.replace(obj, 'metrics-server', namespace='kube-system') + ``` + +Update Status of a deployment + +=== "Sync" + ```python + from lightkube import Client + from lightkube.resources.apps_v1 import Deployment + from lightkube.models.apps_v1 import DeploymentStatus + + client = Client() + obj = Deployment.Status( + status=DeploymentStatus(observedGeneration=99) + ) + client.apply(obj, name='metrics-server', namespace='kube-system') + ``` + +=== "Async" + ```python + from lightkube import AsyncClient + from lightkube.resources.apps_v1 import Deployment + from lightkube.models.apps_v1 import DeploymentStatus + + async def example(): + client = AsyncClient() + obj = Deployment.Status( + status=DeploymentStatus(observedGeneration=99) + ) + await client.apply(obj, name='metrics-server', namespace='kube-system') + ``` + +Create and modify resources using [server side apply](https://kubernetes.io/docs/reference/using-api/server-side-apply/) + +*Note:* `field_manager` is required for server-side apply. You can specify it once in the client constructor +or when calling `apply()`. Also `apiVersion` and `kind` need to be provided as part of +the object definition. + +=== "Sync" + ```python + from lightkube.resources.core_v1 import ConfigMap + from lightkube.models.meta_v1 import ObjectMeta + + client = Client(field_manager="my-manager") + config = ConfigMap( + # note apiVersion and kind need to be specified for server-side apply + apiVersion='v1', kind='ConfigMap', + metadata=ObjectMeta(name='my-config', namespace='default'), + data={'key1': 'value1', 'key2': 'value2'} + ) + + res = client.apply(config) + print(res.data) + # prints {'key1': 'value1', 'key2': 'value2'} + + del config.data['key1'] + config.data['key3'] = 'value3' + + res = client.apply(config) + print(res.data) + # prints {'key2': 'value2', 'key3': 'value3'} + ``` + +=== "Async" + ```python + from lightkube import AsyncClient + from lightkube.resources.core_v1 import ConfigMap + from lightkube.models.meta_v1 import ObjectMeta + + async def example(): + client = AsyncClient(field_manager="my-manager") + config = ConfigMap( + apiVersion='v1', kind='ConfigMap', + metadata=ObjectMeta(name='my-config', namespace='default'), + data={'key1': 'value1', 'key2': 'value2'} + ) + + res = await client.apply(config) + print(res.data) + # prints {'key1': 'value1', 'key2': 'value2'} + + del config.data['key1'] + config.data['key3'] = 'value3' + + res = await client.apply(config) + print(res.data) + # prints {'key2': 'value2', 'key3': 'value3'} + ``` + +### Delete + +Delete a namespaced resource + +=== "Sync" + ```python + client.delete(ConfigMap, name='my-config', namespace='default') + ``` + +=== "Async" + ```python + await client.delete(ConfigMap, name='my-config', namespace='default') + ``` + +### Monitoring & Interaction + +Watch deployments + +=== "Sync" + ```python + from lightkube import Client + from lightkube.resources.apps_v1 import Deployment + + client = Client() + for op, dep in client.watch(Deployment, namespace="default"): + print(f"{dep.namespace.name} {dep.spec.replicas}") + ``` + +=== "Async" + ```python + from lightkube import AsyncClient + from lightkube.resources.apps_v1 import Deployment + + async def example(): + client = AsyncClient() + async for op, dep in client.watch(Deployment, namespace="default"): + print(f"{dep.namespace.name} {dep.spec.replicas}") + ``` + +Stream pod logs + +=== "Sync" + ```python + from lightkube import Client + + client = Client() + for line in client.log('my-pod', follow=True): + print(line) + ``` + +=== "Async" + ```python + from lightkube import AsyncClient + + async def example(): + client = AsyncClient() + async for line in client.log('my-pod', follow=True): + print(line) + ``` + +Execute a command inside a pod + +=== "Sync" + ```python + from lightkube import Client + + client = Client() + + # Capture stdout or raise ApiError if error code is != 0 + res = client.exec('my-pod', namespace='default', command=['ls', '-l', '/'], + stdout=True, raise_on_error=True) + print(res.stdout) + + # Send data to stdin and capture output + res = client.exec('my-pod', namespace='default', command=['cat'], + stdin='hello\n', stdout=True) + print(res.stdout) + print(res.exit_code) + ``` + +=== "Async" + ```python + from lightkube import AsyncClient + + async def example(): + client = AsyncClient() + + # List a directory + res = await client.exec('my-pod', namespace='default', command=['ls', '-l', '/'], + stdout=True, raise_on_error=True) + print(res.stdout) + + # Send data to stdin and capture output + res = await client.exec('my-pod', namespace='default', command=['cat'], + stdin='hello\\n', stdout=True) + print(res.stdout) + print(res.exit_code) + ``` + +## Unsupported features + +The following features are not supported at the moment: + +* Special subresources `attach`, `portforward` and `proxy`. +* `auth-provider` authentication method is not supported. The supported authentication methods are `token`, `username` + `password` and `exec`. + diff --git a/scripts/update_readme.py b/scripts/update_readme.py new file mode 100644 index 0000000..6cfbc1b --- /dev/null +++ b/scripts/update_readme.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Convert pymdownx.tabbed markdown to plain markdown keeping only first tab's content. + +Default: read `docs/index.md` and write `README.md` in repo root. + +The script removes tab markers like: + +=== "Tab title" + indented content... + +and keeps only the content of the first tab for each tab group, unindenting it. +""" +from __future__ import annotations + +import argparse +import re +from pathlib import Path +from typing import List, Optional + + +MARKER_RE = re.compile(r'^[ \t]*===[ \t]*["\'].*?["\'][ \t]*$') + + +def find_next_marker(lines: List[str], start: int) -> Optional[int]: + n = len(lines) + for idx in range(start + 1, n): + if MARKER_RE.match(lines[idx]): + return idx + return None + + +def all_indented_or_blank(lines: List[str], a: int, b: int) -> bool: + # check lines[a:b] (exclusive of b) are blank or start with space/tab + for L in lines[a:b]: + if L.strip() == '': + continue + if L.startswith(' ') or L.startswith('\t'): + continue + return False + return True + + +def unindent_block(block: List[str]) -> List[str]: + # remove common leading indentation (spaces or tabs) from non-blank lines + indents = [] + for L in block: + if L.strip() == '': + continue + m = re.match(r'^[ \t]*', L) + indents.append(len(m.group(0))) + if not indents: + return block + strip = min(indents) + if strip == 0: + return block + out = [] + for L in block: + if L.startswith(' ' * strip) or L.startswith('\t' * strip): + out.append(L[strip:]) + else: + # if line is shorter than strip or different indentation, lstrip just up to strip spaces + out.append(L.lstrip(' \t')) + return out + + +def transform(lines: List[str]) -> List[str]: + out: List[str] = [] + i = 0 + n = len(lines) + while i < n: + line = lines[i] + if MARKER_RE.match(line): + # Found a tab marker. Collect the immediately following indented + # block (the first tab content) and append its unindented form. + start = i + 1 + end = start + while end < n and (lines[end].strip() == '' or lines[end].startswith(' ') or lines[end].startswith('\t')): + end += 1 + block = lines[start:end] + out.extend(unindent_block(block)) + + # Advance to the next non-indented line after the first tab block. + i = end + + # Now skip any immediately consecutive tab markers with only indented + # content between them (they belong to the same tab group). Stop when + # a non-indented line or EOF is encountered. + while i < n and MARKER_RE.match(lines[i]): + # determine the block after this marker + s = i + 1 + e = s + while e < n and (lines[e].strip() == '' or lines[e].startswith(' ') or lines[e].startswith('\t')): + e += 1 + # skip this marker+block by advancing i to e + i = e + + continue + + # not a marker, copy line as-is + out.append(line) + i += 1 + + return out + + +def main(argv=None) -> int: + p = argparse.ArgumentParser(description='Keep only first tab content from pymdownx.tabbed markdown') + p.add_argument('--source', '-s', default='docs/index.md', help='Source markdown file') + p.add_argument('--dest', '-d', default='README.md', help='Destination markdown file') + args = p.parse_args(argv) + + src = Path(args.source) + dst = Path(args.dest) + + if not src.exists(): + print(f'Error: source {src} not found') + return 2 + + text = src.read_text(encoding='utf-8') + lines = text.splitlines(keepends=True) + out_lines = transform(lines) + dst.parent.mkdir(parents=True, exist_ok=True) + dst.write_text(''.join(out_lines), encoding='utf-8') + print(f'Wrote {dst}') + return 0 + + +if __name__ == '__main__': + raise SystemExit(main())