# PyBuses-Entities

Data classes to be used as part of a project that work with a public transport system, with Stops and Buses.

While focused on Buses, PyBuses-Entities can be used with almost any public transport system that works 
on a similar manner.

The project relies on two basic classes:

- **Stop**: a physical place where Buses stop to pick and leave passengers
- **Bus**: a moving vehicle that has a certain route of Stops and stop at them

This project is mainly developed for the [Python-VigoBusAPI](https://github.com/David-Lor/Python_VigoBusAPI) and [VigoBus-TelegramBot](https://github.com/David-Lor/VigoBus-TelegramBot), 
two projects that work together (being the first an intermediate API, and the second one a Telegram bot backend 
that consume data from that API). 
In this public transport system, buses stop at Stops, and for each Stop we can fetch a List of Buses 
with their remaining time until arrival.

The interesting part about these classes is that they can generate a JSON output, which can be served through our API, 
and then converted back to a class instance in our client.

## Requirements

- Python >= 3.6
- pydantic

## Installation

```bash
pip install pybuses-entities
```

## Changelog

- **0.0.2**:
    - add options to remove empty lists & empty dicts on get_dict() method of entities (True by default)
    - add "source" field to AdvancedStop and BusesResult
    - add new sorting methods for Stops: sort by stopid, sort by distance
    - add/improve docstrings
- **0.0.1** - initial release

## Object definition

Stop and Bus objects inherit from pydantic. When creating a new instance of a class, its parameters 
must be passed as kwargs. 
Extra kwargs parameters passed are ignored, so these classes work well with JSON as input and output data.

Both Stop and Bus classes have two superior class levels that inherit from their base classes, providing 
more advanced attributes.

Custom classes can be created in a similar manner to suit the needs of each particular project 
and public transport system, inheriting from these base classes.

### Stop

#### Attributes

- **stopid**: unique identifier of this Stop (required, int)
- **name**: canonical, human-readable name for this Stop, usually its location (required, str)
- **lat**, **lon**: location of this Stop as latitude & longitude coordinates (optional, float)

##### AdvancedStop

- **created**: when this Stop definition was created or registered for the first time (optional, datetime/str/int/float)
- **updated**: when this Stop definition was updated for the last time (optional, datetime/str/int/float) <sup>1</sup>
- **original_name**: when the Stop name was fetched from an external data source and changed by us, 
  we can keep its original name on this attribute (optional, str) <sup>1</sup>
- **tags**: extra tags for this Stop (optional, list of str)
- **extra_names**: optional additional names for this Stop, e.g. translation of the original names or 
  alternative names that users commonly use (optional, list of str)
- **distance**: distance from this Stop to a certain point. Useful, for example, when giving a list of 
  which Stops exist near a certain location (optional, float)
- **source**: name of the data source this data was fetched from (optional, str)

<sup>1</sup> timestamps can be: datetime object, str representation, or int/float epoch timestamp

#### Examples

Classic object declaration:
```python
from pybusent import Stop

stop_1 = Stop(stopid=1, name="The Red Keep")
stop_6960 = Stop(stopid=6960, name="Praza do Rei", lat=42.235056274709, lon=-8.72675751435639)
```

JSON/kwargs declaration (AdvancedStop example):
```python
from pybusent import AdvancedStop

stop_data = {
    "stopid": 6660,
    "name": "Porta do Sol",
    "lat":42.2379152928961,
    "lon":-8.72484658707515,
    "created": 1529137067,
    "updated": 1560673067,
    "original_name": "Porta Do Sol, s/n",  # 1
    "tags": ["center", "centre" "zentrum", "center", "casco viejo", "old town"],  # 2
    "extra_names": ["puerta del sol", "sun gate"],  # 3
    "distance": 25.38
}

stop = AdvancedStop(**stop_data)
```

<sup>1</sup> the original_name could be the name given by the original data source that provided the Stop information, 
and we changed to a better-looking format

<sup>2</sup> since this Stop is located at the town centre, we used tags like "center", "centre"; 
it is also near the old town, so used tags like "old town"

<sup>3</sup> what the heck is "sun gate" supposed to mean? a literal translation from spanish "puerta del sol", 
don't do this useless translations in production ?)

### Bus

#### Attributes

- **busid**: unique identifier for this bus (optional, str) - if not provided, a busid is autogenerated 
as the MD5 sum of the line and route
- **line**: bus line (required, str)
- **route**: bus route (required, str)
- **time**: remaining time for the bus to arrive to a certain Stop (optional, int/float/datetime/timedelta) <sup>1</sup>

##### AdvancedBus

- **arrival**: when the bus will arrive to a certain Stop (optional, int/float/datetime/timedelta) <sup>1</sup> <sup>2</sup>
- **departure**: when the bus will departure from a certain Stop (optional, int/float/datetime/timedelta) <sup>1</sup> <sup>2</sup>
- **stops**: a list of Stops this bus will stop at (optional, list of Stop objects)

<sup>1</sup> bus remaining/arrival/departure times can be specified as relative times 
(how time from now is left until it arrives/departures), or absolute times (datetime object or int/float epoch timestamp).
Relative times can be set as int, float or timedelta, depending on the way to represent this time.

<sup>2</sup> when the transport system differentiates between arrivals and departures (e.g. many train networks), 
the AdvancedBus `arrival` & `departure` times are used INSTEAD of `time`

#### Examples

Classic object declaration:
```python
from datetime import datetime
from pybusent import Bus

# Bus 15C (Mohawk Avenue - Bunker Hill Street) will arrive in 5 minutes
bus = Bus(line="15C", route="Mohawk Avenue - Bunker Hill Street", time=5)

# Bus 21 (Algonquin) will arrive in 1 minute and 30 seconds
bus = Bus(line=21, route="Algonquin", time=timedelta(minutes=1, seconds=30))
print(type(bus.line))  # Line is casted to string although declared as int

# Bus 5B (Dukes - Bohan) will arrive at 14h52
# Creating a datetime object can be a bit more tricky since it requires the date
# Get today date and replace time with the arrival time
dt = datetime.now().replace(hour=14, minute=52, second=0, microsecond=0)
bus = Bus(line="5B", route="Dukes - Bohan", time=dt)

# It can be easier if your data source provides an epoch timestamp
epoch = 1529137067
dt = datetime.fromtimestamp(epoch)
bus = Bus(line="5B", route="Dukes - Bohan", time=dt)
```

JSON/kwargs declaration (AdvancedBus example):
```python
from pybusent import AdvancedBus

bus_data = {
    "line": 5,
    "route": "Middle Park - East Holland",
    "arrival": 5,
    "departure": 7
}
bus = AdvancedBus(**bus_data)
```

Static bus declaration with its Stops route (AdvancedBus example):
```python
from pybusent import AdvancedBus

# Define the route of the buses Line 5, Route 'Middle Park - East Holland'
bus = AdvancedBus(
    line="5",
    route="Middle Park - East Holland",
    stops=[
        Stop(stopid=1231, name="Middle Park East"),
        Stop(stopid=1232, name="Middle Park West"),
        Stop(stopid=1233, name="Lancaster Avenue"),
        Stop(stopid=1234, name="East Holland South"),
        Stop(stopid=1235, name="East Holland North")
    ]
)
```

### BusesResult

This class is used when we want to return a more contextualized List of Buses. BusesResult do not inherit from Bus. 
The following fields can be useful to give context to the Buses result:

#### Attributes

- **buses**: the List of Buses returned
- **more_buses_available**: boolean that can be set if the data source reported that more buses were available, 
  but we only fetched the first N results - e.g. because the data source is split into pages (optional, bool)
- **stop**: when the data source returns Stop info alongside its realtime Buses list, we can return it (optional, Stop)
- **source**: name of the data source this data was fetched from (optional, str)

#### Examples

```python
from pybusent import BusesResult

result = BusesResult(
    buses=[
        Bus(line="5", route="Middle Park - East Holland", time=3),
        Bus(line="15C", route="Dukes - Bohan", time=9),
        Bus(line="5", route="Middle Park - East Holland", time=48),
    ],
    stop=Stop(
        stopid=12812,
        name="Middle Park (Entrance)"
    ),
    more_buses_available=True
)
```

### Custom classes

Stop, AdvancedStop, Bus and AdvancedBus can be inherited to create custom classes, with new attributes 
or changing the base attributes.

Attributes that involve certain logic could have trouble if not designed specifically to support different datatypes 
or being optional when designed to be required.

For example, you can skip the Line or Route on a Bus and the Stop ID will still be auto-generated because this scenario 
was contemplated. 
If not, instantiating a Bus without Line/Route would not be possible, since both methods would be required in order 
to generate the Stop ID.

#### Examples

_"My bus network has no Line numbers, only the Route - I will create a custom Bus class without the Line 
(more like, the Line is not required)"_
```python
from pybusent import Bus

class MyBus(Bus):
    line: Optional[str]
```

_"On my bus network, buses have a flag to know if they have special sits for reduced mobility passengers 
(False by default)"_
```python
from pybusent import Bus

class MyBus(Bus):
    reduced_mobility: bool = False
```

_"On my bus network, the Stop IDs have letters on it, so it must be a string"_
```python
from pybusent import Bus

class MyStop(AdvancedStop):
    stopid: str
```

### Exceptions

A set of exceptions based on Stops and Buses is defined. They include no logic.

- **PyBusesException**: base exception for all the custom exceptions (inherit BaseException)
    - **StopException**: base exception for all the Stop related exceptions
        - **StopNotFound**: raised when a Stop is not found on a certain data source, but that data source cannot 
          confirm that the stop not exists physically. E.g. if we keep a local storage of Stops and a Stop is not found 
          on it, it could be that it was not saved there, but it might physically exist.
        - **StopNotExist**: raised when we are sure a Stop not physically exists. E.g. if we ask a remote API for 
          a Stop and the API replies that it does not exist.
    - **BusException**: base exception for all the Bus related exceptions
