Plus Code/latlong/query to iOS-Shortcuts-like shareable text
Go to file
2024-03-26 18:52:23 +00:00
.github ci: update workflows to use hatch 2024-03-26 18:46:39 +00:00
surplus s+: ruff comply 2024-03-26 18:52:05 +00:00
.gitignore meta: add files, 1.0.0 2023-06-02 19:39:25 +00:00
CHANGELOG.md docs: split up docs 2024-03-26 18:48:13 +00:00
CONTRIBUTING.md docs: split up docs 2024-03-26 18:48:13 +00:00
devbox.json meta: we now use hatch 2024-03-26 18:44:41 +00:00
devbox.lock meta: we now use hatch 2024-03-26 18:44:41 +00:00
DEVELOPING.md docs: split up docs 2024-03-26 18:48:13 +00:00
pyproject.toml meta: add hatch fmt to check script 2024-03-26 18:52:23 +00:00
README.md docs: split up docs 2024-03-26 18:48:13 +00:00
releaser.py meta: we now use hatch 2024-03-26 18:44:41 +00:00
requirements.txt deps: update requirements.txt 2023-09-02 13:42:34 +00:00
UNLICENCE meta: add files, 1.0.0 2023-06-02 19:39:25 +00:00

surplus

surplus is a Python script to convert Google Maps Plus Codes to iOS Shortcuts-like shareable text.

$ surplus 9R3J+R9 Singapore
surplus version 2.2.0
Thomson Plaza
301 Upper Thomson Road
Sin Ming, Bishan
574408
Central, Singapore

installation

Important

