ahaz_cli 0.0.4__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,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: ahaz_cli
3
+ Version: 0.0.4
4
+ Summary: Command-line management interface for Ahaz
5
+ Requires-Dist: docker>=7.1.0
6
+ Requires-Dist: rich>=14.2.0
7
+ Requires-Dist: typer>=0.20.0
8
+ Requires-Dist: ahaz_common
9
+ Requires-Dist: pyyaml>=6.0.3
File without changes
@@ -0,0 +1,4 @@
1
+ if __name__ == "__main__":
2
+ from .cli import app
3
+
4
+ app()
@@ -0,0 +1,128 @@
1
+ import colorsys
2
+ import logging
3
+ import time
4
+ from pathlib import Path
5
+ from typing import Annotated
6
+
7
+ import docker
8
+ import docker.errors
9
+ import typer
10
+ from rich.status import Status
11
+
12
+ from .lib.docker import cleanup_env, create_env, log_docker_logs, try_build_image
13
+ from .lib.file import test_for_file
14
+ from .lib.task import deserialise_task, normalise_task_name
15
+ from .templates import copy_example_images, write_task_yaml
16
+
17
+ log = logging.getLogger(__name__)
18
+ CWD = Path.cwd()
19
+
20
+
21
+ def test(
22
+ build: Annotated[bool, typer.Option("--build", "-b", help="Always build Docker images.")] = False,
23
+ verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Enable verbose logging.")] = False,
24
+ up: Annotated[bool, typer.Option("--up", "-u", help="Start the task environment after testing.")] = False,
25
+ ) -> None:
26
+ """
27
+ Test the task configuration by validating the config file and attempting to build task images.
28
+
29
+ Optionally starts the task environment after testing.
30
+ """
31
+
32
+ if verbose:
33
+ log.setLevel(logging.DEBUG)
34
+
35
+ config = "task.yaml"
36
+ if not test_for_file(config):
37
+ config = "task.yml"
38
+ if not test_for_file(config):
39
+ log.error("No task configuration file found (task.yaml or task.yml)")
40
+ log.error("Are you sure you are in the task directory?")
41
+ return
42
+
43
+ task = deserialise_task(Path(config).read_text())
44
+ log.info(f"Loaded task: {task.name}")
45
+
46
+ log.info("Testing Docker images for all pods...")
47
+ client = docker.from_env()
48
+ for pod in task.pods:
49
+ with Status(f"Checking image for pod '{pod.name}'...", spinner="dots") as status:
50
+ image_tag = f"{pod.image.image_name}:{task.version}"
51
+ if not build:
52
+ # See if we can find the image locally
53
+ try:
54
+ client.images.get(image_tag)
55
+ status.update(f"Image '{image_tag}' found locally.")
56
+ log.info(f"Image '{image_tag}' found locally for pod '{pod.name}'.")
57
+ continue
58
+ except docker.errors.ImageNotFound:
59
+ log.info(f"Image '{image_tag}' not found locally for pod '{pod.name}', building...")
60
+ # Build the image
61
+ status.update(f"Building image '{image_tag}'...")
62
+ log.info(f"Building image '{image_tag}' for pod '{pod.name}'...")
63
+ build_args = {arg.name: arg.value for arg in (pod.image.build_args or [])}
64
+ try:
65
+ try_build_image(image_tag, pod.image.build_context, build_args, verbose)
66
+ except Exception as e:
67
+ log.error(f"Failed to build image '{image_tag}' for pod '{pod.name}': {e}")
68
+ return
69
+
70
+ # Attempt to set up the entire task environment
71
+ if up:
72
+ log.info("Setting up the task environment...")
73
+ containers = create_env(task)
74
+ log_docker_logs(
75
+ containers,
76
+ lambda: cleanup_env(
77
+ task.name, [pod.name for pod in task.pods], [net.name for net in task.networks]
78
+ ),
79
+ )
80
+ log.info("Task test completed.")
81
+
82
+
83
+ def init(
84
+ name: Annotated[str, typer.Argument(help="Name of the task.")],
85
+ path: Annotated[Path, typer.Argument(help="Path to the task.", show_default=False)] = CWD,
86
+ ) -> None:
87
+ """
88
+ Initialize a new task directory with a template configuration and an example image.
89
+
90
+ Creates the following structure:\n
91
+ <out_dir>/\n
92
+ ├─task.yaml\n
93
+ └─hello-world/\n
94
+ ....└──Dockerfile\n
95
+ """
96
+
97
+ log.info(f"Initializing new task '{name}' at '{path}'...")
98
+ out_dir = path / normalise_task_name(name)
99
+ if out_dir.exists():
100
+ log.error(f"Directory '{out_dir}' already exists.")
101
+ return
102
+ out_dir.mkdir(parents=True)
103
+ log.info(f"Writing task template to '{out_dir}'...")
104
+ write_task_yaml(out_dir, name)
105
+ log.info(f"Copying example images to '{out_dir}'...")
106
+ copy_example_images(out_dir)
107
+
108
+
109
+ def useless_gradient_function(step: int) -> str:
110
+ # Calculate hue based on step
111
+ hue = (step * 3 % 360) / 360.0
112
+ # Convert hue to RGB
113
+ red, green, blue = colorsys.hsv_to_rgb(hue, 1.0, 1.0)
114
+ # Output as ANSI
115
+ return f"\x1b[38;2;{int(red * 255)};{int(green * 255)};{int(blue * 255)}m"
116
+
117
+
118
+ # Load bearing function, do not remove :3
119
+ def epic() -> None:
120
+ """
121
+ A very important function.
122
+ """
123
+
124
+ step = 0
125
+ while True:
126
+ print(f"\r{useless_gradient_function(step)}Epic function executed successfully.\x1b[0m", end="")
127
+ step += 1
128
+ time.sleep(0.1)
@@ -0,0 +1,3 @@
1
+ FROM alpine:latest
2
+
3
+ CMD ["echo", "Hello, world!"]
@@ -0,0 +1,36 @@
1
+ name: "My Very Cool Task" # Name of the task; shown to the user
2
+ version: "1.0.0" # Version of the task; used for image tagging, increment when making changes to already-pushed tasks
3
+ description: "Very cool description UwU" # Description of the task; shown to the user
4
+ score: 150 # Maximum score for the task
5
+ scoring_type: "dynamic" # Scoring type; can be "static" or "dynamic"
6
+
7
+ # Definition of the pods that will be created for the task
8
+ pods:
9
+ - name: "hello-world" # Name of the pod; used for referencing in networks and env_vars
10
+ visible_to_user: true # Whether the user can see this pod
11
+ # Definition of the container image for the pod
12
+ image:
13
+ image_name: "hello-world" # Name of the image; used for pulling/building the image
14
+ build_context: "./hello-world" # Context path for building the image, needs to at least contain a Dockerfile
15
+ limits_ram: "128Mi" # RAM limit for the pod
16
+ limits_cpu: 1 # CPU limit for the pod
17
+ # Testing configuration for the pod; used for `ahaz test` command
18
+ testing:
19
+ # Defines ports that will be exposed on the host machine via Docker
20
+ exposed_ports:
21
+ - 1337:80
22
+
23
+ # Network definitions for the task
24
+ networks:
25
+ # Teamnet will always contain the VPN pod - use for user-accessible services
26
+ - name: "teamnet" # Name of the network
27
+ devices: [ "hello-world" ] # Pods connected to this network
28
+ # Other networks are inaccessible to the players
29
+ - name: "super-secret-internal-network"
30
+ devices: [ "hello-world" ]
31
+
32
+ # Environment variables to be set in the pods
33
+ env_vars:
34
+ - pod_name: "hello-world" # Pod to which the environment variable will be set
35
+ name: "COOL_ENV_VAR" # Name of the environment variable
36
+ value: "hello-world" # Value of the environment variable
@@ -0,0 +1,28 @@
1
+ import logging
2
+ from pathlib import Path
3
+
4
+ import rich.logging
5
+ import typer
6
+
7
+ from .ahaz import epic, init, test
8
+
9
+ log = logging.getLogger()
10
+ log.addHandler(rich.logging.RichHandler(markup=True))
11
+ log.setLevel(logging.INFO)
12
+
13
+ SCRIPTS_ROOT = Path(__file__).parent.parent.resolve()
14
+
15
+ app = typer.Typer(
16
+ no_args_is_help=True,
17
+ help="""\
18
+ CLI for interacting with the Ahaz CTF task manager.
19
+ """,
20
+ )
21
+
22
+ app.command()(test)
23
+ app.command()(epic)
24
+ app.command()(init)
25
+
26
+
27
+ if __name__ == "__main__":
28
+ app()
@@ -0,0 +1,215 @@
1
+ import logging
2
+ import threading
3
+ import time
4
+ from typing import Callable
5
+
6
+ import docker
7
+ import rich
8
+ import rich.ansi
9
+ import rich.style
10
+ from ahaz_common.task import Pod, Task
11
+ from docker.errors import BuildError, NotFound
12
+ from docker.models.containers import Container
13
+ from rich.status import Status
14
+
15
+ from .task import normalise_task_name
16
+
17
+ log = logging.getLogger(__name__)
18
+
19
+
20
+ def calculate_string_colour(s: str) -> int:
21
+ """Calculate a consistent colour code for a given string."""
22
+ hash_value = 0
23
+ for char in s:
24
+ hash_value = (hash_value * 31 + ord(char)) & 0xFFFFFFFF
25
+ return 16 + (hash_value % 216) # Use colours from 16 to 231
26
+
27
+
28
+ def number_to_hex_colour(n: int) -> str:
29
+ """Convert a number to a hex colour code."""
30
+ r = (n >> 16) & 0xFF
31
+ g = (n >> 8) & 0xFF
32
+ b = n & 0xFF
33
+ return f"#{r:02x}{g:02x}{b:02x}"
34
+
35
+
36
+ class _ContainerLoggerAdapter(logging.LoggerAdapter):
37
+ def process(self, msg, kwargs):
38
+ if not self.extra:
39
+ self.extra = {}
40
+ cname: str = self.extra.get("container", "<unknown>") # type: ignore
41
+ colour = calculate_string_colour(cname)
42
+ return f"[bold {number_to_hex_colour(colour)}]{cname}[/] [dim]|[/] {msg}", kwargs
43
+
44
+
45
+ def get_container_name(task_name: str, pod_k8s_name: str) -> str:
46
+ return f"ahaz-{normalise_task_name(task_name)}-{pod_k8s_name}"
47
+
48
+
49
+ def get_network_name(task_name: str, network_netname: str) -> str:
50
+ return f"ahaz-{normalise_task_name(task_name)}-{network_netname}"
51
+
52
+
53
+ def try_build_image(image_tag: str, build_context: str, build_args: dict[str, str], verbose: bool) -> None:
54
+ client = docker.from_env()
55
+ log.info(f"Building image '{image_tag}'...")
56
+ try:
57
+ build_logs = client.api.build(
58
+ path=build_context,
59
+ tag=image_tag,
60
+ buildargs=build_args,
61
+ decode=True,
62
+ )
63
+ print("\x1b[2m")
64
+ for chunk in build_logs:
65
+ if "stream" in chunk and verbose:
66
+ # dim the build output
67
+ print(f"{chunk['stream']}", end="")
68
+ print("\x1b[0m")
69
+ except BuildError as e:
70
+ raise e
71
+
72
+
73
+ def config_units_to_docker_units(config_value: str) -> str:
74
+ """
75
+ Convert resource limit strings from config format to Docker-compatible format.
76
+ E.g., "512Mi" -> "512m", "2Gi" -> "2048m"
77
+ """
78
+ if config_value.endswith("Mi"):
79
+ return config_value[:-2] + "m"
80
+ elif config_value.endswith("Gi"):
81
+ gi_value = int(config_value[:-2])
82
+ return str(gi_value * 1024) + "m"
83
+ else:
84
+ raise ValueError(f"Unsupported resource unit in value: {config_value}")
85
+
86
+
87
+ def create_env(task: Task) -> list[tuple[Pod, Container]]:
88
+ client = docker.from_env()
89
+ try:
90
+ with Status("Setting up task environment...", spinner="dots") as status:
91
+ # Set up networks
92
+ network_guide = {}
93
+ for net in task.networks:
94
+ log.info(f"Setting up network '{net.name}'...")
95
+ status.update(f"Setting up network '{net.name}'...")
96
+ network = client.networks.create(
97
+ name=f"{get_network_name(task.name, net.name)}", check_duplicate=True
98
+ )
99
+ for device in net.devices:
100
+ network_guide.setdefault(device, []).append(network)
101
+
102
+ status.update("Setting up containers...")
103
+ containers = []
104
+ for pod in task.pods:
105
+ status.update(f"Creating container for pod '{pod.name}'...")
106
+
107
+ image_tag = f"{pod.image.image_name}:{task.version}"
108
+ env_vars = {env.name: env.value for env in task.env_vars or [] if env.pod_name == pod.name}
109
+ testing = getattr(pod, "testing", None)
110
+ exposed_ports = {
111
+ port.split(":")[1]: int(port.split(":")[0])
112
+ for port in (testing.exposed_ports if testing and testing.exposed_ports else [])
113
+ }
114
+
115
+ container = client.containers.run(
116
+ image=image_tag,
117
+ name=f"{get_container_name(task.name, pod.name)}",
118
+ detach=True,
119
+ tty=True,
120
+ stdin_open=True,
121
+ environment=env_vars,
122
+ mem_limit=config_units_to_docker_units(pod.limits_ram),
123
+ cpu_count=pod.limits_cpu,
124
+ ports=exposed_ports,
125
+ hostname=pod.name,
126
+ stream=True,
127
+ )
128
+ containers.append((pod, container))
129
+
130
+ # Connect to networks
131
+ for network in network_guide.get(pod.name, []):
132
+ status.update(f"Connecting pod '{pod.name}' to network '{network.name}'...")
133
+ network.connect(container)
134
+
135
+ status.update("Task environment set up successfully.")
136
+ log.info("Task environment set up successfully.")
137
+ except Exception as e:
138
+ # Clean up any created containers and networks
139
+ log.error(f"Error setting up task environment: {e}")
140
+ cleanup_env(task.name, [pod.name for pod in task.pods], [net.name for net in task.networks])
141
+ raise e
142
+ return containers
143
+
144
+
145
+ def cleanup_env(task_name: str, pod_names: list[str], network_names: list[str]):
146
+ client = docker.from_env()
147
+ log.info("Cleaning up created containers and networks...")
148
+ for pod_name in pod_names:
149
+ container_name = get_container_name(task_name, pod_name)
150
+ log.info(f"Cleaning up container '{container_name}'...")
151
+ try:
152
+ container = client.containers.get(container_name)
153
+ container.stop()
154
+ container.remove()
155
+ except NotFound:
156
+ log.warning(f"Container '{container_name}' not found during cleanup.")
157
+ pass # Container does not exist, nothing to clean up
158
+ for network_name in network_names:
159
+ network_name_full = get_network_name(task_name, network_name)
160
+ log.info(f"Cleaning up network '{network_name_full}'...")
161
+ try:
162
+ network = client.networks.get(network_name_full)
163
+ network.remove()
164
+ except NotFound:
165
+ log.warning(f"Network '{network_name_full}' not found during cleanup.")
166
+ pass # Network does not exist, nothing to clean up
167
+ log.info("Docker test environment cleaned up successfully!")
168
+
169
+
170
+ def stream_logs(pod: Pod, container: Container, logger: _ContainerLoggerAdapter) -> None:
171
+ while True:
172
+ try:
173
+ buffer = ""
174
+ for chunk in container.logs(stream=True, tail=0, follow=True):
175
+ # chunk can be bytes, str, or a (stdout, stderr) tuple
176
+ if isinstance(chunk, tuple):
177
+ chunk = chunk[0] or chunk[1]
178
+ if isinstance(chunk, bytes):
179
+ buffer += chunk.decode("utf-8", errors="replace")
180
+ else:
181
+ buffer += str(chunk)
182
+ while "\n" in buffer:
183
+ line, buffer = buffer.split("\n", 1)
184
+ if line.strip():
185
+ ansi_line = rich.ansi.AnsiDecoder().decode(line)
186
+ for segment in ansi_line:
187
+ logger.info(segment.plain)
188
+ except Exception as e:
189
+ log.debug(f"Log stream for pod '{pod.name}' ended: {e}")
190
+
191
+
192
+ def log_docker_logs(containers: list[tuple[Pod, Container]], exit_callback: Callable) -> None:
193
+ # Hang until the user interrupts
194
+ log.info("Task environment is running. Press [bold]Ctrl+C[/bold] to stop.")
195
+ print()
196
+ # Create loggers for each container
197
+ loggers: dict[str, _ContainerLoggerAdapter] = {}
198
+ for pod, _ in containers:
199
+ logger = logging.getLogger(pod.name)
200
+ loggers[pod.name] = _ContainerLoggerAdapter(logger, {"container": pod.name})
201
+ try:
202
+ # Fetch logs from containers
203
+ # TODO: I am fairly certain this allows the threads to log also the container shutdown logs,
204
+ # but I have not tested this
205
+ threads = []
206
+ for pod, container in containers:
207
+ t = threading.Thread(target=stream_logs, args=(pod, container, loggers[pod.name]), daemon=True)
208
+ t.start()
209
+ threads.append(t)
210
+
211
+ while True:
212
+ time.sleep(1)
213
+ except KeyboardInterrupt:
214
+ print()
215
+ exit_callback()
@@ -0,0 +1,21 @@
1
+ from pathlib import Path
2
+
3
+
4
+ def read_file(file_path: str) -> str:
5
+ try:
6
+ with open(file_path, "r", encoding="utf-8") as f:
7
+ return f.read()
8
+ except FileNotFoundError as e:
9
+ raise FileNotFoundError(f"File not found: {file_path}") from e
10
+
11
+
12
+ def write_file(file_path: str, content: str) -> None:
13
+ try:
14
+ with open(file_path, "w", encoding="utf-8") as f:
15
+ f.write(content)
16
+ except Exception as e:
17
+ raise IOError(f"Error writing to file: {file_path}") from e
18
+
19
+
20
+ def test_for_file(file_path: str) -> bool:
21
+ return Path(file_path).is_file()
@@ -0,0 +1,82 @@
1
+ import logging
2
+
3
+ import yaml
4
+
5
+ from ahaz_common import Task
6
+
7
+ log = logging.getLogger()
8
+
9
+
10
+ def deserialise_task(task_config: str) -> Task:
11
+ try:
12
+ config_dict = yaml.safe_load(task_config)
13
+ except yaml.YAMLError as e:
14
+ log.error(f"Error parsing YAML: {e}")
15
+ raise ValueError("Invalid task configuration") from e
16
+
17
+ try:
18
+ return Task(**config_dict)
19
+ except TypeError as e:
20
+ log.error(f"Error constructing Task object: {e}")
21
+ raise ValueError("Invalid task configuration structure") from e
22
+
23
+
24
+ # TODO: Figure out a better way to handle serialisation of nested objects
25
+ # TODO: Is this even necessary?
26
+ def serialise_task(task: Task) -> str:
27
+ """
28
+ Serialise a Task into YAML with a fixed field order matching the example:
29
+ name, version, description, score, scoring_type, pods, networks, env_vars
30
+ """
31
+
32
+ # helper getters to be defensive about missing attributes
33
+ def _image_dict(pod):
34
+ img = getattr(pod, "image", None) or {}
35
+ return {
36
+ "image_name": getattr(img, "image_name", None),
37
+ "build_context": getattr(img, "build_context", None),
38
+ }
39
+
40
+ def _testing_dict(pod):
41
+ testing = getattr(pod, "testing", None)
42
+ return {"exposed_ports": testing.exposed_ports if testing is not None else []}
43
+
44
+ # Build top-level mapping in the desired order (insertion order is preserved)
45
+ config_dict = {
46
+ "name": task.name,
47
+ "version": getattr(task, "version", "1.0.0"),
48
+ "description": task.description,
49
+ "score": task.score,
50
+ "scoring_type": task.scoring_type,
51
+ "pods": [
52
+ {
53
+ "name": pod.name,
54
+ "image": _image_dict(pod),
55
+ "limits_ram": getattr(pod, "limits_ram", None),
56
+ "limits_cpu": getattr(pod, "limits_cpu", None),
57
+ "visible_to_user": getattr(pod, "visible_to_user", None),
58
+ # include build for convenience (matches example where build == build_context)
59
+ "build": getattr(getattr(pod, "image", None), "build_context", None),
60
+ "testing": _testing_dict(pod),
61
+ }
62
+ for pod in task.pods or []
63
+ ],
64
+ "networks": [{"name": net.name, "devices": list(net.devices)} for net in task.networks or []],
65
+ "env_vars": [
66
+ {
67
+ # map original fields to the example-style names
68
+ "pod_name": env.pod_name,
69
+ "name": env.name,
70
+ "value": env.value,
71
+ }
72
+ for env in task.env_vars or []
73
+ ],
74
+ }
75
+
76
+ # Preserve insertion order in output
77
+ return yaml.safe_dump(config_dict, sort_keys=False)
78
+
79
+
80
+ def normalise_task_name(name: str) -> str:
81
+ # Make the task lowercase, replace spaces with hyphens, and remove special characters
82
+ return name.lower().replace(" ", "-").replace(r"([^a-z0-9-])", "")
@@ -0,0 +1,24 @@
1
+ from pathlib import Path
2
+
3
+ from .lib.task import deserialise_task, serialise_task
4
+
5
+
6
+ def write_task_yaml(file_path: Path, task_name: str) -> None:
7
+ template_path = Path(__file__).parent / "assets" / "task.yaml"
8
+ task = deserialise_task(template_path.read_text())
9
+
10
+ task.name = task_name
11
+
12
+ task_dest = file_path / "task.yaml"
13
+ task_dest.write_text(serialise_task(task))
14
+
15
+
16
+ def copy_example_images(dest_path: Path) -> None:
17
+ example_image_src = Path(__file__).parent / "assets" / "hello-world"
18
+ example_image_dest = dest_path / "hello-world"
19
+ example_image_dest.mkdir(exist_ok=True)
20
+
21
+ for item in example_image_src.iterdir():
22
+ if item.is_file():
23
+ dest_file = example_image_dest / item.name
24
+ dest_file.write_text(item.read_text())
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: ahaz_cli
3
+ Version: 0.0.4
4
+ Summary: Command-line management interface for Ahaz
5
+ Requires-Dist: docker>=7.1.0
6
+ Requires-Dist: rich>=14.2.0
7
+ Requires-Dist: typer>=0.20.0
8
+ Requires-Dist: ahaz_common
9
+ Requires-Dist: pyyaml>=6.0.3
@@ -0,0 +1,17 @@
1
+ pyproject.toml
2
+ ahaz_cli/__init__.py
3
+ ahaz_cli/__main__.py
4
+ ahaz_cli/ahaz.py
5
+ ahaz_cli/cli.py
6
+ ahaz_cli/templates.py
7
+ ahaz_cli.egg-info/PKG-INFO
8
+ ahaz_cli.egg-info/SOURCES.txt
9
+ ahaz_cli.egg-info/dependency_links.txt
10
+ ahaz_cli.egg-info/entry_points.txt
11
+ ahaz_cli.egg-info/requires.txt
12
+ ahaz_cli.egg-info/top_level.txt
13
+ ahaz_cli/assets/task.yaml
14
+ ahaz_cli/assets/hello-world/Dockerfile
15
+ ahaz_cli/lib/docker.py
16
+ ahaz_cli/lib/file.py
17
+ ahaz_cli/lib/task.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ahaz = ahaz_cli.cli:app
@@ -0,0 +1,5 @@
1
+ docker>=7.1.0
2
+ rich>=14.2.0
3
+ typer>=0.20.0
4
+ ahaz_common
5
+ pyyaml>=6.0.3
@@ -0,0 +1 @@
1
+ ahaz_cli
@@ -0,0 +1,26 @@
1
+ [project]
2
+ name = "ahaz_cli"
3
+ version = "0.0.4"
4
+ description = "Command-line management interface for Ahaz"
5
+ dependencies = [
6
+ "docker>=7.1.0",
7
+ "rich>=14.2.0",
8
+ "typer>=0.20.0",
9
+ "ahaz_common",
10
+ "pyyaml>=6.0.3",
11
+ ]
12
+
13
+ [tool.uv.sources]
14
+ ahaz_common = { workspace = true }
15
+
16
+ [project.scripts]
17
+ ahaz = "ahaz_cli.cli:app"
18
+
19
+ [tool.setuptools]
20
+ include-package-data = true
21
+
22
+ [tool.setuptools.package-data]
23
+ "ahaz_cli" = ["assets/**"]
24
+
25
+ [tool.setuptools.packages.find]
26
+ include = ["ahaz_cli*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+