Repository: r5py/r5py (language: python)

Issue:
Add a shorthand to load all files in a directory into a TransportNetwork
> 3\. Building the network
>         I believe `TransportNetwork()` and `setup_r5()` would be the equivalent function used to create a network, right?
> 
> 
>     * r5py: `network = TransportNetwork(osm_pbf=osm_pbf, gtfs=gtfs)`
> 
>     * r5r: `network =  setup_r5( data_path = "local_dir")`
> 
> 
> I see that users would need to pass the the addresses of a `osm.pbf` and `gtfs.zip` files to `TransportNetwork()`. I find this behavior a bit strange. How does it work when users have two or more GTFS feeds from different agencies. We know R5 automatically detects and takes in one `osm.pbf` and all the `gtfs.zip` files in the path. This one of the reasons we require `r5r` users to input the address of the local directory where the data is stored. Perhaps we should have a separate issue to discuss the workflow we would expect from users and the expected behavior of these download and build network functions.


This is splitting up issue #25 into smaller pieces (re: https://github.com/r5py/r5py/issues/25#issuecomment-1122901350 )


Discussion / hints:
Honestly, this is something that low-key bothered me about r5r. We actually want specific files, not ’everything I have’. Many people like to keep all their data somewhere in a common place (thinking about historical gtfs or network data, for instance), and not in a project-specific directory.

On a side note, `gtfs` is a list of files (can be zero or more).

I could imagine building a wrapper around it, which searches for all possible GTFS files and the first (?) `.osm.pbf` to hand to the TransportNetwork constructor.
Yeah, I think the convention that is used by r5r is originating how also OpenTripPlanner works, i.e. r5r probably follows closely the conventions of r5. I do agree that the logic is not perhaps the best one (at least when working with OTP, you tend to create multiple copies of the same files because of this). Being able to list specific GTFS feeds with a list would be more specific and it would allow you to have as many GTFS feeds in your directory as you want, but still you could control which of them are used. And as @christophfink mentions, it's easy to build a wrapper that searches all the feed zip files and uses them if needed. 

But again, this is a question of how much do we want to follow the original implementations and how they work (OTP + r5 + r5r). For someone, who has used these tools before (OTP, r5, r5r), the original logic might be easier to grasp, but for new users having more control might be a good feature. 
with a wrapper function we can have both :)

overloading `__init__()` is not really a Python thing, 
I would suggest a `@classmethod` factory `TransportNetwork.from_directory()` 
Yep, my vote goes to have a method for doing this (i.e. the one suggested above). 

Any thoughts @rafapereirabr, @wklumpen?

Fix patch:
diff --git a/.gitignore b/.gitignore
index 11bbb417..90a62df9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,6 +29,7 @@ docs/_build/
 docs/data/*.mapdb
 docs/data/*.mapdb.p
 **/.ipynb_checkpoints
+docs/jupyter_execute/
 
 .tox
 .coverage
diff --git a/src/r5py/r5/transport_network.py b/src/r5py/r5/transport_network.py
index 1746beeb..0612a582 100644
--- a/src/r5py/r5/transport_network.py
+++ b/src/r5py/r5/transport_network.py
@@ -4,7 +4,10 @@
 """Wraps a com.conveyal.r5.transit.TransportNetwork."""
 
 
+import json
 import os.path
+import pathlib
+import warnings
 
 import jpype
 import jpype.types
@@ -62,6 +65,58 @@ def __init__(self, osm_pbf, gtfs=[], build_config={}):
         )
         self._transport_network.transitLayer.buildDistanceTables(None)
 
+    @classmethod
+    def from_directory(cls, path):
+        """
+        Find input data in `path`, load an `r5py.TransportNetwork`.
+
+        This mimicks r5r’s behaviour to accept a directory path
+        as the only input to `setup_r5()`.
+
+        If more than one OpenStreetMap extract (`.osm.pbf`) is
+        found in `path`, the (alphabetically) first is used.
+        In case *no* OpenStreetMap extract is found, a `FileNotFound`
+        exception is raised. Any and all GTFS data files are used.
+
+        Arguments:
+        ----------
+        path : str
+            directory path in which to search for GTFS and .osm.pbf files
+
+        Returns:
+        --------
+        TransportNetwork
+            A fully initialised r5py.TransportNetwork
+        """
+        path = pathlib.Path(path)
+        try:
+            potential_osm_pbf_files = sorted(path.glob("*.osm.pbf"))
+            osm_pbf = potential_osm_pbf_files[0]
+            if len(potential_osm_pbf_files > 1):
+                warnings.warn(
+                    (
+                        f"Found more than one OpenStreetMap extract file (`.osm.pbf`), "
+                        f"using alphabetically first one ({osm_pbf.name})"
+                    ),
+                    RuntimeWarning,
+                )
+        except KeyError:
+            raise FileNotFoundError(
+                f"Could not find any OpenStreetMap extract file (`.osm.pbf`) in {path.absolute()}"
+            )
+        gtfs = [
+            potential_gtfs_file
+            for potential_gtfs_file in path.glob("*.zip")
+            if util.contains_gtfs_data(potential_gtfs_file)
+        ]
+        try:
+            with open(os.path.join(str(path), "build.json")) as build_json_file:
+                build_json = json.load(build_json_file)
+        except (FileNotFoundError, json.JSONDecodeError):
+            build_json = {}
+
+        return cls(osm_pbf, gtfs, build_json)
+
     @property
     def linkage_cache(self):
         """Expose the `TransportNetwork`’s `linkageCache` to Python."""
