compass-lib 0.0.2__py3-none-any.whl → 0.0.3__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.
@@ -0,0 +1,638 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Parser for Compass .MAK project files.
3
+
4
+ This module implements the parser for reading Compass project files,
5
+ which define project structure, geographic settings, and data file references.
6
+
7
+ Architecture: The parser produces dictionaries (like loading JSON) which are
8
+ then fed to Pydantic models via a single `model_validate()` call. This keeps
9
+ parsing logic separate from model construction.
10
+ """
11
+
12
+ import re
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from compass_lib.constants import ASCII_ENCODING
17
+ from compass_lib.enums import FormatIdentifier
18
+ from compass_lib.errors import CompassParseException
19
+ from compass_lib.errors import SourceLocation
20
+ from compass_lib.project.models import CompassMakFile
21
+
22
+
23
+ class CompassProjectParser:
24
+ """Parser for Compass .MAK project files.
25
+
26
+ This parser reads Compass project files and produces dictionaries
27
+ (like loading JSON from disk). The dictionaries can then be fed to
28
+ Pydantic models via a single `model_validate()` call.
29
+
30
+ The parser is strict - it raises CompassParseException on errors.
31
+ """
32
+
33
+ # Regex patterns
34
+ EOL_PATTERN = re.compile(r"\r\n?|\n")
35
+ FILE_NAME_PATTERN = re.compile(r"[^,;/]+")
36
+ DATUM_PATTERN = re.compile(r"[^;/]+")
37
+ LINK_STATION_PATTERN = re.compile(r"[^,;/\[]+")
38
+ NUMBER_PATTERN = re.compile(r"[-+]?\d+(\.\d*)?|\.\d+")
39
+
40
+ def __init__(self) -> None:
41
+ """Initialize the parser."""
42
+ self._data: str = ""
43
+ self._pos: int = 0
44
+ self._source: str = "<string>"
45
+ self._line: int = 0
46
+
47
+ # -------------------------------------------------------------------------
48
+ # Dictionary-returning methods (primary API)
49
+ # -------------------------------------------------------------------------
50
+
51
+ def parse_file_to_dict(self, path: Path) -> dict[str, Any]:
52
+ """Parse a project file to dictionary.
53
+
54
+ This is the primary parsing method. It returns a dictionary that
55
+ can be directly fed to `CompassMakFile.model_validate()`.
56
+
57
+ Args:
58
+ path: Path to the .MAK file
59
+
60
+ Returns:
61
+ Dictionary with directives list
62
+
63
+ Raises:
64
+ CompassParseException: On parse errors
65
+ """
66
+ with path.open(mode="r", encoding=ASCII_ENCODING, errors="replace") as f:
67
+ data = f.read()
68
+ return self.parse_string_to_dict(data, str(path))
69
+
70
+ def parse_string_to_dict(
71
+ self,
72
+ data: str,
73
+ source: str = "<string>",
74
+ ) -> dict[str, Any]:
75
+ """Parse project data from a string to dictionary.
76
+
77
+ Args:
78
+ data: Project file content
79
+ source: Source identifier for error messages
80
+
81
+ Returns:
82
+ Dictionary with directives list
83
+
84
+ Raises:
85
+ CompassParseException: On parse errors
86
+ """
87
+ self._data = data
88
+ self._pos = 0
89
+ self._source = source
90
+ self._line = 0
91
+
92
+ directives: list[dict[str, Any]] = []
93
+
94
+ while self._pos < len(self._data):
95
+ self._skip_whitespace()
96
+ if self._pos >= len(self._data):
97
+ break
98
+
99
+ char = self._data[self._pos]
100
+ self._pos += 1
101
+
102
+ if char == "#":
103
+ directives.append(self._parse_survey_file_to_dict())
104
+ elif char == "@":
105
+ directives.append(self._parse_location_to_dict())
106
+ elif char == "&":
107
+ directives.append(self._parse_datum_to_dict())
108
+ elif char == "%":
109
+ directives.append(self._parse_utm_convergence_to_dict(enabled=True))
110
+ elif char == "*":
111
+ directives.append(self._parse_utm_convergence_to_dict(enabled=False))
112
+ elif char == "$":
113
+ directives.append(self._parse_utm_zone_to_dict())
114
+ elif char == "!":
115
+ directives.append(self._parse_flags_to_dict())
116
+ elif char == "[":
117
+ directives.append(self._parse_folder_start_to_dict())
118
+ elif char == "]":
119
+ directives.append(self._parse_folder_end_to_dict())
120
+ elif char == "/":
121
+ directives.append(self._parse_comment_to_dict())
122
+ elif ord(char) >= 0x20:
123
+ # Unknown directive - parse to semicolon for roundtrip fidelity
124
+ directives.append(self._parse_unknown_directive_to_dict(char))
125
+
126
+ return {
127
+ "version": "1.0",
128
+ "format": FormatIdentifier.COMPASS_MAK.value,
129
+ "directives": directives,
130
+ }
131
+
132
+ # -------------------------------------------------------------------------
133
+ # Legacy model-returning methods (thin wrappers for backwards compat)
134
+ # -------------------------------------------------------------------------
135
+
136
+ def parse_file(self, path: Path) -> list["CompassProjectDirective"]: # noqa: F821
137
+ """Parse a project file.
138
+
139
+ DEPRECATED: Use parse_file_to_dict() for new code.
140
+
141
+ Args:
142
+ path: Path to the .MAK file
143
+
144
+ Returns:
145
+ List of parsed directives
146
+
147
+ Raises:
148
+ CompassParseException: On parse errors
149
+ """
150
+
151
+ data = self.parse_file_to_dict(path)
152
+ mak_file = CompassMakFile.model_validate(data)
153
+ return mak_file.directives
154
+
155
+ def parse_string(
156
+ self,
157
+ data: str,
158
+ source: str = "<string>",
159
+ ) -> list["CompassProjectDirective"]: # noqa: F821
160
+ """Parse project data from a string.
161
+
162
+ DEPRECATED: Use parse_string_to_dict() for new code.
163
+
164
+ Args:
165
+ data: Project file content
166
+ source: Source identifier for error messages
167
+
168
+ Returns:
169
+ List of parsed directives
170
+
171
+ Raises:
172
+ CompassParseException: On parse errors
173
+ """
174
+
175
+ parsed = self.parse_string_to_dict(data, source)
176
+ mak_file = CompassMakFile.model_validate(parsed)
177
+ return mak_file.directives
178
+
179
+ def _location(self, text: str = "") -> SourceLocation:
180
+ """Create a SourceLocation at current position."""
181
+ return SourceLocation(
182
+ source=self._source,
183
+ line=self._line,
184
+ column=self._pos - 1,
185
+ text=text,
186
+ )
187
+
188
+ def _skip_whitespace(self) -> None:
189
+ """Skip whitespace characters, tracking line numbers."""
190
+ while self._pos < len(self._data):
191
+ char = self._data[self._pos]
192
+ if char == "\n":
193
+ self._line += 1
194
+ self._pos += 1
195
+ elif char == "\r":
196
+ self._line += 1
197
+ self._pos += 1
198
+ if self._pos < len(self._data) and self._data[self._pos] == "\n":
199
+ self._pos += 1
200
+ elif char.isspace():
201
+ self._pos += 1
202
+ else:
203
+ break
204
+
205
+ def _skip_whitespace_and_comments(self) -> None:
206
+ """Skip whitespace and inline comments."""
207
+ self._skip_whitespace()
208
+ while self._pos < len(self._data) and self._data[self._pos] == "/":
209
+ self._pos += 1
210
+ self._skip_to_end_of_line()
211
+ self._skip_whitespace()
212
+
213
+ def _skip_to_end_of_line(self) -> None:
214
+ """Skip to end of current line."""
215
+ while self._pos < len(self._data):
216
+ char = self._data[self._pos]
217
+ if char == "\n":
218
+ self._line += 1
219
+ self._pos += 1
220
+ break
221
+ if char == "\r":
222
+ self._line += 1
223
+ self._pos += 1
224
+ if self._pos < len(self._data) and self._data[self._pos] == "\n":
225
+ self._pos += 1
226
+ break
227
+ self._pos += 1
228
+
229
+ def _expect(self, char: str) -> None:
230
+ """Expect a specific character.
231
+
232
+ Raises:
233
+ CompassParseError: If character doesn't match
234
+ """
235
+ if self._pos >= len(self._data):
236
+ raise CompassParseException(
237
+ f"expected {char}, got end of file",
238
+ self._location(),
239
+ )
240
+ if self._data[self._pos] != char:
241
+ raise CompassParseException(
242
+ f"expected {char}, got {self._data[self._pos]}",
243
+ self._location(self._data[self._pos]),
244
+ )
245
+ self._pos += 1
246
+
247
+ def _expect_match(
248
+ self,
249
+ pattern: re.Pattern[str],
250
+ error_msg: str,
251
+ ) -> str:
252
+ """Match a pattern at current position.
253
+
254
+ Args:
255
+ pattern: Regex pattern to match
256
+ error_msg: Error message if no match
257
+
258
+ Returns:
259
+ Matched text
260
+
261
+ Raises:
262
+ CompassParseError: If no match
263
+ """
264
+ match = pattern.match(self._data, self._pos)
265
+ if not match or match.start() != self._pos:
266
+ raise CompassParseException(error_msg, self._location())
267
+ self._pos = match.end()
268
+ return match.group()
269
+
270
+ def _expect_number(self, error_msg: str) -> float:
271
+ """Parse a number at current position.
272
+
273
+ Args:
274
+ error_msg: Error message if no number
275
+
276
+ Returns:
277
+ Parsed number
278
+
279
+ Raises:
280
+ CompassParseError: If no valid number
281
+ """
282
+ text = self._expect_match(self.NUMBER_PATTERN, error_msg)
283
+ return float(text)
284
+
285
+ def _expect_utm_zone(self, *, allow_zero: bool = False) -> int:
286
+ """Parse UTM zone (integer 1-60 or -1 to -60, or 0 if allow_zero).
287
+
288
+ Positive zones indicate northern hemisphere.
289
+ Negative zones indicate southern hemisphere.
290
+
291
+ Args:
292
+ allow_zero: If True, allows zone 0 (meaning "not specified")
293
+
294
+ Returns:
295
+ UTM zone number
296
+
297
+ Raises:
298
+ CompassParseError: If invalid zone
299
+ """
300
+ text = self._expect_match(self.NUMBER_PATTERN, "missing UTM zone")
301
+
302
+ if "." in text:
303
+ raise CompassParseException(
304
+ "invalid UTM zone (not an integer)", self._location(text)
305
+ )
306
+
307
+ zone = int(text)
308
+
309
+ # Check if zone is valid
310
+ if zone == 0 and not allow_zero:
311
+ raise CompassParseException(
312
+ "UTM zone cannot be 0. Use 1-60 for north, -1 to -60 for south.",
313
+ self._location(text),
314
+ )
315
+
316
+ if abs(zone) > 60:
317
+ raise CompassParseException(
318
+ f"UTM zone must be between -60 and 60, got {zone}",
319
+ self._location(text),
320
+ )
321
+
322
+ return zone
323
+
324
+ def _parse_length_unit(self) -> str:
325
+ """Parse length unit (f or m).
326
+
327
+ Returns:
328
+ 'f' for feet, 'm' for meters
329
+
330
+ Raises:
331
+ CompassParseError: If invalid unit
332
+ """
333
+ if self._pos >= len(self._data):
334
+ raise CompassParseException("missing length unit", self._location())
335
+
336
+ char = self._data[self._pos].lower()
337
+ if char not in ("f", "m"):
338
+ raise CompassParseException(
339
+ f"invalid length unit: {char}",
340
+ self._location(char),
341
+ )
342
+ self._pos += 1
343
+ return char
344
+
345
+ def _parse_survey_file_to_dict(self) -> dict[str, Any]:
346
+ """Parse #filename,station[location],...; to dictionary."""
347
+ file_name = self._expect_match(
348
+ self.FILE_NAME_PATTERN,
349
+ "missing file name",
350
+ ).strip()
351
+
352
+ link_stations: list[dict[str, Any]] = []
353
+
354
+ while True:
355
+ self._skip_whitespace_and_comments()
356
+
357
+ if self._pos >= len(self._data):
358
+ raise CompassParseException(
359
+ "missing ; at end of file line",
360
+ self._location(),
361
+ )
362
+
363
+ char = self._data[self._pos]
364
+ self._pos += 1
365
+
366
+ if char == ";":
367
+ return {
368
+ "type": "file",
369
+ "file": file_name,
370
+ "link_stations": link_stations,
371
+ }
372
+
373
+ if char == ",":
374
+ self._skip_whitespace_and_comments()
375
+ station_name = self._expect_match(
376
+ self.LINK_STATION_PATTERN,
377
+ "missing station name",
378
+ ).strip()
379
+
380
+ location = None
381
+ self._skip_whitespace_and_comments()
382
+
383
+ if self._pos < len(self._data) and self._data[self._pos] == "[":
384
+ self._pos += 1
385
+ self._skip_whitespace_and_comments()
386
+
387
+ unit = self._parse_length_unit()
388
+ self._skip_whitespace_and_comments()
389
+ self._expect(",")
390
+ self._skip_whitespace_and_comments()
391
+
392
+ easting = self._expect_number("missing easting")
393
+ self._skip_whitespace_and_comments()
394
+ self._expect(",")
395
+ self._skip_whitespace_and_comments()
396
+
397
+ northing = self._expect_number("missing northing")
398
+ self._skip_whitespace_and_comments()
399
+ self._expect(",")
400
+ self._skip_whitespace_and_comments()
401
+
402
+ elevation = self._expect_number("missing elevation")
403
+ self._skip_whitespace_and_comments()
404
+ self._expect("]")
405
+
406
+ location = {
407
+ "easting": easting,
408
+ "northing": northing,
409
+ "elevation": elevation,
410
+ "unit": unit,
411
+ }
412
+
413
+ link_stations.append({"name": station_name, "location": location})
414
+ else:
415
+ raise CompassParseException(
416
+ f"unexpected character: {char}",
417
+ self._location(char),
418
+ )
419
+
420
+ def _parse_location_to_dict(self) -> dict[str, Any]:
421
+ """Parse @easting,northing,elevation,zone,convergence; to dictionary.
422
+
423
+ Note: UTM zone of 0 is allowed here, meaning "no location specified"
424
+ (commonly used when only declination information is needed).
425
+ """
426
+ self._skip_whitespace()
427
+ easting = self._expect_number("missing easting")
428
+ self._skip_whitespace()
429
+ self._expect(",")
430
+ self._skip_whitespace()
431
+ northing = self._expect_number("missing northing")
432
+ self._skip_whitespace()
433
+ self._expect(",")
434
+ self._skip_whitespace()
435
+ elevation = self._expect_number("missing elevation")
436
+ self._skip_whitespace()
437
+ self._expect(",")
438
+ self._skip_whitespace()
439
+ utm_zone = self._expect_utm_zone(allow_zero=True)
440
+ self._skip_whitespace()
441
+ self._expect(",")
442
+ self._skip_whitespace()
443
+ convergence = self._expect_number("missing UTM convergence")
444
+ self._expect(";")
445
+
446
+ return {
447
+ "type": "location",
448
+ "easting": easting,
449
+ "northing": northing,
450
+ "elevation": elevation,
451
+ "utm_zone": utm_zone,
452
+ "utm_convergence": convergence,
453
+ }
454
+
455
+ def _parse_datum_to_dict(self) -> dict[str, Any]:
456
+ """Parse &datum_name; to dictionary."""
457
+ datum = self._expect_match(self.DATUM_PATTERN, "missing datum").strip()
458
+ self._expect(";")
459
+ return {"type": "datum", "datum": datum}
460
+
461
+ def _parse_utm_convergence_to_dict(self, *, enabled: bool) -> dict[str, Any]:
462
+ """Parse %convergence; or *convergence; to dictionary.
463
+
464
+ Args:
465
+ enabled: True if parsed from %, False if parsed from *
466
+ """
467
+ self._skip_whitespace()
468
+ convergence = self._expect_number("missing UTM convergence")
469
+ self._expect(";")
470
+ return {
471
+ "type": "utm_convergence",
472
+ "utm_convergence": convergence,
473
+ "enabled": enabled,
474
+ }
475
+
476
+ def _parse_utm_zone_to_dict(self) -> dict[str, Any]:
477
+ """Parse $zone; to dictionary."""
478
+ self._skip_whitespace()
479
+ zone = self._expect_utm_zone()
480
+ self._expect(";")
481
+ return {"type": "utm_zone", "utm_zone": zone}
482
+
483
+ def _parse_flags_to_dict(self) -> dict[str, Any]:
484
+ """Parse !flags; to dictionary.
485
+
486
+ Parses all 10 documented Compass project flags:
487
+ 1. G/g - Global override settings enabled/disabled
488
+ 2. I/E/A - Declination mode (Ignore/Entered/Auto)
489
+ 3. V/v - Apply UTM convergence enabled/disabled
490
+ 4. O/o - Override LRUD associations enabled/disabled
491
+ 5. T/t - LRUDs at To/From station
492
+ 6. S/s - Apply shot flags enabled/disabled
493
+ 7. X/x - Apply total exclusion flags enabled/disabled
494
+ 8. P/p - Apply plotting exclusion flags enabled/disabled
495
+ 9. L/l - Apply length exclusion flags enabled/disabled
496
+ 10. C/c - Apply close exclusion flags enabled/disabled
497
+ """
498
+ start_pos = self._pos
499
+
500
+ # Initialize all flags
501
+ result: dict[str, Any] = {
502
+ "type": "flags",
503
+ "global_override": False,
504
+ "declination_mode": None,
505
+ "apply_utm_convergence": False,
506
+ "override_lruds": False,
507
+ "lruds_at_to_station": False,
508
+ "apply_shot_flags": False,
509
+ "apply_total_exclusion": False,
510
+ "apply_plotting_exclusion": False,
511
+ "apply_length_exclusion": False,
512
+ "apply_close_exclusion": False,
513
+ }
514
+
515
+ while self._pos < len(self._data):
516
+ char = self._data[self._pos]
517
+ self._pos += 1
518
+
519
+ if char == ";":
520
+ result["raw_flags"] = self._data[start_pos : self._pos - 1]
521
+ return result
522
+
523
+ # Flag 1: G/g - Global override
524
+ if char == "G":
525
+ result["global_override"] = True
526
+ elif char == "g":
527
+ result["global_override"] = False
528
+
529
+ # Flag 2: I/E/A - Declination mode
530
+ elif char == "I":
531
+ result["declination_mode"] = "I"
532
+ elif char == "E":
533
+ result["declination_mode"] = "E"
534
+ elif char == "A":
535
+ result["declination_mode"] = "A"
536
+
537
+ # Flag 3: V/v - Apply UTM convergence
538
+ elif char == "V":
539
+ result["apply_utm_convergence"] = True
540
+ elif char == "v":
541
+ result["apply_utm_convergence"] = False
542
+
543
+ # Flag 4: O/o - Override LRUD associations
544
+ elif char == "O":
545
+ result["override_lruds"] = True
546
+ elif char == "o":
547
+ result["override_lruds"] = False
548
+
549
+ # Flag 5: T/t - LRUDs at To station
550
+ elif char == "T":
551
+ result["lruds_at_to_station"] = True
552
+ elif char == "t":
553
+ result["lruds_at_to_station"] = False
554
+
555
+ # Flag 6: S/s - Apply shot flags
556
+ elif char == "S":
557
+ result["apply_shot_flags"] = True
558
+ elif char == "s":
559
+ result["apply_shot_flags"] = False
560
+
561
+ # Flag 7: X/x - Apply total exclusion flags
562
+ elif char == "X":
563
+ result["apply_total_exclusion"] = True
564
+ elif char == "x":
565
+ result["apply_total_exclusion"] = False
566
+
567
+ # Flag 8: P/p - Apply plotting exclusion flags
568
+ elif char == "P":
569
+ result["apply_plotting_exclusion"] = True
570
+ elif char == "p":
571
+ result["apply_plotting_exclusion"] = False
572
+
573
+ # Flag 9: L/l - Apply length exclusion flags
574
+ elif char == "L":
575
+ result["apply_length_exclusion"] = True
576
+ elif char == "l":
577
+ result["apply_length_exclusion"] = False
578
+
579
+ # Flag 10: C/c - Apply close exclusion flags
580
+ elif char == "C":
581
+ result["apply_close_exclusion"] = True
582
+ elif char == "c":
583
+ result["apply_close_exclusion"] = False
584
+
585
+ # Silently skip unknown characters for lenient parsing
586
+
587
+ raise CompassParseException(
588
+ "missing or incomplete flags",
589
+ self._location(),
590
+ )
591
+
592
+ def _parse_folder_start_to_dict(self) -> dict[str, Any]:
593
+ """Parse [FolderName; to dictionary."""
594
+ self._skip_whitespace()
595
+ # Read until semicolon
596
+ start = self._pos
597
+ while self._pos < len(self._data) and self._data[self._pos] != ";":
598
+ self._pos += 1
599
+ name = self._data[start : self._pos].strip()
600
+ self._expect(";")
601
+ return {"type": "folder_start", "name": name}
602
+
603
+ def _parse_folder_end_to_dict(self) -> dict[str, Any]:
604
+ """Parse ]; to dictionary."""
605
+ self._skip_whitespace()
606
+ self._expect(";")
607
+ return {"type": "folder_end"}
608
+
609
+ def _parse_comment_to_dict(self) -> dict[str, Any]:
610
+ """Parse / comment (to end of line) to dictionary."""
611
+ start = self._pos
612
+ self._skip_to_end_of_line()
613
+ comment = self._data[start : self._pos].strip()
614
+ return {"type": "comment", "comment": comment}
615
+
616
+ def _parse_unknown_directive_to_dict(self, directive_type: str) -> dict[str, Any]:
617
+ """Parse unknown directive to semicolon for roundtrip fidelity."""
618
+ start = self._pos
619
+
620
+ while self._pos < len(self._data):
621
+ char = self._data[self._pos]
622
+ if char == ";":
623
+ content = self._data[start : self._pos]
624
+ self._pos += 1 # Skip the semicolon
625
+ return {
626
+ "type": "unknown",
627
+ "directive_type": directive_type,
628
+ "content": content,
629
+ }
630
+ self._pos += 1
631
+
632
+ # No semicolon found - use rest of data
633
+ content = self._data[start : self._pos]
634
+ return {
635
+ "type": "unknown",
636
+ "directive_type": directive_type,
637
+ "content": content,
638
+ }
@@ -0,0 +1,24 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Survey module for parsing and formatting Compass .DAT files."""
3
+
4
+ from compass_lib.survey.format import format_dat_file
5
+ from compass_lib.survey.format import format_shot
6
+ from compass_lib.survey.format import format_trip
7
+ from compass_lib.survey.format import format_trip_header
8
+ from compass_lib.survey.models import CompassDatFile
9
+ from compass_lib.survey.models import CompassShot
10
+ from compass_lib.survey.models import CompassTrip
11
+ from compass_lib.survey.models import CompassTripHeader
12
+ from compass_lib.survey.parser import CompassSurveyParser
13
+
14
+ __all__ = [
15
+ "CompassDatFile",
16
+ "CompassShot",
17
+ "CompassSurveyParser",
18
+ "CompassTrip",
19
+ "CompassTripHeader",
20
+ "format_dat_file",
21
+ "format_shot",
22
+ "format_trip",
23
+ "format_trip_header",
24
+ ]