ctf-attackapi 0.1.0__tar.gz → 0.2.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ctf-attackapi
3
- Version: 0.1.0
3
+ Version: 0.2.2
4
4
  Summary: Get attack infos in attack-defense CTFs quickly to your exploits. CTF-agnostic and cached.
5
5
  Keywords: Attack-Defense,CTF,Attack API,Attack Info,Flag IDs,FAUST CTF,ENOWARS,saarCTF
6
6
  Author: Markus Bauer
@@ -36,11 +36,11 @@ CTF AttackAPI - Cached and Unified!
36
36
  ===================================
37
37
 
38
38
  [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/license/mit)
39
- ![Python](https://img.shields.io/pypi/pyversions/ctf-attackapi?pypiBaseUrl=https://test.pypi.org)
40
- ![Types](https://img.shields.io/pypi/types/ctf-attackapi?pypiBaseUrl=https://test.pypi.org)
39
+ ![Python](https://img.shields.io/pypi/pyversions/ctf-attackapi)
40
+ ![Types](https://img.shields.io/pypi/types/ctf-attackapi)
41
41
  [![Python package tests](https://github.com/Attacking-Lab/ctf-attackapi/actions/workflows/python-package.yml/badge.svg)](https://github.com/Attacking-Lab/ctf-attackapi/actions/workflows/python-package.yml)
42
- [![PyPI version](https://img.shields.io/pypi/v/ctf-attackapi?pypiBaseUrl=https://test.pypi.org)](https://pypi.org/project/ctf-attackapi)
43
- ![Downloads](https://img.shields.io/pypi/dm/ctf-attackapi?pypiBaseUrl=https://test.pypi.org)
42
+ [![PyPI version](https://img.shields.io/pypi/v/ctf-attackapi)](https://pypi.org/project/ctf-attackapi)
43
+ ![Downloads](https://img.shields.io/pypi/dm/ctf-attackapi)
44
44
  ![Repo size](https://img.shields.io/github/repo-size/Attacking-Lab/ctf-attackapi)
45
45
 
46
46
 
@@ -54,6 +54,20 @@ Downloading that file for every exploit you're firing is costing time and bandwi
54
54
 
55
55
  This package fetches, parses, and caches attack info for you, so you can focus on writing exploits!
56
56
 
57
+ Changes in 0.2.0
58
+ ----------------
59
+
60
+ - New `atklab` dialect for the ATKLAB gameserver (ECSC 2026): attack info under `attack_info`
61
+ instead of `flag_ids`, and rounds instead of ticks. The `saarctf`, `faustctf` and `enowars`
62
+ dialects are unchanged.
63
+ - `AttackInfo` gained `flag_regex` and `current_round`, filled in for the games that report them.
64
+ - The helper behind `flag_id_flat()` is public as `attackapi.flatten_flag_ids()`, for callers that
65
+ flatten a subset of the raw structure themselves.
66
+ - `flag_id_flat()` drops `null` flag IDs instead of returning them as `None` -- a flag store with
67
+ no ID for a round is a hole, not a value.
68
+ - `GenericAdCtfApiAsync` accepts a plain callable decoder, a `progress` hook, and an injectable
69
+ `memory_cache`, so it can back a whole game API rather than just `attack.json`.
70
+
57
71
  Features
58
72
  --------
59
73
 
@@ -63,7 +77,8 @@ Features
63
77
  - Unifies team, IP, and flag info lookup between different CTFs:
64
78
  - Supports [ENOWARS](https://enowars.com)
65
79
  - Supports [FAUST CTF](https://faustctf.net)
66
- - Supports [saarCTF](https://ctf.saarland) (including ECSC gameserver)
80
+ - Supports [saarCTF](https://ctf.saarland)
81
+ - Supports the [Attacking-Lab](https://attacking-lab.com) gameserver (ECSC 2026)
67
82
 
68
83
  Quick-Start
69
84
  -----------
@@ -172,6 +187,9 @@ print(info.team("10.32.1.2")) # query Team object by ID, IP, or name
172
187
  # set of service names
173
188
  print(info.services)
174
189
 
190
+ # flag format and the round this info was generated for, where the game reports them
191
+ print(info.flag_regex, info.current_round)
192
+
175
193
  # raw flag IDs for a service and team.
176
194
  # team can be ID, IP, or name.
177
195
  # Return data format is determined by game API.
@@ -229,3 +247,17 @@ from attackapi.async_api import JsonAdCtfApiAsync
229
247
 
230
248
  info = await JsonAdCtfApiAsync("https://scoreboard.ctf.saarland/api/scoreboard_current.json").retrieve()
231
249
  ```
250
+
251
+ To get parsed objects instead of raw dicts, `GenericAdCtfApiAsync` takes any `bytes -> object` callable:
252
+
253
+ ```python
254
+ from attackapi.async_api import GenericAdCtfApiAsync
255
+
256
+ scoreboard = await GenericAdCtfApiAsync(
257
+ parse_scoreboard, "https://scoreboard.ctf.saarland/api/scoreboard_round_237.json"
258
+ ).retrieve()
259
+ ```
260
+
261
+ Both accept `progress=`, a context-manager factory called with the URL around remote fetches (to drive a
262
+ spinner, for example), and `memory_cache=`, an own `GlobalCache` instead of the process-wide one. The
263
+ process-wide cache is keyed by URL alone, so tests that need isolation should inject their own.
@@ -2,11 +2,11 @@ CTF AttackAPI - Cached and Unified!
2
2
  ===================================
3
3
 
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/license/mit)
5
- ![Python](https://img.shields.io/pypi/pyversions/ctf-attackapi?pypiBaseUrl=https://test.pypi.org)
6
- ![Types](https://img.shields.io/pypi/types/ctf-attackapi?pypiBaseUrl=https://test.pypi.org)
5
+ ![Python](https://img.shields.io/pypi/pyversions/ctf-attackapi)
6
+ ![Types](https://img.shields.io/pypi/types/ctf-attackapi)
7
7
  [![Python package tests](https://github.com/Attacking-Lab/ctf-attackapi/actions/workflows/python-package.yml/badge.svg)](https://github.com/Attacking-Lab/ctf-attackapi/actions/workflows/python-package.yml)
8
- [![PyPI version](https://img.shields.io/pypi/v/ctf-attackapi?pypiBaseUrl=https://test.pypi.org)](https://pypi.org/project/ctf-attackapi)
9
- ![Downloads](https://img.shields.io/pypi/dm/ctf-attackapi?pypiBaseUrl=https://test.pypi.org)
8
+ [![PyPI version](https://img.shields.io/pypi/v/ctf-attackapi)](https://pypi.org/project/ctf-attackapi)
9
+ ![Downloads](https://img.shields.io/pypi/dm/ctf-attackapi)
10
10
  ![Repo size](https://img.shields.io/github/repo-size/Attacking-Lab/ctf-attackapi)
11
11
 
12
12
 
@@ -20,6 +20,20 @@ Downloading that file for every exploit you're firing is costing time and bandwi
20
20
 
21
21
  This package fetches, parses, and caches attack info for you, so you can focus on writing exploits!
22
22
 
23
+ Changes in 0.2.0
24
+ ----------------
25
+
26
+ - New `atklab` dialect for the ATKLAB gameserver (ECSC 2026): attack info under `attack_info`
27
+ instead of `flag_ids`, and rounds instead of ticks. The `saarctf`, `faustctf` and `enowars`
28
+ dialects are unchanged.
29
+ - `AttackInfo` gained `flag_regex` and `current_round`, filled in for the games that report them.
30
+ - The helper behind `flag_id_flat()` is public as `attackapi.flatten_flag_ids()`, for callers that
31
+ flatten a subset of the raw structure themselves.
32
+ - `flag_id_flat()` drops `null` flag IDs instead of returning them as `None` -- a flag store with
33
+ no ID for a round is a hole, not a value.
34
+ - `GenericAdCtfApiAsync` accepts a plain callable decoder, a `progress` hook, and an injectable
35
+ `memory_cache`, so it can back a whole game API rather than just `attack.json`.
36
+
23
37
  Features
24
38
  --------
25
39
 
@@ -29,7 +43,8 @@ Features
29
43
  - Unifies team, IP, and flag info lookup between different CTFs:
30
44
  - Supports [ENOWARS](https://enowars.com)
31
45
  - Supports [FAUST CTF](https://faustctf.net)
32
- - Supports [saarCTF](https://ctf.saarland) (including ECSC gameserver)
46
+ - Supports [saarCTF](https://ctf.saarland)
47
+ - Supports the [Attacking-Lab](https://attacking-lab.com) gameserver (ECSC 2026)
33
48
 
34
49
  Quick-Start
35
50
  -----------
@@ -138,6 +153,9 @@ print(info.team("10.32.1.2")) # query Team object by ID, IP, or name
138
153
  # set of service names
139
154
  print(info.services)
140
155
 
156
+ # flag format and the round this info was generated for, where the game reports them
157
+ print(info.flag_regex, info.current_round)
158
+
141
159
  # raw flag IDs for a service and team.
142
160
  # team can be ID, IP, or name.
143
161
  # Return data format is determined by game API.
@@ -195,3 +213,17 @@ from attackapi.async_api import JsonAdCtfApiAsync
195
213
 
196
214
  info = await JsonAdCtfApiAsync("https://scoreboard.ctf.saarland/api/scoreboard_current.json").retrieve()
197
215
  ```
216
+
217
+ To get parsed objects instead of raw dicts, `GenericAdCtfApiAsync` takes any `bytes -> object` callable:
218
+
219
+ ```python
220
+ from attackapi.async_api import GenericAdCtfApiAsync
221
+
222
+ scoreboard = await GenericAdCtfApiAsync(
223
+ parse_scoreboard, "https://scoreboard.ctf.saarland/api/scoreboard_round_237.json"
224
+ ).retrieve()
225
+ ```
226
+
227
+ Both accept `progress=`, a context-manager factory called with the URL around remote fetches (to drive a
228
+ spinner, for example), and `memory_cache=`, an own `GlobalCache` instead of the process-wide one. The
229
+ process-wide cache is keyed by URL alone, so tests that need isolation should inject their own.
@@ -0,0 +1,89 @@
1
+ [project]
2
+ name = "ctf-attackapi"
3
+ version = "0.2.2"
4
+ description = "Get attack infos in attack-defense CTFs quickly to your exploits. CTF-agnostic and cached."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license_files = ["LICENSE.txt"]
8
+ keywords = [
9
+ "Attack-Defense",
10
+ "CTF",
11
+ "Attack API",
12
+ "Attack Info",
13
+ "Flag IDs",
14
+ "FAUST CTF",
15
+ "ENOWARS",
16
+ "saarCTF",
17
+ ]
18
+ requires-python = ">=3.9"
19
+ classifiers = [
20
+ "Development Status :: 4 - Beta",
21
+ "Intended Audience :: Developers",
22
+ "Intended Audience :: Education",
23
+ "License :: OSI Approved :: MIT License",
24
+ "Programming Language :: Python :: 3.9",
25
+ "Programming Language :: Python :: 3.10",
26
+ "Programming Language :: Python :: 3.11",
27
+ "Programming Language :: Python :: 3.12",
28
+ "Programming Language :: Python :: 3.13",
29
+ "Programming Language :: Python :: 3.14",
30
+ "Topic :: Security",
31
+ "Typing :: Typed",
32
+ ]
33
+ dependencies = [
34
+ "aiohttp>=3.13.3",
35
+ "aiologic>=0.16.0",
36
+ "filelock>=3.19.1",
37
+ "pyyaml>=6.0.3",
38
+ "typing-extensions>=4.15.0",
39
+ ]
40
+
41
+ [[project.authors]]
42
+ name = "Markus Bauer"
43
+ email = "markus.bauer@cispa.saarland"
44
+
45
+ [project.optional-dependencies]
46
+ server = ["gunicorn>=23.0.0"]
47
+
48
+ [project.urls]
49
+ Homepage = "https://github.com/Attacking-Lab/ctf-attackapi"
50
+ Repository = "https://github.com/Attacking-Lab/ctf-attackapi"
51
+ Issues = "https://github.com/Attacking-Lab/ctf-attackapi/issues"
52
+ Background = "https://wiki.attacking-lab.com/attack-defense/"
53
+
54
+ [project.scripts]
55
+ ctf-attackapi-server = "attackapi.server:main"
56
+
57
+ [build-system]
58
+ requires = ["uv_build>=0.9.27,<0.10.0"]
59
+ build-backend = "uv_build"
60
+
61
+ [dependency-groups]
62
+ dev = [
63
+ "mypy>=1.19.1",
64
+ "pytest>=8.4.2",
65
+ "types-pyyaml>=6.0.12.20250915",
66
+ ]
67
+
68
+ [tool.uv.build-backend]
69
+ module-name = "attackapi"
70
+
71
+ [[tool.uv.index]]
72
+ name = "testpypi"
73
+ url = "https://test.pypi.org/simple/"
74
+ publish-url = "https://test.pypi.org/legacy/"
75
+ explicit = true
76
+
77
+ [tool.mypy]
78
+ python_version = "3.9"
79
+ disallow_untyped_defs = true
80
+ packages = [
81
+ "attackapi",
82
+ "tests",
83
+ "examples",
84
+ ]
85
+ mypy_path = [
86
+ "./src",
87
+ "./tests",
88
+ "./examples",
89
+ ]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctf-attackapi"
3
- version = "0.1.0"
3
+ version = "0.2.2"
4
4
  description = "Get attack infos in attack-defense CTFs quickly to your exploits. CTF-agnostic and cached."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -0,0 +1,4 @@
1
+ from .models import Team, AttackInfo, flatten_flag_ids
2
+ from .functional import configure, attack_info, attack_info_async
3
+
4
+ __all__ = ["Team", "AttackInfo", "flatten_flag_ids", "configure", "attack_info", "attack_info_async"]
@@ -0,0 +1,5 @@
1
+ from .api import AdCtfApiAsync, GenericAdCtfApiAsync, GlobalCache, JsonAdCtfApiAsync
2
+ from .decoders import Decoder, Dialect, FunctionDecoder, GenericDecoder
3
+
4
+ __all__ = ["AdCtfApiAsync", "GenericAdCtfApiAsync", "GlobalCache", "JsonAdCtfApiAsync",
5
+ "Decoder", "Dialect", "FunctionDecoder", "GenericDecoder"]
@@ -5,13 +5,13 @@ import tempfile
5
5
  import time
6
6
  from importlib.metadata import version
7
7
  from pathlib import Path
8
- from typing import Optional, Union, Generic, TypeVar, AsyncContextManager, Any
8
+ from contextlib import nullcontext
9
+ from typing import Callable, ContextManager, Optional, Union, Generic, TypeVar, AsyncContextManager, Any
9
10
 
10
11
  import aiologic
11
- from aiohttp import ClientSession, ClientTimeout
12
12
  from filelock import FileLock
13
13
 
14
- from attackapi.async_api.decoders import Decoder, GenericDecoder, JSONDecoder
14
+ from attackapi.async_api.decoders import Decoder, FunctionDecoder, GenericDecoder, JSONDecoder
15
15
  from attackapi.async_api.filelock import acquire_filelock
16
16
  from attackapi.models import AttackInfo
17
17
 
@@ -38,6 +38,10 @@ class GlobalCache(Generic[T]):
38
38
  def set(self, key: str, value: T) -> None:
39
39
  self._cache[key] = (time.time(), value)
40
40
 
41
+ def clear(self) -> None:
42
+ """Drop every cached response. Mostly useful to isolate tests from each other."""
43
+ self._cache.clear()
44
+
41
45
 
42
46
  _api_response_cache: GlobalCache[Any] = GlobalCache()
43
47
 
@@ -82,19 +86,23 @@ def _atomic_write(p: Path, raw: bytes) -> None:
82
86
 
83
87
 
84
88
  class GenericAdCtfApiAsync(Generic[T]):
85
- def __init__(self, decoder: GenericDecoder[T], url: str = "",
89
+ def __init__(self, decoder: Union[GenericDecoder[T], Callable[[bytes], T]], url: str = "",
86
90
  tmp_directory: Union[str, Path] = tempfile.gettempdir(), *,
87
91
  lifetime: float = 30.0, timeout: float = 10.0,
88
- aiohttp_arguments: Optional[dict] = None) -> None:
92
+ aiohttp_arguments: Optional[dict] = None,
93
+ memory_cache: Optional[GlobalCache] = None,
94
+ progress: Optional[Callable[[str], ContextManager[None]]] = None) -> None:
89
95
  """
90
96
  Create a new API client.
91
97
 
92
- :param decoder: A decoder for API responses
98
+ :param decoder: A decoder for API responses, or a plain bytes -> object callable
93
99
  :param url: URL of your game's API
94
100
  :param tmp_directory: where to store cache files
95
101
  :param lifetime: How long to cache data for (in seconds)
96
102
  :param timeout: How long to wait for API calls (in seconds)
97
103
  :param aiohttp_arguments: Optional arguments to pass to aiohttp.ClientSession
104
+ :param memory_cache: Optional in-memory cache to use instead of the process-wide one
105
+ :param progress: Optional context-manager factory, called with the URL around remote fetches
98
106
  """
99
107
  if timeout < 1:
100
108
  raise ValueError("Timeout must be at least 1 second")
@@ -104,7 +112,9 @@ class GenericAdCtfApiAsync(Generic[T]):
104
112
  self._url = url
105
113
  self._lifetime = lifetime
106
114
  self._timeout = timeout
107
- self._decoder = decoder
115
+ self._decoder = decoder if isinstance(decoder, GenericDecoder) else FunctionDecoder(decoder)
116
+ self._memory_cache = memory_cache if memory_cache is not None else _api_response_cache
117
+ self._progress = progress
108
118
  self._aiohttp_arguments = aiohttp_arguments or {
109
119
  "headers": {"User-Agent": "python/attackapi " + version("ctf-attackapi")}
110
120
  }
@@ -124,19 +134,19 @@ class GenericAdCtfApiAsync(Generic[T]):
124
134
  if info is not None:
125
135
  return info
126
136
  # not found? lock it to avoid concurrent loads
127
- async with _api_response_cache.lock:
137
+ async with self._memory_cache.lock:
128
138
  # check again to avoid race conditions
129
139
  info = self._check_memory_cache()
130
140
  if info is not None:
131
141
  return info
132
142
  # not found => load from file or API
133
- info = await self._attack_info_from_file()
134
- _api_response_cache.set(self._cache_key, info)
143
+ info = await self._from_file()
144
+ self._memory_cache.set(self._cache_key, info)
135
145
  return info
136
146
 
137
147
  def _check_memory_cache(self) -> Optional[T]:
138
- if (age := _api_response_cache.age(self._cache_key)) is not None and age <= self._lifetime:
139
- return _api_response_cache.get(self._cache_key)
148
+ if (age := self._memory_cache.age(self._cache_key)) is not None and age <= self._lifetime:
149
+ return self._memory_cache.get(self._cache_key)
140
150
  return None
141
151
 
142
152
  def _check_file_cache(self) -> Optional[T]:
@@ -146,7 +156,7 @@ class GenericAdCtfApiAsync(Generic[T]):
146
156
  return self._decoder.parse(raw)
147
157
  return None
148
158
 
149
- async def _attack_info_from_file(self) -> T:
159
+ async def _from_file(self) -> T:
150
160
  # Step 2: try to load from file
151
161
  # this does not work on Windows, because concurrent read + replacing files is not possible.
152
162
  # for better performance please use Linux
@@ -161,23 +171,29 @@ class GenericAdCtfApiAsync(Generic[T]):
161
171
  if info is not None:
162
172
  return info
163
173
  # not found => load from API
164
- raw = await self._attack_info_from_remote()
174
+ raw = await self._from_remote()
165
175
  info = self._decoder.parse(raw)
166
176
  # and save to file (atomic)
167
177
  self._file_cache.set(raw)
168
178
  return info
169
179
 
170
- async def _attack_info_from_remote(self) -> bytes:
171
- async with ClientSession(**self._aiohttp_arguments) as session:
172
- async with session.get(self._url, timeout=ClientTimeout(total=self._timeout)) as response:
173
- response.raise_for_status()
174
- return await response.read()
180
+ async def _from_remote(self) -> bytes:
181
+ # imported lazily so that cache hits do not pay for importing aiohttp
182
+ from aiohttp import ClientSession, ClientTimeout
183
+
184
+ with self._progress(self._url) if self._progress is not None else nullcontext():
185
+ async with ClientSession(**self._aiohttp_arguments) as session:
186
+ async with session.get(self._url, timeout=ClientTimeout(total=self._timeout)) as response:
187
+ response.raise_for_status()
188
+ return await response.read()
175
189
 
176
190
 
177
191
  class JsonAdCtfApiAsync(GenericAdCtfApiAsync[dict]):
178
192
  def __init__(self, url: str, tmp_directory: Union[str, Path] = tempfile.gettempdir(), *,
179
193
  lifetime: float = 30.0, timeout: float = 10.0,
180
- aiohttp_arguments: Optional[dict] = None) -> None:
194
+ aiohttp_arguments: Optional[dict] = None,
195
+ memory_cache: Optional[GlobalCache] = None,
196
+ progress: Optional[Callable[[str], ContextManager[None]]] = None) -> None:
181
197
  """
182
198
  Create a new API client.
183
199
 
@@ -185,11 +201,13 @@ class JsonAdCtfApiAsync(GenericAdCtfApiAsync[dict]):
185
201
  :param tmp_directory: where to store cache files
186
202
  :param lifetime: How long to cache data for (in seconds)
187
203
  :param timeout: How long to wait for API calls (in seconds)
188
- :param decoder: A custom decoder for API responses, if the default one doesn't work for your game
189
204
  :param aiohttp_arguments: Optional arguments to pass to aiohttp.ClientSession
205
+ :param memory_cache: Optional in-memory cache to use instead of the process-wide one
206
+ :param progress: Optional context-manager factory, called with the URL around remote fetches
190
207
  """
191
208
  super().__init__(decoder=JSONDecoder(), url=url, tmp_directory=tmp_directory, lifetime=lifetime,
192
- timeout=timeout, aiohttp_arguments=aiohttp_arguments)
209
+ timeout=timeout, aiohttp_arguments=aiohttp_arguments, memory_cache=memory_cache,
210
+ progress=progress)
193
211
 
194
212
 
195
213
  class AdCtfApiAsync(GenericAdCtfApiAsync[AttackInfo]):
@@ -199,7 +217,9 @@ class AdCtfApiAsync(GenericAdCtfApiAsync[AttackInfo]):
199
217
 
200
218
  def __init__(self, url: str = "", tmp_directory: Union[str, Path] = tempfile.gettempdir(), *,
201
219
  lifetime: float = 30.0, timeout: float = 10.0, decoder: Optional[Decoder] = None,
202
- aiohttp_arguments: Optional[dict] = None) -> None:
220
+ aiohttp_arguments: Optional[dict] = None,
221
+ memory_cache: Optional[GlobalCache] = None,
222
+ progress: Optional[Callable[[str], ContextManager[None]]] = None) -> None:
203
223
  """
204
224
  Create a new API client.
205
225
 
@@ -209,13 +229,16 @@ class AdCtfApiAsync(GenericAdCtfApiAsync[AttackInfo]):
209
229
  :param timeout: How long to wait for API calls (in seconds)
210
230
  :param decoder: A custom decoder for API responses, if the default one doesn't work for your game
211
231
  :param aiohttp_arguments: Optional arguments to pass to aiohttp.ClientSession
232
+ :param memory_cache: Optional in-memory cache to use instead of the process-wide one
233
+ :param progress: Optional context-manager factory, called with the URL around remote fetches
212
234
  """
213
235
  if not url:
214
236
  if "CTF_API" not in os.environ:
215
237
  raise Exception("Please call configure() or set CTF_API environment variable!")
216
238
  url = os.environ["CTF_API"]
217
239
  super().__init__(decoder=decoder or Decoder(), url=url, tmp_directory=tmp_directory, lifetime=lifetime,
218
- timeout=timeout, aiohttp_arguments=aiohttp_arguments)
240
+ timeout=timeout, aiohttp_arguments=aiohttp_arguments, memory_cache=memory_cache,
241
+ progress=progress)
219
242
 
220
243
  async def attack_info(self) -> AttackInfo:
221
244
  """
@@ -1,8 +1,9 @@
1
1
  """
2
2
  Summary of different API formats:
3
- saarctf: flag_ids => {service_name => {ip => data}} {tick: a, tick2: [b, c]}
4
- faust: flag_ids => {service_name => {ID => data}} [a, b]
5
- enowars: services => {service_name => {ip => data}} {tick: {X: [a], Y: [b]}}
3
+ saarctf: flag_ids => {service_name => {ip => data}} {tick: a, tick2: [b, c]}
4
+ atklab: attack_info => {service_name => {ip => data}} {round: {store_index: a}}
5
+ faust: flag_ids => {service_name => {ID => data}} [a, b]
6
+ enowars: services => {service_name => {ip => data}} {tick: {X: [a], Y: [b]}}
6
7
 
7
8
  Additional team list:
8
9
  saarctf: teams => [0 => {id: ..., name: ..., logo: ...}]
@@ -12,7 +13,7 @@ enowars: availableTeams: [IP1, IP2, ...]
12
13
  import json
13
14
  from abc import abstractmethod, ABC
14
15
  from dataclasses import dataclass
15
- from typing import Optional, Generic, TypeVar
16
+ from typing import Callable, Optional, Generic, TypeVar
16
17
 
17
18
  from attackapi.models import AttackInfo, Team
18
19
 
@@ -52,6 +53,17 @@ class SaarctfDialect(Dialect):
52
53
  return f"10.{32 + (team_id // 200)}.{team_id % 200}.2" # actually not needed, saarCTF API exposes full team info
53
54
 
54
55
 
56
+ class AtklabDialect(Dialect):
57
+ """
58
+ ATKLAB gameserver (ECSC 2026). Team entries are shaped like saarCTF's, but the attack info
59
+ sits under "attack_info" and rounds are called rounds, not ticks.
60
+ """
61
+
62
+ def matches(self, data: dict) -> bool:
63
+ return "attack_info" in data and "teams" in data \
64
+ and len(data["teams"]) > 0 and isinstance(data["teams"][0], dict)
65
+
66
+
55
67
  class FaustDialect(Dialect):
56
68
  def matches(self, data: dict) -> bool:
57
69
  return "flag_ids" in data and "teams" in data and len(data["teams"]) > 0 and isinstance(data["teams"][0], int)
@@ -70,6 +82,7 @@ class EnowarsDialect(Dialect):
70
82
 
71
83
 
72
84
  DIALECTS = [
85
+ AtklabDialect("atklab"),
73
86
  SaarctfDialect("saarctf"),
74
87
  FaustDialect("faustctf", "fd66:666:{:d}::2"),
75
88
  EnowarsDialect("enowars", "10.1.{:d}.1"),
@@ -90,6 +103,16 @@ class JSONDecoder(GenericDecoder[dict]):
90
103
  return json.loads(raw)
91
104
 
92
105
 
106
+ class FunctionDecoder(GenericDecoder[T]):
107
+ """Adapts a plain ``bytes -> T`` callable to the decoder interface."""
108
+
109
+ def __init__(self, parse: Callable[[bytes], T]) -> None:
110
+ self._parse = parse
111
+
112
+ def parse(self, raw: bytes) -> T:
113
+ return self._parse(raw)
114
+
115
+
93
116
  class Decoder(GenericDecoder[AttackInfo]):
94
117
  """
95
118
  A decoder converts the game APIs response (in bytes) into teams, services, and attack information.
@@ -108,16 +131,23 @@ class Decoder(GenericDecoder[AttackInfo]):
108
131
  :raises ValueError: If data is invalid
109
132
  :return:
110
133
  """
111
- info = AttackInfo(raw=raw)
112
134
  data = json.loads(raw)
113
135
  dialect = self._get_dialect(data)
136
+ info = AttackInfo(
137
+ flag_regex=data.get("flag_regex"),
138
+ # saarCTF counts ticks, the ATKLAB gameserver counts rounds; same number
139
+ current_round=data.get("current_round", data.get("current_tick")),
140
+ raw=raw
141
+ )
114
142
 
115
143
  if "flag_ids" in data:
116
144
  self._parse_services(info, data["flag_ids"])
145
+ elif "attack_info" in data:
146
+ self._parse_services(info, data["attack_info"])
117
147
  elif "services" in data:
118
148
  self._parse_services(info, data["services"])
119
149
  else:
120
- raise ValueError("Unknown format - no flag_ids or services key found")
150
+ raise ValueError("Unknown format - no flag_ids, attack_info or services key found")
121
151
 
122
152
  if "teams" in data:
123
153
  self._parse_teams(dialect, info, data["teams"])
@@ -1,22 +1,30 @@
1
1
  from dataclasses import dataclass, field, asdict
2
- from typing import Optional, Union, Any, cast
2
+ from typing import Optional, Union, Any
3
3
  from typing_extensions import TypeAlias
4
4
 
5
5
  RawFlagIds: TypeAlias = Union[list, dict[str, Union[str, list, dict]]]
6
6
 
7
7
 
8
- def _flat(flag_ids: Any) -> list[str]:
8
+ def flatten_flag_ids(flag_ids: Any) -> list[str]:
9
+ """
10
+ Collect every non-null scalar flag ID out of an arbitrarily nested flag-ID structure.
11
+ Independent of the game API's exact nesting, but loses the round / flag-store structure.
12
+ """
13
+ if flag_ids is None: # a flag store with no ID for this round/team
14
+ return []
15
+ if isinstance(flag_ids, str):
16
+ return [flag_ids]
9
17
  if isinstance(flag_ids, list):
10
18
  result = []
11
19
  for value in flag_ids:
12
- result += _flat(value)
20
+ result += flatten_flag_ids(value)
13
21
  return result
14
22
  if isinstance(flag_ids, dict):
15
23
  result = []
16
24
  for value in flag_ids.values():
17
- result += _flat(value)
25
+ result += flatten_flag_ids(value)
18
26
  return result
19
- return [cast(str, flag_ids)]
27
+ return [str(flag_ids)]
20
28
 
21
29
 
22
30
  @dataclass(frozen=True)
@@ -46,6 +54,8 @@ class AttackInfo:
46
54
  team_lookup: dict[str, Team] = field(default_factory=dict)
47
55
  services: set[str] = field(default_factory=set)
48
56
  flag_ids: dict[str, dict[str, RawFlagIds]] = field(default_factory=dict)
57
+ flag_regex: Optional[str] = None
58
+ current_round: Optional[int] = None
49
59
  raw: bytes = b"" # everything, as given by the game API
50
60
 
51
61
  def team(self, name: Union[str, int]) -> Optional[Team]:
@@ -100,7 +110,7 @@ class AttackInfo:
100
110
  :return:
101
111
  """
102
112
  flag_ids = self.flag_id_raw(service, team)
103
- return _flat(flag_ids) if flag_ids is not None else []
113
+ return flatten_flag_ids(flag_ids) if flag_ids is not None else []
104
114
 
105
115
  def attack_info_raw(self, service: str, team: Union[str, int, Team]) -> Optional[RawFlagIds]:
106
116
  """
@@ -1,4 +0,0 @@
1
- from .models import Team, AttackInfo
2
- from .functional import configure, attack_info, attack_info_async
3
-
4
- __all__ = ["Team", "AttackInfo", "configure", "attack_info", "attack_info_async"]
@@ -1,4 +0,0 @@
1
- from .api import AdCtfApiAsync, JsonAdCtfApiAsync
2
- from .decoders import Decoder, Dialect
3
-
4
- __all__ = ["AdCtfApiAsync", "JsonAdCtfApiAsync", "Decoder", "Dialect"]