Repository: OSOceanAcoustics/echopype

Issue:
Using open_raw for AZFP data files with name other than .01A 
We can convert AZFP data files using open_raw for the .01A file. However, not all AZFP data files are .01A files. There are 2 parts that the filename can change (the number and letter of the file)
1. The number: 
When we collect data with multiple phases (max 12 phases) in one deployment, the data files will format as .01A (for phase 1), .02A(for phase 2),... So the AZFP file name could be from .01A to .12A
2. The letter:
When we deploy the instrument multiple times in one hour, the data files name will be ascending in alphabetical order each time it's deployed, which the file name could be from .01A to .01Z. 


![image](https://user-images.githubusercontent.com/72575070/129432079-1b1c72c1-68dc-4155-968c-d415cd5b1d96.png)

![image](https://user-images.githubusercontent.com/72575070/129432111-5fb7eb22-1d9d-4faa-addd-11b2a7cc3894.png)


@leewujung @imranmaj hope you can fix this problem. Let me know if you need more information about the AZFP files. 

Discussion:

Patch:
diff --git a/echopype/convert/api.py b/echopype/convert/api.py
index 3799c938..1f96b3ad 100644
--- a/echopype/convert/api.py
+++ b/echopype/convert/api.py
@@ -308,12 +308,11 @@ def _check_file(
     # TODO: https://github.com/OSOceanAcoustics/echopype/issues/229
     #  to add compatibility for pathlib.Path objects for local paths
     fsmap = fsspec.get_mapper(raw_file, **storage_options)
-    ext = SONAR_MODELS[sonar_model]["ext"]
+    validate_ext = SONAR_MODELS[sonar_model]["validate_ext"]
     if not fsmap.fs.exists(fsmap.root):
         raise FileNotFoundError(f"There is no file named {Path(raw_file).name}")
 
-    if Path(raw_file).suffix.upper() != ext.upper():
-        raise ValueError(f"Expecting a {ext} file but got {raw_file}")
+    validate_ext(Path(raw_file).suffix.upper())
 
     return str(raw_file), str(xml)
 
diff --git a/echopype/core.py b/echopype/core.py
index 39e491f3..b4b55042 100644
--- a/echopype/core.py
+++ b/echopype/core.py
@@ -1,5 +1,6 @@
 import os
-from typing import TYPE_CHECKING, Any, Dict, Union
+import re
+from typing import TYPE_CHECKING, Any, Callable, Dict, Union
 
 from fsspec.mapping import FSMap
 from typing_extensions import Literal
@@ -13,16 +14,33 @@ from .convert.set_groups_azfp import SetGroupsAZFP
 from .convert.set_groups_ek60 import SetGroupsEK60
 from .convert.set_groups_ek80 import SetGroupsEK80
 
-# Please update keep this updated with the keys of the SONAR_MODELS dict
 if TYPE_CHECKING:
+    # Please keep SonarModelsHint updated with the keys of the SONAR_MODELS dict
     SonarModelsHint = Literal["AZFP", "EK60", "EK80", "EA640", "AD2CP"]
     PathHint = Union[str, os.PathLike, FSMap]
     FileFormatHint = Literal[".nc", ".zarr"]
     EngineHint = Literal["netcdf4", "zarr"]
 
+
+def validate_azfp_ext(test_ext: str):
+    if not re.fullmatch(r"\.\d{2}[a-zA-Z]", test_ext):
+        raise ValueError(
+            'Expecting a file in the form ".XXY" '
+            f"where XX is a number and Y is a letter but got {test_ext}"
+        )
+
+
+def validate_ext(ext: str) -> Callable[[str], None]:
+    def inner(test_ext: str):
+        if ext.casefold() != test_ext.casefold():
+            raise ValueError(f"Expecting a {ext} file but got {test_ext}")
+
+    return inner
+
+
 SONAR_MODELS: Dict["SonarModelsHint", Dict[str, Any]] = {
     "AZFP": {
-        "ext": ".01A",
+        "validate_ext": validate_azfp_ext,
         "xml": True,
         "parser": ParseAZFP,
         "set_groups": SetGroupsAZFP,
@@ -38,7 +56,7 @@ SONAR_MODELS: Dict["SonarModelsHint", Dict[str, Any]] = {
         },
     },
     "EK60": {
-        "ext": ".raw",
+        "validate_ext": validate_ext(".raw"),
         "xml": False,
         "parser": ParseEK60,
         "set_groups": SetGroupsEK60,
@@ -53,7 +71,7 @@ SONAR_MODELS: Dict["SonarModelsHint", Dict[str, Any]] = {
         },
     },
     "EK80": {
-        "ext": ".raw",
+        "validate_ext": validate_ext(".raw"),
         "xml": False,
         "parser": ParseEK80,
         "set_groups": SetGroupsEK80,
@@ -68,7 +86,7 @@ SONAR_MODELS: Dict["SonarModelsHint", Dict[str, Any]] = {
         },
     },
     "EA640": {
-        "ext": ".raw",
+        "validate_ext": validate_ext(".raw"),
         "xml": False,
         "parser": ParseEK80,
         "set_groups": SetGroupsEK80,
@@ -83,7 +101,7 @@ SONAR_MODELS: Dict["SonarModelsHint", Dict[str, Any]] = {
         },
     },
     "AD2CP": {
-        "ext": ".ad2cp",
+        "validate_ext": validate_ext(".ad2cp"),
         "xml": False,
         "parser": ParseAd2cp,
         "set_groups": SetGroupsAd2cp,


Test patch:
diff --git a/.ci_helpers/run-test.py b/.ci_helpers/run-test.py
index a12442e0..37903a24 100644
--- a/.ci_helpers/run-test.py
+++ b/.ci_helpers/run-test.py
@@ -24,6 +24,7 @@ EXIT_CODES = {
 }
 
 MODULES_TO_TEST = {
+    "root": {},  # This is to test the root folder.
     "convert": {},
     "calibrate": {},
     "echodata": {},
@@ -84,10 +85,16 @@ if __name__ == "__main__":
             ]
     test_to_run = {}
     for module, mod_extras in MODULES_TO_TEST.items():
-        file_globs = [
-            f"echopype/{module}/*",
-            f"echopype/tests/{module}/*",
-        ]
+        if module == "root":
+            file_globs = [
+                "echopype/*",
+                "echopype/tests/*",
+            ]
+        else:
+            file_globs = [
+                f"echopype/{module}/*",
+                f"echopype/tests/{module}/*",
+            ]
         if "extra_globs" in mod_extras:
             file_globs = file_globs + mod_extras["extra_globs"]
         for f in file_list:
@@ -104,6 +111,12 @@ if __name__ == "__main__":
     for k, v in test_to_run.items():
         print(f"=== RUNNING {k.upper()} TESTS===")
         print(f"Touched files: {','.join([os.path.basename(p) for p in v])}")
+        if k == "root":
+            file_glob_str = "echopype/tests/test_*.py"
+            cov_mod_arg = ["--cov=echopype"]
+        else:
+            file_glob_str = f"echopype/tests/{k}/*.py"
+            cov_mod_arg = [f"--cov=echopype/{k}"]
         if args.include_cov:
             if k == "old":
                 pytest_args = original_pytest_args + [
@@ -111,8 +124,8 @@ if __name__ == "__main__":
                     "--cov=echopype/process",
                 ]
             else:
-                pytest_args = original_pytest_args + [f"--cov=echopype/{k}"]
-        test_files = glob.glob(f"echopype/tests/{k}/*.py")
+                pytest_args = original_pytest_args + cov_mod_arg
+        test_files = glob.glob(file_glob_str)
         final_args = pytest_args + test_files
         print(f"Pytest args: {final_args}")
         exit_code = pytest.main(final_args)
diff --git a/echopype/tests/test_core.py b/echopype/tests/test_core.py
new file mode 100644
index 00000000..4f8681b2
--- /dev/null
+++ b/echopype/tests/test_core.py
@@ -0,0 +1,62 @@
+from typing import TYPE_CHECKING
+
+import pytest
+
+if TYPE_CHECKING:
+    from ..core import SonarModelsHint
+from ..core import SONAR_MODELS
+
+@pytest.mark.parametrize(["sonar_model", "ext"], [
+    ("AZFP", ".01A"),
+    ("AZFP", ".01a"),
+    ("AZFP", ".05C"),
+    ("AZFP", ".12q"),
+
+    ("EK60", ".raw"),
+    ("EK60", ".RAW"),
+
+    ("EK80", ".raw"),
+    ("EK80", ".RAW"),
+
+    ("EA640", ".raw"),
+    ("EA640", ".RAW"),
+
+    ("AD2CP", ".ad2cp"),
+    ("AD2CP", ".AD2CP"),
+])
+def test_file_extension_validation(sonar_model: "SonarModelsHint", ext: str):
+    SONAR_MODELS[sonar_model]["validate_ext"](ext)
+
+
+@pytest.mark.parametrize(["sonar_model", "ext"], [
+    ("AZFP", ".001A"),
+    ("AZFP", ".01AA"),
+    ("AZFP", ".01aa"),
+    ("AZFP", ".05AA"),
+    ("AZFP", ".07!"),
+    ("AZFP", ".01!"),
+    ("AZFP", ".0!A"),
+    ("AZFP", ".012"),
+    ("AZFP", ".0AA"),
+    ("AZFP", ".AAA"),
+    ("AZFP", "01A"),
+
+    ("EK60", "raw"),
+    ("EK60", ".foo"),
+
+    ("EK80", "raw"),
+    ("EK80", ".foo"),
+
+    ("EA640", "raw"),
+    ("EA640", ".foo"),
+
+    ("AD2CP", "ad2cp"),
+    ("AD2CP", ".foo"),
+])
+def test_file_extension_validation_should_fail(sonar_model: "SonarModelsHint", ext: str):
+    try:
+        SONAR_MODELS[sonar_model]["validate_ext"](ext)
+    except ValueError:
+        pass
+    else:
+        raise ValueError(f"\"{ext}\" should have been rejected for sonar model {sonar_model}")