mooring-data-generator 0.11.0__py3-none-any.whl

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.
File without changes
@@ -0,0 +1,340 @@
1
+ import json
2
+ import logging
3
+ import random
4
+ from math import ceil
5
+
6
+ from .models import BentData, BerthData, HookData, PortData, RadarData, ShipData
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ # A list of well-known Western Australian port names
11
+ WA_PORT_NAMES: list[str] = [
12
+ "Port Hedland",
13
+ "Dampier",
14
+ "Fremantle",
15
+ "Kwinana",
16
+ "Bunbury",
17
+ "Esperance",
18
+ "Albany",
19
+ "Geraldton",
20
+ "Broome",
21
+ "Wyndham",
22
+ "Derby",
23
+ "Carnarvon",
24
+ ]
25
+
26
+
27
+ NAUTICAL_SUPERLATIVES: list[str] = [
28
+ "Majestic",
29
+ "Sovereign",
30
+ "Resolute",
31
+ "Valiant",
32
+ "Vigilant",
33
+ "Dauntless",
34
+ "Liberty",
35
+ "Enduring",
36
+ "Gallant",
37
+ "Noble",
38
+ "Guardian",
39
+ "Intrepid",
40
+ "Courageous",
41
+ "Steadfast",
42
+ "Regal",
43
+ "Stalwart",
44
+ "Indomitable",
45
+ "Invincible",
46
+ "Triumphant",
47
+ "Victorious",
48
+ "Glorious",
49
+ "Fearless",
50
+ "Mighty",
51
+ "Bold",
52
+ "Brave",
53
+ "Formidable",
54
+ "Relentless",
55
+ "Valorous",
56
+ "Audacious",
57
+ "Diligent",
58
+ "Implacable",
59
+ "Indefatigable",
60
+ "Prosperous",
61
+ "Seaborne",
62
+ "Seagoing",
63
+ "Oceanic",
64
+ "Maritime",
65
+ "Coastal",
66
+ "Pelagic",
67
+ "Windward",
68
+ "Leeward",
69
+ "Tempestuous",
70
+ "Sturdy",
71
+ ]
72
+
73
+ NAUTICAL_BASE_NAMES: list[str] = [
74
+ # Western
75
+ "Amelia",
76
+ "Charlotte",
77
+ "Olivia",
78
+ "Sophia",
79
+ "Emily",
80
+ "Grace",
81
+ # East Asian
82
+ "Hana",
83
+ "Mei",
84
+ "Yuna",
85
+ "Sakura",
86
+ "Aiko",
87
+ "Keiko",
88
+ # South Asian
89
+ "Asha",
90
+ "Priya",
91
+ "Anika",
92
+ "Riya",
93
+ "Sana",
94
+ "Neha",
95
+ # Southeast Asian
96
+ "Linh",
97
+ "Thao",
98
+ "Trang",
99
+ "Ngoc",
100
+ "Anh",
101
+ "Nicha",
102
+ # Latin (Spanish/Portuguese/LatAm)
103
+ "Camila",
104
+ "Valentina",
105
+ "Isabela",
106
+ "Gabriela",
107
+ "Lucia",
108
+ "Paula",
109
+ ]
110
+
111
+
112
+ BENT_NAMES: list[str] = [f"BNT{x:03d}" for x in range(1, 999)]
113
+
114
+ SHIP_IDS: list[str] = [f"{x:04d}" for x in range(1, 9999)]
115
+
116
+
117
+ MEAN_TENSIONS = 6
118
+ STDEV_TENSIONS = 5
119
+ MEAN_DISTANCES = 9.38
120
+ STDEV_DISTANCES = 6.73
121
+ MEAN_CHANGES = 0.68
122
+ STDEV_CHANGES = 2.6
123
+
124
+ BENT_COUNT_MIN = 9
125
+ BENT_COUNT_MAX = 15
126
+
127
+ HOOK_COUNT_MULTIPLIER = 3
128
+
129
+
130
+ def random_single_use_choice(list_of_strings: list[str]) -> str:
131
+ """Source a one-time random string from a list of strings"""
132
+ random_str = random.choice(list_of_strings)
133
+ list_of_strings.remove(random_str)
134
+ return random_str
135
+
136
+
137
+ def random_ship_name() -> str:
138
+ """Generate a random ship name by combining a nautical superlative with a potential ship name.
139
+
140
+ The format will be "<Superlative> <Name>". Example: "Majestic Amelia" or "Valiant Sophia".
141
+ """
142
+ global NAUTICAL_SUPERLATIVES
143
+ global NAUTICAL_BASE_NAMES
144
+ return f"{random_single_use_choice(NAUTICAL_SUPERLATIVES)} {random_single_use_choice(NAUTICAL_BASE_NAMES)}"
145
+
146
+
147
+ def random_wa_port_name() -> str:
148
+ """Return a random Western Australian port name.
149
+ Preventing the option from being selected in the future."""
150
+ global WA_PORT_NAMES
151
+ return random_single_use_choice(WA_PORT_NAMES)
152
+
153
+
154
+ def random_bent_name() -> str:
155
+ """Return a random bent name."""
156
+ global BENT_NAMES
157
+ return random_single_use_choice(BENT_NAMES)
158
+
159
+
160
+ def generate_ship() -> ShipData:
161
+ """Generate a ship data instance with unique random name and unique id"""
162
+ global SHIP_IDS
163
+ return ShipData(
164
+ name=random_ship_name(),
165
+ vessel_id=random_single_use_choice(SHIP_IDS),
166
+ )
167
+
168
+
169
+ class HookWorker:
170
+ """a worker class for generating and managing changes in Hook data."""
171
+
172
+ def __init__(self, hook_number: int, attached_line: str):
173
+ self.name: str = f"Hook {hook_number}"
174
+ self.active: bool = random.choice([True, False, False])
175
+ # a 5% change of being in fault state
176
+ self.fault: bool = random.choices([True, False], weights=[0.05, 0.95])[0]
177
+ self.attached_line = None
178
+ self.tension = None
179
+ if self.active:
180
+ self.attached_line = attached_line
181
+ self.update()
182
+
183
+ def update(self):
184
+ if self.active:
185
+ self.tension = abs(ceil(random.gauss(MEAN_CHANGES, STDEV_CHANGES)))
186
+
187
+ @property
188
+ def data(self) -> HookData:
189
+ # noinspection PyTypeChecker
190
+ return HookData(
191
+ name=self.name,
192
+ tension=self.tension,
193
+ faulted=self.fault,
194
+ attached_line=self.attached_line,
195
+ )
196
+
197
+
198
+ class BentWorker:
199
+ """a worker class for managing bents and cascading data"""
200
+
201
+ def __init__(self, bent_number: int, total_bents: int):
202
+ self.bent_number: int = bent_number
203
+ self.name = random_bent_name()
204
+ self.hooks: list[HookWorker] = []
205
+ bent_position = bent_number / total_bents
206
+ if bent_position < 0.2:
207
+ attached_line = "HEAD"
208
+ elif 0.8 < bent_position:
209
+ attached_line = "STERN"
210
+ elif 0.4 < bent_position < 0.6:
211
+ attached_line = "BREAST"
212
+ else:
213
+ attached_line = "SPRING"
214
+ hook_count_start: int = (
215
+ (self.bent_number * HOOK_COUNT_MULTIPLIER) - HOOK_COUNT_MULTIPLIER + 1
216
+ )
217
+ for hook_number in range(hook_count_start, hook_count_start + HOOK_COUNT_MULTIPLIER):
218
+ self.hooks.append(HookWorker(hook_number, attached_line=attached_line))
219
+
220
+ def update(self):
221
+ """update the bent and cascading data"""
222
+ for hook in self.hooks:
223
+ hook.update()
224
+
225
+ @property
226
+ def data(self) -> BentData:
227
+ return BentData(
228
+ name=self.name,
229
+ hooks=[hook.data for hook in self.hooks],
230
+ )
231
+
232
+
233
+ class RadarWorker:
234
+ """a worker class for generating and managing changes in Radar data."""
235
+
236
+ def __init__(self, name: str):
237
+ self.name: str = name
238
+ self.active: bool = random.choice([True, False, False])
239
+ self.distance: float | None = None
240
+ self.change: float | None = None
241
+ if self.active:
242
+ self.distance: float = abs(random.gauss(MEAN_DISTANCES, STDEV_DISTANCES))
243
+ self.change: float = abs(random.gauss(MEAN_CHANGES, STDEV_CHANGES))
244
+
245
+ def update(self) -> tuple[float, float]:
246
+ if self.active:
247
+ new_distance: float = abs(random.gauss(MEAN_TENSIONS, STDEV_TENSIONS))
248
+ new_change: float = abs(self.distance - new_distance)
249
+ self.distance = new_distance
250
+ self.change = new_change
251
+ return self.distance, self.change
252
+
253
+ @property
254
+ def data(self) -> RadarData:
255
+ # noinspection PyTypeChecker
256
+ return RadarData(
257
+ name=self.name,
258
+ ship_distance=self.distance,
259
+ distance_change=self.change,
260
+ distance_status="ACTIVE" if self.active else "INACTIVE",
261
+ )
262
+
263
+
264
+ class BerthWorker:
265
+ """a worker class for generating and managing changes in Berth data."""
266
+
267
+ def __init__(self, berth_code: str):
268
+ self.berth_code: str = berth_code
269
+ self.bent_count: int = random.randint(BENT_COUNT_MIN, BENT_COUNT_MAX)
270
+ self.hook_count: int = self.bent_count * HOOK_COUNT_MULTIPLIER
271
+ self.ship: ShipData = generate_ship()
272
+ self.radars: list[RadarWorker] = []
273
+ for radar_num in range(1, random.choice([5, 6, 6, 6]) + 1):
274
+ radar_name = f"B{berth_code}RD{radar_num}"
275
+ self.radars.append(RadarWorker(radar_name))
276
+
277
+ self.bents: list[BentWorker] = []
278
+ for bent_num in range(1, self.bent_count + 1):
279
+ self.bents.append(BentWorker(bent_num, self.bent_count))
280
+
281
+ @property
282
+ def name(self) -> str:
283
+ return f"Berth {self.berth_code}"
284
+
285
+ def update(self):
286
+ for radar in self.radars:
287
+ radar.update()
288
+ for bent in self.bents:
289
+ bent.update()
290
+
291
+ @property
292
+ def data(self) -> BerthData:
293
+ return BerthData(
294
+ name=self.name,
295
+ bent_count=self.bent_count,
296
+ hook_count=self.hook_count,
297
+ ship=self.ship,
298
+ radars=[radar.data for radar in self.radars],
299
+ bents=[bent.data for bent in self.bents],
300
+ )
301
+
302
+
303
+ class PortWorker:
304
+ """a worker class for generating and managing change of ports"""
305
+
306
+ def __init__(self):
307
+ self.name: str = random_wa_port_name()
308
+ self.berth_count: int = random.randint(1, 8)
309
+ self.berths: list[BerthWorker] = []
310
+ for berth_num in range(1, self.berth_count + 1):
311
+ berth_code: str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"[berth_num]
312
+ self.berths.append(BerthWorker(berth_code))
313
+
314
+ def update(self):
315
+ for berth in self.berths:
316
+ berth.update()
317
+
318
+ @property
319
+ def data(self) -> PortData:
320
+ return PortData(
321
+ name=self.name,
322
+ berths=[berth.data for berth in self.berths],
323
+ )
324
+
325
+
326
+ def build_random_port() -> PortWorker:
327
+ """Construct a `PortData` instance with a random WA port name."""
328
+ return PortWorker()
329
+
330
+
331
+ def main() -> None:
332
+ """Generate a single random WA port and print it as JSON."""
333
+ port = build_random_port()
334
+ # Use Pydantic's by_alias to apply PascalCase field names from BasePayloadModel
335
+ payload = port.data.model_dump(by_alias=True)
336
+ logger.info(json.dumps(payload, ensure_ascii=False, indent=2))
337
+
338
+
339
+ if __name__ == "__main__":
340
+ main()
@@ -0,0 +1,56 @@
1
+ import argparse
2
+ import logging
3
+ from pathlib import Path
4
+
5
+ from . import file_worker, http_worker
6
+ from .openapi import generate_openapi_spec
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ parser = argparse.ArgumentParser(description="Mooring data generator")
11
+ parser.add_argument(
12
+ "url", nargs="?", help="HTTP endpoint URL (required if --file is not provided)"
13
+ )
14
+ parser.add_argument("--file", type=str, help="Path to output JSON file (e.g., path/filename.json)")
15
+ parser.add_argument(
16
+ "--openapi",
17
+ action="store_true",
18
+ help="Output OpenAPI specification for the data output instead of running the generator",
19
+ )
20
+
21
+
22
+ def main() -> None:
23
+ """Run the cli tooling for mooring data generator"""
24
+ args = parser.parse_args()
25
+
26
+ # Handle --openapi flag
27
+ if args.openapi:
28
+ spec = generate_openapi_spec()
29
+ print(spec)
30
+ return
31
+
32
+ # Validate that either url or --file is provided
33
+ if not args.url and not args.file:
34
+ parser.error("Either url or --file must be provided")
35
+
36
+ if args.url and args.file:
37
+ parser.error("Cannot use both url and --file at the same time")
38
+
39
+ if args.file:
40
+ # Use file_worker
41
+ file_path = Path(args.file)
42
+ logger.info(f"Starting mooring data generator and will save to files: {file_path}")
43
+ print(f"Starting mooring data generator and will save to files: {file_path}")
44
+ print("Press CTRL+C to stop mooring data generator.")
45
+ file_worker.run(file_path)
46
+ else:
47
+ # Use http_worker
48
+ url: str = args.url
49
+ logger.info(f"Starting mooring data generator and will HTTP POST to {url}")
50
+ print(f"Starting mooring data generator and will HTTP POST to {url}")
51
+ print("Press CTRL+C to stop mooring data generator.")
52
+ http_worker.run(url)
53
+
54
+
55
+ if __name__ == "__main__":
56
+ main()
@@ -0,0 +1,75 @@
1
+ import json
2
+ import logging
3
+ import sys
4
+ import time
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+
8
+ from .builder import build_random_port
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def run(path: Path) -> None:
14
+ """Continuously generate port data and save to JSON files every 2 seconds.
15
+
16
+ - Builds a random port via builder.build_random_port(), store in variable `port`.
17
+ - Forever loop until interrupted (Ctrl+C):
18
+ - Generate filename using pattern: path.parent / f"{path.stem}_{timestamp}{path.suffix}"
19
+ - Save JSON payload from `port.data.model_dump(by_alias=True)` to the file.
20
+ If the file write fails, print a message to stdout and continue.
21
+ - Call `port.update()` to mutate the generated data for next iteration.
22
+ - Sleep for 2 seconds.
23
+
24
+ Args:
25
+ path: Pathlib Path object used to build the output filename pattern
26
+ """
27
+ port = build_random_port()
28
+ loops = 0
29
+ payloads = 1
30
+ try:
31
+ while True:
32
+ loops += 1
33
+ try:
34
+ print(f" loop: {loops:<8} saving payload: {payloads}", end="\r")
35
+ payload = port.data.model_dump(by_alias=True)
36
+
37
+ # Build filename with timestamp
38
+ timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
39
+ output_file = path.parent / f"{path.stem}_{timestamp}{path.suffix}"
40
+
41
+ # Ensure parent directory exists
42
+ output_file.parent.mkdir(parents=True, exist_ok=True)
43
+
44
+ # Write JSON to file
45
+ with open(output_file, "w", encoding="utf-8") as f:
46
+ json.dump(payload, f, indent=2)
47
+
48
+ payloads += 1
49
+ except (OSError, IOError) as e:
50
+ # Notify stdout on failure but continue processing
51
+ logger.error(f"File write failed: {e}")
52
+
53
+ except Exception as e:
54
+ logger.error(f"Unknown Error: {e}")
55
+ logger.exception(e)
56
+ finally:
57
+ # Update model and wait regardless of write success
58
+ try:
59
+ port.update()
60
+ except Exception as e: # Defensive: updating should not kill the loop
61
+ logger.error(f"Port update failed: {e}")
62
+ logger.exception(e)
63
+ raise e
64
+ time.sleep(2)
65
+ except KeyboardInterrupt:
66
+ # Graceful shutdown on Ctrl+C
67
+ logger.info("Interrupted by user. Exiting.")
68
+
69
+
70
+ if __name__ == "__main__":
71
+ # Allow running this module directly: python -m mooring_data_generator.file_worker <PATH>
72
+ if len(sys.argv) != 2:
73
+ print("Usage: file_worker.py <PATH>")
74
+ sys.exit(1)
75
+ run(Path(sys.argv[1]))
@@ -0,0 +1,69 @@
1
+ import json
2
+ import logging
3
+ import sys
4
+ import time
5
+ import urllib.error
6
+ import urllib.request
7
+
8
+ from .builder import build_random_port
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def run(url: str) -> None:
14
+ """Continuously POST generated port data to the given URL every 2 seconds.
15
+
16
+ - Builds a random port via builder.build_random_port(), store in variable `port`.
17
+ - Forever loop until interrupted (Ctrl+C):
18
+ - Send JSON payload from `port.data.model_dump(by_alias=True)` to the URL.
19
+ If the HTTP request fails, print a message to stdout and continue.
20
+ - Call `port.update()` to mutate the generated data for next iteration.
21
+ - Sleep for 2 seconds.
22
+ """
23
+ port = build_random_port()
24
+ loops = 0
25
+ payloads = 1
26
+ try:
27
+ while True:
28
+ loops += 1
29
+ try:
30
+ print(f" loop: {loops:<8} sending payload: {payloads}", end="\r")
31
+ payload = port.data.model_dump(by_alias=True)
32
+ data_bytes = json.dumps(payload).encode("utf-8")
33
+ req = urllib.request.Request(
34
+ url,
35
+ data=data_bytes,
36
+ headers={"Content-Type": "application/json"},
37
+ method="POST",
38
+ )
39
+ with urllib.request.urlopen(req, timeout=10) as resp: # noqa: S310 (stdlib only)
40
+ # We don't need to print on success; quietly proceed.
41
+ _ = resp.read()
42
+ payloads += 1
43
+ except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, OSError) as e:
44
+ # Notify stdout on failure but continue processing
45
+ logger.error(f"HTTP send failed: {e}")
46
+
47
+ except Exception as e:
48
+ logger.error(f"Unknown Error: {e}")
49
+ logger.exception(e)
50
+ finally:
51
+ # Update model and wait regardless of send success
52
+ try:
53
+ port.update()
54
+ except Exception as e: # Defensive: updating should not kill the loop
55
+ logger.error(f"Port update failed: {e}")
56
+ logger.exception(e)
57
+ raise e
58
+ time.sleep(2)
59
+ except KeyboardInterrupt:
60
+ # Graceful shutdown on Ctrl+C
61
+ logger.info("Interrupted by user. Exiting.")
62
+
63
+
64
+ if __name__ == "__main__":
65
+ # Allow running this module directly: python -m mooring_data_generator.http_worker <URL>
66
+ if len(sys.argv) != 2:
67
+ print("Usage: http_worker.py <URL>")
68
+ sys.exit(1)
69
+ run(sys.argv[1])
@@ -0,0 +1,73 @@
1
+ from typing import Annotated, Literal, TypedDict
2
+
3
+ from pydantic import AliasGenerator, BaseModel, ConfigDict, Field, alias_generators
4
+
5
+
6
+ class TensionLimits(TypedDict):
7
+ high_tension: int
8
+ medium_tension: int
9
+ low_tension: int
10
+
11
+
12
+ TENSION_LIMITS: dict[str, TensionLimits] = {
13
+ "default": TensionLimits(
14
+ high_tension=24,
15
+ medium_tension=14,
16
+ low_tension=4,
17
+ ),
18
+ "Berth A": TensionLimits(
19
+ high_tension=25,
20
+ medium_tension=15,
21
+ low_tension=5,
22
+ ),
23
+ }
24
+
25
+
26
+ class BasePayloadModel(BaseModel):
27
+ model_config = ConfigDict(
28
+ alias_generator=AliasGenerator(
29
+ serialization_alias=alias_generators.to_pascal,
30
+ )
31
+ )
32
+
33
+
34
+ class ShipData(BasePayloadModel):
35
+ name: str
36
+ vessel_id: Annotated[str, Field(pattern=r"^[0-9]{4}$")]
37
+
38
+
39
+ class RadarData(BasePayloadModel):
40
+ name: Annotated[str, Field(pattern=r"^B[A-Z]RD[0-9]$")]
41
+ # Radar Should share the name of the Berth, Berth A Radar 1 = BARD1
42
+ ship_distance: Annotated[float, Field(ge=0, lt=100)] | None
43
+ # min 2.8 - 6.7 | max 3.4 - 30.7
44
+ distance_change: Annotated[float, Field(gt=-100, lt=100)] | None
45
+ # min 0.0007 - 0.06 | max 0.029 - 5.76
46
+ distance_status: Literal["INACTIVE", "ACTIVE"]
47
+
48
+
49
+ class HookData(BasePayloadModel):
50
+ name: Annotated[str, Field(pattern=r"^Hook [1-9][0-9]?$")]
51
+ tension: Annotated[int, Field(ge=0, lt=99)] | None
52
+ faulted: bool
53
+ attached_line: Literal["BREAST", "HEAD", "SPRING", "STERN"] | None
54
+
55
+
56
+ class BentData(BasePayloadModel):
57
+ name: Annotated[str, Field(pattern=r"^BNT[0-9]{3}$")]
58
+ # naming convention BNT + unique id
59
+ hooks: list[HookData]
60
+
61
+
62
+ class BerthData(BasePayloadModel):
63
+ name: Annotated[str, Field(pattern=r"^Berth [A-Z]$")]
64
+ bent_count: Annotated[int, Field(gt=0, lt=40)] # the count of bents: min 9 | max 15
65
+ hook_count: Annotated[int, Field(gt=0, lt=60)] # the count of hooks: min 27 | max 48
66
+ ship: ShipData
67
+ radars: list[RadarData]
68
+ bents: list[BentData]
69
+
70
+
71
+ class PortData(BasePayloadModel):
72
+ name: str # the name of the port
73
+ berths: list[BerthData]
@@ -0,0 +1,52 @@
1
+ import json
2
+
3
+ from .models import PortData
4
+
5
+
6
+ def generate_openapi_spec() -> str:
7
+ """Generate an OpenAPI 3.0 specification for the mooring data output.
8
+
9
+ Returns:
10
+ JSON string of the OpenAPI specification
11
+ """
12
+ # Get the Pydantic model's JSON schema
13
+ port_schema = PortData.model_json_schema()
14
+
15
+ # Build OpenAPI 3.0 specification
16
+ openapi_spec = {
17
+ "openapi": "3.0.3",
18
+ "info": {
19
+ "title": "Mooring Data Generator API",
20
+ "description": "API specification for mooring data generated by mooring-data-generator",
21
+ "version": "1.0.0",
22
+ },
23
+ "paths": {
24
+ "/": {
25
+ "post": {
26
+ "summary": "Receive mooring data",
27
+ "description": "Endpoint that receives generated mooring data via HTTP POST",
28
+ "requestBody": {
29
+ "required": True,
30
+ "content": {
31
+ "application/json": {
32
+ "schema": {"$ref": "#/components/schemas/PortData"}
33
+ }
34
+ },
35
+ },
36
+ "responses": {"200": {"description": "Successfully received mooring data"}},
37
+ }
38
+ }
39
+ },
40
+ "components": {"schemas": {}},
41
+ }
42
+
43
+ # Extract all schemas from the Pydantic model
44
+ # The model_json_schema() includes all nested models in $defs
45
+ if "$defs" in port_schema:
46
+ openapi_spec["components"]["schemas"] = port_schema["$defs"]
47
+
48
+ # Add the main PortData schema
49
+ port_data_schema = {k: v for k, v in port_schema.items() if k != "$defs"}
50
+ openapi_spec["components"]["schemas"]["PortData"] = port_data_schema
51
+
52
+ return json.dumps(openapi_spec, indent=2)
@@ -0,0 +1,185 @@
1
+ import argparse
2
+ import json
3
+ import sys
4
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
5
+
6
+ DEFAULT_PORT = 8000
7
+ MAX_SHOW = 1024 * 1
8
+
9
+
10
+ class PrintingRequestHandler(BaseHTTPRequestHandler):
11
+ """A very simple request handler that prints the full request and responds 200.
12
+
13
+ - Prints: client address, method, path, HTTP version, headers, and body (if any)
14
+ - Responds: 200 OK with a small text/plain body
15
+ - Avoids using any external packages
16
+ """
17
+
18
+ # Max bytes of body to show; set to None for full body
19
+ max_show: int = MAX_SHOW
20
+
21
+ # Format mode: None for default, "json" for JSON output
22
+ format_mode: bool = False
23
+
24
+ # Disable default logging to stderr; we print our own structured output
25
+ def log_message(self, format: str, *args) -> None: # noqa: A003 - match BaseHTTPRequestHandler
26
+ return
27
+
28
+ def _read_body(self) -> bytes:
29
+ length = int(self.headers.get("Content-Length", 0) or 0)
30
+ if length > 0:
31
+ return self.rfile.read(length)
32
+ return b""
33
+
34
+ def _print_body(self, body: bytes, content_type: str) -> None:
35
+ """Safely limit body printout size (unless max_show is None"""
36
+ if self.format_mode:
37
+ if content_type == "application/json":
38
+ body = json.dumps(json.loads(body.decode("utf-8")), indent=2).encode("utf-8")
39
+ if self.max_show is None:
40
+ shown = body
41
+ else:
42
+ shown = body[: self.max_show]
43
+ try:
44
+ print(shown.decode("utf-8", errors="replace"))
45
+ except Exception as e:
46
+ print(f"-- {e}")
47
+ print(repr(shown))
48
+ if self.max_show is not None and len(body) > self.max_show:
49
+ print(f"-- {len(body) - self.max_show} more bytes not shown --")
50
+
51
+ def _print_request(self, body: bytes) -> None:
52
+ """print the request to stdoutmo"""
53
+ # First line
54
+ http_version = {
55
+ 9: "HTTP/0.9",
56
+ 10: "HTTP/1.0",
57
+ 11: "HTTP/1.1",
58
+ }.get(self.protocol_version_number(), self.protocol_version)
59
+
60
+ client_host, client_port = self.client_address
61
+ print("=" * 80)
62
+ print(f"Client: {client_host}:{client_port}")
63
+ print(f"Request: {self.command} {self.path} {http_version}")
64
+ print("-- Headers --")
65
+ for k, v in self.headers.items():
66
+ print(f"{k}: {v}")
67
+ if body:
68
+ print("-- Body (bytes) --")
69
+ content_type = self.headers.get("Content-Type", "")
70
+ self._print_body(body, content_type)
71
+ else:
72
+ print("-- No Body --")
73
+ print("=" * 80)
74
+ sys.stdout.flush()
75
+
76
+ def _respond_ok(self) -> None:
77
+ message = b"OK\n"
78
+ self.send_response(200, "OK")
79
+ self.send_header("Content-Type", "text/plain; charset=utf-8")
80
+ self.send_header("Content-Length", str(len(message)))
81
+ self.end_headers()
82
+ self.wfile.write(message)
83
+
84
+ # Map common methods to the same handler
85
+ def do_GET(self) -> None: # noqa: N802 - required by BaseHTTPRequestHandler
86
+ body = self._read_body()
87
+ self._print_request(body)
88
+ self._respond_ok()
89
+
90
+ def do_POST(self) -> None: # noqa: N802
91
+ body = self._read_body()
92
+ self._print_request(body)
93
+ self._respond_ok()
94
+
95
+ def do_PUT(self) -> None: # noqa: N802
96
+ body = self._read_body()
97
+ self._print_request(body)
98
+ self._respond_ok()
99
+
100
+ def do_PATCH(self) -> None: # noqa: N802
101
+ body = self._read_body()
102
+ self._print_request(body)
103
+ self._respond_ok()
104
+
105
+ def do_DELETE(self) -> None: # noqa: N802
106
+ body = self._read_body()
107
+ self._print_request(body)
108
+ self._respond_ok()
109
+
110
+ def do_OPTIONS(self) -> None: # noqa: N802
111
+ # Minimal CORS-friendly response
112
+ self.send_response(200, "OK")
113
+ self.send_header("Access-Control-Allow-Origin", "*")
114
+ self.send_header("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
115
+ self.send_header("Access-Control-Allow-Headers", "*, Content-Type, Authorization")
116
+ self.end_headers()
117
+
118
+ # Helper to expose numeric protocol version (for display)
119
+ def protocol_version_number(self) -> int:
120
+ try:
121
+ return int(self.protocol_version.split("/")[-1].replace(".", ""))
122
+ except Exception:
123
+ return 11 # assume HTTP/1.1 if unknown
124
+
125
+
126
+ def serve(port: int = DEFAULT_PORT, host: str = "0.0.0.0") -> tuple[str, int]:
127
+ """Start the HTTP server and block forever.
128
+
129
+ Returns the actual bound address (host, port). Useful when passing port=0.
130
+ """
131
+ server = ThreadingHTTPServer((host, port), PrintingRequestHandler)
132
+ bound_host, bound_port = server.server_address
133
+ print(f"Listening on http://{bound_host}:{bound_port} (press Ctrl+C to stop)")
134
+ try:
135
+ server.serve_forever()
136
+ except KeyboardInterrupt:
137
+ print("Shutting down...")
138
+ finally:
139
+ server.server_close()
140
+ return bound_host, bound_port
141
+
142
+
143
+ def cli(argv: list[str] | None = None) -> None:
144
+ parser = argparse.ArgumentParser(description="Simple HTTP request printer")
145
+ parser.add_argument(
146
+ "--port",
147
+ "-p",
148
+ type=int,
149
+ default=DEFAULT_PORT,
150
+ help=f"Port to listen on (default: {DEFAULT_PORT})",
151
+ )
152
+ parser.add_argument(
153
+ "--host",
154
+ "-H",
155
+ default="0.0.0.0",
156
+ help="Host/interface to bind (default: 0.0.0.0)",
157
+ )
158
+ parser.add_argument(
159
+ "--full",
160
+ action="store_true",
161
+ help="Print full request bodies without truncation",
162
+ )
163
+ parser.add_argument(
164
+ "--format",
165
+ action="store_true",
166
+ help="Output format for request content (json uses json.dumps, otherwise content as received)",
167
+ )
168
+
169
+ args = parser.parse_args(argv)
170
+
171
+ # Configure handler truncation behavior
172
+ if args.full:
173
+ PrintingRequestHandler.max_show = None # show full body
174
+ else:
175
+ PrintingRequestHandler.max_show = MAX_SHOW
176
+
177
+ # Configure output format
178
+ if args.format:
179
+ PrintingRequestHandler.format_mode = args.format
180
+
181
+ serve(port=args.port, host=args.host)
182
+
183
+
184
+ if __name__ == "__main__":
185
+ cli()
@@ -0,0 +1,178 @@
1
+ Metadata-Version: 2.3
2
+ Name: mooring-data-generator
3
+ Version: 0.11.0
4
+ Summary: Random-data generator to simulate fictional port data flows for use at a Hackathon
5
+ Author: Ben Fitzhardinge
6
+ Author-email: Ben Fitzhardinge <ben@benjamin.dog>
7
+ Classifier: Development Status :: 4 - Beta
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Topic :: Software Development :: Testing :: Traffic Generation
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Requires-Dist: pydantic>=2.8
17
+ Maintainer: Ben Fitzhardinge
18
+ Maintainer-email: Ben Fitzhardinge <ben@benjamin.dog>
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+
22
+ # Mooring Data Generator
23
+
24
+ A simple script to generate fake mooring data for use in a hackathon.
25
+ This script will send data payloads to and endpoint to simulate the data which might exist.
26
+
27
+ These will be http POST queries to the url provided as an argument at run time.
28
+
29
+ The script will run forever until the user sends a Ctrl+C command to end the script.
30
+
31
+ ## Install
32
+
33
+ ### With UV (recommended)
34
+
35
+ If you don't have UV on your system, read [the install instructions for UV](https://docs.astral.sh/uv/getting-started/installation/)
36
+
37
+ ```shell
38
+ uv tool install -U mooring-data-generator
39
+ ```
40
+
41
+ ### Vanilla python (If you don't want UV)
42
+
43
+ ```shell
44
+ pip install -U mooring-data-generator
45
+ ```
46
+
47
+ ## Usage
48
+
49
+ ### Running the package
50
+
51
+ #### Sending data via HTTP POST
52
+
53
+ ```shell
54
+ mooring-data-generator http://127.0.0.1:8000/my/endpoint/
55
+ ```
56
+
57
+ > [!IMPORTANT]
58
+ > replace `http://127.0.0.1:8000/my/endpoint/` with the appropriate url for your system
59
+
60
+ #### Saving data to a file
61
+
62
+ You can also save the generated mooring data to a JSON file instead of sending it via HTTP:
63
+
64
+ ```shell
65
+ mooring-data-generator --file output.json
66
+ ```
67
+
68
+ This will continuously generate mooring data and save it to the specified file.
69
+
70
+ > [!NOTE]
71
+ > You can only use either the URL (HTTP POST) or `--file` option, not both at the same time
72
+
73
+ #### Getting the OpenAPI specification
74
+
75
+ You can output an OpenAPI 3.0 specification for the mooring data format:
76
+
77
+ ```shell
78
+ mooring-data-generator --openapi
79
+ ```
80
+
81
+ This will print a complete OpenAPI specification in JSON format
82
+ that describes the structure of the mooring data being generated.
83
+ This is useful for:
84
+
85
+ - Understanding the data format
86
+ - Generating client libraries
87
+ - API documentation
88
+ - Integration planning
89
+
90
+ The specification can be saved to a file for use with OpenAPI tools:
91
+
92
+ ```shell
93
+ mooring-data-generator --openapi > openapi.json
94
+ ```
95
+
96
+ ## Testing data is being sent
97
+
98
+ There's a helper application included in this package
99
+ to allow you to check that the data is being sent.
100
+
101
+ `mooring-data-receiver` will display to the console all http traffic it receives.
102
+
103
+ ```shell
104
+ mooring-data-receiver
105
+ ```
106
+
107
+ By default it will run listening to any traffic `0.0.0.0` on port `8000`
108
+
109
+ You can adjust this if needed by using a commend like
110
+
111
+ ```shell
112
+ mooring-data-receiver --host 127.0.0.1 --port 5000
113
+ ```
114
+
115
+ ### Formatting output
116
+
117
+ You can use the `--format` flag to control how the request body is displayed:
118
+
119
+ ```shell
120
+ mooring-data-receiver --format
121
+ ```
122
+
123
+ When `--format` is used, the request body content
124
+ will be formatted using `json.dumps(indent=2)` for better readability.
125
+ Without this flag, the content is displayed as received.
126
+
127
+ ## Troubleshooting
128
+
129
+ ### Command not found
130
+
131
+ If you are having trouble with the command not being found,
132
+ you can attempt to run it as a module calling python
133
+
134
+ ```shell
135
+ python -m mooring-data-generator http://127.0.0.1:8000/my/endpoint/
136
+ ```
137
+
138
+ ### Pip not found
139
+
140
+ If `pip` can't be found on your system.
141
+
142
+ First, make sure you have Python installed.
143
+
144
+ ```shell
145
+ python --version
146
+ ```
147
+
148
+ you can call `pip` from python directly as a module.
149
+
150
+ ```shell
151
+ python -m pip install -U mooring-data-generator
152
+ ```
153
+
154
+ ## Release a new version
155
+
156
+ ### Be sure the tests pass
157
+
158
+ ```shell
159
+ uv sync --all-groups
160
+ uv run ruff format
161
+ uv run ruff check
162
+ uv run tox
163
+ ```
164
+
165
+ ### bump version and tag new release
166
+
167
+ ```shell
168
+ uv version --bump minor
169
+ git commit -am "Release version v$(uv version --short)"
170
+ git tag -a "v$(uv version --short)" -m "v$(uv version --short)"
171
+ ```
172
+
173
+ ### push to github
174
+
175
+ ```shell
176
+ git push
177
+ git push --tags
178
+ ```
@@ -0,0 +1,12 @@
1
+ mooring_data_generator/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ mooring_data_generator/builder.py,sha256=CzNLpNvBaRDWfcbNFdiL-HFqLK5a4xoCn3dLzmNo9NQ,9096
3
+ mooring_data_generator/cli.py,sha256=H6jILYMEa6M-cwmHuD1UhGqSbiiH4FRQ8RxUtxxfa1Q,1821
4
+ mooring_data_generator/file_worker.py,sha256=PbXF7oWNaqsUCFzxRK5QETg6oDtHl7cUNYqWa8cyw5g,2721
5
+ mooring_data_generator/http_worker.py,sha256=4Aa9jkpNAOIXZFJtfUI8Orn2RmZE2UDmsiRV6-Pd56Y,2586
6
+ mooring_data_generator/models.py,sha256=TaJcnZ5SAzhgmaIdrZAMuzB_pUQqJtK04OcIgomb3aI,2087
7
+ mooring_data_generator/openapi.py,sha256=zLkBYRu5n0RuoTkoUwGx4zEGKSWktbFoQ86AXGoisvk,1799
8
+ mooring_data_generator/receiver.py,sha256=IevBKmdJket4qMb4zW41GDeBSjvyDriBJDjmyHIVFtg,6186
9
+ mooring_data_generator-0.11.0.dist-info/WHEEL,sha256=DpNsHFUm_gffZe1FgzmqwuqiuPC6Y-uBCzibcJcdupM,78
10
+ mooring_data_generator-0.11.0.dist-info/entry_points.txt,sha256=ZfpADZ8zW3FjJOgNafj14t_Va-Ga0ddlL9WEAYXHnjM,136
11
+ mooring_data_generator-0.11.0.dist-info/METADATA,sha256=ivbU4yLIxvbv_j914a7Spip9HzautsxYvBs4bC7h-pM,4369
12
+ mooring_data_generator-0.11.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.9.8
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,4 @@
1
+ [console_scripts]
2
+ mooring-data-generator = mooring_data_generator.cli:main
3
+ mooring-data-receiver = mooring_data_generator.receiver:cli
4
+