python 3.11 or later is required due to a bug in earlier versions. (python/cpython#88089)

for most, you can install surplus built from the latest stable release:

pipx install https://github.com/markjoshwel/surplus/releases/latest/download/surplus-latest-py3-none-any.whl

or directly from the repository using pip:

pipx install git+https://github.com/markjoshwel/surplus.git@main

Termux users: consider surplus on wheels, a sister project that allows you to run surplus regularly throughout the day and send it to someone on a messaging platform.

surplus is also a public domain dedicated single python file, so feel free to grab that and embed it into your own program as you see fit.

see licence for licensing information.

usage

command-line usage

usage: surplus [-h] [-d] [-v] [-c {pluscode,localcode,latlong,sharetext}]
               [-u USER_AGENT] [--show-user-agent] [-t]
               [query ...]

Google Maps Plus Code to iOS Shortcuts-like shareable text

positional arguments:
  query                 full-length Plus Code (6PH58QMF+FX), shortened
                        Plus Code/'local code' (8QMF+FX Singapore),
                        latlong (1.3336875, 103.7749375), string query
                        (e.g., 'Wisma Atria'), or '-' to read from stdin

options:
  -h, --help            show this help message and exit
  -d, --debug           prints lat, long and reverser response dict to
                        stderr
  -v, --version         prints version information to stderr and exits
  -c {pluscode,localcode,latlong,sharetext}, --convert-to {pluscode,localcode,latlong,sharetext}
                        converts query a specific output type, defaults
                        to 'sharetext'
  -u USER_AGENT, --user-agent USER_AGENT
                        user agent string to use for geocoding service,
                        defaults to fingerprinted user agent string
  --show-user-agent     prints fingerprinted user agent string and exits
  -t, --using-termux-location
                        treats input as a termux-location output json
                        string, and parses it accordingly

example api usage

here are a few examples to get you quickly started using surplus in your own program:

  1. let surplus do the heavy lifting

    >>> from surplus import surplus, Behaviour
    >>> result = surplus("Ngee Ann Polytechnic, Singapore", Behaviour())
    >>> result.get()
    'Ngee Ann Polytechnic\n535 Clementi Road\nBukit Timah\n599489\nNorthwest, Singapore'
    
  2. handle queries separately

    >>> import surplus
    >>> behaviour = surplus.Behaviour("6PH59R48+WP")
    >>> query = surplus.parse_query(behaviour)
    >>> result = surplus.surplus(query.get(), behaviour)
    >>> result.get()
    'MacRitchie Nature Trail\nCentral Water Catchment\n574325\nCentral, Singapore'
    
  3. start from a Query object

    >>> import surplus
    >>> localcode = surplus.LocalCodeQuery(code="8R3M+F8", locality="Singapore")
    >>> geocoder = surplus.SurplusDefaultGeocoding().geocoder
    >>> pluscode_str = localcode.to_full_plus_code(geocoder=geocoder).get()
    >>> pluscode = surplus.PlusCodeQuery(pluscode_str)
    >>> result = surplus.surplus(pluscode, surplus.Behaviour())
    >>> result.get()
    'Wisma Atria\n435 Orchard Road\n238877\nCentral, Singapore'
    

notes:

  • you can change what surplus does when passing in a custom Behaviour object

  • most surplus functions return a Result object. while you can call .get() to obtain the proper return value, this is dangerous and might raise an exception

see the api reference for more information.

api reference

constants

  • VERSION: tuple[int, int, int]

    a tuple of integers representing the version of surplus, in the format [major, minor, patch]

  • VERSION_SUFFIX: typing.Final[str]
    BUILD_BRANCH: typing.Final[str]
    BUILD_COMMIT: typing.Final[str]
    BUILD_DATETIME: typing.Final[datetime]

    string and a datetime.datetime object containing version and build information, set by releaser.py

  • CONNECTION_MAX_RETRIES: int = 9
    CONNECTION_WAIT_SECONDS: int = 10

    defines if and how many times to retry a connection, alongside how many seconds to wait in between tries, for Nominatim

    Note

    this constant only affects the default surplus Nominatim geocoding functions. custom functions do not read from this, unless deliberately programmed to do so

  • SHAREABLE_TEXT_LINE_0_KEYS: dict[str, tuple[str, ...]]
    SHAREABLE_TEXT_LINE_1_KEYS: dict[str, tuple[str, ...]]
    SHAREABLE_TEXT_LINE_2_KEYS: dict[str, tuple[str, ...]]
    SHAREABLE_TEXT_LINE_3_KEYS: dict[str, tuple[str, ...]]
    SHAREABLE_TEXT_LINE_4_KEYS: dict[str, tuple[str, ...]]
    SHAREABLE_TEXT_LINE_5_KEYS: dict[str, tuple[str, ...]]
    SHAREABLE_TEXT_LINE_6_KEYS: dict[str, tuple[str, ...]]

    a dictionary of iso3166-2 country-portion string keys with a tuple of Nominatim keys used in shareable text line 0-6 as their values

    {
      "default": (...),
      "SG": (...,),
      ...
    }
    
  • SHAREABLE_TEXT_LINE_SETTINGS: dict[str, dict[int, tuple[str, bool]]]

    a dictionary of iso3166-2 country-portion string keys with a dictionary as their values

    the dictionary values are dictionaries with integers as keys, and a tuple of two strings

    the first string is the separator string to use, and the second string is a boolean flag that if True will check the line for seen names

    {
        "default": {
            0: (", ", False),
            ...
            6: (", ", False),
        },
        "IT": {
            0: (", ", False),
            ...
            6: (", ", False),
        },
        ...
    }
    
  • SHAREABLE_TEXT_NAMES: dict[str, tuple[str, ...]]

    a dictionary of iso3166-2 country-portion string keys with a tuple of strings as their values a tuple of strings containing Nominatim keys used in shareable text line 0-2 and special keys in line 3

    used for seen name checks

  • SHAREABLE_TEXT_LOCALITY: dict[str, tuple[str, ...]]

    a dictionary of iso3166-2 country-portion string keys with a tuple of strings as their values

    used when generating the locality portions of shortened Plus Codes/local codes

    {
      "default": (...),
      "SG": (...,),
      ...
    }
    
  • SHAREABLE_TEXT_DEFAULT: typing.Final[str]
    constant for what is the "default" key in the SHAREABLE* constants

  • EMPTY_LATLONG: typing.Final[Latlong]
    a constant for an empty latlong coordinate, with latitude and longitude set to 0.0

exception classes

  • class SurplusError(Exception)
    base skeleton exception for handling and typing surplus exception classes
  • class NoSuitableLocationError(SurplusError)
  • class IncompletePlusCodeError(SurplusError)
  • class PlusCodeNotFoundError(SurplusError)
  • class LatlongParseError(SurplusError)
  • class EmptyQueryError(SurplusError)
  • class UnavailableFeatureError(SurplusError)

types

Query

Query: typing.TypeAlias = PlusCodeQuery | LocalCodeQuery | LatlongQuery | StringQuery

type alias representing either a PlusCodeQuery, LocalCodeQuery, LatlongQuery or StringQuery

ResultType

ResultType = TypeVar("ResultType")

generic type used by Result

SurplusGeocoderProtocol

typing_extensions.Protocol class for documentation and static type checking of surplus geocoder functions

  • signature and conforming function signature

    class SurplusGeocoderProtocol(Protocol):
        def __call__(self, place: str) -> Latlong:
            ...
    

    functions that conform to this protocol should have the following signature:

    def example(place: str) -> Latlong: ...
    
  • information on conforming functions

    function takes in a location name as a string, and returns a Latlong.

    function MUST supply a bounding_box attribute to the to-be-returned Latlong. the bounding box is used when surplus shortens Plus Codes.

    function can and should be at minimum functools.lru_cache()-wrapped if the geocoding service asks for caching

    exceptions are handled by the caller

SurplusReverserProtocol

typing_extensions.Protocol class for documentation and static type checking of surplus reverser functions

  • signature and conforming function signature

    class SurplusReverserProtocol(Protocol):
        def __call__(self, latlong: Latlong, level: int = 18) -> dict[str, Any]:
            ...
    

    functions that conform to this protocol should have the following signature:

    def example(latlong: Latlong, level: int = 18) -> dict[str, Any]: ...
    
  • information on conforming functions

    function takes in a Latlong object and return a dictionary with SHAREABLE_TEXT_LINE_*_KEYS keys at the dictionaries' top-level.
    keys are used to access address information.

    function should also take in an int representing the level of detail for the returned address, 0-18 (country-level to building), inclusive. should default to 18.

    keys for latitude, longitude and an iso3166-2 (or closest equivalent) should also be included at the dictionaries top level as the keys latitude, longitude and ISO3166-2 (non-case sensitive, or at least something starting with ISO3166) respectively.

    {
        'ISO3166-2-lvl6': 'SG-03',
        'amenity': 'Ngee Ann Polytechnic',
        ...
        'country': 'Singapore',
        'latitude': 1.33318835,
        'longitude': 103.77461234638255,
        'postcode': '599489',
        'raw': {...},
    }
    

    function can and should be at minimum functools.lru_cache()-wrapped if the geocoding service asks for caching

    exceptions are handled by the caller

class Behaviour

typing.NamedTuple representing how surplus operations should behave

attributes

  • query: str | list[str] = ""
    original user-passed query string or a list of strings from splitting user-passed query string by spaces

  • geocoder: SurplusGeocoderProtocol = default_geocoding.geocoder
    name string to location function, see SurplusGeocoderProtocol for more information

  • reverser: SurplusReverserProtocol = default_geocoding.reverser
    Latlong object to address information dictionary function, see SurplusReverserProtocol for more information

  • stderr: typing.TextIO = sys.stderr
    TextIO-like object representing a writeable file. defaults to sys.stderr.

  • stdout: typing.TextIO = sys.stdout
    TextIO-like object representing a writeable file. defaults to sys.stdout.

  • debug: bool = False
    whether to print debug information to stderr

  • version_header: bool = False
    whether to print version information and exit

  • convert_to_type: ConversionResultTypeEnum = ConversionResultTypeEnum.SHAREABLE_TEXT
    what type to convert the query to

  • using_termux_location: bool = False
    treats query as a termux-location output json string, and parses it accordingly

  • show_user_agent: bool = False
    whether to print the user agent string to stderr

class SurplusDefaultGeocoding

Important

this has replaced the now deprecated default geocoding functions, default_geocoder() and default_reverser(), in surplus 2.1 and later.

see SurplusGeocoderProtocol and SurplusReverserProtocol for more information how to implement a compliant custom geocoder functions.

dataclasses.dataclass providing the default geocoding functionality for surplus, via OpenStreetMap Nominatim

attributes

  • user_agent: str = default_fingerprint
    pass in a custom user agent here, else it will be the default fingerprinted user agent

example usage

from surplus import surplus, Behaviour, SurplusDefaultGeocoding

geocoding = SurplusDefaultGeocoding("custom user agent")
geocoding.update_geocoding_functions()  # not necessary but recommended

behaviour = Behaviour(
    ...,
    geocoder=geocoding.geocoder,
    reverser=geocoding.reverser
)

result = surplus("query", behaviour=behaviour)

...

methods

SurplusDefaultGeocoding.update_geocoding_functions()

re-initialise the geocoding functions with the current user agent, also generate a new user agent if not set properly

it is recommended to call this before using surplus as by default the geocoding functions are uninitialised

  • signature

    def update_geocoding_functions(self) -> None: ...
    

SurplusDefaultGeocoding.geocoder()

Warning

this function is primarily given to be passed into a Behaviour object, and is not meant to be called directly.

default geocoder for surplus

see SurplusGeocoderProtocol for more information on surplus geocoder functions

SurplusDefaultGeocoding.reverser()

Warning

this function is primarily given to be passed into a Behaviour object, and is not meant to be called directly.

default reverser for surplus

see SurplusReverserProtocol for more information on surplus reverser functions

class ConversionResultTypeEnum

enum.Enum representing what the result type of conversion should be

values

  • PLUS_CODE: str = "pluscode"
  • LOCAL_CODE: str = "localcode"
  • LATLONG: str = "latlong"
  • SHAREABLE_TEXT: str = "sharetext"

class Result

typing.NamedTuple representing the result for safe value retrieval

attributes

  • value: ResultType
    value to return or fallback value if erroneous

  • error: BaseException | None = None
    exception if any

example usage

# do something
def some_operation(path) -> Result[str]:
    try:
        file = open(path)
        contents = file.read()

    except Exception as exc:
        # must pass a default value
        return Result[str]("", error=exc)

    else:
        return Result[str](contents)

# call function and handle result
result = some_operation("some_file.txt")

if not result:  # check if the result is erroneous
    # .cry() raises the exception
    # (or returns it as a string error message using string=True)
    result.cry()
    ...

else:
    # .get() raises exception or returns value,
    # but since we checked for errors this is safe
    print(result.get())

methods

Result.__bool__()

method that returns True if self.error is not None

  • signature

    def __bool__(self) -> bool: ...
    
  • returns bool

Result.cry()

method that raises self.error if is an instance of BaseException, returns self.error if is an instance of str, or returns an empty string if self.error is None

  • signature

    def cry(self, string: bool = False) -> str: ...
    
  • arguments

    • string: bool = False
      if self.error is an Exception, returns it as a string error message
  • returns str

Result.get()

method that returns self.value if Result is non-erroneous else raises error

  • signature

    def get(self) -> ResultType: ...
    
  • returns self.value

class Latlong

typing.NamedTuple representing a latitude-longitude coordinate pair

attributes

  • latitude: float

  • longitude: float

  • bounding_box: tuple[float, float, float, float] | None = None
    a four-tuple representing a bounding box, (lat1, lat2, lon1, lon2) or None.

    the user does not need to enter this. the attribute is only used when shortening plus codes, and would be supplied by the geocoding service during shortening.

methods

Latlong.__str__()

method that returns a comma-and-space-separated string of self.latitude and self.longitude

  • signature

    def __str__(self) -> str: ...
    
  • returns str

class PlusCodeQuery

typing.NamedTuple representing a full-length Plus Code (e.g., 6PH58QMF+FX)

attributes

  • code: str

methods

PlusCodeQuery.to_lat_long_coord()

  • signature

    def to_lat_long_coord(self, geocoder: SurplusGeocoderProtocol) -> Result[Latlong]:
        ...
    
  • arguments

    • geocoder: SurplusGeocoderProtocol
      name string to location function, see SurplusGeocoderProtocol for more information
  • returns Result[Latlong]

PlusCodeQuery.__str__()

method that returns string representation of query

  • signature

    def __str__(self) -> str: ...
    
  • returns str

class LocalCodeQuery

typing.NamedTuple representing a shortened Plus Code with locality, referred to by surplus as a "local code"

attributes

  • code: str
    Plus Code portion of local code, e.g., "8QMF+FX"

  • locality: str
    remaining string of local code, e.g., "Singapore"

methods

LocalCodeQuery.to_full_plus_code()

exclusive method that returns a full-length Plus Code as a string

  • signature

    def to_full_plus_code(self, geocoder: SurplusGeocoderProtocol) -> Result[str]:
        ...
    
  • arguments

    • geocoder: SurplusGeocoderProtocol
      name string to location function, see SurplusGeocoderProtocol for more information
  • returns Result[str]

LocalCodeQuery.to_lat_long_coord()

method that returns a latitude-longitude coordinate pair

  • signature

    def to_lat_long_coord(self, geocoder: SurplusGeocoderProtocol) -> Result[Latlong]:
        ...
    
  • arguments

    • geocoder: SurplusGeocoderProtocol
      name string to location function, see SurplusGeocoderProtocol for more information
  • returns Result[Latlong]

LocalCodeQuery.__str__()

method that returns string representation of query

  • signature

    def __str__(self) -> str: ...
    
  • returns str

class LatlongQuery

typing.NamedTuple representing a latitude-longitude coordinate pair

attributes

  • latlong: Latlong

methods

LatlongQuery.to_lat_long_coord()

method that returns a latitude-longitude coordinate pair

  • signature

    def to_lat_long_coord(self, geocoder: SurplusGeocoderProtocol) -> Result[Latlong]:
        ...
    
  • arguments

    • geocoder: SurplusGeocoderProtocol
      name string to location function, see SurplusGeocoderProtocol for more information
  • returns Result[Latlong]

LatlongQuery.__str__()

method that returns string representation of query

  • signature

    def __str__(self) -> str: ...
    
  • returns str

class StringQuery

typing.NamedTuple representing a pure string query

attributes

  • query: str

methods

StringQuery.to_lat_long_coord()

method that returns a latitude-longitude coordinate pair

  • signature

    def to_lat_long_coord(self, geocoder: SurplusGeocoderProtocol) -> Result[Latlong]:
        ...
    
  • arguments

    • geocoder: SurplusGeocoderProtocol
      name string to location function, see SurplusGeocoderProtocol for more information
  • returns Result[Latlong]

StringQuery.__str__()

method that returns string representation of query

  • signature

    def __str__(self) -> str: ...
    
  • returns str

def surplus()

query to shareable text conversion function

  • signature

    def surplus(query: Query | str, behaviour: Behaviour) -> Result[str]: ..
    
  • arguments

  • returns Result[str]

def parse_query()

function that parses a query string into a query object

def generate_fingerprinted_user_agent()

function that attempts to return a unique user agent string.

  • signature
def generate_fingerprinted_user_agent() -> Result[str]:
  • returns Result[str]

    this result will always have a valid value as erroneous results will have a resulting value of 'surplus/<version> (generic-user)'

    valid results will have a value of 'surplus/<version> (<fingerprin hasht>)', where the fingerprint hash is a 12 character hexadecimal string

details on the fingerprinted user agent

why do this in the first place?
if too many people use surplus at the same time, Nominatim will start to think it's just one person being greedy. so to prevent this, surplus will try to generate a unique user agent string for each user through fingerprinting.

at the time of writing, the pre-hashed fingerprint string is as follows:

unique_info: str = f"{version}-{system_info}-{hostname}-{mac_address}"

it contains the following, in order, alongside an example:

  1. version - the surplus version alongside a suffix, if any

    2.2.0-local
    
  2. system_info - generic machine and operating system information

    Linux-6.5.0-locietta-WSL2-xanmod1-x86_64-with-glibc2.35
    
  3. hostname - your computer's hostname

    mark
    
  4. mac_address - your computer's mac address

    A9:36:3C:98:79:33
    

after hashing, this string becomes a 12 character hexadecimal string, as shown below:

surplus/2024.0.0 (1fdbfa0b0cfb)
                  ^^^^^^^^^^^^
                  this is the hashed result of unique_info

if at any time the retrieval of any of these four elements fail, surplus will just give up and default to 'surplus/<version> (generic-user)'.

you can see the fingerprinted user agent string by running the following command:

$ surplus --show-user-agent
...

if any of this seems weird to you, that's fine. pass in a custom user agent flag to surplus with -u or --user-agent to override the default user agent, or override the default user agent in your own code by passing in a custom user agent string to Behaviour.

$ surplus --user_agent "a-shiny-custom-and-unique-user-agent" 77Q4+7X Austin, Texas, USA
...
>>> from surplus import surplus, Behaviour
>>> surplus(..., Behaviour(user_agent="a-shiny-custom-and-unique-user-agent"))
...

licence

surplus is free and unencumbered software released into the public domain. for more information, please refer to the UNLICENCE, https://unlicense.org, or the python module docstring.

however, direct dependencies of surplus are licensed under different, but still permissive and open-source licences.

  • geopy: Python Geocoding Toolbox

    MIT Licence

    • geographiclib: The geodesic routines from GeographicLib

      MIT Licence

  • pluscodes: Compute Plus Codes (Open Location Codes)

    Apache 2.0