diff --git a/src/r5py/util/__init__.py b/src/r5py/util/__init__.py
index 67cefebd..61890d47 100644
--- a/src/r5py/util/__init__.py
+++ b/src/r5py/util/__init__.py
@@ -4,6 +4,7 @@
 
 from . import config, jvm, verbosity  # noqa: F401
 from .camel_to_snake_case import camel_to_snake_case
+from .contains_gtfs_data import contains_gtfs_data
 from .snake_to_camel_case import snake_to_camel_case
 
-__all__ = ["camel_to_snake_case", "config", "snake_to_camel_case"]
+__all__ = ["camel_to_snake_case", "config", "contains_gtfs_data", "snake_to_camel_case"]
diff --git a/src/r5py/util/contains_gtfs_data.py b/src/r5py/util/contains_gtfs_data.py
new file mode 100644
index 00000000..4e569a40
--- /dev/null
+++ b/src/r5py/util/contains_gtfs_data.py
@@ -0,0 +1,48 @@
+#!/usr/bin/env python3
+
+"""Check whether a file contains a GTFS data set."""
+
+
+import zipfile
+
+
+__all__ = ["contains_gtfs_data"]
+
+
+# https://developers.google.com/transit/gtfs/reference#field_definitions
+GTFS_REQUIRED_FILES = (
+    "agency.txt",
+    "stops.txt",
+    "routes.txt",
+    "trips.txt",
+    "stop_times.txt",
+)
+
+
+def contains_gtfs_data(path):
+    """
+    Check whether the file in `path` contains a GTFS dataset.
+
+    This is a rather heuristic approach: it tries to open the file
+    as a ZIP archive, and confirm the presence of the files required
+    by the GTFS standard reference.
+
+    Arguments:
+    ----------
+    path : str | pathlib.Path | file-like
+        The file to check. Should be opened in binary mode, if passed as a
+        file-like object.
+
+    Returns:
+    --------
+    bool : `True` if `path` likely contains a GTFS dataset, `False` if not.
+    """
+    try:
+        archive = zipfile.ZipFile(path)
+        assert all(
+            gtfs_field in archive.namelist() for gtfs_field in GTFS_REQUIRED_FILES
+        )
+        contains_gtfs_data = True
+    except (AssertionError, FileNotFoundError, zipfile.BadZipFile):
+        contains_gtfs_data = False
+    return contains_gtfs_data


Test patch:
diff --git a/tests/test_contains_gtfs_data.py b/tests/test_contains_gtfs_data.py
new file mode 100644
index 00000000..543c101d
--- /dev/null
+++ b/tests/test_contains_gtfs_data.py
@@ -0,0 +1,28 @@
+#!/usr/bin/env python3
+
+
+import pathlib
+
+import pytest  # noqa: F401
+
+import r5py
+
+DATA_DIRECTORY = pathlib.Path(__file__).absolute().parents[1] / "docs/data"
+GTFS_FILE = DATA_DIRECTORY / "GTFS.zip"
+NOT_GTFS_FILE = DATA_DIRECTORY / "kantakaupunki.osm.pbf"
+
+
+class TestContainsGtfsData:
+    @pytest.mark.parametrize(
+        ["path", "expected"],
+        [
+            (GTFS_FILE, True),
+            (str(GTFS_FILE), True),
+            (open(GTFS_FILE, "rb"), True),
+            (NOT_GTFS_FILE, False),
+            (str(NOT_GTFS_FILE), False),
+            (open(NOT_GTFS_FILE, "rb"), False),
+        ],
+    )
+    def test_contains_gtfs_data(self, path, expected):
+        assert r5py.util.contains_gtfs_data(path) == expected