openspeleo-lib 0.0.1__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,8 @@
1
+ #!/usr/bin/env python
2
+
3
+ import importlib.metadata
4
+
5
+ __version__ = importlib.metadata.version("openspeleo_lib")
6
+
7
+ # Initialize the logger
8
+ from openspeleo_lib import logger # noqa: F401
@@ -0,0 +1 @@
1
+ #!/usr/bin/env python
@@ -0,0 +1,69 @@
1
+ import argparse
2
+ import logging
3
+ import pathlib
4
+
5
+ from openspeleo_lib.interfaces import ArianeInterface
6
+
7
+ logger = logging.getLogger(__name__)
8
+ logger.setLevel(logging.INFO)
9
+
10
+
11
+ def convert(args):
12
+ parser = argparse.ArgumentParser(
13
+ prog="convert", description="Convert a Survey File"
14
+ )
15
+ parser.add_argument(
16
+ "-i",
17
+ "--input_file",
18
+ type=pathlib.Path,
19
+ required=True,
20
+ help="Path to the TML file to be validated",
21
+ )
22
+
23
+ parser.add_argument(
24
+ "-o",
25
+ "--output_file",
26
+ type=pathlib.Path,
27
+ default=None,
28
+ required=True,
29
+ help="Path to save the converted file at.",
30
+ )
31
+
32
+ parser.add_argument(
33
+ "-w",
34
+ "--overwrite",
35
+ action="store_true",
36
+ help="Allow overwrite an already existing file.",
37
+ default=False,
38
+ )
39
+
40
+ parser.add_argument(
41
+ "-f",
42
+ "--format",
43
+ type=str,
44
+ choices=["json"],
45
+ required=True,
46
+ help="Conversion format used.",
47
+ )
48
+
49
+ parsed_args = parser.parse_args(args)
50
+
51
+ input_file: pathlib.Path = parsed_args.input_file
52
+ output_file: pathlib.Path = parsed_args.output_file
53
+
54
+ if not input_file.exists():
55
+ raise FileNotFoundError(f"File not found: `{input_file}`")
56
+
57
+ if output_file.exists() and not parsed_args.overwrite:
58
+ raise FileExistsError(
59
+ f"The file `{output_file}` already existing. "
60
+ "Please pass the flag `--overwrite` to ignore."
61
+ )
62
+
63
+ match input_file.suffix:
64
+ case ".tml":
65
+ survey = ArianeInterface.from_file(input_file)
66
+ case _:
67
+ raise ValueError(f"Unsupported file format: `{input_file.suffix}`")
68
+
69
+ survey.to_json(filepath=output_file)
@@ -0,0 +1,33 @@
1
+ # #!/usr/bin/env python3
2
+
3
+ import argparse
4
+ from importlib.metadata import entry_points
5
+
6
+ import openspeleo_lib
7
+
8
+
9
+ def main():
10
+ registered_commands = entry_points(group="openspeleo_lib.actions")
11
+
12
+ parser = argparse.ArgumentParser(prog="openspeleo_lib")
13
+ parser.add_argument(
14
+ "-v",
15
+ "--version",
16
+ action="version",
17
+ version=f"%(prog)s version: {openspeleo_lib.__version__}",
18
+ )
19
+ parser.add_argument(
20
+ "command",
21
+ choices=registered_commands.names,
22
+ )
23
+ parser.add_argument(
24
+ "args",
25
+ help=argparse.SUPPRESS,
26
+ nargs=argparse.REMAINDER,
27
+ )
28
+
29
+ args = argparse.Namespace()
30
+ parser.parse_args(namespace=args)
31
+
32
+ main_fn = registered_commands[args.command].load()
33
+ return main_fn(args.args)
@@ -0,0 +1,32 @@
1
+ import argparse
2
+ import logging
3
+ import pathlib
4
+
5
+ from openspeleo_lib.interfaces import ArianeInterface
6
+
7
+ logger = logging.getLogger(__name__)
8
+ logger.setLevel(logging.INFO)
9
+
10
+
11
+ def validate(args):
12
+ parser = argparse.ArgumentParser(
13
+ prog="validate_tml", description="Validate a TML file"
14
+ )
15
+ parser.add_argument(
16
+ "-i",
17
+ "--input_file",
18
+ type=pathlib.Path,
19
+ required=True,
20
+ help="Path to the TML file to be validated",
21
+ )
22
+
23
+ parsed_args = parser.parse_args(args)
24
+
25
+ input_file = parsed_args.input_file
26
+
27
+ if not input_file.exists():
28
+ raise FileNotFoundError(f"File not found: `{input_file}`")
29
+
30
+ _ = ArianeInterface.from_file(input_file)
31
+
32
+ logger.info("Filepath: `%(input_file)s` ... VALID", {"input_file": input_file})
@@ -0,0 +1,15 @@
1
+ # Length for the shot field `name` used by compass
2
+ OSPL_SHOTNAME_MIN_LENGTH = 2
3
+ OSPL_SHOTNAME_MAX_LENGTH = 20
4
+ OSPL_SHOTNAME_DEFAULT_LENGTH = 6
5
+
6
+ # Length for the section field `name`
7
+ OSPL_SECTIONNAME_MIN_LENGTH = 2
8
+ OSPL_SECTIONNAME_MAX_LENGTH = 50
9
+
10
+
11
+ # Maximum retry attempts for any while loop
12
+ OSPL_MAX_RETRY_ATTEMPTS = 100
13
+
14
+ # Filenames
15
+ ARIANE_DATA_FILENAME = "Data.xml"
@@ -0,0 +1,12 @@
1
+ from pathlib import Path
2
+
3
+ import orjson
4
+
5
+
6
+ def write_debugdata_to_disk(data: dict, filepath: Path) -> None:
7
+ with filepath.open(mode="w") as f:
8
+ f.write(
9
+ orjson.dumps(
10
+ data, None, option=(orjson.OPT_INDENT_2 | orjson.OPT_SORT_KEYS)
11
+ ).decode("utf-8")
12
+ )
@@ -0,0 +1,26 @@
1
+ from enum import Enum
2
+
3
+
4
+ class CustomEnum(Enum):
5
+ @classmethod
6
+ def reverse(cls, name):
7
+ return cls._value2member_map_[name]
8
+
9
+
10
+ class ArianeProfileType(CustomEnum):
11
+ VERTICAL = "VERTICAL"
12
+ HORIZONTAL = "HORIZONTAL"
13
+ PERPENDICULAR = "PERPENDICULAR"
14
+ BISECTION = "BISECTION"
15
+
16
+
17
+ class ArianeShotType(CustomEnum):
18
+ REAL = "REAL"
19
+ START = "START"
20
+ VIRTUAL = "VIRTUAL"
21
+ CLOSURE = "CLOSURE"
22
+
23
+
24
+ class LengthUnits(CustomEnum):
25
+ FEET = "FT"
26
+ METERS = "M"
@@ -0,0 +1,6 @@
1
+ class DuplicateValueError(ValueError):
2
+ pass
3
+
4
+
5
+ class MaxRetriesError(RuntimeError):
6
+ pass
@@ -0,0 +1,91 @@
1
+ import contextlib
2
+ import random
3
+ from collections import defaultdict
4
+ from typing import Any
5
+ from typing import NewType
6
+
7
+ from openspeleo_lib.constants import OSPL_MAX_RETRY_ATTEMPTS
8
+ from openspeleo_lib.constants import OSPL_SHOTNAME_DEFAULT_LENGTH
9
+ from openspeleo_lib.constants import OSPL_SHOTNAME_MAX_LENGTH
10
+ from openspeleo_lib.errors import DuplicateValueError
11
+ from openspeleo_lib.errors import MaxRetriesError
12
+
13
+
14
+ class UniqueValueGenerator:
15
+ _used_values = None
16
+ VOCAB = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
17
+
18
+ def __init__(self):
19
+ raise NotImplementedError("This class should not be instantiated.")
20
+
21
+ @classmethod
22
+ @contextlib.contextmanager
23
+ def activate_uniqueness(cls):
24
+ try:
25
+ cls._used_values = defaultdict(set)
26
+ yield
27
+ finally:
28
+ cls._used_values = None
29
+
30
+ @classmethod
31
+ def register(cls, vartype: type, value: Any) -> None:
32
+ """Register the generated value."""
33
+ if cls._used_values is None: # uniqueness is not activated
34
+ return
35
+
36
+ value = vartype(value)
37
+
38
+ if value in cls._used_values[vartype]:
39
+ raise DuplicateValueError(
40
+ f"Value `{value}` for type `{vartype}` has already been registered."
41
+ )
42
+
43
+ cls._used_values[vartype].add(value)
44
+
45
+ @classmethod
46
+ def get(cls, vartype: type, **kwargs) -> Any:
47
+ """Get unique value for an object primary key."""
48
+ iter_idx = 0
49
+ while True:
50
+ iter_idx += 1
51
+ if iter_idx > OSPL_MAX_RETRY_ATTEMPTS:
52
+ raise MaxRetriesError(
53
+ "Impossible to find an available value to use. "
54
+ "Max retry attempts reached: "
55
+ f"{OSPL_MAX_RETRY_ATTEMPTS}"
56
+ )
57
+ try:
58
+ if vartype is str or (
59
+ isinstance(vartype, NewType) and vartype.__supertype__ is str
60
+ ):
61
+ value = cls._generate_str(**kwargs)
62
+
63
+ elif vartype is int or (
64
+ isinstance(vartype, NewType) and vartype.__supertype__ is int
65
+ ):
66
+ value = cls._generate_int(
67
+ known_values=(
68
+ cls._used_values[vartype] if cls._used_values else []
69
+ )
70
+ )
71
+ else:
72
+ raise TypeError(f"Unsupported type: `{vartype}`")
73
+
74
+ cls.register(vartype=vartype, value=value)
75
+ break
76
+ except DuplicateValueError:
77
+ continue
78
+
79
+ return value
80
+
81
+ @classmethod
82
+ def _generate_str(cls, str_len: int = OSPL_SHOTNAME_DEFAULT_LENGTH) -> str:
83
+ if str_len > OSPL_SHOTNAME_MAX_LENGTH:
84
+ raise ValueError(
85
+ f"Maximum length allowed: {OSPL_SHOTNAME_MAX_LENGTH}, received: {str_len}" # noqa: E501
86
+ )
87
+ return "".join(random.choices(cls.VOCAB, k=str_len))
88
+
89
+ @classmethod
90
+ def _generate_int(cls, known_values: list[int] | set[int]) -> str:
91
+ return max(known_values, default=0) + 1
@@ -0,0 +1,3 @@
1
+ from openspeleo_lib.interfaces.ariane.interface import ArianeInterface
2
+
3
+ __all__ = ["ArianeInterface"]
File without changes
@@ -0,0 +1,122 @@
1
+ import contextlib
2
+ import logging
3
+ from pathlib import Path
4
+
5
+ from openspeleo_core.legacy import deserialize_xmlfield_to_dict
6
+
7
+ # from openspeleo_core.legacy import remove_none_values
8
+ # from openspeleo_core.legacy import apply_key_mapping
9
+ from openspeleo_core.mapping import apply_key_mapping
10
+
11
+ from openspeleo_lib.debug_utils import write_debugdata_to_disk
12
+ from openspeleo_lib.interfaces.ariane.name_map import ARIANE_INVERSE_MAPPING
13
+
14
+ logger = logging.getLogger(__name__)
15
+ DEBUG = False
16
+
17
+
18
+ def ariane_decode(data: dict) -> dict:
19
+ # ===================== DICT FORMATTING TO OSPL ===================== #
20
+
21
+ # 1. Apply key mapping: From Ariane to OSPL
22
+ data = apply_key_mapping(data, mapping=ARIANE_INVERSE_MAPPING)
23
+
24
+ if DEBUG:
25
+ write_debugdata_to_disk(data, Path("data.import.step01-mapped.json"))
26
+
27
+ # 1.1 Formatting Top Lvl - ariane unit is lowercase - OSPL unit is uppercase
28
+ data["unit"] = data["unit"].upper()
29
+
30
+ # 2. Collapse `ariane_viewer_layers`:
31
+ # - BEFORE: data["ariane_viewer_layers"]["layer_list"]
32
+ # - AFTER: data["ariane_viewer_layers"]
33
+ data["ariane_viewer_layers"] = data["ariane_viewer_layers"].pop("layer_list")
34
+
35
+ if DEBUG:
36
+ write_debugdata_to_disk(data, Path("data.import.step02-collapsed.json"))
37
+
38
+ # 3. Sort `shots` into `sections`
39
+ sections = {}
40
+ for shot in data.pop("data")["shots"]:
41
+ # 3.1 Collapse `radius_vectors`:
42
+ # - BEFORE: shot["shape"]["radius_collection"]["radius_vector"]
43
+ # - AFTER: shot["shape"]["radius_vectors"]
44
+ with contextlib.suppress(KeyError):
45
+ shot["shape"]["radius_vectors"] = shot["shape"].pop("radius_collection")[
46
+ "radius_vector"
47
+ ]
48
+
49
+ # Formatting the color back to OSPL format
50
+ shot["color"] = shot.pop("color").replace("0x", "#")
51
+
52
+ # 3.2 Separate shots into sections
53
+ try:
54
+ if (section_name := shot.pop("section_name")) not in sections:
55
+ sections[section_name] = {
56
+ "section_name": section_name,
57
+ "date": shot.pop("date", None),
58
+ "shots": [],
59
+ }
60
+ if ariane_explorer_field := shot.pop("explorers"):
61
+ _data = deserialize_xmlfield_to_dict(ariane_explorer_field)
62
+
63
+ if isinstance(_data, str):
64
+ _data = {"explorers": _data}
65
+ else:
66
+ _data = apply_key_mapping(_data, mapping=ARIANE_INVERSE_MAPPING)
67
+
68
+ sections[section_name].update(_data)
69
+
70
+ else:
71
+ for key in ["date", "explorers"]:
72
+ with contextlib.suppress(KeyError):
73
+ _value = shot.pop(key)
74
+
75
+ if key == "explorers" and isinstance(_value, str):
76
+ _data = deserialize_xmlfield_to_dict(_value)
77
+
78
+ if isinstance(_data, dict):
79
+ _data = apply_key_mapping(
80
+ _data,
81
+ mapping=ARIANE_INVERSE_MAPPING,
82
+ )
83
+ else:
84
+ _data = {key: _value}
85
+ else:
86
+ _data = {key: _value}
87
+
88
+ for sub_key, value in _data.items():
89
+ if sections[section_name][sub_key] != value:
90
+ logger.warning(
91
+ "Section `%(section)s` has different `%(key)s`: "
92
+ "`%(section_val)s` != `%(shot_val)s`",
93
+ {
94
+ "section": section_name,
95
+ "key": sub_key,
96
+ "section_val": sections[section_name][sub_key],
97
+ "shot_val": value,
98
+ },
99
+ )
100
+
101
+ with contextlib.suppress(KeyError):
102
+ if not isinstance(
103
+ (radius_vectors := shot["shape"]["radius_vectors"]),
104
+ (tuple, list),
105
+ ):
106
+ shot["shape"]["radius_vectors"] = [radius_vectors]
107
+
108
+ sections[section_name]["shots"].append(shot)
109
+
110
+ except KeyError as e:
111
+ logging.warning(
112
+ "Incomplete shot data: `%(shot)s` - Error: %(error)s",
113
+ {"shot": shot, "error": e},
114
+ )
115
+ continue # if data is incomplete, skip this shot
116
+
117
+ data["sections"] = list(sections.values())
118
+
119
+ if DEBUG:
120
+ write_debugdata_to_disk(data, Path("data.import.step03-sections.json"))
121
+
122
+ return data
@@ -0,0 +1,75 @@
1
+ import contextlib
2
+ import logging
3
+ from pathlib import Path
4
+
5
+ from openspeleo_core.legacy import serialize_dict_to_xmlfield
6
+
7
+ # from openspeleo_core.legacy import apply_key_mapping
8
+ from openspeleo_core.mapping import apply_key_mapping
9
+
10
+ from openspeleo_lib.debug_utils import write_debugdata_to_disk
11
+ from openspeleo_lib.interfaces.ariane.name_map import ARIANE_MAPPING
12
+
13
+ logger = logging.getLogger(__name__)
14
+ DEBUG = False
15
+
16
+
17
+ def ariane_encode(data: dict) -> dict:
18
+ # ==================== FORMATING FROM OSPL TO TML =================== #
19
+
20
+ # 1. Formatting Unit - ariane unit is lowercase - OSPL unit is uppercase
21
+ data["unit"] = data["unit"].lower()
22
+
23
+ if DEBUG:
24
+ write_debugdata_to_disk(data, Path("data.export.step01.json"))
25
+
26
+ # 2. Flatten sections into shots
27
+ shots = []
28
+ for section in data.pop("sections"):
29
+ for shot in section.pop("shots"):
30
+ shot["section_name"] = section["section_name"]
31
+ shot["date"] = section["date"]
32
+
33
+ # ~~~~~~~~~~~~~~~~ Processing Explorers/Surveyors ~~~~~~~~~~~~~~~ #
34
+ _explo_data = {}
35
+ for key in ["explorers", "surveyors"]:
36
+ if (_value := section[key]) is not None:
37
+ _explo_data[key] = _value
38
+
39
+ # In case only "explorer" data exists - Ariane doesn't store in format XML
40
+ if len(_explo_data) == 1:
41
+ with contextlib.suppress(KeyError):
42
+ _explo_data = _explo_data["explorers"]
43
+
44
+ if isinstance(_explo_data, dict):
45
+ _explo_data = apply_key_mapping(_explo_data, mapping=ARIANE_MAPPING)
46
+
47
+ shot["explorers"] = serialize_dict_to_xmlfield(_explo_data)
48
+ # --------------------------------------------------------------- #
49
+
50
+ radius_vectors = shot["shape"].pop("radius_vectors")
51
+ shot["shape"]["radius_collection"] = {"radius_vector": radius_vectors}
52
+ shot["color"] = shot.pop("color").replace("#", "0x")
53
+
54
+ shots.append(shot)
55
+
56
+ data["data"] = {"shots": shots}
57
+
58
+ if DEBUG:
59
+ write_debugdata_to_disk(data, Path("data.export.step02.json"))
60
+
61
+ # 3. Restore ArianeViewerLayer => Layers[LayerList] = [Layer1, Layer2, ...]
62
+ data["ariane_viewer_layers"] = {"layer_list": data.pop("ariane_viewer_layers")}
63
+
64
+ if DEBUG:
65
+ write_debugdata_to_disk(data, Path("data.export.step03.json"))
66
+
67
+ # 4. Apply key mapping in reverse order
68
+ data = apply_key_mapping(data, mapping=ARIANE_MAPPING)
69
+
70
+ if DEBUG:
71
+ write_debugdata_to_disk(data, Path("data.export.mapped.json"))
72
+
73
+ # ------------------------------------------------------------------- #
74
+
75
+ return data
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env python
2
+
3
+ from enum import IntEnum
4
+ from pathlib import Path
5
+ from typing import Self
6
+
7
+
8
+ class BaseEnum(IntEnum):
9
+ @classmethod
10
+ def from_str(cls, value: str) -> Self:
11
+ try:
12
+ return cls[value.upper()]
13
+ except KeyError as e:
14
+ raise ValueError(f"Unknown value: {value.upper()}") from e
15
+
16
+
17
+ class ArianeFileType(BaseEnum):
18
+ TML = 0
19
+ TMLU = 1
20
+
21
+ @classmethod
22
+ def from_path(cls, filepath: Path | str) -> Self:
23
+ filepath = Path(filepath)
24
+
25
+ try:
26
+ return cls.from_str(filepath.suffix[1:])
27
+
28
+ except ValueError as e:
29
+ raise TypeError(e) from e
30
+
31
+
32
+ class UnitType(BaseEnum):
33
+ METRIC = 0
34
+ IMPERIAL = 1
35
+
36
+
37
+ class ProfileType(BaseEnum):
38
+ VERTICAL = 0
39
+
40
+
41
+ class ShotType(BaseEnum):
42
+ REAL = 1
43
+ VIRTUAL = 2
44
+ START = 3
45
+ CLOSURE = 4
@@ -0,0 +1,106 @@
1
+ import logging
2
+ import zipfile
3
+ from pathlib import Path
4
+
5
+ from openspeleo_core import ariane_core
6
+
7
+ from openspeleo_lib.constants import ARIANE_DATA_FILENAME
8
+ from openspeleo_lib.debug_utils import write_debugdata_to_disk
9
+ from openspeleo_lib.interfaces.ariane.decoding import ariane_decode
10
+ from openspeleo_lib.interfaces.ariane.encoding import ariane_encode
11
+ from openspeleo_lib.interfaces.ariane.enums_cls import ArianeFileType
12
+ from openspeleo_lib.interfaces.base import BaseInterface
13
+ from openspeleo_lib.models import Survey
14
+
15
+ logger = logging.getLogger(__name__)
16
+ DEBUG = False
17
+
18
+
19
+ class ArianeInterface(BaseInterface):
20
+ @classmethod
21
+ def to_file(cls, survey: Survey, filepath: Path) -> None:
22
+ if (
23
+ filetype := ArianeFileType.from_path(filepath=filepath)
24
+ ) != ArianeFileType.TML:
25
+ raise TypeError(
26
+ f"Unsupported fileformat: `{filetype.name}`. "
27
+ f"Expected: `{ArianeFileType.TML.name}`"
28
+ )
29
+
30
+ data = survey.model_dump(mode="json")
31
+
32
+ # ------------------------------------------------------------------- #
33
+
34
+ if DEBUG:
35
+ write_debugdata_to_disk(data, Path("data.export.before.json"))
36
+
37
+ data = ariane_encode(data)
38
+
39
+ if DEBUG:
40
+ write_debugdata_to_disk(data, Path("data.export.after.json"))
41
+
42
+ # ------------------------------------------------------------------- #
43
+
44
+ # =========================== DICT TO XML =========================== #
45
+
46
+ # xml_str = dict_to_xml(data)
47
+ xml_str = ariane_core.dict_to_xml_str(data, root_name="CaveFile")
48
+
49
+ if DEBUG:
50
+ with Path("data.export.xml").open(mode="w") as f:
51
+ f.write(xml_str)
52
+
53
+ # ========================== WRITE TO DISK ========================== #
54
+
55
+ with zipfile.ZipFile(filepath, "w", compression=zipfile.ZIP_DEFLATED) as zf:
56
+ logging.debug(
57
+ "Exporting %(filetype)s File: `%(filepath)s`",
58
+ {"filetype": filetype.name, "filepath": filepath},
59
+ )
60
+ zf.writestr(ARIANE_DATA_FILENAME, xml_str)
61
+
62
+ @classmethod
63
+ def _from_file(cls, filepath: str | Path) -> Survey:
64
+ # ========================= INPUT VALIDATION ======================== #
65
+
66
+ if (
67
+ filetype := ArianeFileType.from_path(filepath=filepath)
68
+ ) != ArianeFileType.TML:
69
+ raise TypeError(
70
+ f"Unsupported fileformat: `{filetype.name}`. "
71
+ f"Expected: `{ArianeFileType.TML.name}`"
72
+ )
73
+
74
+ logging.debug(
75
+ "Loading %(filetype)s File: `%(filepath)s`",
76
+ {"filetype": filetype.name, "filepath": filepath},
77
+ )
78
+
79
+ # ------------------------------------------------------------------- #
80
+
81
+ # =========================== XML TO DICT =========================== #
82
+
83
+ match filetype:
84
+ case ArianeFileType.TML:
85
+ data = ariane_core.load_ariane_tml_file_to_dict(path=filepath)[
86
+ "CaveFile"
87
+ ]
88
+
89
+ case _:
90
+ raise NotImplementedError(
91
+ f"Not supported yet - Format: `{filetype.name}`"
92
+ )
93
+
94
+ # ------------------------------------------------------------------- #
95
+
96
+ if DEBUG:
97
+ write_debugdata_to_disk(data, Path("data.import.before.json"))
98
+
99
+ data = ariane_decode(data)
100
+
101
+ if DEBUG:
102
+ write_debugdata_to_disk(data, Path("data.import.after.json"))
103
+
104
+ # ------------------------------------------------------------------- #
105
+
106
+ return Survey.model_validate(data)