diff --git a/Justfile b/Justfile index 55d5561..c2d38e5 100644 --- a/Justfile +++ b/Justfile @@ -26,6 +26,7 @@ update: {{PIP}} install -U \ build \ pytest \ + docutils \ pytest-sugar \ pytest-clarity \ freezegun \ @@ -132,3 +133,8 @@ strftime: f"Format Specifiers:\n{pt.get_string()}\n\n" "Notes:\n* - Locale-dependent\n+ - C99 extension\n! - when extension" ) + +# Preview README.rst +docs: + rst2html5 --output README.html README.rst + open README.html diff --git a/README.rst b/README.rst index f601c96..fd578cd 100644 --- a/README.rst +++ b/README.rst @@ -1,3 +1,4 @@ +========= when 🌐🕐 ========= @@ -17,64 +18,107 @@ Scenario -------- Your favorite sporting event, concert, performance, conference, or symposium is happening -in Ulan Bator and all you know is the time of event is relative to the location city or time zone. -But wait! You need to know when that occurs relative to you while traveling to Seoul or Paris. +in Ulan Bator and all you know is the time of event relative to the city or time zone. +So what time is that for you in your local time? What time did it or will it occur at some +other time? What about for your friends in other locations around the world? + +Features +-------- + +``when`` can refer to source and target locations via the ``--source`` and ``--target`` specifiers. + +* ``when`` can download a GeoNames_ cities database for referencing locations by city name +* All IANA time zone definitions are available, as well as the most common time zone aliases (i.e.: ``EST`` => ``US/Eastern``) +* Display current lunar state +* List common holidays for a given country and/or year (US or configurable) +* Show dates for full moons +* Extensive configuration options for results +* JSON output Installation ------------- +============ -Install from PyPI:: +Install from PyPI: + +.. code:: bash $ pip install when -or using pipx_:: +or using pipx_: + +.. code:: bash $ pipx install when -or:: +or: + +.. code:: bash $ pipx install git+https://github.com/dakrauth/when.git -.. _pipx: https://pypa.github.io/pipx/ +.. note:: + + Once installed, if you wish to utilize ``when``'s full capabilities, you should + install the GeoNames cities database as describe next. + -To access city names, you must install the cities database:: + +Database installation +--------------------- + +To access city names, you need to install the cities database after installing the ``when`` application: + +.. code:: bash when --db [options] -For ``options``: +Where ``options`` are: + +* ``--db-size``: You can specify a database down size by using one of the following: + + - ``sm`` - cities with population > 15,000 + country capitals (~2.9M download, ~2M DB) + - ``md`` - **default**, same as ``sm``, plus: + - cities with population > 5,000 (~4.8M download, ~3.1M DB) + - seat of first-order admin division, i.e. US state + - ``lg`` - same as ``md``, plus: + - cities with population > 1,000 (~9.5M download, ~5.8M DB) + - seat of admin division down to third level (counties) + - ``xl`` - same as ``lg``, plus: + - cities with population > 500 (~12.1M download, ~7.2M DB) + - seat of admin division down to fourth order +* ``--db-pop``: Filter non-admin division seats providing a minimum city population size +* ``--db-force``: Force an existing database to be overwritten -You can specify minimum city size by adding ``--size SIZE``, where *SIZE* can be one of: +Database Usage +============== -- ``15000`` - cities with population > 15000 or country capitals -- ``5000`` - cities with population > 5000 or seat of first-order admin division, i.e. US state -- ``1000`` - cities with population > 1000 or seat of third order admin division -- ``500`` - cities with population > 500 or seat of fourth-order admin division +* Search: ``--db-search`` -Additionally, you can filter non-admin division seats using ``--pop POP``. + Once installed, you can search the database: -The appropriate GeoNames Gazetteer is downloaded and a Sqlite database generated. + .. code:: bash -Usage ------ + $ when --db-search New York + 5106292, West New York, West New York, US, New Jersey, America/New_York + 5128581, New York City, New York City, US, New York, America/New_York -Once installed, you can search the database:: +* Aliases: ``--db-alias`` - $ when --db-search New York - 5106292, West New York, West New York, US, New Jersey, America/New_York - 5128581, New York City, New York City, US, New York, America/New_York + You can add aliases for easier search. In the example directly above, we see that New York City has + a GeoNames ID of 5128581. Pass that to the ``--db-alias`` option along with another name that + you would like to use: + .. code:: bash -Additionally, you can add aliases. In the example directly above, we see that New York City has -a GeoNames ID of 5128581. Pass that to the ``--db-alias`` option along with another name that -you would like to use:: + $ when --db-alias 5128581 NYC + $ when --source NYC + 2023-07-06 07:58:33-0400 (EDT, America/New_York) 187d27w (New York City, New York, US)[🌕 Full Moon] - $ when --db-alias 5128581 NYC - $ when --source NYC - 2023-07-06 07:58:33-0400 (EDT, America/New_York) 187d27w (New York City, New York, US)[🌕 Full Moon] +* Alias listing: ``--db-aliases`` -Example -------- +Examples +======== For the sake of clarity, in the following examples I am in Seoul, Korea. @@ -101,24 +145,100 @@ For the sake of clarity, in the following examples I am in Seoul, Korea. 1945-03-07 22:00:00-0400 (EWT, America/New_York) 066d10w [🌘 Waning Crescent] -Develop -------- +Development +=========== -Requirements Python 3.10+. Also, [just](https://github.com/casey/just) for convenience. +Requires Python 3.10+ and just_ for convenience. .. code:: bash $ git clone git@github.com:dakrauth/when.git $ cd when - $ just - $ just venv + $ just # or just help + +Set up dev env: + +.. code:: bash + + $ just init + +Test, and code coverage: + +.. code:: bash + $ just test + $ just cov + +Only run a test matching matching a given substring: + +.. code:: bash + + $ just test -k test_sometest + +Interactive development: + +.. code:: bash + $ . ./venv/bin/activate $ when --help $ when --db Further Reading ---------------- +=============== + +`Time Zones Aren’t Offsets – Offsets Aren’t Time Zones`_ + +.. _pipx: https://pypa.github.io/pipx/ +.. _just: https://github.com/casey/just +.. _`Time Zones Aren’t Offsets – Offsets Aren’t Time Zones`: https://spin.atomicobject.com/time-zones-offsets/) +.. _GeoNames: https://www.geonames.org/export/ + +Complete Usage +============== + +.. code:: bash + + usage: when [--delta {long,short}] [--offset [+-]?(\d+wdhm)+] [-h] [--prefix] [-s SOURCE] [-t TARGET] [-f FORMAT] [-g] [--all] + [--holidays COUNTRY_CODE] [-v] [-V] [--json] [--config] [--db] [--db-force] [--db-search] [--exact] [--db-alias DB_ALIAS] + [--db-aliases] [--db-size DB_SIZE] [--db-pop DB_POP] [--tz-alias] [--fullmoon] + [timestr ...] + + Convert times to and from time zones or cities + + positional arguments: + timestr Timestamp to parse, defaults to local time + + options: + --delta {long,short} Show the delta to the given timestamp + --offset [+-]?(\d+wdhm)+ + Show the difference from a given offset + -h, --help Show helpful usage information + --prefix Show when's directory + -s SOURCE, --source SOURCE + Timezone / city to convert the timestr from, defaulting to local time + -t TARGET, --target TARGET + Timezone / city to convert the timestr to (globbing patterns allowed, can be comma delimited), defaulting to local + time + -f FORMAT, --format FORMAT + Output formatting. Additional formats can be shown using the -v option with -h + -g, --group Group sources together under same target results + --all Show times in all common timezones + --holidays COUNTRY_CODE + Show holidays for given country code. + -v, --verbosity Verbosity (-v, -vv, etc). Use -v to show `when` extension detailed help + -V, --version show program's version number and exit + --json Output results in nicely formatted JSON + --config Toggle config mode. With no other option, dump current configuration settings + --db Create cities database, used with --db-size and --db-pop + --db-force Force an existing database to be overwritten + --db-search Search database for the given city + --exact DB searches must be exact + --db-alias DB_ALIAS Create a new alias from the city id + --db-aliases Show all DB aliases + --db-size DB_SIZE Geonames file size. Can be one of 'xl' ('xlarge'), 'lg' ('large'), 'md' ('medium'), 'sm' ('small'). + --db-pop DB_POP City population minimum. + --tz-alias Search for a time zone alias + --fullmoon Show full moon(s) for given year or month. Can be in the format of: 'next' | 'prev' | YYYY[-MM] + + Use -v option for details -[Time Zones Aren’t Offsets – Offsets Aren’t Time Zones -](https://spin.atomicobject.com/time-zones-offsets/) diff --git a/src/when/cli.py b/src/when/cli.py index 43eb893..1ced84d 100755 --- a/src/when/cli.py +++ b/src/when/cli.py @@ -1,15 +1,13 @@ #!/usr/bin/env python import argparse -import logging import sys +import logging from pathlib import Path from . import __version__, core, db, utils, lunar, exceptions from . import timezones from .config import Settings, __doc__ as FORMAT_HELP -logger = logging.getLogger(__name__) - def get_parser(settings): class DBSizeAction(argparse.Action): @@ -227,11 +225,14 @@ def log_config(verbosity, settings): logging.getLogger("asyncio").setLevel(logging.WARNING) logging.basicConfig(level=log_level, format=log_format, force=True) + logger = utils.logger() logger.debug( "Configuration files read: %s", ", ".join(str(s) for s in settings.read_from) if settings.read_from else "None", ) + return logger + def main(sys_args, when=None, settings=None): if "--pdb" in sys_args: # pragma: no cover @@ -258,7 +259,7 @@ def main(sys_args, when=None, settings=None): parser = get_parser(settings) args = parser.parse_args(sys_args) - log_config(args.verbosity, settings) + logger = log_config(args.verbosity, settings) logger.debug(args) if args.help: parser.print_help() diff --git a/src/when/core.py b/src/when/core.py index 43a31a2..bbaa4bb 100644 --- a/src/when/core.py +++ b/src/when/core.py @@ -13,7 +13,7 @@ from .db import client from .lunar import lunar_phase -logger = logging.getLogger(__name__) +logger = utils.logger() def holidays(settings, co="US", ts=None): diff --git a/src/when/db/client.py b/src/when/db/client.py index f42e787..4b88b18 100644 --- a/src/when/db/client.py +++ b/src/when/db/client.py @@ -8,7 +8,7 @@ from .. import utils from ..exceptions import DBError -logger = logging.getLogger(__name__) +logger = utils.logger() DB_FILENAME = Path(__file__).parent / "when.db" DB_SCHEMA = """ @@ -41,7 +41,7 @@ SELECT c.id, c.name, c.ascii, c.sub, c.co, c.tz FROM city c WHERE - (c.id = :value OR c.name = :value OR c.ascii = :value) + (c.id = :value OR UPPER(c.name) = :value OR UPPER(c.ascii) = :value) """ ALIASES_LISTING_QUERY = """ @@ -165,6 +165,10 @@ def add_alias(self, name, gid): [(val.strip(), gid) for val in name.split(",")], ) + @property + def size(self): + return self.filename.stat().st_size if self.filename.exists() else 0 + @utils.timer def create_db(self, data, remove_existing=True): if self.filename.exists(): @@ -179,7 +183,7 @@ def create_db(self, data, remove_existing=True): cur.executemany("INSERT INTO city VALUES (?, ?, ?, ?, ?, ?, ?)", data) nrows = cur.rowcount - print(f"Inserted {nrows} rows") + logger.info(f"Inserted {nrows:,} rows ({self.size:,} bytes)") def _execute(self, con, sql, params): return con.execute(sql, params).fetchall() @@ -192,29 +196,32 @@ def _search(self, sql, value, params): return City.from_results(results) def parse_search(self, value): - bits = [a.strip() for a in value.split(",")] + bits = [a.strip().upper() for a in value.split(",")] nbits = len(bits) if nbits > 3: raise DBError(f"Invalid city search expression: {value}") match nbits: case 1: - return [value, None, None] + return [bits[0], None, None] case 2: - return [bits[0], bits[1], None] + return [bits[0], None, bits[1]] case 3: return bits def search(self, value, exact=False): - value, co, sub = self.parse_search(value) + value, sub, co = self.parse_search(value) if exact: + data = {"value": value} sql = XSEARCH_QUERY if co: - sql = f"{sql} AND c.co = :co AND c.sub = :sub" if sub else f"{sql} AND c.co = :co" + data["co"] = co + sql = f"{sql} AND UPPER(c.co) = :co" + if sub: + data["sub"] = sub + sql = f"{sql} AND UPPER(c.sub) = :sub" - return self._search( - sql, value, {"value": value, "co": co.upper() if co else co, "sub": sub} - ) + return self._search(sql, value, data) like_exprs = ["c.name LIKE :like", "c.ascii LIKE :like"] if co: @@ -230,7 +237,7 @@ def search(self, value, exact=False): { "like": f"%{value}%", "value": value, - "co": co.upper() if co else co, - "sub": sub.upper() if sub else sub, + "co": co, + "sub": sub, }, ) diff --git a/src/when/db/make.py b/src/when/db/make.py index e3c857b..a209948 100644 --- a/src/when/db/make.py +++ b/src/when/db/make.py @@ -1,12 +1,35 @@ import io +import time import logging import zipfile -from collections import defaultdict +from collections import defaultdict, namedtuple from pathlib import Path from .. import utils -logger = logging.getLogger(__name__) +logger = utils.logger() +GeoCity = namedtuple( + "GeoCity", + "gid," + "name," + "aname," + "alt," + "lat," + "lng," + "fclass," + "fcode," + "co," + "cc2," + "a1," + "a2," + "a3," + "a4," + "pop," + "el," + "dem," + "tz," + "mod" +) DB_DIR = Path(__file__).parent GEONAMES_CITIES_URL_FMT = "https://download.geonames.org/export/dump/cities{}.zip" @@ -27,7 +50,12 @@ def fetch_cities(size, dirname=DB_DIR): if txt_filename.exists(): return txt_filename - zip_bytes = utils.fetch(GEONAMES_CITIES_URL_FMT.format(size)) + url = GEONAMES_CITIES_URL_FMT.format(size) + logger.info(f"Beginning download from {url}") + start = time.time() + zip_bytes = utils.fetch(url) + end = time.time() + logger.info(f"Received {len(zip_bytes):,} bytes in {end-start:,}s") zip_filename = io.BytesIO(zip_bytes) with zipfile.ZipFile(zip_filename) as z: z.extract(txt_filename.name, txt_filename.parent) @@ -77,49 +105,28 @@ def process_geonames_txt(fobj, minimum_population=15_000, admin_1=None): skip_if = {"PPL", "PPLL", "PPLS", "PPLF", "PPLR"} admin_1 = admin_1 or {} data = [] - i = 0 - for line in fobj: + for i, line in enumerate(fobj, 1): i += 1 - ( - gid, - name, - aname, - alt, - lat, - lng, - fclass, - fcode, - co, - cc2, - a1, - a2, - a3, - a4, - pop, - el, - dem, - tz, - mod, - ) = line.rstrip().split("\t") - - pop = int(pop) if pop else 0 + ct = GeoCity(*line.rstrip().split("\t")) + + pop = int(ct.pop) if ct.pop else 0 if ( - (fcode in skip) - or (fcode in skip_if and (pop < minimum_population)) - or (fcode == "PPLA5" and name.startswith("Marseille") and name[-1].isdigit()) + (ct.fcode in skip) + or (ct.fcode in skip_if and (pop < minimum_population)) + or (ct.fcode == "PPLA5" and ct.name.startswith("Marseille") and ct.name[-1].isdigit()) ): - skipped[fcode] += 1 + skipped[ct.fcode] += 1 continue - fcodes[fcode] += 1 - sub = admin_1.get(f"{co}.{a1}", a1) - data.append([int(gid), name, aname, co, sub, tz, int(pop)]) + fcodes[ct.fcode] += 1 + sub = admin_1.get(f"{ct.co}.{ct.a1}", ct.a1) + data.append([int(ct.gid), ct.name, ct.aname, ct.co, sub, ct.tz, pop]) for title, dct in [["KEPT", fcodes], ["SKIP", skipped]]: for k, v in sorted(dct.items(), key=lambda kv: kv[1], reverse=True): logger.debug(f"{title} {k:5}: {v}") - logger.debug(f"Processed {i} lines, kept {len(data)}") + logger.info(f"Processed {i:,} lines, kept {len(data):,}") return data @@ -139,5 +146,6 @@ def fetch_admin_1(dirname=DB_DIR): else: txt = utils.fetch(GEONAMES_ADMIN1_URL).decode() filename.write_text(txt) + logger.info(f"Downloaded {len(txt):,} bytes from {GEONAMES_ADMIN1_URL}") return load_admin1(txt) diff --git a/src/when/utils.py b/src/when/utils.py index ecba71f..b9e0185 100644 --- a/src/when/utils.py +++ b/src/when/utils.py @@ -3,6 +3,8 @@ import re import sys import time +import logging +from functools import cache from datetime import datetime, timedelta from pathlib import Path @@ -15,6 +17,11 @@ from .exceptions import WhenError +@cache +def logger(): + return logging.getLogger("when") + + def gettz(name=None): tz = _gettz(name) if name is None: