|
18 | 18 | import shapely.geometry.base |
19 | 19 | from deprecated import deprecated |
20 | 20 |
|
| 21 | +try: |
| 22 | + # pyproj is an optional dependency |
| 23 | + import pyproj |
| 24 | +except ImportError: |
| 25 | + pyproj = None |
| 26 | + |
| 27 | + |
21 | 28 | logger = logging.getLogger(__name__) |
22 | 29 |
|
23 | 30 |
|
@@ -535,7 +542,7 @@ class BBoxDict(dict): |
535 | 542 | def __init__(self, *, west: float, south: float, east: float, north: float, crs: Optional[Union[str, int]] = None): |
536 | 543 | super().__init__(west=west, south=south, east=east, north=north) |
537 | 544 | if crs is not None: |
538 | | - self.update(crs=crs_to_epsg_code(crs)) |
| 545 | + self.update(crs=normalize_crs(crs)) |
539 | 546 |
|
540 | 547 | # TODO: provide west, south, east, north, crs as @properties? Read-only or read-write? |
541 | 548 |
|
@@ -635,86 +642,58 @@ def get(self, fraction: float) -> str: |
635 | 642 | return f"{self.left}{bar:{self.fill}<{width}s}{self.right}" |
636 | 643 |
|
637 | 644 |
|
638 | | -def crs_to_epsg_code(crs: Union[str, int, dict, None]) -> Optional[int]: |
639 | | - """Convert a CRS string or int to an integer EPGS code, where CRS usually comes from user input. |
640 | | -
|
641 | | - Three cases: |
642 | | -
|
643 | | - - If it is already an integer we just keep it. |
644 | | - - If it is None it stays None, and empty strings become None as well. |
645 | | - - If it is a string we try to parse it with the pyproj library. |
646 | | - - Strings of the form "EPSG:<int>" will be converted to teh value <int> |
647 | | - - For any other strings formats, it will work if pyproj supports is, |
648 | | - otherwise it won't. |
649 | | -
|
650 | | - The result is **always** an EPSG code, so the CRS should be one that is |
651 | | - defined in EPSG. For any other definitions pyproj will only give you the |
652 | | - closest EPSG match and that result is possibly inaccurate. |
653 | | -
|
654 | | - Note that we also need to support WKT string (WKT2), |
655 | | - see also: https://github.com/Open-EO/openeo-processes/issues/58 |
| 645 | +def normalize_crs(crs: Any, *, use_pyproj: bool = True) -> Union[None, int, str]: |
| 646 | + """ |
| 647 | + Normalize given data structure (typically just an int or string) |
| 648 | + that encodes a CRS (Coordinate Reference System) to an EPSG (int) code or WKT2 CRS string. |
656 | 649 |
|
657 | | - For very the oldest supported version of Python: v3.6 there is a problem |
658 | | - because the pyproj version that is compatible with Python 3.6 is too old |
659 | | - and does not properly support WKT2. |
| 650 | + Behavior and data structure support depends on the availability of the ``pyproj`` library: |
660 | 651 |
|
| 652 | + - If the ``pyproj`` library is available: use that to do parsing and conversion. |
| 653 | + This means that anything that is supported by ``pyproj.CRS.from_user_input`` is allowed. |
| 654 | + See the ``pyproj`` docs for more details. |
| 655 | + - Otherwise, some best effort validation is done: |
| 656 | + EPSG looking int/str values will be parsed as such, other strings will be assumed to be WKT2 already. |
| 657 | + Other data structures will not be accepted. |
661 | 658 |
|
662 | | - For a list of CRS input formats that proj supports |
663 | | - see: https://pyproj4.github.io/pyproj/stable/api/crs/crs.html#pyproj.crs.CRS.from_user_input |
| 659 | + :param crs: data structure that encodes a CRS, typically just an int or string value. |
| 660 | + If the ``pyproj`` library is available, everything supported by it is allowed |
| 661 | + :param use_pyproj: whether ``pyproj`` should be leveraged at all |
| 662 | + (mainly useful for testing the "no pyproj available" code path) |
664 | 663 |
|
665 | | - :param crs: |
666 | | - Input from user for the Coordinate Reference System to convert to an |
667 | | - EPSG code. |
| 664 | + :return: EPSG code as int, or WKT2 string. Or None if input was empty . |
668 | 665 |
|
669 | 666 | :raises ValueError: |
670 | | - When the crs is a not a supported CRS string. |
671 | | - :raises TypeError: |
672 | | - When crs is none of the supported types: str, int, None |
| 667 | + When the given CRS data can not be parsed/converted/normalized. |
673 | 668 |
|
674 | | - :return: An EPGS code if it could be found, otherwise None |
675 | 669 | """ |
676 | | - |
677 | | - # Only convert to the default if it is an explicitly allowed type. |
678 | 670 | if crs in (None, "", {}): |
679 | 671 | return None |
680 | 672 |
|
681 | | - if not isinstance(crs, (int, str, dict)): |
682 | | - raise TypeError("The allowed type for the parameter 'crs' are: str, int, dict and None") |
683 | | - |
684 | | - # If we want to stop processing as soon as we have an int value, then we |
685 | | - # should not accept values that are complete non-sense, as best as we can. |
686 | | - crs_intermediate = crs |
687 | | - if isinstance(crs, int): |
688 | | - crs_intermediate = crs |
689 | | - elif isinstance(crs, str): |
690 | | - # This conversion is needed to support strings that only contain an integer, |
691 | | - # e.g. "4326" though it is a string, is a otherwise a correct EPSG code. |
| 673 | + if pyproj and use_pyproj: |
692 | 674 | try: |
693 | | - crs_intermediate = int(crs) |
694 | | - except ValueError as exc: |
695 | | - # So we need to process it with pyproj, below. |
696 | | - logger.debug("crs_to_epsg_code received crs input that was not an int: crs={crs}, exception caught: {exc}") |
697 | | - |
698 | | - if isinstance(crs_intermediate, int): |
699 | | - if crs_intermediate <= 0: |
700 | | - raise ValueError(f"When crs is an integer value it has to be > 0.") |
701 | | - else: |
702 | | - return crs_intermediate |
703 | | - |
704 | | - try: |
705 | | - import pyproj.crs |
706 | | - except ImportError as exc: |
707 | | - message = ( |
708 | | - f"Cannot convert CRS string: {crs}. " |
709 | | - + "Need pyproj to convert this CRS string but the pyproj library is not installed." |
710 | | - ) |
711 | | - logger.error(message) |
712 | | - raise ValueError(message) from ImportError |
| 675 | + # (if available:) let pyproj do the validation/parsing |
| 676 | + crs_obj = pyproj.CRS.from_user_input(crs) |
| 677 | + # Convert back to EPSG int or WKT2 string |
| 678 | + crs = crs_obj.to_epsg() or crs_obj.to_wkt() |
| 679 | + except pyproj.ProjError as e: |
| 680 | + raise ValueError(f"Failed to normalize CRS data with pyproj: {crs}") from e |
713 | 681 | else: |
714 | | - try: |
715 | | - converted_crs = pyproj.crs.CRS.from_user_input(crs) |
716 | | - except pyproj.exceptions.CRSError as exc: |
717 | | - logger.error(f"Could not convert CRS string to EPSG code: crs={crs}, exception: {exc}", exc_info=True) |
718 | | - raise ValueError(crs) from exc |
| 682 | + # Best effort simple validation/normalization |
| 683 | + if isinstance(crs, int) and crs > 0: |
| 684 | + # Assume int is already valid EPSG code |
| 685 | + pass |
| 686 | + elif isinstance(crs, str): |
| 687 | + # Parse as EPSG int code if it looks like that, |
| 688 | + # otherwise: leave it as-is, assuming it is a valid WKT2 CRS string |
| 689 | + if re.match(r"^(epsg:)?\d+$", crs.strip(), flags=re.IGNORECASE): |
| 690 | + crs = int(crs.split(":")[-1]) |
| 691 | + elif "GEOGCRS[" in crs: |
| 692 | + # Very simple WKT2 CRS detection heuristic |
| 693 | + logger.warning(f"Assuming this is a valid WK2 CRS string: {repr_truncate(crs)}") |
| 694 | + else: |
| 695 | + raise ValueError(f"Can not normalize CRS string {repr_truncate(crs)}") |
719 | 696 | else: |
720 | | - return converted_crs.to_epsg() |
| 697 | + raise ValueError(f"Can not normalize CRS data {type(crs)}") |
| 698 | + |
| 699 | + return crs |
0 commit comments