diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 34f39dd..264297b 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -3,6 +3,8 @@ name: qc on: workflow_dispatch: push: + paths: + - '**.py' jobs: analyse: @@ -17,18 +19,18 @@ jobs: - name: install dependencies run: devbox run poetry install - - name: buil wheel + - name: build wheel id: build run: devbox run poetry build - name: analyse with mypy - run: devbox run poetry run mypy surplus.py test.py + run: devbox run poetry run mypy **/*.py - name: check for black formatting compliance - run: devbox run poetry run "black --check surplus.py test.py" + run: devbox run poetry run "black --check **/*.py" - name: analyse isort compliance - run: devbox run poetry run "isort --check surplus.py test.py" + run: devbox run poetry run "isort --check **/*.py" test: runs-on: ubuntu-latest diff --git a/.github/workflows/publish-slsa3.yml b/.github/workflows/publish-slsa3.yml index b7001ae..c71acf9 100644 --- a/.github/workflows/publish-slsa3.yml +++ b/.github/workflows/publish-slsa3.yml @@ -27,6 +27,9 @@ jobs: id: build run: devbox run poetry build + - name: duplicate non-versioned wheel + run: cp dist/surplus-*.whl dist/surplus-py3-none-any.whl + - name: generate provenance subjects id: hash run: | diff --git a/README.md b/README.md index 408f743..aadc8df 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,25 @@ # surplus surplus is a Python script to convert -[Google Map Plus Codes](https://maps.google.com/pluscodes/) -to iOS Shortcuts-like human text. +[Google Maps Plus Codes](https://maps.google.com/pluscodes/) +to iOS Shortcuts-like shareable text. - [installation](#installation) -- [command-line usage](#command-line-usaage) +- [usage](#usage) + - [command-line usage](#command-line-usage) + - [example api usage](#example-api-usage) - [developer's guide](#developers-guide) - - [api reference](#api-reference) - [contributor's guide](#contributors-guide) - [reporting incorrect output](#reporting-incorrect-output) - [the reporting process](#the-reporting-process) - [what counts as "incorrect"](#what-counts-as-incorrect) - [output technical details](#the-technical-details-of-surpluss-output) +- [api reference](#api-reference) - [licence](#licence) ```text $ surplus 9R3J+R9 Singapore -surplus version 1.1.3 +surplus version 2.0.0 Thomson Plaza 301 Upper Thomson Road Sin Ming, Bishan @@ -25,47 +27,108 @@ Sin Ming, Bishan Central, Singapore ``` -```python ->>> from surplus import surplus, Localcode ->>> Localcode(code="8RPQ+JW", locality="Singapore").full_length() -(True, '6PH58RPQ+JW') ->>> surplus("6PH58RPQ+JW") -(True, 'Caldecott Stn Exit 4\nToa Payoh Link\n298106\nCentral, Singapore') -``` - ## installation -install surplus directly from the repository using pip: +> [!IMPORTANT] +> python 3.11 or later is required due to a bug in earlier versions. +> [(python/cpython#88089)](https://github.com/python/cpython/issues/88089) + +for most, you can install surplus built from the latest stable release: ```text -pip install git+https://github.com/markjoshwel/surplus +pip install https://github.com/markjoshwel/surplus/releases/latest/download/surplus-py3-none-any.whl ``` -## command-line usage +or directly from the repository using pip: ```text -usage: surplus [-h] [-d] [-v] [query ...] +pip install git+https://github.com/markjoshwel/surplus.git@main +``` -Plus Code to iOS-Shortcuts-like shareable text +surplus is also a public domain dedicated [single python file](surplus/surplus.py), so +feel free to grab that and embed it into your own program as you see fit. + +see [licence](#licence) for licensing information. + +## usage + +### command-line usage + +```text +usage: surplus [-h] [-d] [-v] [-c {pluscode,localcode,latlong,string}] + [query ...] + +Google Maps Plus Code to iOS Shortcuts-like shareable text positional arguments: - query full-length Plus Code (6PH58QMF+FX), - local code (8QMF+FX Singapore), or - latlong (1.3336875, 103.7749375) + query full-length Plus Code (6PH58QMF+FX), shortened + Plus Code/'local code' (8QMF+FX Singapore), + latlong (1.3336875, 103.7749375), or string + query (e.g., 'Wisma Atria') 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 + -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' ``` +### 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 lifiting + + ```python + >>> 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 seperately + + ```python + >>> import surplus + >>> behaviour = surplus.Behaviour("6PH58R3M+F8") + >>> 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 + + ```python + >>> import surplus + >>> localcode = surplus.LocalCodeQuery(code="8R3M+F8", locality="Singapore") + >>> pluscode_str = localcode.to_full_plus_code(geocoder=surplus.default_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 by passing in a custom [`Behaviour`](#class-behaviour) + object + +- most surplus functions return a [`Result`](#class-result) object. while you can + call [`.get()`](#resultget) to obtain the proper return value, this is dangerous and + might raise an exception + +see the [api reference](#api-reference) for more information. + ## developer's guide prerequisites: -- [Python >=3.10](https://www.python.org/) +- [Python >=3.11](https://www.python.org/) - [Poetry](https://python-poetry.org/) alternatively, use [devbox](https://get.jetpack.io/devbox) for a hermetic development environment powered by [Nix](https://nixos.org/). @@ -76,6 +139,8 @@ poetry install poetry shell ``` +for information on surplus's exposed api, see the [api reference](#api-reference). + ## contributor's guide 1. fork the repository and branch off from the `future` branch @@ -87,7 +152,7 @@ poetry shell when contributing your first changes, please include an empty commit for a copyright waiver using the following message (replace 'Your Name' with your name or nickname): -``` +```text Your Name Copyright Waiver I dedicate any and all copyright interest in this software to the @@ -101,7 +166,7 @@ the command to create an empty commit is `git commit --allow-empty` ### reporting incorrect output -> **Note** +> [!NOTE] > this section is independent from the rest of the contributing section. different output from the iOS Shortcuts app is expected, however incorrect output is not. @@ -110,27 +175,28 @@ different output from the iOS Shortcuts app is expected, however incorrect outpu open an issue in the [repositories issue tracker](https://github.com/markjoshwel/surplus/issues/new), -and include the following: +and do the following: -1. ensure that your issue is not an error of incorrect data returned by your reverser - function, which by default is OpenStreetMap Nominatim. - (_don't know what the above means? then using the default reverser._) +1. ensure that your issue is not an error of incorrect data returned by your reverser + function, which by default is OpenStreetMap Nominatim. + (_don't know what the above means? then you are using the default reverser._) - also look at "[what counts as 'incorrect'](#what-counts-as-incorrect)" before - moving on. + also look at the [what counts as "incorrect"](#what-counts-as-incorrect) section + before moving on. -2. include the erroneous Plus Code, local code, latitude and longitude coordinate, or - query string. +2. include the erroneous query. + (_the Plus Code/local code/latlong coord/query string you passed into surplus_) 3. include output from the teminal with the - [`--debug` flag](#command-line-usage) passed to the surplus CLI or with + [`--debug` flag](#command-line-usage) passed to the surplus CLI or with `debug=True` set in function calls. - > **Note** - > if you are using custom stdout and stderr parameters and redirecting output, - > include that instead. + > [!NOTE] + > if you are using the surplus API and have passed custom stdout and stderr parameters + > to redirect output, include that instead. -4. how it should look like instead, with reasoning if the error not obvious. (e.g., missing details) +4. how it should look like instead, with reasoning if the error is not obvious. (e.g., + missing details) for reference, see how the following issues were written: @@ -140,11 +206,11 @@ and include the following: #### what counts as "incorrect" -- **example 1** +- **example** (correct) - iOS Shortcuts Output - ``` + ```text Plaza Singapura 68 Orchard Rd 238839 @@ -153,7 +219,7 @@ and include the following: - surplus Output - ``` + ```text Plaza Singapura 68 Orchard Road Museum @@ -164,41 +230,46 @@ and include the following: this _should not_ be reported as incorrect, as the only difference between the two is that surplus displays more information. - note: for singaporean readers, "Musuem" here is correct as it refers to the - [Museum planning area](https://en.wikipedia.org/wiki/Museum_Planning_Area), - in which Plaza Singapura is located in. - other examples that _should not_ be reported are: - name of place is incorrect/different this may be due to incorrect data from the geolocator function, which is OpenStreetMap Nominatim by default. - in the case of Nominatim, it means that there the data on OpenStreetMap is incorrect. + in the case of Nominatim, it means that the data on OpenStreetMap is incorrect. (_if so, then consider updating OpenStreetMap to help not just you, but other surplus and OpenStreetMap users!_) -you should report when the output does not make logical sense, or something similar +**you should report** when the output does not make logical sense, or something similar wherein the output of surplus is illogical to read or is not correct in the traditional sense of a correct address. -see the linked issues in [the reporting process](#the-reporting-process) for examples +see the linked issues in [the reporting process](#the-reporting-process) for examples of incorrect outputs. ## the technical details of surplus's output -``` -$ s+ 8QJF+RP --debug -surplus version 1.1.3, debug mode -debug: args.query='8QJF+RP Singapore' -debug: squery=['8QJF+RP', 'Singapore'] -debug: pcode='8QJF+RP', locality='Singapore' -debug: lat=1.3320625, lon=103.7743125 -debug: location={...} -debug: seen_names=['Ngee Ann Polytechnic', '', '', ''] -debug: d='' _dvtm4=[False, False, False, False] _dvcm4=[False, False, False, False] -debug: d='Bukit Timah' _dvtm4=[True, True, True, True] _dvcm4=[True, True, True, True] -debug: d='Singapore' _dvtm4=[True, True, False, True] _dvcm4=[True, True, True, True] +> [!NOTE] +> this is a breakdown of surplus's output when converting to shareable text. +> when converting to other output types, output may be different. + +```text +$ s+ --debug 8QJF+RP Singapore +surplus version 2.0.0, debug mode +debug: parse_query: behaviour.query=['8QJF+RP', 'Singapore'] +debug: _match_plus_code: portion_plus_code='8QJF+RP', portion_locality='Singapore' +debug: cli: query=Result(value=LocalCodeQuery(code='8QJF+RP', locality='Singapore'), error=None) +debug: cli: latlong.get()=Latlong(latitude=1.3320625, longitude=103.7743125) +debug: cli: location={'amenity': 'Ngee Ann Polytechnic', 'house_number': '535', 'road': 'Clementi Road', 'suburb': 'Bukit Timah', 'city': 'Singapore', 'county': 'Northwest', 'ISO3166-2-lvl6': 'SG-03', 'postcode': '599489', 'country': 'Singapore', 'country_code': 'sg', 'raw': "{...}", 'latitude': '1.33318835', 'longitude': '103.77461234638255'} +debug: _generate_text: seen_names=['Ngee Ann Polytechnic', 'Clementi Road'] +debug: _generate_text_line: [True] -> True -------- 'Ngee Ann Polytechnic' +debug: _generate_text_line: [True] -> True -------- '535' +debug: _generate_text_line: [True] -> True -------- 'Clementi Road' +debug: _generate_text_line: [True, True] -> True -------- 'Bukit Timah' +debug: _generate_text_line: [False, True] -> False filtered 'Singapore' +debug: _generate_text_line: [True] -> True -------- '599489' +debug: _generate_text_line: [True] -> True -------- 'Northwest' +debug: _generate_text_line: [True] -> True -------- 'Singapore' 0 Ngee Ann Polytechnic 1 2 @@ -215,88 +286,105 @@ Northwest, Singapore variables -- **variable `args.query`** +- **variable `behaviour.query`** - space-combined query given by user, comes from - [`argparse.ArgumentParser.parse_args`](https://docs.python.org/3/library/argparse.html#argparse.ArgumentParser.parse_args) - -- **variable `squery`** - - query split by comma + the original query string or a list of strings from space-splitting the original query + string passed to [`parse_query()`](#def-parse_query) for parsing ```text $ s+ 77Q4+7X Austin, Texas, USA -------------------------- query - squery -> ['77Q4+7X', 'Austin', 'Texas', 'USA'] + behaviour.query -> ['77Q4+7X', 'Austin', 'Texas', 'USA'] + ``` + + ```text + >>> surplus("77Q4+7X Austin, Texas, USA", surplus.Behaviour()) + + behaviour.query -> '77Q4+7X Austin, Texas, USA' ``` -- **variables `pcode` and `locality`** +- **variables `portion_plus_code` and `portion_locality`** - (_only shown if the query is a local code, not shown on full-length plus codes, + (_only shown if the query is a local code, not shown on full-length plus codes, latlong coordinates or string queries_) represents the plus code and locality portions of a [shortened plus code](https://en.wikipedia.org/wiki/Open_Location_Code#Common_usage_and_shortening) - (_referred to as a "short/local code" in the codebase_) respectively. + (_referred to as a "local code" in the codebase_) respectively -- **variables `lat` and `lon`** +- **variable `query`** + + query is a variable of type [`Result`](#class-result)[`[Query]`](#query) + + this variable is displayed to show what query type [`parse_query()`](#def-parse_query) has + recognised, and if there were any errors during query parsing + +- **expression `latlong.get()=`** (_only shown if the query is a plus code_) - the latitude longitude coordinates derived from the plus code. + the latitude longitude coordinates derived from the plus code - **variable `location`** - the response dictionary from the reverser passed to - [`surplus.surplus()`](#surplussurplus) + the response dictionary from the reverser function passed to + [`surplus()`](#def-surplus) + + for more information on the reverser function, see [`Behaviour`](#class-behaviour) and + [`default_reverser`](#def-default_reverser) - **variable `seen_names`** - a list of unique names/elements found in certain nominatim keys used in final output - lines 0-3. + a list of unique important names found in certain nominatim keys used in final output + lines 0-3 -- **variables for seen name checks** +- **`_generate_text_line` seen name checks** - the variables come from a check to reduce repeated elements found in `seen_names`. + ```text + # filter function boolean list status element + # ============================= ======== ====================== + debug: _generate_text_line: [True] -> True -------- 'Ngee Ann Polytechnic' + debug: _generate_text_line: [False, True] -> False filtered 'Singapore' + ``` - - **variable `d`** + a check is done on shareable text line 4 keys (`SHAREABLE_TEXT_LINE_4_KEYS` - general + regional location) to reduce repeated elements found in `seen_names` - current element in the iteration of the final output line 4 (general regional - location) nominatim keys + reasoning is, if an element on line 4 (general regional location) is the exact same as + a previously seen name, there is no need to include the element - - **variable `_dvmt4`** + - **filter function boolean list** - list used in an `all()` check to see if the current nominatim key (variable `d`) can - be wholly found in any of the seen names, in the general regional location, or in - the road name. + `_generate_text_line`, an internal function defined inside `_generate_text` can be + passed a filter function as a way to filter out certain elements on a line - reasoning is, if the previous lines wholly state the general regional location of the - query, there is no need to restate it. - - ``` - # psuedocode - _dvtm4 = [ - d != "", - d not in road, - d not in [output line 4 (general regional location) nominatim keys], - any(_dvcm4), + ```python + # the filter used in _generate_text, for line 4's seen name checks + filter=lambda ak: [ + # everything here should be True if the element is to be kept + ak not in general_global_info, + not any(True if (ak in sn) else False for sn in seen_names), ] ``` - - **variable `_dvcm4`** + `general_global_info` is a list of strings containing elements from line 6. (general + global information) - list used in an `any()` check to see if the current nominatim key (variable `d`) can - be wholly found in any of the seen names. + - **status** - ```python - _dvcm4 = [True if (d not in sn) else False for sn in seen_names] - ``` + what `all(filter(detail))` evaluates to, `filter` being the filter function passed to + `_generate_text_line` and `detail` being the current element -breakdown of each output line, accompanied by their nominatim key: + - **element** -``` + the current iteration from iterating through a list of strings containing elements + from line 4. (general regional location) + +line breakdown of shareable text output, accompanied by their Nominatim keys: + +```text 0 name of a place 1 building name 2 highway name @@ -312,7 +400,7 @@ breakdown of each output line, accompanied by their nominatim key: - examples - ``` + ```text The University of Queensland Ngee Ann Polytechnic Botanic Gardens @@ -320,9 +408,9 @@ breakdown of each output line, accompanied by their nominatim key: - nominatim keys - ``` - emergency, historic, military, natural, landuse, place, railway, man_made, - aerialway, boundary, amenity, aeroway, club, craft, leisure, office, mountain_pass, + ```text + emergency, historic, military, natural, landuse, place, railway, man_made, + aerialway, boundary, amenity, aeroway, club, craft, leisure, office, mountain_pass, shop, tourism, bridge, tunnel, waterway ``` @@ -330,14 +418,14 @@ breakdown of each output line, accompanied by their nominatim key: - examples - ``` + ```text Novena Square Office Tower A Visitor Centre ``` - nominatim keys - ``` + ```text building ``` @@ -345,14 +433,14 @@ breakdown of each output line, accompanied by their nominatim key: - examples - ``` + ```text Marina Coastal Expressway Lornie Highway ``` - nominatim keys - ``` + ```text highway ``` @@ -360,8 +448,7 @@ breakdown of each output line, accompanied by their nominatim key: - examples - ``` - 131 Toa Payoh Rise + ```text 535 Clementi Road Macquarie Street Braddell Road @@ -369,7 +456,7 @@ breakdown of each output line, accompanied by their nominatim key: - nominatim keys - ``` + ```text house_number, house_name, road ``` @@ -377,7 +464,7 @@ breakdown of each output line, accompanied by their nominatim key: - examples - ``` + ```text St Lucia, Greater Brisbane The Drag, Austin Toa Payoh Crest @@ -385,8 +472,8 @@ breakdown of each output line, accompanied by their nominatim key: - nominatim keys - ``` - residential, neighbourhood, allotments, quarter, city_district, district, borough, + ```text + residential, neighbourhood, allotments, quarter, city_district, district, borough, suburb, subdivision, municipality, city, town, village ``` @@ -394,7 +481,7 @@ breakdown of each output line, accompanied by their nominatim key: - examples - ``` + ```text 310131 78705 4066 @@ -402,7 +489,7 @@ breakdown of each output line, accompanied by their nominatim key: - nominatim key - ``` + ```text postcode ``` @@ -410,7 +497,7 @@ breakdown of each output line, accompanied by their nominatim key: - examples - ``` + ```text Travis County, Texas, United States Southeast, Singapore Queensland, Australia @@ -418,161 +505,571 @@ breakdown of each output line, accompanied by their nominatim key: - nominatim keys - ``` + ```text region, county, state, state_district, country, continent ``` ## api reference -### `surplus.surplus()` +- [constants](#constants) +- [exception classes](#exception-classes) +- [types](#types) + - [`Query`](#query) + - [`ResultType`](#resulttype) +- [`class Behaviour`](#class-behaviour) +- [`class ConversionResultTypeEnum`](#class-conversionresulttypeenum) +- [`class Result`](#class-result) + - [`Result.__bool__()`](#result__bool__) + - [`Result.cry()`](#resultcry) + - [`Result.get()`](#resultget) +- [`class Latlong`](#class-latlong) + - [`Latlong.__str__()`](#latlong__str__) +- [`class PlusCodeQuery`](#class-pluscodequery) + - [`PlusCodeQuery.to_lat_long_coord()`](#pluscodequeryto_lat_long_coord) + - [`PlusCodeQuery.__str__()`](#pluscodequery__str__) +- [`class LocalCodeQuery`](#class-localcodequery) + - [`LocalCodeQuery.to_full_plus_code()`](#localcodequeryto_full_plus_code) + - [`LocalCodeQuery.to_lat_long_coord()`](#localcodequeryto_lat_long_coord) + - [`LocalCodeQuery.__str__()`](#localcodequery__str__) +- [`class LatlongQuery`](#class-latlongquery) + - [`LatlongQuery.to_lat_long_coord()`](#latlongqueryto_lat_long_coord) + - [`LatlongQuery.__str__()`](#latlongquery__str__) +- [`class StringQuery`](#class-stringquery) + - [`StringQuery.to_lat_long_coord()`](#stringqueryto_lat_long_coord) + - [`StringQuery.__str__()`](#stringquery__str__) +- [`def surplus()`](#def-surplus) +- [`def parse_query()`](#def-parse_query) +- [`def default_geocoder()`](#def-default_geocoder) +- [`def default_reverser()`](#def-default_reverser) -pluscode to shareable text conversion function +### constants -- signature +- `VERSION: tuple[int, int, int]` + + a tuple of integers representing the version of surplus, in the format + `[major, minor, patch]` + +- `SHAREABLE_TEXT_LINE_0_KEYS: tuple[str, ...]` + `SHAREABLE_TEXT_LINE_1_KEYS: tuple[str, ...]` + `SHAREABLE_TEXT_LINE_2_KEYS: tuple[str, ...]` + `SHAREABLE_TEXT_LINE_3_KEYS: tuple[str, ...]` + `SHAREABLE_TEXT_LINE_4_KEYS: tuple[str, ...]` + `SHAREABLE_TEXT_LINE_5_KEYS: tuple[str, ...]` + `SHAREABLE_TEXT_LINE_6_KEYS: tuple[str, ...]` + + a tuple of strings containing nominatim keys used in shareable text line 0-6 + +- `SHAREABLE_TEXT_NAMES: tuple[str, ...]` + + a tuple of strings containing nominatim keys used in shareable text line 0-2 and + special keys in line 3 + +- `EMPTY_LATLONG: Latlong` + a constant for an empty latlong coordinate, with latitude and longitude set to 0.0 + +### exception classes + +- `class SurplusException(Exception)` + base skeleton exception for handling and typing surplus exception classes +- `class NoSuitableLocationError(SurplusException)` +- `class IncompletePlusCodeError(SurplusException)` +- `class PlusCodeNotFoundError(SurplusException)` +- `class LatlongParseError(SurplusException)` +- `class EmptyQueryError(SurplusException)` +- `class UnavailableFeatureError(SurplusException)` + +### types + +#### `Query` + +```python +Query: typing.TypeAlias = PlusCodeQuery | LocalCodeQuery | LatlongQuery | StringQuery +``` + +[type alias](https://docs.python.org/3/library/typing.html#type-aliases) representing +either a +[`PlusCodeQuery`](#class-pluscodequery), +[`LocalCodeQuery`](#class-localcodequery), +[`LatlongQuery`](#class-latlongquery) or +[`StringQuery`](#class-stringquery) + +#### `ResultType` + +```python +ResultType = TypeVar("ResultType") +``` + +[generic type](https://docs.python.org/3/library/typing.html#generics) used by +[`Result`](#class-result) + +### `class Behaviour` + +[`typing.NamedTuple`](https://docs.python.org/3/library/typing.html#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: typing.Callable[[str], Latlong] = default_geocoder` + name string to location function, must take in a string and return a + [`Latlong`](#class-latlong), exceptions are handled by the caller + +- `reverser: Callable[[Latlong], dict[str, Any]] = default_reverser` + [`Latlong`](#class-latlong) object to dictionary function, must take in a string and return a + dict. keys found in SHAREABLE_TEXT_LINE_*_KEYS used to access address details are placed + top-level in the dict, exceptions are handled by the caller. + see the [playground notebook](playground.ipynb) for example output + +- `stderr: typing.TextIO = sys.stderr` + [TextIO-like object](https://docs.python.org/3/library/io.html#text-i-o) + representing a writeable file. + defaults to [`sys.stderr`](https://docs.python.org/3/library/sys.html#sys.stderr). + +- `stdout: typing.TextIO = sys.stdout` + [TextIO-like object](https://docs.python.org/3/library/io.html#text-i-o) + representing a writeable file. + defaults to [`sys.stdout`](https://docs.python.org/3/library/sys.html#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 + +### `class ConversionResultTypeEnum` + +[enum.Enum](https://docs.python.org/3/library/enum.html) +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`](https://docs.python.org/3/library/typing.html#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 + +```python +# 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 + +- [`def __bool__(self) -> bool: ...`](#result__bool__) +- [`def cry(self, string: bool = False) -> str: ...`](#resultcry) +- [`def get(self) -> ResultType: ...`](#resultget) + +#### `Result.__bool__()` + +method that returns `True` if `self.error` is not `None` + +- signature ```python - def surplus( - query: str | Localcode | Latlong, - reverser: typing.Callable = geopy.geocoders.Nominatim(user_agent="surplus").reverse, - debug: bool = False, - ) -> tuple[bool, str]: - ... + 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 + + ```python + def cry(self, string: bool = False) -> str: ... ``` - arguments - - `query: str | surplus.Localcode | surplus.Latlong` - - str - normal longcode (6PH58QMF+FX) - - [`surplus.Localcode`](#surpluslocalcode) - shortcode with locality (8QMF+FX Singapore) - - [`surplus.Latlong`](#surpluslatlong) - latlong + - `string: bool = False` + if `self.error` is an Exception, returns it as a string error message - - `reverser: typing.Callable = geopy.geocoders.Nominatim(user_agent="surplus").reverser` - latlong to location function, accesses a dict from .raw attribute of return object - function should be able to take a string with two floats and return a `geopy.Location`-like object (None checking is done) +- returns `str` - ```python - # code used by surplus - location: dict[str, Any] = reverser(f"{lat}, {lon}").raw - ``` +#### `Result.get()` - dict should be similar to [nominatim raw dicts](https://nominatim.org/release-docs/latest/api/Output/#addressdetails) +method that returns `self.value` if Result is non-erroneous else raises error - - `debug: bool = False` - prints lat, long and reverser response dict to stderr +- signature -- returns `tuple[bool, str]` + ```python + def get(self) -> ResultType: ... + ``` - - `(True, )` - conversion succeeded, second element is the resultant string - - `(False, )` - conversion failed, second element is an error message string +- returns `self.value` ---- +### `class Latlong` -### `surplus.parse_query()` +[`typing.NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple) +representing a latitude-longitude coordinate pair -function that parses a string Plus Code, local code or latlong into a str, [`surplus.Localcode`](#surpluslocalcode) or [`surplus.Latlong`](#surpluslatlong) respectively +attributes -- signature: +- `latitude: float` +- `longitude: float` - ```python - def parse_query( - query: str, debug: bool = False - ) -> tuple[typing.Literal[True], str | Localcode | Latlong] | tuple[typing.Literal[False], str]: - ``` +methods -- arguments: +- [`def __str__(self) -> str: ...`](#latlong__str__) - - `query: str` - string Plus Code, local code or latlong +#### `Latlong.__str__()` -- returns `tuple[typing.Literal[True], str | Localcode | Latlong] | tuple[typing.Literal[False], str]` +method that returns a comma-and-space-seperated string of `self.latitude` and +`self.longitude` - - `(True, )` - conversion succeeded, second element is resultant Plus code string, [`surplus.Localcode`](#surpluslocalcode) or [`surplus.Latlong`](#surpluslatlong) - - `(False, )` - conversion failed, second element is an error message string +- signature -### `surplus.handle_query()` + ```python + def __str__(self) -> str: ... + ``` -function that gets returns a [surplus.Latlong](#surpluslatlong) from a Plus Code string, [`surplus.Localcode`](#surpluslocalcode) or [`surplus.Latlong`](#surpluslatlong) object. -used after [`surplus.parse_query()`](#surplusparse_query). +- returns `str` -- signature: +### `class PlusCodeQuery` - ```python - def handle_query( - query: str | Localcode | Latlong, debug: bool = False - ) -> tuple[typing.Literal[True], Latlong] | tuple[typing.Literal[False], str]: - ``` +[`typing.NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple) +representing a full-length Plus Code (e.g., 6PH58QMF+FX) -- arguments: +attributes - - `query: str | Localcode | Latlong` - - str - normal longcode (6PH58QMF+FX) - - [`surplus.Localcode`](#surpluslocalcode) - shortcode with locality (8QMF+FX Singapore) - - [`surplus.Latlong`](#surpluslatlong) - latlong +- `code: str` -- returns `tuple[typing.Literal[True], Latlong] | tuple[typing.Literal[False], str]` - - `(True, )` - conversion succeeded, second element is a [`surplus.Latlong`](#surpluslatlong) - - `(False, )` - conversion failed, second element is an error message string +methods -### `surplus.Localcode` +- [`def to_lat_long_coord(self, ...) -> Result[Latlong]: ...`](#pluscodequeryto_lat_long_coord) +- [`def __str__(self) -> str: ...`](#pluscodequery__str__) -`typing.NamedTuple` representing short Plus Code with locality +#### `PlusCodeQuery.to_lat_long_coord()` -- parameters: +- signature - - `code: str` - Plus Code - e.g.: `"8QMF+FX"` - - `locality: str` - e.g.: `"Singapore"` + ```python + def to_lat_long_coord(self, geocoder: Callable[[str], Latlong]) -> Result[Latlong]: + ... + ``` -#### `surplus.Localcode.full_length()` +- arguments -method that calculates full-length Plus Code using locality + - `geocoder: typing.Callable[[str], Latlong]` + name string to location function, must take in a string and return a + [`Latlong`](#class-latlong), exceptions are handled by the caller -- signature: +- returns [`Result`](#class-result)[`[Latlong]`](#class-latlong) - ```python - def full_length( - self, geocoder: Callable = Nominatim(user_agent="surplus").geocode - ) -> tuple[bool, str]: - ``` +#### `PlusCodeQuery.__str__()` -- arguments: +method that returns string representation of query - - `geocoder: typing.Callable = geopy.geocoders.Nominatim(user_agent="surplus").geocode` - place/locality to location function, accesses .longitude and .latitude if returned object is not None +- signature -- returns: + ```python + def __str__(self) -> str: ... + ``` - - `(True, )` - conversion succeeded, second element is the resultant Plus Code string - - `(False, )` - conversion failed, second element is an error message string +- returns `str` -#### `surplus.Latlong` +### `class LocalCodeQuery` -`typing.NamedTuple` representing a pair of latitude and longitude coordinates +[`typing.NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple) +representing a +[shortened Plus Code](https://en.wikipedia.org/wiki/Open_Location_Code#Common_usage_and_shortening) +with locality, referred to by surplus as a "local code" -- parameters: +attributes - - `lat: float` - latitudinal coordinate - - `long: float` - longitudinal coordinate +- `code: str` + Plus Code portion of local code, e.g., "8QMF+FX" + +- `locality: str` + remaining string of local code, e.g., "Singapore" + +methods + +- [`def to_full_plus_code(self, ...) -> Result[str]: ...`](#localcodequeryto_full_plus_code) +- [`def to_lat_long_coord(self, ...) -> Result[Latlong]: ...`](#localcodequeryto_lat_long_coord) +- [`def __str__(self) -> str: ...`](#localcodequery__str__) + +#### `LocalCodeQuery.to_full_plus_code()` + +exclusive method that returns a full-length Plus Code as a string + +- signature + + ```python + def to_full_plus_code(self, geocoder: Callable[[str], Latlong]) -> Result[str]: + ... + ``` + +- arguments + + - `geocoder: typing.Callable[[str], Latlong]` + name string to location function, must take in a string and return a + [`Latlong`](#class-latlong), exceptions are handled by the caller + +- returns [`Result`](#class-result)`[str]` + +#### `LocalCodeQuery.to_lat_long_coord()` + +method that returns a latitude-longitude coordinate pair + +- signature + + ```python + def to_lat_long_coord(self, geocoder: Callable[[str], Latlong]) -> Result[Latlong]: + ... + ``` + +- arguments + + - `geocoder: typing.Callable[[str], Latlong]` + name string to location function, must take in a string and return a + [`Latlong`](#class-latlong), exceptions are handled by the caller + +- returns [`Result`](#class-result)[`[Latlong]`](#class-latlong) + +#### `LocalCodeQuery.__str__()` + +method that returns string representation of query + +- signature + + ```python + def __str__(self) -> str: ... + ``` + +- returns `str` + +### `class LatlongQuery` + +[`typing.NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple) +representing a latitude-longitude coordinate pair + +attributes + +- `latlong: Latlong` + +methods + +- [`def to_lat_long_coord(self, ...) -> Result[Latlong]: ...`](#latlongqueryto_lat_long_coord) +- [`def __str__(self) -> str: ...`](#latlongquery__str__) + +#### `LatlongQuery.to_lat_long_coord()` + +method that returns a latitude-longitude coordinate pair + +- signature + + ```python + def to_lat_long_coord(self, geocoder: Callable[[str], Latlong]) -> Result[Latlong]: + ... + ``` + +- arguments + + - `geocoder: typing.Callable[[str], Latlong]` + name string to location function, must take in a string and return a + [`Latlong`](#class-latlong), exceptions are handled by the caller + +- returns [`Result`](#class-result)[`[Latlong]`](#class-latlong) + +#### `LatlongQuery.__str__()` + +method that returns string representation of query + +- signature + + ```python + def __str__(self) -> str: ... + ``` + +- returns `str` + +### `class StringQuery` + +[`typing.NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple) +representing a pure string query + +attributes + +- `query: str` + +methods + +- [`def to_lat_long_coord(self, ...) -> Result[Latlong]: ...`](#stringqueryto_lat_long_coord) +- [`def __str__(self) -> str: ...`](#stringquery__str__) + +#### `StringQuery.to_lat_long_coord()` + +method that returns a latitude-longitude coordinate pair + +- signature + + ```python + def to_lat_long_coord(self, geocoder: Callable[[str], Latlong]) -> Result[Latlong]: + ... + ``` + +- arguments + + - `geocoder: typing.Callable[[str], Latlong]` + name string to location function, must take in a string and return a + [`Latlong`](#class-latlong), exceptions are handled by the caller + +- returns [`Result`](#class-result)[`[Latlong]`](#class-latlong) + +#### `StringQuery.__str__()` + +method that returns string representation of query + +- signature + + ```python + def __str__(self) -> str: ... + ``` + +- returns `str` + +### `def surplus()` + +query to shareable text conversion function + +- signature + + ```python + def surplus(query: Query | str, behaviour: Behaviour) -> Result[str]: .. + ``` + +- arguments + + - `query: str | Query` + [query object](#query) to convert or string to attempt to query for then convert + + - `behaviour: Behaviour` + [surplus behaviour namedtuple](#class-behaviour) + +- returns [`Result`](#class-result)`[str]` + +### `def parse_query()` + +function that parses a query string into a query object + +- signature + + ```python + def parse_query(behaviour: Behaviour) -> Result[Query]: ... + ``` + +- arguments + + - `behaviour: Behaviour` + [surplus behaviour namedtuple](#class-behaviour) + +- returns [`Result`](#class-result)[`[Query]`](#query) + +### `def default_geocoder()` + +default geocoder for surplus, uses OpenStreetMap Nominatim + +> [!NOTE] +> function is not used by surplus and not directly by the user, but is exposed for +> convenience being [Behaviour](#class-behaviour) objects. +> pass in a custom function to [Behaviour](#class-behaviour) to override the default reverser. + +- signature + + ```python + def default_geocoder(place: str) -> Latlong: + ``` + +### `def default_reverser()` + +default reverser for surplus, uses OpenStreetMap Nominatim + +> [!NOTE] +> function is not used by surplus and not directly by the user, but is exposed for +> convenience being [Behaviour](#class-behaviour) objects. +> pass in a custom function to [Behaviour](#class-behaviour) to override the default reverser. + +- signature + + ```python + def default_reverser(latlong: Latlong) -> dict[str, Any]: + ``` ## licence surplus is free and unencumbered software released into the public domain. for more information, please refer to the [UNLICENCE](/UNLICENCE), , or the python module docstring. + +however, direct dependencies of surplus are licensed under different, but still permissive +and open-source licences. + +```text +geopy 2.4.0 Python Geocoding Toolbox +└── geographiclib >=1.52,<3 +pluscodes 2022.1.3 Compute Plus Codes (Open Location Codes). +``` + +- [geopy](https://pypi.org/project/geopy/): + Python Geocoding Toolbox + + MIT License + + - [geographiclib](https://pypi.org/project/geographiclib/): + The geodesic routines from GeographicLib + + MIT License + +- [pluscodes](https://pypi.org/project/pluscodes/): + Compute Plus Codes (Open Location Codes) + + Apache 2.0 diff --git a/devbox.json b/devbox.json index 1dab9c9..63a0c48 100644 --- a/devbox.json +++ b/devbox.json @@ -1,12 +1,16 @@ { "packages": [ "python311", + "python311Packages.ipykernel", "poetry" ], "shell": { - "init_hook": "poetry shell" + "init_hook": [ + "poetry env use $(which python)", + "poetry shell" + ] }, "nixpkgs": { "commit": "f80ac848e3d6f0c12c52758c0f25c10c97ca3b62" } -} \ No newline at end of file +} diff --git a/devbox.lock b/devbox.lock index baa25f4..7e3b009 100644 --- a/devbox.lock +++ b/devbox.lock @@ -7,6 +7,9 @@ "python311": { "plugin_version": "0.0.1", "resolved": "github:NixOS/nixpkgs/f80ac848e3d6f0c12c52758c0f25c10c97ca3b62#python311" + }, + "python311Packages.ipykernel": { + "resolved": "github:NixOS/nixpkgs/f80ac848e3d6f0c12c52758c0f25c10c97ca3b62#python311Packages.ipykernel" } } } \ No newline at end of file diff --git a/playground.ipynb b/playground.ipynb new file mode 100644 index 0000000..06156dd --- /dev/null +++ b/playground.ipynb @@ -0,0 +1,373 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# surplus 2.0.0 playground notebook\n", + "\n", + "wrangling with environments for devbox users using codium/vs code:\n", + "\n", + "```text\n", + "$ devbox shell # enter devbox env\n", + "(surplus-py3.11) (devbox) $ exit # leave poetry env\n", + "(devbox) $ codium . # open ide\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "OUTPUT_LINE_X_KEYS: Final[tuple[str, ...]] = (\"region\",\"county\",\"state\",\"state_district\",\"country\",\"continent\",)\n" + ] + } + ], + "source": [ + "# converting nominatim keys to OUTPUT_LINE_X_KEYS format\n", + "\n", + "keys = \"\"\"\n", + "region, county, state, state_district, country, continent\n", + "\"\"\"\n", + "\n", + "split_keys = [f'\"{key.strip()}\"' for key in keys.strip().split(\",\")]\n", + "\n", + "print(f\"OUTPUT_LINE_X_KEYS: Final[tuple[str, ...]] = ({','.join(split_keys)},)\")" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "from surplus import PlusCodeQuery, LocalCodeQuery, LatlongQuery, StringQuery\n", + "from surplus import Latlong, Result\n", + "from surplus import default_geocoder, default_reverser" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Generic Result NamedTuple" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "True\tNone \t3\n", + "False\tZeroDivisionError('division by zero') \tdivision by zero (ZeroDivisionError)\n" + ] + }, + { + "ename": "ZeroDivisionError", + "evalue": "division by zero", + "output_type": "error", + "traceback": [ + "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[0;31mZeroDivisionError\u001b[0m Traceback (most recent call last)", + "\u001b[1;32m/home/m/works/surplus/playground.ipynb Cell 5\u001b[0m in \u001b[0;36m\u001b[0;34m()\u001b[0m\n\u001b[1;32m 8\u001b[0m \u001b[39mprint\u001b[39m(\u001b[39m\"\u001b[39m\u001b[39m{}\u001b[39;00m\u001b[39m\\t\u001b[39;00m\u001b[39m{:<40}\u001b[39;00m\u001b[39m\\t\u001b[39;00m\u001b[39m{}\u001b[39;00m\u001b[39m\"\u001b[39m\u001b[39m.\u001b[39mformat(\u001b[39mbool\u001b[39m(nom_result), \u001b[39mrepr\u001b[39m(nom_result\u001b[39m.\u001b[39merror), nom_result\u001b[39m.\u001b[39mget()))\n\u001b[1;32m 9\u001b[0m \u001b[39mprint\u001b[39m(\n\u001b[1;32m 10\u001b[0m \u001b[39m\"\u001b[39m\u001b[39m{}\u001b[39;00m\u001b[39m\\t\u001b[39;00m\u001b[39m{:<40}\u001b[39;00m\u001b[39m\\t\u001b[39;00m\u001b[39m{}\u001b[39;00m\u001b[39m\"\u001b[39m\u001b[39m.\u001b[39mformat(\n\u001b[1;32m 11\u001b[0m \u001b[39mbool\u001b[39m(exc_result), \u001b[39mrepr\u001b[39m(exc_result\u001b[39m.\u001b[39merror), exc_result\u001b[39m.\u001b[39mcry(string\u001b[39m=\u001b[39m\u001b[39mTrue\u001b[39;00m)\n\u001b[1;32m 12\u001b[0m )\n\u001b[1;32m 13\u001b[0m )\n\u001b[0;32m---> 14\u001b[0m \u001b[39mprint\u001b[39m(\u001b[39m\"\u001b[39m\u001b[39m{}\u001b[39;00m\u001b[39m\\t\u001b[39;00m\u001b[39m{:<40}\u001b[39;00m\u001b[39m\\t\u001b[39;00m\u001b[39m{}\u001b[39;00m\u001b[39m\"\u001b[39m\u001b[39m.\u001b[39mformat(\u001b[39mbool\u001b[39m(exc_result), \u001b[39mrepr\u001b[39m(exc_result\u001b[39m.\u001b[39merror), exc_result\u001b[39m.\u001b[39;49mget()))\n", + "File \u001b[0;32m~/works/surplus/surplus.py:247\u001b[0m, in \u001b[0;36mResult.get\u001b[0;34m(self)\u001b[0m\n\u001b[1;32m 245\u001b[0m \u001b[39m\"\"\"method that returns self.value if Result is non-erroneous else raises error\"\"\"\u001b[39;00m\n\u001b[1;32m 246\u001b[0m \u001b[39mif\u001b[39;00m \u001b[39misinstance\u001b[39m(\u001b[39mself\u001b[39m\u001b[39m.\u001b[39merror, \u001b[39mBaseException\u001b[39;00m):\n\u001b[0;32m--> 247\u001b[0m \u001b[39mraise\u001b[39;00m \u001b[39mself\u001b[39m\u001b[39m.\u001b[39merror\n\u001b[1;32m 248\u001b[0m \u001b[39mreturn\u001b[39;00m \u001b[39mself\u001b[39m\u001b[39m.\u001b[39mvalue\n", + "\u001b[1;32m/home/m/works/surplus/playground.ipynb Cell 5\u001b[0m in \u001b[0;36m\u001b[0;34m()\u001b[0m\n\u001b[1;32m 1\u001b[0m nom_result \u001b[39m=\u001b[39m Result[\u001b[39mint\u001b[39m](\u001b[39m3\u001b[39m)\n\u001b[1;32m 3\u001b[0m \u001b[39mtry\u001b[39;00m:\n\u001b[0;32m----> 4\u001b[0m \u001b[39m1\u001b[39;49m \u001b[39m/\u001b[39;49m \u001b[39m0\u001b[39;49m\n\u001b[1;32m 5\u001b[0m \u001b[39mexcept\u001b[39;00m \u001b[39mException\u001b[39;00m \u001b[39mas\u001b[39;00m exc:\n\u001b[1;32m 6\u001b[0m exc_result \u001b[39m=\u001b[39m Result[\u001b[39mint\u001b[39m](\u001b[39m-\u001b[39m\u001b[39m1\u001b[39m, error\u001b[39m=\u001b[39mexc)\n", + "\u001b[0;31mZeroDivisionError\u001b[0m: division by zero" + ] + } + ], + "source": [ + "nom_result = Result[int](3)\n", + "\n", + "try:\n", + " 1 / 0\n", + "except Exception as exc:\n", + " exc_result = Result[int](-1, error=exc)\n", + "\n", + "print(\"{}\\t{:<40}\\t{}\".format(bool(nom_result), repr(nom_result.error), nom_result.get()))\n", + "print(\n", + " \"{}\\t{:<40}\\t{}\".format(\n", + " bool(exc_result), repr(exc_result.error), exc_result.cry(string=True)\n", + " )\n", + ")\n", + "print(\"{}\\t{:<40}\\t{}\".format(bool(exc_result), repr(exc_result.error), exc_result.get()))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Query Types" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "Result(value=Latlong(latitude=1.3336875, longitude=103.7746875), error=None)" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "PlusCodeQuery(code=\"6PH58QMF+FV\").to_lat_long_coord(geocoder=default_geocoder)" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "Result(value=Latlong(latitude=1.3336875, longitude=103.7746875), error=None)" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "plus_code = LocalCodeQuery(code=\"8QMF+FV\", locality=\"Singapore\").to_full_plus_code(\n", + " geocoder=default_geocoder\n", + ")\n", + "\n", + "PlusCodeQuery(code=plus_code.get()).to_lat_long_coord(geocoder=default_geocoder)" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "Result(value=Latlong(latitude=1.3336875, longitude=103.7746875), error=None)" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "LocalCodeQuery(code=\"8QMF+FV\", locality=\"Singapore\").to_lat_long_coord(\n", + " geocoder=default_geocoder\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "Result(value=Latlong(latitude=1.33318835, longitude=103.77461234638255), error=None)" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "LatlongQuery(\n", + " latlong=Latlong(latitude=1.33318835, longitude=103.77461234638255)\n", + ").to_lat_long_coord(geocoder=default_geocoder)" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "Result(value=Latlong(latitude=1.33318835, longitude=103.77461234638255), error=None)" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "StringQuery(query=\"Ngee Ann Polytechnic\").to_lat_long_coord(geocoder=default_geocoder)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## return dictionary of `reverser` function\n", + "\n", + "all the necessary keys (see `SHAREABLE_TEXT_LINE_*` constants) should be at the top-level of the dictionary.\n", + "these keys will be casted into strings for safety guarantee when shareable text lines are being generated.\n", + "\n", + "while not necessary, consider keeping the original response dict under the \"raw\" key.\n", + "helps with debugging using `-d/--debug`!" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "reverser_return = {\n", + " \"amenity\": \"Ngee Ann Polytechnic\",\n", + " \"house_number\": \"535\",\n", + " \"road\": \"Clementi Road\",\n", + " \"suburb\": \"Bukit Timah\",\n", + " \"city\": \"Singapore\",\n", + " \"county\": \"Northwest\",\n", + " \"ISO3166-2-lvl6\": \"SG-03\",\n", + " \"postcode\": \"599489\",\n", + " \"country\": \"Singapore\",\n", + " \"country_code\": \"sg\",\n", + " \"raw\": {\n", + " \"place_id\": 297946059,\n", + " \"licence\": \"Data © OpenStreetMap contributors, ODbL 1.0. http://osm.org/copyright\",\n", + " \"osm_type\": \"relation\",\n", + " \"osm_id\": 2535118,\n", + " \"lat\": \"1.33318835\",\n", + " \"lon\": \"103.77461234638255\",\n", + " \"class\": \"amenity\",\n", + " \"type\": \"university\",\n", + " \"place_rank\": 30,\n", + " \"importance\": 0.34662169301918117,\n", + " \"addresstype\": \"amenity\",\n", + " \"name\": \"Ngee Ann Polytechnic\",\n", + " \"display_name\": \"Ngee Ann Polytechnic, 535, Clementi Road, Bukit Timah, Singapore, Northwest, 599489, Singapore\",\n", + " \"address\": {\n", + " \"amenity\": \"Ngee Ann Polytechnic\",\n", + " \"house_number\": \"535\",\n", + " \"road\": \"Clementi Road\",\n", + " \"suburb\": \"Bukit Timah\",\n", + " \"city\": \"Singapore\",\n", + " \"county\": \"Northwest\",\n", + " \"ISO3166-2-lvl6\": \"SG-03\",\n", + " \"postcode\": \"599489\",\n", + " \"country\": \"Singapore\",\n", + " \"country_code\": \"sg\",\n", + " },\n", + " \"boundingbox\": [\"1.3289692\", \"1.3372184\", \"103.7701481\", \"103.7783945\"],\n", + " },\n", + " \"latitude\": 1.33318835,\n", + " \"longitude\": 103.77461234638255,\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'ISO3166-2-lvl6': 'SG-03',\n", + " 'amenity': 'Ngee Ann Polytechnic',\n", + " 'city': 'Singapore',\n", + " 'country': 'Singapore',\n", + " 'country_code': 'sg',\n", + " 'county': 'Northwest',\n", + " 'house_number': '535',\n", + " 'latitude': 1.33318835,\n", + " 'longitude': 103.77461234638255,\n", + " 'postcode': '599489',\n", + " 'raw': {'address': {'ISO3166-2-lvl6': 'SG-03',\n", + " 'amenity': 'Ngee Ann Polytechnic',\n", + " 'city': 'Singapore',\n", + " 'country': 'Singapore',\n", + " 'country_code': 'sg',\n", + " 'county': 'Northwest',\n", + " 'house_number': '535',\n", + " 'postcode': '599489',\n", + " 'road': 'Clementi Road',\n", + " 'suburb': 'Bukit Timah'},\n", + " 'addresstype': 'amenity',\n", + " 'boundingbox': ['1.3289692',\n", + " '1.3372184',\n", + " '103.7701481',\n", + " '103.7783945'],\n", + " 'class': 'amenity',\n", + " 'display_name': 'Ngee Ann Polytechnic, 535, Clementi Road, Bukit '\n", + " 'Timah, Singapore, Northwest, 599489, Singapore',\n", + " 'importance': 0.34662169301918117,\n", + " 'lat': '1.33318835',\n", + " 'licence': 'Data © OpenStreetMap contributors, ODbL 1.0. '\n", + " 'http://osm.org/copyright',\n", + " 'lon': '103.77461234638255',\n", + " 'name': 'Ngee Ann Polytechnic',\n", + " 'osm_id': 2535118,\n", + " 'osm_type': 'relation',\n", + " 'place_id': 297946059,\n", + " 'place_rank': 30,\n", + " 'type': 'university'},\n", + " 'road': 'Clementi Road',\n", + " 'suburb': 'Bukit Timah'}\n" + ] + } + ], + "source": [ + "import pprint\n", + "\n", + "latlong = LocalCodeQuery(code=\"8QMF+FV\", locality=\"Singapore\").to_lat_long_coord(\n", + " default_geocoder\n", + ")\n", + "if not latlong:\n", + " latlong.cry()\n", + "\n", + "else:\n", + " location = default_reverser(latlong.get())\n", + " pprint.pprint(location)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.1" + }, + "orig_nbformat": 4 + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/poetry.lock b/poetry.lock index b0a3f97..9debda9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,47 +1,87 @@ # This file is automatically @generated by Poetry and should not be changed by hand. +[[package]] +name = "appnope" +version = "0.1.3" +description = "Disable App Nap on macOS >= 10.9" +category = "dev" +optional = false +python-versions = "*" +files = [ + {file = "appnope-0.1.3-py2.py3-none-any.whl", hash = "sha256:265a455292d0bd8a72453494fa24df5a11eb18373a60c7c0430889f22548605e"}, + {file = "appnope-0.1.3.tar.gz", hash = "sha256:02bd91c4de869fbb1e1c50aafc4098827a7a54ab2f39d9dcba6c9547ed920e24"}, +] + +[[package]] +name = "asttokens" +version = "2.3.0" +description = "Annotate AST trees with source code positions" +category = "dev" +optional = false +python-versions = "*" +files = [ + {file = "asttokens-2.3.0-py2.py3-none-any.whl", hash = "sha256:bef1a51bc256d349e9f94e7e40e44b705ed1162f55294220dd561d24583d9877"}, + {file = "asttokens-2.3.0.tar.gz", hash = "sha256:2552a88626aaa7f0f299f871479fc755bd4e7c11e89078965e928fb7bb9a6afe"}, +] + +[package.dependencies] +six = ">=1.12.0" + +[package.extras] +test = ["astroid", "pytest"] + +[[package]] +name = "backcall" +version = "0.2.0" +description = "Specifications for callback functions passed in to an API" +category = "dev" +optional = false +python-versions = "*" +files = [ + {file = "backcall-0.2.0-py2.py3-none-any.whl", hash = "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255"}, + {file = "backcall-0.2.0.tar.gz", hash = "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e"}, +] + [[package]] name = "black" -version = "23.3.0" +version = "23.7.0" description = "The uncompromising code formatter." category = "dev" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "black-23.3.0-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:0945e13506be58bf7db93ee5853243eb368ace1c08a24c65ce108986eac65915"}, - {file = "black-23.3.0-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:67de8d0c209eb5b330cce2469503de11bca4085880d62f1628bd9972cc3366b9"}, - {file = "black-23.3.0-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:7c3eb7cea23904399866c55826b31c1f55bbcd3890ce22ff70466b907b6775c2"}, - {file = "black-23.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32daa9783106c28815d05b724238e30718f34155653d4d6e125dc7daec8e260c"}, - {file = "black-23.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:35d1381d7a22cc5b2be2f72c7dfdae4072a3336060635718cc7e1ede24221d6c"}, - {file = "black-23.3.0-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:a8a968125d0a6a404842fa1bf0b349a568634f856aa08ffaff40ae0dfa52e7c6"}, - {file = "black-23.3.0-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:c7ab5790333c448903c4b721b59c0d80b11fe5e9803d8703e84dcb8da56fec1b"}, - {file = "black-23.3.0-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:a6f6886c9869d4daae2d1715ce34a19bbc4b95006d20ed785ca00fa03cba312d"}, - {file = "black-23.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f3c333ea1dd6771b2d3777482429864f8e258899f6ff05826c3a4fcc5ce3f70"}, - {file = "black-23.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:11c410f71b876f961d1de77b9699ad19f939094c3a677323f43d7a29855fe326"}, - {file = "black-23.3.0-cp37-cp37m-macosx_10_16_x86_64.whl", hash = "sha256:1d06691f1eb8de91cd1b322f21e3bfc9efe0c7ca1f0e1eb1db44ea367dff656b"}, - {file = "black-23.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50cb33cac881766a5cd9913e10ff75b1e8eb71babf4c7104f2e9c52da1fb7de2"}, - {file = "black-23.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:e114420bf26b90d4b9daa597351337762b63039752bdf72bf361364c1aa05925"}, - {file = "black-23.3.0-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:48f9d345675bb7fbc3dd85821b12487e1b9a75242028adad0333ce36ed2a6d27"}, - {file = "black-23.3.0-cp38-cp38-macosx_10_16_universal2.whl", hash = "sha256:714290490c18fb0126baa0fca0a54ee795f7502b44177e1ce7624ba1c00f2331"}, - {file = "black-23.3.0-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:064101748afa12ad2291c2b91c960be28b817c0c7eaa35bec09cc63aa56493c5"}, - {file = "black-23.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:562bd3a70495facf56814293149e51aa1be9931567474993c7942ff7d3533961"}, - {file = "black-23.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:e198cf27888ad6f4ff331ca1c48ffc038848ea9f031a3b40ba36aced7e22f2c8"}, - {file = "black-23.3.0-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:3238f2aacf827d18d26db07524e44741233ae09a584273aa059066d644ca7b30"}, - {file = "black-23.3.0-cp39-cp39-macosx_10_16_universal2.whl", hash = "sha256:f0bd2f4a58d6666500542b26354978218a9babcdc972722f4bf90779524515f3"}, - {file = "black-23.3.0-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:92c543f6854c28a3c7f39f4d9b7694f9a6eb9d3c5e2ece488c327b6e7ea9b266"}, - {file = "black-23.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a150542a204124ed00683f0db1f5cf1c2aaaa9cc3495b7a3b5976fb136090ab"}, - {file = "black-23.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:6b39abdfb402002b8a7d030ccc85cf5afff64ee90fa4c5aebc531e3ad0175ddb"}, - {file = "black-23.3.0-py3-none-any.whl", hash = "sha256:ec751418022185b0c1bb7d7736e6933d40bbb14c14a0abcf9123d1b159f98dd4"}, - {file = "black-23.3.0.tar.gz", hash = "sha256:1c7b8d606e728a41ea1ccbd7264677e494e87cf630e399262ced92d4a8dac940"}, + {file = "black-23.7.0-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:5c4bc552ab52f6c1c506ccae05681fab58c3f72d59ae6e6639e8885e94fe2587"}, + {file = "black-23.7.0-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:552513d5cd5694590d7ef6f46e1767a4df9af168d449ff767b13b084c020e63f"}, + {file = "black-23.7.0-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:86cee259349b4448adb4ef9b204bb4467aae74a386bce85d56ba4f5dc0da27be"}, + {file = "black-23.7.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:501387a9edcb75d7ae8a4412bb8749900386eaef258f1aefab18adddea1936bc"}, + {file = "black-23.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb074d8b213749fa1d077d630db0d5f8cc3b2ae63587ad4116e8a436e9bbe995"}, + {file = "black-23.7.0-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:b5b0ee6d96b345a8b420100b7d71ebfdd19fab5e8301aff48ec270042cd40ac2"}, + {file = "black-23.7.0-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:893695a76b140881531062d48476ebe4a48f5d1e9388177e175d76234ca247cd"}, + {file = "black-23.7.0-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:c333286dc3ddca6fdff74670b911cccedacb4ef0a60b34e491b8a67c833b343a"}, + {file = "black-23.7.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:831d8f54c3a8c8cf55f64d0422ee875eecac26f5f649fb6c1df65316b67c8926"}, + {file = "black-23.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:7f3bf2dec7d541b4619b8ce526bda74a6b0bffc480a163fed32eb8b3c9aed8ad"}, + {file = "black-23.7.0-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:f9062af71c59c004cd519e2fb8f5d25d39e46d3af011b41ab43b9c74e27e236f"}, + {file = "black-23.7.0-cp38-cp38-macosx_10_16_universal2.whl", hash = "sha256:01ede61aac8c154b55f35301fac3e730baf0c9cf8120f65a9cd61a81cfb4a0c3"}, + {file = "black-23.7.0-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:327a8c2550ddc573b51e2c352adb88143464bb9d92c10416feb86b0f5aee5ff6"}, + {file = "black-23.7.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d1c6022b86f83b632d06f2b02774134def5d4d4f1dac8bef16d90cda18ba28a"}, + {file = "black-23.7.0-cp38-cp38-win_amd64.whl", hash = "sha256:27eb7a0c71604d5de083757fbdb245b1a4fae60e9596514c6ec497eb63f95320"}, + {file = "black-23.7.0-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:8417dbd2f57b5701492cd46edcecc4f9208dc75529bcf76c514864e48da867d9"}, + {file = "black-23.7.0-cp39-cp39-macosx_10_16_universal2.whl", hash = "sha256:47e56d83aad53ca140da0af87678fb38e44fd6bc0af71eebab2d1f59b1acf1d3"}, + {file = "black-23.7.0-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:25cc308838fe71f7065df53aedd20327969d05671bac95b38fdf37ebe70ac087"}, + {file = "black-23.7.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:642496b675095d423f9b8448243336f8ec71c9d4d57ec17bf795b67f08132a91"}, + {file = "black-23.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:ad0014efc7acf0bd745792bd0d8857413652979200ab924fbf239062adc12491"}, + {file = "black-23.7.0-py3-none-any.whl", hash = "sha256:9fd59d418c60c0348505f2ddf9609c1e1de8e7493eab96198fc89d9f865e7a96"}, + {file = "black-23.7.0.tar.gz", hash = "sha256:022a582720b0d9480ed82576c920a8c1dde97cc38ff11d8d8859b3bd6ca9eedb"}, ] [package.dependencies] click = ">=8.0.0" +ipython = {version = ">=7.8.0", optional = true, markers = "extra == \"jupyter\""} mypy-extensions = ">=0.4.3" packaging = ">=22.0" pathspec = ">=0.9.0" platformdirs = ">=2" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +tokenize-rt = {version = ">=3.2.0", optional = true, markers = "extra == \"jupyter\""} [package.extras] colorama = ["colorama (>=0.4.3)"] @@ -51,14 +91,14 @@ uvloop = ["uvloop (>=0.15.2)"] [[package]] name = "click" -version = "8.1.3" +version = "8.1.7" description = "Composable command line interface toolkit" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, - {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, ] [package.dependencies] @@ -76,6 +116,33 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "decorator" +version = "5.1.1" +description = "Decorators for Humans" +category = "dev" +optional = false +python-versions = ">=3.5" +files = [ + {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"}, + {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, +] + +[[package]] +name = "executing" +version = "1.2.0" +description = "Get the currently executing AST node of a frame, and other information" +category = "dev" +optional = false +python-versions = "*" +files = [ + {file = "executing-1.2.0-py2.py3-none-any.whl", hash = "sha256:0314a69e37426e3608aada02473b4161d4caf5a4b244d1d0c48072b8fee7bacc"}, + {file = "executing-1.2.0.tar.gz", hash = "sha256:19da64c18d2d851112f09c287f8d3dbbdf725ab0e569077efb6cdcbd3497c107"}, +] + +[package.extras] +tests = ["asttokens", "littleutils", "pytest", "rich"] + [[package]] name = "geographiclib" version = "2.0" @@ -90,14 +157,14 @@ files = [ [[package]] name = "geopy" -version = "2.3.0" +version = "2.4.0" description = "Python Geocoding Toolbox" category = "main" optional = false python-versions = ">=3.7" files = [ - {file = "geopy-2.3.0-py3-none-any.whl", hash = "sha256:4a29a16d41d8e56ba8e07310802a1cbdf098eeb6069cc3d6d3068fc770629ffc"}, - {file = "geopy-2.3.0.tar.gz", hash = "sha256:228cd53b6eef699b2289d1172e462a90d5057779a10388a7366291812601187f"}, + {file = "geopy-2.4.0-py3-none-any.whl", hash = "sha256:d2639a46d0ce4c091e9688b750ba94348a14b898a1e55c68f4b4a07e7d1afa20"}, + {file = "geopy-2.4.0.tar.gz", hash = "sha256:a59392bf17adb486b25dbdd71fbed27733bdf24a2dac588047a619de56695e36"}, ] [package.dependencies] @@ -112,6 +179,45 @@ dev-test = ["coverage", "pytest (>=3.10)", "pytest-asyncio (>=0.17)", "sphinx (< requests = ["requests (>=2.16.2)", "urllib3 (>=1.24.2)"] timezone = ["pytz"] +[[package]] +name = "ipython" +version = "8.15.0" +description = "IPython: Productive Interactive Computing" +category = "dev" +optional = false +python-versions = ">=3.9" +files = [ + {file = "ipython-8.15.0-py3-none-any.whl", hash = "sha256:45a2c3a529296870a97b7de34eda4a31bee16bc7bf954e07d39abe49caf8f887"}, + {file = "ipython-8.15.0.tar.gz", hash = "sha256:2baeb5be6949eeebf532150f81746f8333e2ccce02de1c7eedde3f23ed5e9f1e"}, +] + +[package.dependencies] +appnope = {version = "*", markers = "sys_platform == \"darwin\""} +backcall = "*" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +decorator = "*" +jedi = ">=0.16" +matplotlib-inline = "*" +pexpect = {version = ">4.3", markers = "sys_platform != \"win32\""} +pickleshare = "*" +prompt-toolkit = ">=3.0.30,<3.0.37 || >3.0.37,<3.1.0" +pygments = ">=2.4.0" +stack-data = "*" +traitlets = ">=5" + +[package.extras] +all = ["black", "curio", "docrepr", "exceptiongroup", "ipykernel", "ipyparallel", "ipywidgets", "matplotlib", "matplotlib (!=3.2.0)", "nbconvert", "nbformat", "notebook", "numpy (>=1.21)", "pandas", "pytest (<7)", "pytest (<7.1)", "pytest-asyncio", "qtconsole", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "stack-data", "testpath", "trio", "typing-extensions"] +black = ["black"] +doc = ["docrepr", "exceptiongroup", "ipykernel", "matplotlib", "pytest (<7)", "pytest (<7.1)", "pytest-asyncio", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "stack-data", "testpath", "typing-extensions"] +kernel = ["ipykernel"] +nbconvert = ["nbconvert"] +nbformat = ["nbformat"] +notebook = ["ipywidgets", "notebook"] +parallel = ["ipyparallel"] +qtconsole = ["qtconsole"] +test = ["pytest (<7.1)", "pytest-asyncio", "testpath"] +test-extra = ["curio", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.21)", "pandas", "pytest (<7.1)", "pytest-asyncio", "testpath", "trio"] + [[package]] name = "isort" version = "5.12.0" @@ -130,51 +236,85 @@ pipfile-deprecated-finder = ["pip-shims (>=0.5.2)", "pipreqs", "requirementslib" plugins = ["setuptools"] requirements-deprecated-finder = ["pip-api", "pipreqs"] +[[package]] +name = "jedi" +version = "0.19.0" +description = "An autocompletion tool for Python that can be used for text editors." +category = "dev" +optional = false +python-versions = ">=3.6" +files = [ + {file = "jedi-0.19.0-py2.py3-none-any.whl", hash = "sha256:cb8ce23fbccff0025e9386b5cf85e892f94c9b822378f8da49970471335ac64e"}, + {file = "jedi-0.19.0.tar.gz", hash = "sha256:bcf9894f1753969cbac8022a8c2eaee06bfa3724e4192470aaffe7eb6272b0c4"}, +] + +[package.dependencies] +parso = ">=0.8.3,<0.9.0" + +[package.extras] +docs = ["Jinja2 (==2.11.3)", "MarkupSafe (==1.1.1)", "Pygments (==2.8.1)", "alabaster (==0.7.12)", "babel (==2.9.1)", "chardet (==4.0.0)", "commonmark (==0.8.1)", "docutils (==0.17.1)", "future (==0.18.2)", "idna (==2.10)", "imagesize (==1.2.0)", "mock (==1.0.1)", "packaging (==20.9)", "pyparsing (==2.4.7)", "pytz (==2021.1)", "readthedocs-sphinx-ext (==2.1.4)", "recommonmark (==0.5.0)", "requests (==2.25.1)", "six (==1.15.0)", "snowballstemmer (==2.1.0)", "sphinx (==1.8.5)", "sphinx-rtd-theme (==0.4.3)", "sphinxcontrib-serializinghtml (==1.1.4)", "sphinxcontrib-websupport (==1.2.4)", "urllib3 (==1.26.4)"] +qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] +testing = ["Django (<3.1)", "attrs", "colorama", "docopt", "pytest (<7.0.0)"] + +[[package]] +name = "matplotlib-inline" +version = "0.1.6" +description = "Inline Matplotlib backend for Jupyter" +category = "dev" +optional = false +python-versions = ">=3.5" +files = [ + {file = "matplotlib-inline-0.1.6.tar.gz", hash = "sha256:f887e5f10ba98e8d2b150ddcf4702c1e5f8b3a20005eb0f74bfdbd360ee6f304"}, + {file = "matplotlib_inline-0.1.6-py3-none-any.whl", hash = "sha256:f1f41aab5328aa5aaea9b16d083b128102f8712542f819fe7e6a420ff581b311"}, +] + +[package.dependencies] +traitlets = "*" + [[package]] name = "mypy" -version = "1.3.0" +version = "1.5.1" description = "Optional static typing for Python" category = "dev" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "mypy-1.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c1eb485cea53f4f5284e5baf92902cd0088b24984f4209e25981cc359d64448d"}, - {file = "mypy-1.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4c99c3ecf223cf2952638da9cd82793d8f3c0c5fa8b6ae2b2d9ed1e1ff51ba85"}, - {file = "mypy-1.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:550a8b3a19bb6589679a7c3c31f64312e7ff482a816c96e0cecec9ad3a7564dd"}, - {file = "mypy-1.3.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:cbc07246253b9e3d7d74c9ff948cd0fd7a71afcc2b77c7f0a59c26e9395cb152"}, - {file = "mypy-1.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:a22435632710a4fcf8acf86cbd0d69f68ac389a3892cb23fbad176d1cddaf228"}, - {file = "mypy-1.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6e33bb8b2613614a33dff70565f4c803f889ebd2f859466e42b46e1df76018dd"}, - {file = "mypy-1.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7d23370d2a6b7a71dc65d1266f9a34e4cde9e8e21511322415db4b26f46f6b8c"}, - {file = "mypy-1.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:658fe7b674769a0770d4b26cb4d6f005e88a442fe82446f020be8e5f5efb2fae"}, - {file = "mypy-1.3.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6e42d29e324cdda61daaec2336c42512e59c7c375340bd202efa1fe0f7b8f8ca"}, - {file = "mypy-1.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:d0b6c62206e04061e27009481cb0ec966f7d6172b5b936f3ead3d74f29fe3dcf"}, - {file = "mypy-1.3.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:76ec771e2342f1b558c36d49900dfe81d140361dd0d2df6cd71b3db1be155409"}, - {file = "mypy-1.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ebc95f8386314272bbc817026f8ce8f4f0d2ef7ae44f947c4664efac9adec929"}, - {file = "mypy-1.3.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:faff86aa10c1aa4a10e1a301de160f3d8fc8703b88c7e98de46b531ff1276a9a"}, - {file = "mypy-1.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:8c5979d0deb27e0f4479bee18ea0f83732a893e81b78e62e2dda3e7e518c92ee"}, - {file = "mypy-1.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c5d2cc54175bab47011b09688b418db71403aefad07cbcd62d44010543fc143f"}, - {file = "mypy-1.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:87df44954c31d86df96c8bd6e80dfcd773473e877ac6176a8e29898bfb3501cb"}, - {file = "mypy-1.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:473117e310febe632ddf10e745a355714e771ffe534f06db40702775056614c4"}, - {file = "mypy-1.3.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:74bc9b6e0e79808bf8678d7678b2ae3736ea72d56eede3820bd3849823e7f305"}, - {file = "mypy-1.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:44797d031a41516fcf5cbfa652265bb994e53e51994c1bd649ffcd0c3a7eccbf"}, - {file = "mypy-1.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ddae0f39ca146972ff6bb4399f3b2943884a774b8771ea0a8f50e971f5ea5ba8"}, - {file = "mypy-1.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1c4c42c60a8103ead4c1c060ac3cdd3ff01e18fddce6f1016e08939647a0e703"}, - {file = "mypy-1.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e86c2c6852f62f8f2b24cb7a613ebe8e0c7dc1402c61d36a609174f63e0ff017"}, - {file = "mypy-1.3.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:f9dca1e257d4cc129517779226753dbefb4f2266c4eaad610fc15c6a7e14283e"}, - {file = "mypy-1.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:95d8d31a7713510685b05fbb18d6ac287a56c8f6554d88c19e73f724a445448a"}, - {file = "mypy-1.3.0-py3-none-any.whl", hash = "sha256:a8763e72d5d9574d45ce5881962bc8e9046bf7b375b0abf031f3e6811732a897"}, - {file = "mypy-1.3.0.tar.gz", hash = "sha256:e1f4d16e296f5135624b34e8fb741eb0eadedca90862405b1f1fde2040b9bd11"}, + {file = "mypy-1.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f33592ddf9655a4894aef22d134de7393e95fcbdc2d15c1ab65828eee5c66c70"}, + {file = "mypy-1.5.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:258b22210a4a258ccd077426c7a181d789d1121aca6db73a83f79372f5569ae0"}, + {file = "mypy-1.5.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9ec1f695f0c25986e6f7f8778e5ce61659063268836a38c951200c57479cc12"}, + {file = "mypy-1.5.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:abed92d9c8f08643c7d831300b739562b0a6c9fcb028d211134fc9ab20ccad5d"}, + {file = "mypy-1.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:a156e6390944c265eb56afa67c74c0636f10283429171018446b732f1a05af25"}, + {file = "mypy-1.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6ac9c21bfe7bc9f7f1b6fae441746e6a106e48fc9de530dea29e8cd37a2c0cc4"}, + {file = "mypy-1.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:51cb1323064b1099e177098cb939eab2da42fea5d818d40113957ec954fc85f4"}, + {file = "mypy-1.5.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:596fae69f2bfcb7305808c75c00f81fe2829b6236eadda536f00610ac5ec2243"}, + {file = "mypy-1.5.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:32cb59609b0534f0bd67faebb6e022fe534bdb0e2ecab4290d683d248be1b275"}, + {file = "mypy-1.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:159aa9acb16086b79bbb0016145034a1a05360626046a929f84579ce1666b315"}, + {file = "mypy-1.5.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f6b0e77db9ff4fda74de7df13f30016a0a663928d669c9f2c057048ba44f09bb"}, + {file = "mypy-1.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:26f71b535dfc158a71264e6dc805a9f8d2e60b67215ca0bfa26e2e1aa4d4d373"}, + {file = "mypy-1.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fc3a600f749b1008cc75e02b6fb3d4db8dbcca2d733030fe7a3b3502902f161"}, + {file = "mypy-1.5.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:26fb32e4d4afa205b24bf645eddfbb36a1e17e995c5c99d6d00edb24b693406a"}, + {file = "mypy-1.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:82cb6193de9bbb3844bab4c7cf80e6227d5225cc7625b068a06d005d861ad5f1"}, + {file = "mypy-1.5.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4a465ea2ca12804d5b34bb056be3a29dc47aea5973b892d0417c6a10a40b2d65"}, + {file = "mypy-1.5.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9fece120dbb041771a63eb95e4896791386fe287fefb2837258925b8326d6160"}, + {file = "mypy-1.5.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d28ddc3e3dfeab553e743e532fb95b4e6afad51d4706dd22f28e1e5e664828d2"}, + {file = "mypy-1.5.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:57b10c56016adce71fba6bc6e9fd45d8083f74361f629390c556738565af8eeb"}, + {file = "mypy-1.5.1-cp38-cp38-win_amd64.whl", hash = "sha256:ff0cedc84184115202475bbb46dd99f8dcb87fe24d5d0ddfc0fe6b8575c88d2f"}, + {file = "mypy-1.5.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8f772942d372c8cbac575be99f9cc9d9fb3bd95c8bc2de6c01411e2c84ebca8a"}, + {file = "mypy-1.5.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5d627124700b92b6bbaa99f27cbe615c8ea7b3402960f6372ea7d65faf376c14"}, + {file = "mypy-1.5.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:361da43c4f5a96173220eb53340ace68cda81845cd88218f8862dfb0adc8cddb"}, + {file = "mypy-1.5.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:330857f9507c24de5c5724235e66858f8364a0693894342485e543f5b07c8693"}, + {file = "mypy-1.5.1-cp39-cp39-win_amd64.whl", hash = "sha256:c543214ffdd422623e9fedd0869166c2f16affe4ba37463975043ef7d2ea8770"}, + {file = "mypy-1.5.1-py3-none-any.whl", hash = "sha256:f757063a83970d67c444f6e01d9550a7402322af3557ce7630d3c957386fa8f5"}, + {file = "mypy-1.5.1.tar.gz", hash = "sha256:b031b9601f1060bf1281feab89697324726ba0c0bae9d7cd7ab4b690940f0b92"}, ] [package.dependencies] mypy-extensions = ">=1.0.0" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typing-extensions = ">=3.10" +typing-extensions = ">=4.1.0" [package.extras] dmypy = ["psutil (>=4.0)"] install-types = ["pip"] -python2 = ["typed-ast (>=1.4.0,<2)"] reports = ["lxml"] [[package]] @@ -201,33 +341,76 @@ files = [ {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"}, ] +[[package]] +name = "parso" +version = "0.8.3" +description = "A Python Parser" +category = "dev" +optional = false +python-versions = ">=3.6" +files = [ + {file = "parso-0.8.3-py2.py3-none-any.whl", hash = "sha256:c001d4636cd3aecdaf33cbb40aebb59b094be2a74c556778ef5576c175e19e75"}, + {file = "parso-0.8.3.tar.gz", hash = "sha256:8c07be290bb59f03588915921e29e8a50002acaf2cdc5fa0e0114f91709fafa0"}, +] + +[package.extras] +qa = ["flake8 (==3.8.3)", "mypy (==0.782)"] +testing = ["docopt", "pytest (<6.0.0)"] + [[package]] name = "pathspec" -version = "0.11.1" +version = "0.11.2" description = "Utility library for gitignore style pattern matching of file paths." category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "pathspec-0.11.1-py3-none-any.whl", hash = "sha256:d8af70af76652554bd134c22b3e8a1cc46ed7d91edcdd721ef1a0c51a84a5293"}, - {file = "pathspec-0.11.1.tar.gz", hash = "sha256:2798de800fa92780e33acca925945e9a19a133b715067cf165b8866c15a31687"}, + {file = "pathspec-0.11.2-py3-none-any.whl", hash = "sha256:1d6ed233af05e679efb96b1851550ea95bbb64b7c490b0f5aa52996c11e92a20"}, + {file = "pathspec-0.11.2.tar.gz", hash = "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3"}, +] + +[[package]] +name = "pexpect" +version = "4.8.0" +description = "Pexpect allows easy control of interactive console applications." +category = "dev" +optional = false +python-versions = "*" +files = [ + {file = "pexpect-4.8.0-py2.py3-none-any.whl", hash = "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937"}, + {file = "pexpect-4.8.0.tar.gz", hash = "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c"}, +] + +[package.dependencies] +ptyprocess = ">=0.5" + +[[package]] +name = "pickleshare" +version = "0.7.5" +description = "Tiny 'shelve'-like database with concurrency support" +category = "dev" +optional = false +python-versions = "*" +files = [ + {file = "pickleshare-0.7.5-py2.py3-none-any.whl", hash = "sha256:9649af414d74d4df115d5d718f82acb59c9d418196b7b4290ed47a12ce62df56"}, + {file = "pickleshare-0.7.5.tar.gz", hash = "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca"}, ] [[package]] name = "platformdirs" -version = "3.5.1" +version = "3.10.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "platformdirs-3.5.1-py3-none-any.whl", hash = "sha256:e2378146f1964972c03c085bb5662ae80b2b8c06226c54b2ff4aa9483e8a13a5"}, - {file = "platformdirs-3.5.1.tar.gz", hash = "sha256:412dae91f52a6f84830f39a8078cecd0e866cb72294a5c66808e74d5e88d251f"}, + {file = "platformdirs-3.10.0-py3-none-any.whl", hash = "sha256:d7c24979f292f916dc9cbf8648319032f551ea8c49a4c9bf2fb556a02070ec1d"}, + {file = "platformdirs-3.10.0.tar.gz", hash = "sha256:b45696dab2d7cc691a3226759c0d3b00c47c8b6e293d96f6436f733303f77f6d"}, ] [package.extras] -docs = ["furo (>=2023.3.27)", "proselint (>=0.13)", "sphinx (>=6.2.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] [[package]] name = "pluscodes" @@ -244,30 +427,147 @@ files = [ dev = ["black (==22.3.0)", "build (==0.8.0)", "coverage (==6.4)", "isort (==5.8.0)", "pylintv (==2.15.0)", "pytest (==7.1.1)", "pytest-cov (==3.0.0)", "twine (==4.0.1)"] [[package]] -name = "tomli" -version = "2.0.1" -description = "A lil' TOML parser" +name = "prompt-toolkit" +version = "3.0.39" +description = "Library for building powerful interactive command lines in Python" +category = "dev" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "prompt_toolkit-3.0.39-py3-none-any.whl", hash = "sha256:9dffbe1d8acf91e3de75f3b544e4842382fc06c6babe903ac9acb74dc6e08d88"}, + {file = "prompt_toolkit-3.0.39.tar.gz", hash = "sha256:04505ade687dc26dc4284b1ad19a83be2f2afe83e7a828ace0c72f3a1df72aac"}, +] + +[package.dependencies] +wcwidth = "*" + +[[package]] +name = "ptyprocess" +version = "0.7.0" +description = "Run a subprocess in a pseudo terminal" +category = "dev" +optional = false +python-versions = "*" +files = [ + {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, + {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, +] + +[[package]] +name = "pure-eval" +version = "0.2.2" +description = "Safely evaluate AST nodes without side effects" +category = "dev" +optional = false +python-versions = "*" +files = [ + {file = "pure_eval-0.2.2-py3-none-any.whl", hash = "sha256:01eaab343580944bc56080ebe0a674b39ec44a945e6d09ba7db3cb8cec289350"}, + {file = "pure_eval-0.2.2.tar.gz", hash = "sha256:2b45320af6dfaa1750f543d714b6d1c520a1688dec6fd24d339063ce0aaa9ac3"}, +] + +[package.extras] +tests = ["pytest"] + +[[package]] +name = "pygments" +version = "2.16.1" +description = "Pygments is a syntax highlighting package written in Python." category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, - {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, + {file = "Pygments-2.16.1-py3-none-any.whl", hash = "sha256:13fc09fa63bc8d8671a6d247e1eb303c4b343eaee81d861f3404db2935653692"}, + {file = "Pygments-2.16.1.tar.gz", hash = "sha256:1daff0494820c69bc8941e407aa20f577374ee88364ee10a98fdbe0aece96e29"}, +] + +[package.extras] +plugins = ["importlib-metadata"] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] +[[package]] +name = "stack-data" +version = "0.6.2" +description = "Extract data from python stack frames and tracebacks for informative displays" +category = "dev" +optional = false +python-versions = "*" +files = [ + {file = "stack_data-0.6.2-py3-none-any.whl", hash = "sha256:cbb2a53eb64e5785878201a97ed7c7b94883f48b87bfb0bbe8b623c74679e4a8"}, + {file = "stack_data-0.6.2.tar.gz", hash = "sha256:32d2dd0376772d01b6cb9fc996f3c8b57a357089dec328ed4b6553d037eaf815"}, +] + +[package.dependencies] +asttokens = ">=2.1.0" +executing = ">=1.2.0" +pure-eval = "*" + +[package.extras] +tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"] + +[[package]] +name = "tokenize-rt" +version = "5.2.0" +description = "A wrapper around the stdlib `tokenize` which roundtrips." +category = "dev" +optional = false +python-versions = ">=3.8" +files = [ + {file = "tokenize_rt-5.2.0-py2.py3-none-any.whl", hash = "sha256:b79d41a65cfec71285433511b50271b05da3584a1da144a0752e9c621a285289"}, + {file = "tokenize_rt-5.2.0.tar.gz", hash = "sha256:9fe80f8a5c1edad2d3ede0f37481cc0cc1538a2f442c9c2f9e4feacd2792d054"}, +] + +[[package]] +name = "traitlets" +version = "5.9.0" +description = "Traitlets Python configuration system" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "traitlets-5.9.0-py3-none-any.whl", hash = "sha256:9e6ec080259b9a5940c797d58b613b5e31441c2257b87c2e795c5228ae80d2d8"}, + {file = "traitlets-5.9.0.tar.gz", hash = "sha256:f6cde21a9c68cf756af02035f72d5a723bf607e862e7be33ece505abf4a3bad9"}, +] + +[package.extras] +docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"] +test = ["argcomplete (>=2.0)", "pre-commit", "pytest", "pytest-mock"] + [[package]] name = "typing-extensions" -version = "4.6.3" +version = "4.7.1" description = "Backported and Experimental Type Hints for Python 3.7+" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "typing_extensions-4.6.3-py3-none-any.whl", hash = "sha256:88a4153d8505aabbb4e13aacb7c486c2b4a33ca3b3f807914a9b4c844c471c26"}, - {file = "typing_extensions-4.6.3.tar.gz", hash = "sha256:d91d5919357fe7f681a9f2b5b4cb2a5f1ef0a1e9f59c4d8ff0d3491e05c0ffd5"}, + {file = "typing_extensions-4.7.1-py3-none-any.whl", hash = "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36"}, + {file = "typing_extensions-4.7.1.tar.gz", hash = "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2"}, +] + +[[package]] +name = "wcwidth" +version = "0.2.6" +description = "Measures the displayed width of unicode strings in a terminal" +category = "dev" +optional = false +python-versions = "*" +files = [ + {file = "wcwidth-0.2.6-py2.py3-none-any.whl", hash = "sha256:795b138f6875577cd91bba52baf9e445cd5118fd32723b460e30a0af30ea230e"}, + {file = "wcwidth-0.2.6.tar.gz", hash = "sha256:a5220780a404dbe3353789870978e472cfe477761f06ee55077256e509b156d0"}, ] [metadata] lock-version = "2.0" -python-versions = "^3.10" -content-hash = "9837c2f9210fa01e5287a85b36c2c95030cc6be0e4ecffa7fc2a5285970b41d3" +python-versions = "^3.11" +content-hash = "f9270d7fb45c708e923f4afe041b26f77becb8e4adfcd517742e67f6b8f9d6b9" diff --git a/pyproject.toml b/pyproject.toml index 82c23fe..16b1adb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,26 +1,37 @@ [tool.poetry] name = "surplus" -version = "1.1.3" -description = "Plus Code to iOS-Shortcuts-like shareable text" +version = "2.0.0" +description = "Python script to convert Google Maps Plus Codes to iOS Shortcuts-like shareable text." authors = ["Mark Joshwel "] -license = "Unlicence" +license = "Unlicense" readme = "README.md" -include = ["surplus.py"] +repository = "https://github.com/markjoshwel/surplus" +keywords = ["pluscodes", "openlocationcode"] +packages = [ + {include = "surplus"} +] [tool.poetry.dependencies] -python = "^3.10" +python = "^3.11" pluscodes = "^2022.1.3" geopy = "^2.3.0" [tool.poetry.group.dev.dependencies] -black = "^23.3.0" -mypy = "^1.3.0" +black = {extras = ["jupyter"], version = "^23.7.0"} +mypy = "^1.5.1" isort = "^5.12.0" [tool.poetry.scripts] surplus = 'surplus:cli' "s+" = 'surplus:cli' +[tool.black] +line-length = 90 + +[tool.isort] +line_length = 90 +profile = "black" + [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" diff --git a/requirements.txt b/requirements.txt index 181f69c..f058c34 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,8 @@ -geographiclib==2.0 ; python_version >= "3.10" and python_version < "4.0" \ +geographiclib==2.0 ; python_version >= "3.11" and python_version < "4.0" \ --hash=sha256:6b7225248e45ff7edcee32becc4e0a1504c606ac5ee163a5656d482e0cd38734 \ --hash=sha256:f7f41c85dc3e1c2d3d935ec86660dc3b2c848c83e17f9a9e51ba9d5146a15859 -geopy==2.3.0 ; python_version >= "3.10" and python_version < "4.0" \ - --hash=sha256:228cd53b6eef699b2289d1172e462a90d5057779a10388a7366291812601187f \ - --hash=sha256:4a29a16d41d8e56ba8e07310802a1cbdf098eeb6069cc3d6d3068fc770629ffc -pluscodes==2022.1.3 ; python_version >= "3.10" and python_version < "4.0" \ +geopy==2.4.0 ; python_version >= "3.11" and python_version < "4.0" \ + --hash=sha256:a59392bf17adb486b25dbdd71fbed27733bdf24a2dac588047a619de56695e36 \ + --hash=sha256:d2639a46d0ce4c091e9688b750ba94348a14b898a1e55c68f4b4a07e7d1afa20 +pluscodes==2022.1.3 ; python_version >= "3.11" and python_version < "4.0" \ --hash=sha256:50625f472f8d4e8822e005180c2eb41bf09e45e429f362d3cded346f1169dae8 diff --git a/surplus.py b/surplus.py deleted file mode 100644 index 89af89d..0000000 --- a/surplus.py +++ /dev/null @@ -1,480 +0,0 @@ -""" -surplus: Plus Code to iOS-Shortcuts-like shareable text -------------------------------------------------------- -by mark and contributors - -This is free and unencumbered software released into the public domain. - -Anyone is free to copy, modify, publish, use, compile, sell, or -distribute this software, either in source code form or as a compiled -binary, for any purpose, commercial or non-commercial, and by any -means. - -In jurisdictions that recognize copyright laws, the author or authors -of this software dedicate any and all copyright interest in the -software to the public domain. We make this dedication for the benefit -of the public at large and to the detriment of our heirs and -successors. We intend this dedication to be an overt act of -relinquishment in perpetuity of all present and future rights to this -software under copyright law. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR -OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, -ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -OTHER DEALINGS IN THE SOFTWARE. - -For more information, please refer to -""" - -from argparse import ArgumentParser -from collections import OrderedDict -from sys import stderr -from typing import Any, Callable, Final, Literal, NamedTuple - -from geopy import Location # type: ignore -from geopy.geocoders import Nominatim # type: ignore -from pluscodes import PlusCode # type: ignore -from pluscodes.openlocationcode import recoverNearest # type: ignore -from pluscodes.validator import Validator # type: ignore - -VERSION: Final[tuple[int, int, int]] = (1, 1, 3) - - -class Localcode(NamedTuple): - """ - typing.NamedTuple representing short Plus Code with locality - - code: str - Plus Code - e.g.: "8QMF+FX" - locality: str - e.g.: "Singapore" - """ - - code: str - locality: str - - def full_length( - self, geocoder: Callable = Nominatim(user_agent="surplus").geocode - ) -> tuple[bool, str]: - """ - method that calculates full-length Plus Code using locality - - geocoder: typing.Callable = geopy.geocoders.Nominatim(user_agent="surplus").geocode - place/locality to location function, accesses .longitude and .latitude if - returned object is not None - - returns tuple[bool, str] - (True, ) - conversion was successful, str is resultant Plus Code - (False, ) - conversion failed, str is error message - """ - location: Location | None = geocoder(self.locality) - lat: float = 0.0 - lon: float = 0.0 - - if location is None: - return False, f"no coordinates found for '{self.locality}'" - - recv_pcode = recoverNearest( - code=self.code, - referenceLongitude=location.longitude, - referenceLatitude=location.latitude, - ) - - return True, recv_pcode - - -class Latlong(NamedTuple): - """ - typing.NamedTuple representing a pair of latitude and longitude coordinates - - lat: float - latitudinal coordinate - long: float - longitudinal coordinate - """ - - lat: float - long: float - - -def surplus( - query: str | Localcode | Latlong, - reverser: Callable = Nominatim(user_agent="surplus").reverse, - debug: bool = False, -) -> tuple[bool, str]: - """ - pluscode to shareable text conversion function - - query: str | surplus.Localcode | surplus.Latlong - str - normal longcode (6PH58QMF+FX) - surplus.Localcode - shortcode with locality (8QMF+FX Singapore) - surplus.Latlong - latlong - - reverser: typing.Callable = geopy.geocoders.Nominatim(user_agent="surplus").reverser - latlong to data function, accesses a dict from .raw attribute of return object - function should be able to take a string with two floats and return a - geopy.Location-like object (None checking is done) - - # code used by surplus - location: dict[str, Any] = reverser(f"{lat}, {lon}").raw - - dict should be similar to nominatim raw dicts, see - - - debug: bool = False - prints lat, long and reverser response dict to stderr - - returns tuple[bool, str] - (True, ) - conversion was successful, str is resultant text - (False, ) - conversion failed, str is error message - """ - - def _unique(l: list[str]) -> list[str]: - """(internal function) returns a in-order unique list from list""" - unique: OrderedDict = OrderedDict() - for line in l: - unique.update({line: None}) - return list(unique.keys()) - - def _generate_text(address: dict[str, str], debug: bool = False) -> list[str]: - """(internal function) separation of concern function for text generation""" - - text: list[str] = [] - seen_names: list[str] = [] - - text.append( - ("0\t" if debug else "") - + ", ".join( - seen_names := [ - d - for d in _unique( - [ - address.get(detail, "") - for detail in ( - "emergency, historic, military, natural, landuse, place, " - "railway, man_made, aerialway, boundary, amenity, aeroway, " - "club, craft, leisure, office, mountain_pass, shop, " - "tourism, bridge, tunnel, waterway" - ).split(", ") - ] - ) - if d != "" - ] - ) - ) - - if address.get("building") != address.get("house_number"): - seen_names += [address.get("building", "")] - text.append(("1\t" if debug else "") + address.get("building", "")) - - seen_names += [address.get("highway", "")] - text.append(("2\t" if debug else "") + address.get("highway", "")) - - seen_names += [address.get("house_name", "")] - text.append( - ("3\t" if debug else "") - + ( - address.get("house_number", "") - + (" " + address.get("house_name", "")).strip() - + " " - + address.get("road", "") - ).strip() - ) - - if debug: - stderr.write(f"debug: {seen_names=}\n") - - text.append("4\t" if debug else "") - basket: list[str] = [] - for d in _unique( - [ - address.get(detail, "") - for detail in ( - "residential, neighbourhood, allotments, quarter, " - "city_district, district, borough, suburb, subdivision, " - "municipality, city, town, village" - ).split(", ") - ] - ): - if all( - _dvtm4 := [ - d != "", - d not in address.get("road", ""), - d - not in [ - address.get(detail, "") - for detail in ( - "region, state, state_district, county, " - "state, country, continent" - ).split(", ") - ], - any( - _dvcm4 := [ - True if (d not in sn) else False for sn in seen_names - ] - ), - ] - ): - basket.append(d) - - if debug: - stderr.write(f"debug: {d=}\t{_dvtm4=}\t{_dvcm4=}\n") - - text[-1] += ", ".join(basket) - - text.append(("5\t" if debug else "") + address.get("postcode", "")) - - text.append( - ("6\t" if debug else "") - + ", ".join( - [ - d - for d in _unique( - [ - address.get(detail, "") - for detail in ( - "region, county, state, state_district, " - "country, continent" - ).split(", ") - ] - ) - if d != "" - ] - ) - ) - - return [d for d in _unique(text) if all([d != None, d != ""])] - - _latlong = handle_query(query=query, debug=debug) - - if _latlong[0] is False: - assert isinstance(_latlong[1], str) - return False, _latlong[1] - - assert isinstance(_latlong[1], Latlong) - latlong = _latlong[1] - - try: - _reversed: Location | None = reverser(f"{latlong.lat}, {latlong.long}") - - if _reversed is None: - raise Exception(f"reverser function returned None") - - location: dict[str, Any] = _reversed.raw - - except Exception as reverr: - return ( - False, - f"error while reversing latlong ({Latlong}): {reverr.__class__.__name__} - {reverr}", - ) - - if debug: - stderr.write(f"debug: {location=}\n") - return True, "\n".join( - _generate_text(address=location.get("address", {}), debug=debug) - + _generate_text(address=location.get("address", {})) - ) - - return True, "\n".join(_generate_text(address=location.get("address", {}))) - - -def parse_query( - query: str, debug: bool = False -) -> tuple[Literal[True], str | Localcode | Latlong] | tuple[Literal[False], str]: - """ - function that parses a string Plus Code, local code or latlong into a str, - surplus.Localcode or surplus.Latlong respectively - - query: str - string Plus Code, local code or latlong - debug: bool = False - prints query parsing information to stderr - - returns tuple[bool, str | Localcode | Latlong] - (True, ) - conversion was successful, second element is result - (False, ) - conversion failed, str is error message - """ - - def _word_match( - oquery: str, squery: list[str] - ) -> tuple[Literal[True], str | Localcode | Latlong] | tuple[Literal[False], str]: - """ - internal helper code reuse function - - looks through each 'word' and attempts to match to a Plus Code - if found, remove from original query and strip of whitespace and commas - use resulting stripped query as locality - """ - - pcode: str = "" - - for word in squery: - if Validator().is_valid(word): - pcode = word - - if Validator().is_full(word): - return True, word - - if pcode != "": # found a pluscode - locality = oquery.replace(pcode, "") - locality = locality.strip().strip(",").strip() - - if debug: - stderr.write(f"debug: {pcode=}, {locality=}\n") - - return True, Localcode(code=pcode, locality=locality) - - return False, "unable to find a pluscode/match to a format" - - squery = [word.strip(",").strip() for word in query.split()] - - if debug: - stderr.write(f"debug: {squery=}\n") - - match squery: - # attempt to match to conjoined latlong ('lat,long') - case [a]: - try: - plat, plong = a.split(",") - lat = float(plat) - long = float(plong) - - except ValueError: - return _word_match(oquery=query, squery=squery) - - else: - return True, Latlong(lat=lat, long=long) - - # attempt to match to latlong ('lat, long') - case [a, b]: - try: - lat = float(a) - long = float(b) - - except ValueError: - return _word_match(oquery=query, squery=squery) - - else: - return True, Latlong(lat=lat, long=long) - - case _: - return _word_match(oquery=query, squery=squery) - - -def handle_query( - query: str | Localcode | Latlong, debug: bool = False -) -> tuple[Literal[True], Latlong] | tuple[Literal[False], str]: - """ - function that gets returns a surplus.Latlong from a Plus Code string, - surplus.Localcode or surplus.Latlong object. - used after surplus.parse_query(). - - query: str | Localcode | Latlong - - debug: bool = False - - returns tuple[bool, str | Latlong] - (True, Latlong) - conversion was successful, second element is latlong - (False, ) - conversion failed, str is error message - """ - lat: float = 0.0 - lon: float = 0.0 - - if isinstance(query, Latlong): - return True, query - - else: # instances: str | Localcode - str_pcode: str = "" - - if isinstance(query, Localcode): - result = query.full_length() - - if not result[0]: - return False, result[1] - - str_pcode = result[1] - - else: - str_pcode = query - - try: - pcode = PlusCode(str_pcode) - - except KeyError: - return ( - False, - "code given is not a full-length Plus Code (including area code), e.g.: 6PH58QMF+FX", - ) - - except Exception as pcderr: - return ( - False, - f"error while decoding Plus Code: {pcderr.__class__.__name__} - {pcderr}", - ) - - lat = pcode.area.center().lat - lon = pcode.area.center().lon - - if debug: - stderr.write(f"debug: {lat=}, {lon=}\n") - - return True, Latlong(lat=lat, long=lon) - - -def cli() -> None: - parser = ArgumentParser( - prog="surplus", - description=__doc__[__doc__.find(":") + 2 : __doc__.find("\n", 1)], - ) - parser.add_argument( - "query", - type=str, - help="full-length Plus Code (6PH58QMF+FX), local code (8QMF+FX Singapore), or latlong (1.3336875, 103.7749375)", - nargs="*", - ) - parser.add_argument( - "-d", - "--debug", - action="store_true", - default=False, - help="prints lat, long and reverser response dict to stderr", - ) - parser.add_argument( - "-v", - "--version", - action="store_true", - default=False, - help="prints version information to stderr and exits", - ) - args = parser.parse_args() - - stderr.write( - f"surplus version {'.'.join([str(v) for v in VERSION])}" - + (f", debug mode" if args.debug else "") - + "\n" - ) - - if args.version: - exit() - - if args.debug: - stderr.write("debug: args.query='" + " ".join(args.query) + "'\n") - - query = parse_query(" ".join(args.query), debug=args.debug) - if not query[0]: - stderr.write(f"{query[-1]}\n") - exit(1) - - result: tuple[bool, str] = surplus(query[-1], debug=args.debug) - if not result[0]: - stderr.write(f"{result[-1]}\n") - exit(2) - - print(result[-1]) - - -if __name__ == "__main__": - cli() diff --git a/surplus/__init__.py b/surplus/__init__.py new file mode 100644 index 0000000..c1983a1 --- /dev/null +++ b/surplus/__init__.py @@ -0,0 +1,69 @@ +""" +surplus: Google Maps Plus Code to iOS Shortcuts-like shareable text +------------------------------------------------------------------- +by mark and contributors + +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to +""" + +# surplus was and would've been a single-file module, but typing is in the way :( +# https://github.com/python/typing/issues/1333 + +from .surplus import ( + EMPTY_LATLONG, + SHAREABLE_TEXT_LINE_0_KEYS, + SHAREABLE_TEXT_LINE_1_KEYS, + SHAREABLE_TEXT_LINE_2_KEYS, + SHAREABLE_TEXT_LINE_3_KEYS, + SHAREABLE_TEXT_LINE_4_KEYS, + SHAREABLE_TEXT_LINE_5_KEYS, + SHAREABLE_TEXT_LINE_6_KEYS, + SHAREABLE_TEXT_NAMES, + USER_AGENT, + VERSION, + Behaviour, + ConversionResultTypeEnum, + EmptyQueryError, + IncompletePlusCodeError, + Latlong, + LatlongParseError, + LatlongQuery, + LocalCodeQuery, + NoSuitableLocationError, + PlusCodeNotFoundError, + PlusCodeQuery, + Query, + ResultType, + StringQuery, + SurplusException, + UnavailableFeatureError, + cli, + default_geocoder, + default_reverser, + handle_args, + parse_query, + surplus, +) diff --git a/surplus/py.typed b/surplus/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/surplus/surplus.py b/surplus/surplus.py new file mode 100644 index 0000000..41816a9 --- /dev/null +++ b/surplus/surplus.py @@ -0,0 +1,1096 @@ +""" +surplus: Google Maps Plus Code to iOS Shortcuts-like shareable text +------------------------------------------------------------------- +by mark and contributors + +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to +""" + +from argparse import ArgumentParser +from collections import OrderedDict +from enum import Enum +from sys import stderr, stdout +from typing import ( + Any, + Callable, + Final, + Generic, + NamedTuple, + Sequence, + TextIO, + TypeAlias, + TypeVar, +) + +from geopy import Location as _geopy_Location # type: ignore +from geopy.geocoders import Nominatim as _geopy_Nominatim # type: ignore +from pluscodes import PlusCode as _PlusCode # type: ignore +from pluscodes.validator import Validator as _PlusCode_Validator # type: ignore + +from pluscodes.openlocationcode import ( # type: ignore # isort: skip + recoverNearest as _PlusCode_recoverNearest, +) + +# constants + +VERSION: Final[tuple[int, int, int]] = (2, 0, 0) +USER_AGENT: Final[str] = "surplus" +SHAREABLE_TEXT_LINE_0_KEYS: Final[tuple[str, ...]] = ( + "emergency", + "historic", + "military", + "natural", + "landuse", + "place", + "railway", + "man_made", + "aerialway", + "boundary", + "amenity", + "aeroway", + "club", + "craft", + "leisure", + "office", + "mountain_pass", + "shop", + "tourism", + "bridge", + "tunnel", + "waterway", +) +SHAREABLE_TEXT_LINE_1_KEYS: Final[tuple[str, ...]] = ("building",) +SHAREABLE_TEXT_LINE_2_KEYS: Final[tuple[str, ...]] = ("highway",) +SHAREABLE_TEXT_LINE_3_KEYS: Final[tuple[str, ...]] = ( + "house_number", + "house_name", + "road", +) +SHAREABLE_TEXT_LINE_4_KEYS: Final[tuple[str, ...]] = ( + "residential", + "neighbourhood", + "allotments", + "quarter", + "city_district", + "district", + "borough", + "suburb", + "subdivision", + "municipality", + "city", + "town", + "village", +) +SHAREABLE_TEXT_LINE_5_KEYS: Final[tuple[str, ...]] = ("postcode",) +SHAREABLE_TEXT_LINE_6_KEYS: Final[tuple[str, ...]] = ( + "region", + "county", + "state", + "state_district", + "country", + "continent", +) +SHAREABLE_TEXT_NAMES: Final[tuple[str, ...]] = ( + SHAREABLE_TEXT_LINE_0_KEYS + + SHAREABLE_TEXT_LINE_1_KEYS + + SHAREABLE_TEXT_LINE_2_KEYS + + ("house_name", "road") +) + +# exceptions + + +class SurplusException(Exception): + """base skeleton exception for handling and typing surplus exception classes""" + + ... + + +class NoSuitableLocationError(SurplusException): + ... + + +class IncompletePlusCodeError(SurplusException): + ... + + +class PlusCodeNotFoundError(SurplusException): + ... + + +class LatlongParseError(SurplusException): + ... + + +class EmptyQueryError(SurplusException): + ... + + +class UnavailableFeatureError(SurplusException): + ... + + +# data structures + + +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" + """ + + PLUS_CODE = "pluscode" + LOCAL_CODE = "localcode" + LATLONG = "latlong" + SHAREABLE_TEXT = "sharetext" + + +ResultType = TypeVar("ResultType") + + +class Result(NamedTuple, Generic[ResultType]): + """ + typing.NamedTuple representing a result for safe value retrieval + + arguments + value: ResultType + value to return or fallback value if erroneous + error: BaseException | None = None + exception if any + + methods + def __bool__(self) -> bool: ... + def get(self) -> ResultType: ... + def cry(self, string: bool = False) -> str: ... + + example + # 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()) + """ + + value: ResultType + error: BaseException | None = None + + def __bool__(self) -> bool: + """method that returns True if self.error is not None""" + return self.error is None + + def cry(self, string: bool = False) -> str: + """ + 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 + + arguments + string: bool = False + if self.error is an Exception, returns it as a string error message + """ + + if isinstance(self.error, BaseException): + if string: + message = f"{self.error}" + name = self.error.__class__.__name__ + return f"{message} ({name})" if (message != "") else name + + raise self.error + + if isinstance(self.error, str): + return self.error + + return "" + + def get(self) -> ResultType: + """method that returns self.value if Result is non-erroneous else raises error""" + if isinstance(self.error, BaseException): + raise self.error + return self.value + + +class Latlong(NamedTuple): + """ + typing.NamedTuple representing a latitude-longitude coordinate pair + + arguments + latitude: float + longitude: float + + methods + def __str__(self) -> str: ... + """ + + latitude: float + longitude: float + + def __str__(self) -> str: + """ + method that returns a comma-and-space-seperated string of self.latitude and + self.longitude + """ + return f"{self.latitude}, {self.longitude}" + + +EMPTY_LATLONG: Final[Latlong] = Latlong(latitude=0.0, longitude=0.0) + + +class PlusCodeQuery(NamedTuple): + """ + typing.NamedTuple representing a full-length Plus Code (e.g., 6PH58QMF+FX) + + arguments + code: str + + methods + def to_lat_long_coord(self, ...) -> Result[Latlong]: ... + def __str__(self) -> str: ... + """ + + code: str + + def to_lat_long_coord(self, geocoder: Callable[[str], Latlong]) -> Result[Latlong]: + """ + method that returns a latitude-longitude coordinate pair + + arguments + geocoder: typing.Callable[[str], Latlong] + name string to location function, must take in a string and return a + Latlong, exceptions are handled by the caller + + returns Result[Latlong] + """ + + latitude: float = 0.0 + longitude: float = 0.0 + + try: + plus_code = _PlusCode(self.code) + latitude = plus_code.area.center().lat + longitude = plus_code.area.center().lon + + except KeyError: + return Result[Latlong]( + EMPTY_LATLONG, + error=IncompletePlusCodeError( + "PlusCodeQuery.to_lat_long_coord: " + "Plus Code is not full-length (e.g., 6PH58QMF+FX)" + ), + ) + + except Exception as exc: + return Result[Latlong](EMPTY_LATLONG, error=exc) + + return Result[Latlong](Latlong(latitude=latitude, longitude=longitude)) + + def __str__(self) -> str: + """method that returns string representation of query""" + return f"{self.code}" + + +class LocalCodeQuery(NamedTuple): + """ + typing.NamedTuple representing a shortened Plus Code with locality, referred to by + surplus as a "local code" + + arguments + code: str + Plus Code portion of local code, e.g., "8QMF+FX" + locality: str + remaining string of local code, e.g., "Singapore" + + methods + def to_full_plus_code(self, ...) -> Result[str]: ... + def to_lat_long_coord(self, ...) -> Result[Latlong]: ... + def __str__(self) -> str: ... + """ + + code: str + locality: str + + def to_full_plus_code(self, geocoder: Callable[[str], Latlong]) -> Result[str]: + """ + exclusive method that returns a full-length Plus Code as a string + + arguments + geocoder: typing.Callable[[str], Latlong] + name string to location function, must take in a string and return a + Latlong, exceptions are handled by the caller + + returns Result[str] + """ + + try: + locality_location = geocoder(self.locality) + + recovered_pluscode = _PlusCode_recoverNearest( + code=self.code, + referenceLatitude=locality_location.latitude, + referenceLongitude=locality_location.longitude, + ) + + return Result[str](recovered_pluscode) + + except Exception as exc: + return Result[str]("", error=exc) + + def to_lat_long_coord(self, geocoder: Callable[[str], Latlong]) -> Result[Latlong]: + """ + method that returns a latitude-longitude coordinate pair + + arguments + geocoder: typing.Callable[[str], Latlong] + name string to location function, must take in a string and return a + Latlong, exceptions are handled by the caller + + returns Result[Latlong] + """ + + recovered_pluscode = self.to_full_plus_code(geocoder=geocoder) + + if not recovered_pluscode: + return Result[Latlong](EMPTY_LATLONG, error=recovered_pluscode.error) + + return Result[Latlong]( + PlusCodeQuery(recovered_pluscode.get()) + .to_lat_long_coord(geocoder=geocoder) + .get() # PlusCodeQuery can get latlong coord safely, so no need to handle + ) + + def __str__(self) -> str: + """method that returns string representation of query""" + return f"{self.code} {self.locality}" + + +class LatlongQuery(NamedTuple): + """ + typing.NamedTuple representing a latitude-longitude coordinate pair + + arguments + latlong: Latlong + + methods + def to_lat_long_coord(self, ...) -> Result[Latlong]: ... + def __str__(self) -> str: ... + """ + + latlong: Latlong + + def to_lat_long_coord(self, geocoder: Callable[[str], Latlong]) -> Result[Latlong]: + """ + method that returns a latitude-longitude coordinate pair + + arguments + geocoder: typing.Callable[[str], Latlong] + name string to location function, must take in a string and return a + Latlong, exceptions are handled by the caller + + returns Result[Latlong] + """ + + return Result[Latlong](self.latlong) + + def __str__(self) -> str: + """method that returns string representation of query""" + return f"{self.latlong.latitude}, {self.latlong.longitude}" + + +class StringQuery(NamedTuple): + """ + typing.NamedTuple representing a pure string query + + arguments + query: str + + methods + def to_lat_long_coord(self, ...) -> Result[Latlong]: ... + def __str__(self) -> str: ... + """ + + query: str + + def to_lat_long_coord(self, geocoder: Callable[[str], Latlong]) -> Result[Latlong]: + """ + method that returns a latitude-longitude coordinate pair + + arguments + geocoder: typing.Callable[[str], Latlong] + name string to location function, must take in a string and return a + Latlong, exceptions are handled by the caller + + returns Result[Latlong] + """ + + try: + return Result[Latlong](geocoder(self.query)) + + except Exception as exc: + return Result[Latlong](EMPTY_LATLONG, error=exc) + + def __str__(self) -> str: + """method that returns string representation of query""" + return self.query + + +Query: TypeAlias = PlusCodeQuery | LocalCodeQuery | LatlongQuery | StringQuery + + +def default_geocoder(place: str) -> Latlong: + """default geocoder for surplus, uses OpenStreetMap Nominatim""" + + location: _geopy_Location | None = _geopy_Nominatim(user_agent=USER_AGENT).geocode( + place + ) + + if location is None: + raise NoSuitableLocationError( + f"No suitable location could be geolocated from '{place}'" + ) + + return Latlong( + latitude=location.latitude, + longitude=location.longitude, + ) + + +def default_reverser(latlong: Latlong) -> dict[str, Any]: + """default reverser for surplus, uses OpenStreetMap Nominatim""" + location: _geopy_Location | None = _geopy_Nominatim(user_agent=USER_AGENT).reverse( + str(latlong) + ) + + if location is None: + raise NoSuitableLocationError(f"could not reverse '{str(latlong)}'") + + location_dict: dict[str, Any] = {} + + for key in (address := location.raw.get("address", {})): + location_dict[key] = address.get(key, "") + + location_dict["raw"] = location.raw + location_dict["latitude"] = location.latitude + location_dict["longitude"] = location.longitude + + return location_dict + + +class Behaviour(NamedTuple): + """ + typing.NamedTuple representing how surplus operations should behave + + arguments + query: str | list[str] = "" + original user-passed query string or a list of strings from splitting + user-passed query string by spaces + geocoder: Callable[[str], Latlong] = default_geocoderi + name string to location function, must take in a string and return a Latlong, + exceptions are handled by the caller + reverser: Callable[[str], dict[str, Any]] = default_reverser + Latlong object to dictionary function, must take in a string and return a + dict. keys found in SHAREABLE_TEXT_LINE_*_KEYS used to access address details + are placed top-level in the dict, exceptions are handled by the caller. + see the playground notebook for example output + stderr: TextIO = stderr + TextIO-like object representing a writeable file. defaults to sys.stderr + stdout: TextIO = 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 query to + """ + + query: str | list[str] = "" + geocoder: Callable[[str], Latlong] = default_geocoder + reverser: Callable[[Latlong], dict[str, Any]] = default_reverser + stderr: TextIO = stderr + stdout: TextIO = stdout + debug: bool = False + version_header: bool = False + convert_to_type: ConversionResultTypeEnum = ConversionResultTypeEnum.SHAREABLE_TEXT + + +# functions + + +def parse_query(behaviour: Behaviour) -> Result[Query]: + """ + function that parses a query string into a query object + + arguments + behaviour: Behaviour + + returns Result[Query] + """ + + def _match_plus_code( + behaviour: Behaviour, + ) -> Result[Query]: + """ + internal helper code reuse function + + looks through each 'word' and attempts to match to a Plus Code + if found, remove from original query and strip of whitespace and commas + use resulting stripped query as locality + """ + + validator = _PlusCode_Validator() + portion_plus_code: str = "" + portion_locality: str = "" + original_query: str = "" + split_query: list[str] = [] + + if isinstance(behaviour.query, list): + original_query = " ".join(behaviour.query) + split_query = behaviour.query + + else: + original_query = str(behaviour.query) + split_query = behaviour.query.split(" ") + + for word in split_query: + if validator.is_valid(word): + portion_plus_code = word + + if validator.is_full(word): + return Result[Query](PlusCodeQuery(portion_plus_code)) + + break + + # didn't find a plus code. :( + if portion_plus_code == "": + return Result[Query]( + LatlongQuery(EMPTY_LATLONG), + error=PlusCodeNotFoundError("unable to find a Plus Code"), + ) + + # found a plus code! + portion_locality = original_query.replace(portion_plus_code, "") + portion_locality = portion_locality.strip().strip(",").strip() + + # did find plus code, but not full-length. :( + if (portion_locality == "") and (not validator.is_full(portion_plus_code)): + return Result[Query]( + LatlongQuery(EMPTY_LATLONG), + error=IncompletePlusCodeError( + "_match_plus_code: Plus Code is not full-length (e.g., 6PH58QMF+FX)" + ), + ) + + if behaviour.debug: + behaviour.stderr.write( + f"debug: _match_plus_code: {portion_plus_code=}, {portion_locality=}\n" + ) + + return Result[Query]( + LocalCodeQuery( + code=portion_plus_code, + locality=portion_locality, + ) + ) + + # types to handle: + # + # plus codes + # 6PH58R3M+F8 + # local codes + # 8RQQ+4Q Singapore (single-word-long locality suffix) + # St Lucia, Queensland, Australia G227+XF (multi-word-long locality prefix) + # latlong coords + # 1.3521,103.8198 (single-word-long with comma) + # 1.3521, 103.8198 (space-seperated with comma) + # 1.3521 103.8198 (space-seperated without comma) + # string queries + # Ngee Ann Polytechnic, Singapore (has a comma) + # Toa Payoh North (no commas) + + if behaviour.debug: + behaviour.stderr.write(f"debug: parse_query: {behaviour.query=}\n") + + # check if empty + if (behaviour.query == []) or (behaviour.query == ""): + return Result[Query]( + LatlongQuery(EMPTY_LATLONG), + error=EmptyQueryError("empty query string passed"), + ) + + # try to find a plus/local code + if mpc_result := _match_plus_code(behaviour=behaviour): + # found one! + return Result[Query](mpc_result.get()) + + # is a plus/local code, but missing details + if isinstance(mpc_result.error, IncompletePlusCodeError): + return mpc_result # propagate back up to caller + + # handle query + original_query: str = "" + split_query: list[str] = [] + + if isinstance(behaviour.query, str): + original_query = behaviour.query + split_query = behaviour.query.split(" ") + + else: + original_query = " ".join(behaviour.query) + split_query = behaviour.query + + if behaviour.debug: + behaviour.stderr.write(f"debug: {split_query=}\ndebug: {original_query=}\n") + + # not a plus/local code, try to match for latlong or string query + match split_query: + case [single]: + # possibly a: + # comma-seperated single-word-long latlong coord + # (fallback) single word string query + + if "," not in single: # no comma, not a latlong coord + return Result[Query](StringQuery(original_query)) + + else: # has comma, possibly a latlong coord + comma_split_single: list[str] = single.split(",") + + if len(comma_split_single) > 2: + return Result[Query]( + LatlongQuery(EMPTY_LATLONG), + error=LatlongParseError("unable to parse latlong coord"), + ) + + try: # try to type cast query + latitude = float(comma_split_single[0].strip(",")) + longitude = float(comma_split_single[-1].strip(",")) + + except ValueError: # not a latlong coord, fallback + return Result[Query](StringQuery(single)) + + else: # are floats, so is a latlong coord + return Result[Query]( + LatlongQuery( + Latlong( + latitude=latitude, + longitude=longitude, + ) + ) + ) + + case [left_single, right_single]: + # possibly a: + # space-seperated latlong coord + # (fallback) space-seperated string query + + try: # try to type cast query + latitude = float(left_single.strip(",")) + longitude = float(right_single.strip(",")) + + except ValueError: # not a latlong coord, fallback + return Result[Query](StringQuery(original_query)) + + else: # are floats, so is a latlong coord + return Result[Query]( + LatlongQuery(Latlong(latitude=latitude, longitude=longitude)) + ) + + case _: + # possibly a: + # (fallback) space-seperated string query + + return Result[Query](StringQuery(original_query)) + + +def handle_args() -> Behaviour: + """ + internal function that handles command-line arguments + + returns Behaviour + program behaviour namedtuple + """ + + parser = ArgumentParser( + prog="surplus", + description=__doc__[__doc__.find(":") + 2 : __doc__.find("\n", 1)], + ) + parser.add_argument( + "query", + type=str, + help=( + "full-length Plus Code (6PH58QMF+FX), " + "shortened Plus Code/'local code' (8QMF+FX Singapore), " + "latlong (1.3336875, 103.7749375), " + "or string query (e.g., 'Wisma Atria')" + ), + nargs="*", + ) + parser.add_argument( + "-d", + "--debug", + action="store_true", + default=False, + help="prints lat, long and reverser response dict to stderr", + ) + parser.add_argument( + "-v", + "--version", + action="store_true", + default=False, + help="prints version information to stderr and exits", + ) + parser.add_argument( + "-c", + "--convert-to", + type=str, + choices=[str(v.value) for v in ConversionResultTypeEnum], + help=( + "converts query a specific output type, defaults to " + f"'{Behaviour([]).convert_to_type.value}'" + ), + default=Behaviour([]).convert_to_type.value, + ) + + args = parser.parse_args() + behaviour = Behaviour( + query=args.query, + geocoder=default_geocoder, + reverser=default_reverser, + stderr=stderr, + stdout=stdout, + debug=args.debug, + version_header=args.version, + convert_to_type=ConversionResultTypeEnum(args.convert_to), + ) + + return behaviour + + +def _unique(l: Sequence[str]) -> list[str]: + """(internal function) returns a in-order unique list from list""" + unique: OrderedDict = OrderedDict() + for line in l: + unique.update({line: None}) + return list(unique.keys()) + + +def _generate_text( + location: dict[str, Any], behaviour: Behaviour, debug: bool = False +) -> str: + """(internal function) generate shareable text from location dict""" + + def _generate_text_line( + line_number: int, + line_keys: Sequence[str], + seperator: str = ", ", + filter: Callable[[str], list[bool]] = lambda e: [True], + ) -> str: + """ + (internal function) generate a line of shareable text from a list of keys + + arguments + line_number: int + line number to prefix with + line_keys: Sequence[str] + list of keys to .get() from location dict + filter: Callable[[str], list[bool]] = lambda e: True + function that takes in a string and returns a list of bools, used to + filter elements from line_keys. list will be passed to all(). if all + returns True, then the element is kept. + + returns str + """ + + line_prefix: str = f"{line_number}\t" if debug else "" + basket: list[str] = [] + + for detail in _unique([str(location.get(detail, "")) for detail in line_keys]): + if detail == "": + continue + + # filtering: if all(filter(detail)) returns True, + # then the element is kept/added to 'basket' + + if filter_status := all(detail_check := filter(detail)) is True: + if debug: + behaviour.stderr.write( + "debug: _generate_text_line: " + f"{str(detail_check):<20} -> {str(filter_status):<5} " + f"-------- '{detail}'\n" + ) + + basket.append(detail) + + else: # filter function returned False, so element is filtered/skipped + if debug: + behaviour.stderr.write( + "debug: _generate_text_line: " + f"{str(detail_check):<20} -> {str(filter_status):<5}" + f" filtered '{detail}'\n" + ) + continue + + line = line_prefix + seperator.join(basket) + return (line + "\n") if (line != "") else "" + + # iso3166-2 handling: this allows surplus to have special key arrangements for a + # specific iso3166-2 code for edge cases + # (https://en.wikipedia.org/wiki/ISO_3166-2) + + # get iso3166-2 before doing anything + iso3166_2: str = "" + for key in location: + if key.startswith("iso3166"): + iso3166_2 = location.get(key, "") + + # skeleton code to allow for changing keys based on iso3166-2 code + st_line0_keys = SHAREABLE_TEXT_LINE_0_KEYS + st_line1_keys = SHAREABLE_TEXT_LINE_1_KEYS + st_line2_keys = SHAREABLE_TEXT_LINE_2_KEYS + st_line3_keys = SHAREABLE_TEXT_LINE_3_KEYS + st_line4_keys = SHAREABLE_TEXT_LINE_4_KEYS + st_line5_keys = SHAREABLE_TEXT_LINE_5_KEYS + st_line6_keys = SHAREABLE_TEXT_LINE_6_KEYS + st_names = SHAREABLE_TEXT_NAMES + + match iso3166_2.split("-"): + case _: + pass + + # start generating text + text: list[str] = [] + + seen_names: list[str] = [ + detail + for detail in _unique( + [str(location.get(location_key, "")) for location_key in st_names] + ) + if detail != "" + ] + + if debug: + behaviour.stderr.write(f"debug: _generate_text: {seen_names=}\n") + + general_global_info: list[str] = [ + str(location.get(detail, "")) for detail in st_line6_keys + ] + + text.append(_generate_text_line(0, st_line0_keys)) + text.append(_generate_text_line(1, st_line1_keys)) + text.append(_generate_text_line(2, st_line2_keys)) + text.append(_generate_text_line(3, st_line3_keys, seperator=" ")) + text.append( + _generate_text_line( + 4, + st_line4_keys, + filter=lambda ak: [ + # everything here should be True if the element is to be kept + ak not in general_global_info, + not any(True if (ak in sn) else False for sn in seen_names), + ], + ) + ) + text.append(_generate_text_line(5, st_line5_keys)) + text.append(_generate_text_line(6, st_line6_keys)) + + return "".join(_unique(text)).rstrip() + + +def surplus(query: Query | str, behaviour: Behaviour) -> Result[str]: + """ + query to shareable text conversion function + + query: Query | str + query object to convert or string to attempt to query for then convert + behaviour: Behaviour + surplus behaviour namedtuple + + returns Result[str] + """ + + if not isinstance(query, (PlusCodeQuery, LocalCodeQuery, LatlongQuery, StringQuery)): + query_result = parse_query( + behaviour=Behaviour( + query=str(query), + geocoder=behaviour.geocoder, + reverser=behaviour.reverser, + stderr=behaviour.stderr, + stdout=behaviour.stdout, + debug=behaviour.debug, + version_header=behaviour.version_header, + convert_to_type=behaviour.convert_to_type, + ) + ) + + if not query_result: + return Result[str]("", error=query_result.error) + + query = query_result.get() + + # operate on query + text: str = "" + + match behaviour.convert_to_type: + case ConversionResultTypeEnum.SHAREABLE_TEXT: + # get latlong and handle result + latlong = query.to_lat_long_coord(geocoder=behaviour.geocoder) + + if not latlong: + return Result[str]("", error=latlong.error) + + if behaviour.debug: + behaviour.stderr.write(f"debug: cli: {latlong.get()=}\n") + + # reverse location and handle result + try: + location: dict[str, Any] = behaviour.reverser(latlong.get()) + + except Exception as exc: + return Result[str]("", error=exc) + + if behaviour.debug: + behaviour.stderr.write(f"debug: cli: {location=}\n") + + # generate text + if behaviour.debug: + behaviour.stderr.write( + _generate_text( + location=location, + behaviour=behaviour, + debug=behaviour.debug, + ) + + "\n" + ) + + text = _generate_text( + location=location, + behaviour=behaviour, + ) + + return Result[str](text) + + case ConversionResultTypeEnum.PLUS_CODE: + # TODO: https://github.com/markjoshwel/surplus/issues/18 + return Result[str]( + text, + error=UnavailableFeatureError( + "converting to Plus Code is not implemented yet" + ), + ) + + case ConversionResultTypeEnum.LOCAL_CODE: + # TODO: https://github.com/markjoshwel/surplus/issues/18 + return Result[str]( + text, + error=UnavailableFeatureError( + "converting to Plus Code is not implemented yet" + ), + ) + + case ConversionResultTypeEnum.LATLONG: + # TODO: https://github.com/markjoshwel/surplus/issues/18 + return Result[str]( + text, + error=UnavailableFeatureError( + "converting to Latlong is not implemented yet" + ), + ) + + case _: + return Result[str]( + "", error=f"unknown conversion result type '{behaviour.convert_to_type}'" + ) + + +# command-line entry + + +def cli() -> int: + """command-line entry point, returns an exit code int""" + + # handle arguments and print version header + behaviour = handle_args() + + (behaviour.stdout if behaviour.version_header else behaviour.stderr).write( + f"surplus version {'.'.join([str(v) for v in VERSION])}" + + (f", debug mode" if behaviour.debug else "") + + "\n" + ) + + if behaviour.version_header: + exit(0) + + # parse query and handle result + query = parse_query(behaviour=behaviour) + + if behaviour.debug: + behaviour.stderr.write(f"debug: cli: {query=}\n") + + if not query: + behaviour.stderr.write(f"error: {query.cry(string=not behaviour.debug)}\n") + return -1 + + # run surplus + text = surplus( + query=query.get(), + behaviour=behaviour, + ) + + # handle and display surplus result + if not text: + behaviour.stderr.write(f"error: {text.cry(string=not behaviour.debug)}\n") + return -2 + + behaviour.stdout.write(text.get() + "\n") + return 0 + + +if __name__ == "__main__": + exit(cli()) diff --git a/test.py b/test.py index aa97476..0b9dab5 100644 --- a/test.py +++ b/test.py @@ -1,3 +1,5 @@ +# type: ignore + """ surplus test runner ------------------- @@ -29,6 +31,7 @@ OTHER DEALINGS IN THE SOFTWARE. For more information, please refer to """ +from io import StringIO from sys import stderr from textwrap import indent from traceback import format_exception @@ -37,52 +40,80 @@ from typing import Final, NamedTuple import surplus INDENT: Final[int] = 3 +MINIMUM_PASS_RATE: Final[float] = 0.7 # because results can be flaky class ContinuityTest(NamedTuple): query: str - expected: str + expected: list[str] class TestFailure(NamedTuple): test: ContinuityTest exception: Exception output: str + stderr: StringIO tests: list[ContinuityTest] = [ ContinuityTest( - query="8RPQ+JW Singapore", - expected=( - "Caldecott Stn Exit 4\n" "Toa Payoh Link\n" "298106\n" "Central, Singapore" - ), + query="8R3M+F8 Singapore", + expected=("Wisma Atria\n" "435 Orchard Road\n" "238877\n" "Central, Singapore"), ), ContinuityTest( query="9R3J+R9 Singapore", - expected=( - "Thomson Plaza\n" - "301 Upper Thomson Road\n" - "Sin Ming, Bishan\n" - "574408\n" - "Central, Singapore" - ), + expected=[ + ( + "Thomson Plaza\n" + "301 Upper Thomson Road\n" + "Sin Ming, Bishan\n" + "574408\n" + "Central, Singapore" + ) + ], ), ContinuityTest( query="3RQ3+HW3 Pemping, Batam City, Riau Islands, Indonesia", - expected=("Batam\n" "Kepulauan Riau, Indonesia"), + expected=[ + ("Batam\n" "Kepulauan Riau, Indonesia"), + ("Batam\n" "Sumatera, Kepulauan Riau, Indonesia"), + ], ), - # ContinuityTest( - # query="CQPP+QM9 Singapore", - # expected=( - # "Woodlands Integrated Transport Hub\n" "738343\n" "Northwest, Singapore" - # ), - # ), ContinuityTest( - query="8RRX+75Q Singapore", + query="St Lucia, Queensland, Australia G227+XF", + expected=[ + ( + "The University of Queensland\n" + "Macquarie Street\n" + "St Lucia, Greater Brisbane\n" + "4072\n" + "Queensland, Australia" + ), + ( + "The University of Queensland\n" + "Eleanor Schonell Bridge\n" + "St Lucia, Greater Brisbane, Dutton Park\n" + "4072\n" + "Queensland, Australia" + ), + ], + ), + ContinuityTest( + query="Ngee Ann Polytechnic, Singapore", expected=( - "Braddell Station/Blk 106\n" - "Lorong 1 Toa Payoh\n" - "319758\n" + "Ngee Ann Polytechnic\n" + "535 Clementi Road\n" + "Bukit Timah\n" + "599489\n" + "Northwest, Singapore" + ), + ), + ContinuityTest( + query="1.3521, 103.8198", + expected=( + "MacRitchie Nature Trail\n" + "Central Water Catchment\n" + "574325\n" "Central, Singapore" ), ), @@ -106,29 +137,33 @@ def main() -> int: for idx, test in enumerate(tests, start=1): print(f"[{idx}/{len(tests)}] {test.query}") + + test_stderr = StringIO() + output: str = "" + behaviour = surplus.Behaviour(test.query, stderr=test_stderr, debug=True) try: - query = surplus.parse_query(query=test.query) + query = surplus.parse_query(behaviour) - if query[0] is False: - raise QueryParseFailure(f"test query parse result returned False") + if not query: + raise QueryParseFailure(query.cry()) - result = surplus.surplus(query=query[1]) + result = surplus.surplus(query.get(), behaviour) - if result[0] is False: - raise SurplusFailure(result[1]) + if not result: + raise SurplusFailure(result.cry()) - output = result[1] + output = result.get() - print(indent(text=output, prefix=INDENT * " ")) - - if output != test.expected: - raise ContinuityFailure(f"test did not match output") + if output not in test.expected: + raise ContinuityFailure("did not match any expected outputs") except Exception as exc: - failures.append(TestFailure(test=test, exception=exc, output=output)) - stderr.write(indent(text="(fail)", prefix=INDENT * " ") + "\n") + failures.append( + TestFailure(test=test, exception=exc, output=output, stderr=test_stderr) + ) + stderr.write(indent(text="(fail)", prefix=INDENT * " ") + "\n\n") else: stderr.write(indent(text="(pass)", prefix=INDENT * " ") + "\n\n") @@ -143,17 +178,39 @@ def main() -> int: indent("\n".join(format_exception(fail.exception)), prefix=INDENT * " ") + "\n" ) - + (indent(text="Expected:", prefix=INDENT * " ") + "\n") - + (indent(text=repr(fail.test.expected), prefix=(2 * INDENT) * " ") + "\n") - + (indent(text=fail.test.expected, prefix=(2 * INDENT) * " ") + "\n\n") - + (indent(text="Actual:", prefix=INDENT * " ") + "\n") - + (indent(text=repr(fail.output), prefix=(2 * INDENT) * " ") + "\n") - + (indent(text=fail.output, prefix=(2 * INDENT) * " ")) + + (indent(text="Expected:", prefix=INDENT * " ")) ) - print(f"\ncomplete: {len(tests) - len(failures)} passed, {len(failures)} failed") + for expected_output in fail.test.expected: + print( + indent(text=repr(expected_output), prefix=(2 * INDENT) * " ") + + "\n" + + (indent(text=expected_output, prefix=(2 * INDENT) * " ") + "\n") + ) - return len(failures) + print( + indent(text="Actual:", prefix=INDENT * " ") + + "\n" + + (indent(text=repr(fail.output), prefix=(2 * INDENT) * " ") + "\n") + + (indent(text=fail.output, prefix=(2 * INDENT) * " ") + "\n\n") + + (indent(text="stderr:", prefix=INDENT * " ") + "\n") + + (indent(text=fail.stderr.getvalue(), prefix=(2 * INDENT) * " ")) + ) + + passes = len(tests) - len(failures) + pass_rate = passes / len(tests) + + print( + f"complete: {passes} passed, {len(failures)} failed " + f"({pass_rate * 100:.0f}%/{MINIMUM_PASS_RATE * 100:.0f}%)" + ) + + if pass_rate < MINIMUM_PASS_RATE: + print("continuity pass rate is under minimum, test suite failed ;<") + return 1 + + print("continuity tests passed :>") + return 0 if __name__ == "__main__":