From 35eea0aa5411c3d8a3153e22679fa58c5921bd45 Mon Sep 17 00:00:00 2001 From: Benjamin Webb Date: Mon, 17 Jul 2023 11:16:35 -0400 Subject: [PATCH 1/3] Use Click in yourls_client.py --- build/Dockerfile | 2 +- build/requirements.txt | 1 + build/yourls_client.py | 82 +++++++++++++++++------------------------- 3 files changed, 34 insertions(+), 51 deletions(-) diff --git a/build/Dockerfile b/build/Dockerfile index 2ad9d85..740125d 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -1,4 +1,4 @@ -FROM python +FROM python:3.9.13-slim RUN mkdir /build COPY . /build/ diff --git a/build/requirements.txt b/build/requirements.txt index 9c8e545..eaf4ea8 100644 --- a/build/requirements.txt +++ b/build/requirements.txt @@ -1,2 +1,3 @@ +click pyourls3==1.0.1 mysql-connector diff --git a/build/yourls_client.py b/build/yourls_client.py index a074873..5265cfc 100644 --- a/build/yourls_client.py +++ b/build/yourls_client.py @@ -1,8 +1,8 @@ # ================================================================= # -# Authors: Benjamin Webb +# Authors: Benjamin Webb # -# Copyright (c) 2021 Benjamin Webb +# Copyright (c) 2023 Benjamin Webb # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation @@ -27,14 +27,15 @@ # # ================================================================= +import click import os +from pathlib import Path import yourls_api -import time -import argparse CSV = 'csv' XML = 'xml' + def walk_path(path, t=CSV): """ Walks os directory path collecting all CSV files. @@ -48,58 +49,39 @@ def walk_path(path, t=CSV): if name.startswith('example'): continue elif name.endswith(t): - file_list.append(os.path.join(root, name)) + file_list.append(Path(os.path.join(root, name))) return file_list -def make_parser(): - """ - Creates and argv parser object. - - :return: ArgumentParser. with defaults if not specified. - """ - parser = argparse.ArgumentParser(description='Upload csv files to yourls database') - parser.add_argument('path', type=str, nargs='+', - help='path to csv files. accepts directory, url, and .csv paths') - parser.add_argument('-s','--uri_stem', action='store', dest='uri_stem', type=str, - default='https://geoconnex.us/', - help='uri stem to be removed from short url for keyword') - parser.add_argument('-k','--keyword', action='store', dest='keyword', type=str, - default='id', - help='field in CSV to be used as keyword') - parser.add_argument('-l','--long_url', action='store', dest='url', type=str, - default='target', - help='field in CSV to be used as long url') - parser.add_argument('-t','--title', action='store', dest='title', type=str, - default='description', - help='field in CSV to be used as title') - parser.add_argument('-a','--addr', action='store', dest='addr', type=str, - default='http://localhost:8082/', - help='yourls database hostname') - parser.add_argument('-u','--user', action='store', dest='user', type=str, - default='yourls-admin', - help='user for yourls database') - parser.add_argument('-p','--pass', action='store', dest='passwd', type=str, - default='apassword', - help='password for yourls database') - parser.add_argument('--key', action='store', dest='key', - default=None, - help='password for yourls database') - return parser - -def main(): - parser = make_parser() - kwargs = parser.parse_args() - - urls = yourls_api.yourls( **vars(kwargs) ) - time.sleep(10) - for p in kwargs.path: +@click.command() +@click.pass_context +@click.argument('path', type=str, nargs=-1) +@click.option('-s', '--uri_stem', type=str, default='https://geoconnex.us/', + help='uri stem to be removed from short url for keyword') +@click.option('-k', '--keyword', type=str, default='id', + help='field in CSV to be used as keyword') +@click.option('-l', '--long_url', type=str, default='target', + help='field in CSV to be used as long url') +@click.option('-t', '--title', type=str, default='description', + help='field in CSV to be used as title') +@click.option('-a', '--addr', type=str, default='http://localhost:8082/', + help='yourls database hostname') +@click.option('-u', '--user', type=str, default='yourls-admin', + help='user for yourls database') +@click.option('-p', '--passwd', type=str, default='apassword', + help='password for yourls database') +@click.option('--to-db', type=bool, default=True, + help='Attempt to connect to database') +def run(ctx, **kwargs): + urls = yourls_api.yourls(**kwargs) + for p in kwargs['path']: if p.endswith('.csv'): - urls.handle_csv( p ) + urls.handle_csv(p) else: urls.handle_csv(walk_path(p)) - urls.make_sitemap(walk_path(p,t=XML)) + urls.make_sitemap(walk_path(p, t=XML)) + if __name__ == "__main__": - main() + run() From 889762bd8fd9cd39a06d451ab817f11bfd3b9462 Mon Sep 17 00:00:00 2001 From: Benjamin Webb Date: Mon, 17 Jul 2023 11:22:23 -0400 Subject: [PATCH 2/3] Improve yourls_api.py - Cleanup yourls_api.py (pass flake8) - Improve lastmod detection during sitemap creation --- .github/workflows/build.yml | 6 +- build/yourls_api.py | 222 +++++++++++++++++++++--------------- 2 files changed, 134 insertions(+), 94 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3b75356..e49227b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -40,7 +40,7 @@ jobs: YOURLS_DB_USER: ${{ secrets.YOURLS_DB_USER }} YOURLS_DB_PASSWORD: ${{ secrets.YOURLS_DB_PASSWORD }} run: | - docker compose -p pidsgeoconnexus up -d --build + docker compose up -d --build docker build -t build_yourls_uploader ./build/ - name: Fill Yourls table with data from namespaces @@ -50,11 +50,11 @@ jobs: run: | docker run --rm --network=pidsgeoconnexus_yourls_host --name build_yourls \ --env YOURLS_DB_PASSWORD=${YOURLS_DB_PASSWORD:-arootpassword} --env YOURLS_DB_USER=${YOURLS_DB_USER:-root} \ - -v /sitemap:/sitemap build_yourls_uploader + -v ./sitemap:/sitemap build_yourls_uploader docker exec pidsgeoconnexus-mysql-1 sh -c "mysqldump --databases yourls -u ${YOURLS_DB_USER:-root} --password=${YOURLS_DB_PASSWORD:-arootpassword} --hex-blob --default-character-set=utf8mb4 --skip-triggers --set-gtid-purged=OFF | gzip > /docker-entrypoint-initdb.d/yourls.sql.gz" mkdir backup cp ./mysql/yourls.sql.gz backup/yourls.sql.gz - cp -R /sitemap yourls/sitemap + cp -R ./sitemap yourls/sitemap docker compose down - name: Zip sitemap diff --git a/build/yourls_api.py b/build/yourls_api.py index 2d47888..d58e4e8 100644 --- a/build/yourls_api.py +++ b/build/yourls_api.py @@ -1,8 +1,8 @@ # ================================================================= # -# Authors: Benjamin Webb +# Authors: Benjamin Webb # -# Copyright (c) 2021 Benjamin Webb +# Copyright (c) 2023 Benjamin Webb # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation @@ -27,34 +27,57 @@ # # ================================================================= -from pyourls3.client import * -import mysql.connector -from datetime import datetime as dt -import os + import csv import json +from datetime import datetime as dt +import mysql.connector +import os +from pathlib import Path +from pyourls3.client import exceptions, Yourls +import requests +from shutil import copy2 +import time import xml.etree.ElementTree as ET -from shutil import copyfile -SITEMAP = '/sitemap/' URI_STEM = os.environ.get('URI_STEM', 'https://geoconnex.us') -SITEMAP_FOREACH = "\n\t\n\t\t {} \n\t\t {} \n\t\n" -URLSET_FOREACH = "\n\t\n\t\t {} \n\t\t {} \n\t\n" - -# https://stackoverflow.com/questions/60286623/python-loses-connection-to-mysql-database-after-about-a-day -mydb = mysql.connector.connect( - host=os.environ.get('YOURLS_DB_HOST') or 'mysql', - user=os.environ.get('YOURLS_DB_USER') or 'root', - password=os.environ.get('YOURLS_DB_PASSWORD') or 'arootpassword', - database="yourls", - pool_name="yourls_loader", - pool_size = 3 -) +SITEMAP = Path('/sitemap') +SITEMAP_ARGS = {'encoding': 'utf-8', 'xml_declaration': True} +SITEMAP_FOREACH = ''' + + {} + {} +\n +''' +URLSET_FOREACH = ''' + + {} + {} + +''' + + +try: + # https://stackoverflow.com/questions/60286623/python-loses-connection-to-mysql-database-after-about-a-day + mysql.connector.connect( + host=os.environ.get('YOURLS_DB_HOST') or 'mysql', + user=os.environ.get('YOURLS_DB_USER') or 'root', + password=os.environ.get('YOURLS_DB_PASSWORD') or 'arootpassword', + database="yourls", + pool_name="yourls_loader", + pool_size=3 + ) +except Exception as err: + print(err) + print('Unable to connect to database') + + def connection(): """Get a connection and a cursor from the pool""" - db = mysql.connector.connect(pool_name = 'yourls_loader') + db = mysql.connector.connect(pool_name='yourls_loader') return (db, db.cursor()) + def url_join(*parts): """ helper function to join a URL from a number of parts/fragments. @@ -66,11 +89,11 @@ def url_join(*parts): """ return '/'.join([p.strip().strip('/') for p in parts]) + class yourls(Yourls): def __init__(self, **kwargs): self.kwargs = kwargs - self.__to_db = kwargs.get('_via_yourls_', True) - + self.__to_db = kwargs.get('to_db',) if self.__to_db: mydb, cursor = connection() sql_statement = 'DELETE FROM yourls_url WHERE ip = "0.0.0.0"' @@ -80,7 +103,7 @@ def __init__(self, **kwargs): cursor.close() mydb.close() else: - _ = self._check_kwargs(('addr', 'user', 'passwd', 'key')) + _ = self._check_kwargs(('addr', 'user', 'passwd')) Yourls.__init__(self, *[v for k, v in _]) def _check_kwargs(self, keys): @@ -88,7 +111,8 @@ def _check_kwargs(self, keys): Parses kwargs for desired keys. :param keys: required, list. List of keys to retried from **kwargs. - :return: generator. Generator of key value pairs for each key in **kwargs. + + :return: generator. key value pairs for each key in **kwargs. :raises: pyourls3.exceptions.Pyourls3ParamError """ @@ -104,7 +128,8 @@ def check_kwargs(self, keys, **kwargs): :param keys: required, list. List of keys to retried from **kwargs. :param **kwargs: required, dict. - :return: generator. Generator of key value pairs for each key in **kwargs. + + :return: generator. key value pairs for each key in **kwargs. :raises: pyourls3.exceptions.Pyourls3ParamError """ @@ -118,36 +143,40 @@ def shorten_quick(self, **kwargs): """ Sends an API request to shorten a specified URL. - :param **kwargs: required, dict. Expects url, keyword, and title to be specified. - :return: dictionary. Full JSON response from the API, parsed into a dict + :param **kwargs: required, dict. Expects url, keyword, and title. + + :return: dictionary. Full JSON response from the API - :raises: pyourls3.exceptions.Pyourls3ParamError, pyourls3.exceptions.Pyourls3HTTPError, + :raises: pyourls3.exceptions.Pyourls3ParamError, pyourls3.exceptions.Pyourls3APIError """ _ = self.check_kwargs(('url', 'keyword', 'title'), **kwargs) specific_args = {'action': 'shorten_quick', **{k: v for k, v in _}} - r = requests.post(self.api_endpoint, data={**self.global_args, **specific_args}) + r = requests.post(self.api_endpoint, data={ + **self.global_args, **specific_args}) try: j = r.json() except json.decoder.JSONDecodeError: - raise exceptions.Pyourls3HTTPError(r.status_code, self.api_endpoint) + raise exceptions.Pyourls3HTTPError( + r.status_code, self.api_endpoint) if j.get("status") == "success": return j else: - raise exceptions.Pyourls3APIError(j["message"], j.get("code", j.get("errorCode"))) + raise exceptions.Pyourls3APIError( + j["message"], j.get("code", j.get("errorCode"))) - - def shorten_csv(self, filename, csv = ''): + def shorten_csv(self, filename, csv=''): """ Sends an API request to shorten a specified CSV. :param filename: required, string. Name of CSV to be shortened. :param csv: optional, list. Pre-parsed csv as list of strings. - :return: dictionary. Full JSON response from the API, parsed into a dict - :raises: pyourls3.exceptions.Pyourls3ParamError, pyourls3.exceptions.Pyourls3HTTPError, + :return: dictionary. Full JSON response from the API + + :raises: pyourls3.exceptions.Pyourls3ParamError, pyourls3.exceptions.Pyourls3APIError """ if not filename: @@ -167,22 +196,25 @@ def shorten_csv(self, filename, csv = ''): try: j = r.json() except json.decoder.JSONDecodeError: - raise exceptions.Pyourls3HTTPError(r.status_code, self.api_endpoint) + raise exceptions.Pyourls3HTTPError( + r.status_code, self.api_endpoint) if j.get("status") == "success": return j else: - raise exceptions.Pyourls3APIError(j["message"], j.get("code", j.get("errorCode"))) + raise exceptions.Pyourls3APIError( + j["message"], j.get("code", j.get("errorCode"))) - def post_mysql(self, filename, csv_ = ''): + def post_mysql(self, filename, csv_=''): """ Sends an API request to shorten a specified CSV. :param filename: required, string. Name of CSV to be shortened. :param csv_: optional, list. Pre-parsed csv as list of strings. - :return: dictionary. Full JSON response from the API, parsed into a dict - :raises: pyourls3.exceptions.Pyourls3ParamError, pyourls3.exceptions.Pyourls3HTTPError, + :return: dictionary. Full JSON response from the API + + :raises: pyourls3.exceptions.Pyourls3ParamError, pyourls3.exceptions.Pyourls3APIError """ print(filename) @@ -191,7 +223,7 @@ def post_mysql(self, filename, csv_ = ''): # Clean input for inserting time_ = self._get_filetime(filename) - extra = [time_,'0.0.0.0', 0] + extra = [time_, '0.0.0.0', 0] file = csv_ if csv_ else open(filename, 'r') lines = file.split("\n") split_ = [line.split(',') for line in lines[:-1]] @@ -205,20 +237,20 @@ def post_mysql(self, filename, csv_ = ''): # Commit file to database SQL_STATEMENT = ("INSERT INTO yourls_url " - "(`keyword`, `url`, `title`, `timestamp`, `ip`, `clicks`)" - "VALUES (%s, %s, %s, %s, %s, %s)") + "(`keyword`, `url`, `title`, `timestamp`, `ip`, `clicks`)" # noqa + "VALUES (%s, %s, %s, %s, %s, %s)") mydb, cursor = connection() try: cursor.executemany(SQL_STATEMENT, split_) except mysql.connector.errors.ProgrammingError: - [print(l) if l != 6 else None for l in split_] - + print(split_) + mydb.commit() # print(cursor.rowcount, "was inserted.") cursor.close() mydb.close() - def _make_sitemap(self, filename, csv_ = ''): + def _make_sitemap(self, filename, csv_=''): """ Create sitmap.xml from csv file. @@ -226,64 +258,73 @@ def _make_sitemap(self, filename, csv_ = ''): :param csv_: optional, list. Pre-parsed csv as list of strings. :return: None. - :raises: pyourls3.exceptions.Pyourls3ParamError, pyourls3.exceptions.Pyourls3HTTPError, + :raises: pyourls3.exceptions.Pyourls3ParamError, pyourls3.exceptions.Pyourls3APIError """ if not filename: raise exceptions.Pyourls3ParamError('filename') - fname_ = filename.split('_')[0] - file = csv_ if csv_ else open(fname_, 'r') - lines = file.split("\n") - split_ = [line.split(',').pop(0) for line in lines[:-1]] - - # Build sitemaps for each csv file - tree = ET.parse('./sitemap-url.xml') - sitemap = tree.getroot() - for line in split_: - if '$' in line: - return - - time_ = self._get_filetime(fname_) - url_ = url_join(URI_STEM, line) - t = URLSET_FOREACH.format(url_, time_) - link_xml = ET.fromstring(t) - sitemap.append(link_xml) - # Write sitemap.xml - tree.write(f'{filename}.xml', encoding='utf-8', xml_declaration=True) + file = csv_ if csv_ else open(filename, 'r') + chunky_parsed = self.chunkify(file, 50000) + for i, chunk in enumerate(chunky_parsed): + lines = chunk.split("\n") + split_ = [line.split(',').pop(0) for line in lines[:-1]] + + # Build sitemaps for each csv file + tree = ET.parse('./sitemap-url.xml') + ET.indent(tree, ' ') + sitemap = tree.getroot() + for line in split_: + if '$' in line: + return + + time_ = self._get_filetime(filename) + url_ = url_join(URI_STEM, line) + t = URLSET_FOREACH.format(url_, time_) + + link_xml = ET.fromstring(t) + sitemap.append(link_xml) + + # Write sitemap.xml + fidx = f'{filename.stem}__{i}' + sitemap_time = os.path.getmtime(filename) + sitemap_file = (filename.parent / fidx).with_suffix('.xml') + tree.write(sitemap_file, **SITEMAP_ARGS) + os.utime(sitemap_file, (time.time(), sitemap_time)) def make_sitemap(self, files): tree = ET.parse('./sitemap-schema.xml') sitemap = tree.getroot() for f in files: # Make sure file is sitemap - format_ = f[:-4].split('__').pop() try: - int(format_) + int(f.stem.split('__').pop()) except ValueError: continue # Check buildpath - _ = f.split('/') - name_ = _.pop() - parent = '/'.join(_[_.index('namespaces')+1:]) - path_ = f'/sitemap/{parent}' - if not os.path.exists(path_): - os.makedirs(path_) + try: + parent = f.parent.relative_to("namespaces") + except ValueError: + parent = f.parent.relative_to("/build/namespaces") + path_ = (SITEMAP / parent) + path_.mkdir(parents=True, exist_ok=True) # Copy xml to /sitemaps - fpath_ = f'{path_}/{name_}' - copyfile(f, fpath_) + fpath_ = path_ / f.name + copy2(f, fpath_) # create to link /sitemap/_sitemap.xml time_ = self._get_filetime(fpath_) - url_ = url_join(URI_STEM, fpath_) + url_ = url_join(URI_STEM, str(fpath_)) t = SITEMAP_FOREACH.format(url_, time_) link_xml = ET.fromstring(t) sitemap.append(link_xml) + ET.indent(tree, ' ') - tree.write('/sitemap/_sitemap.xml', encoding='utf-8', xml_declaration=True) + sitemap_out = SITEMAP / '_sitemap.xml' + tree.write(sitemap_out, **SITEMAP_ARGS) print('finished task') def _get_filetime(self, fpath_): @@ -307,7 +348,7 @@ def handle_csv(self, file): """ Parses and shortens CSV file. - :param file: required, string or list of strings. Name of csv files to be shortened + :param file: required, name of csv to be shortened """ if isinstance(file, list): self._handle_csvs(file) @@ -315,19 +356,17 @@ def handle_csv(self, file): parsed_csv = self.parse_csv(file) if self.__to_db: - chunky_parsed = self.chunkify( parsed_csv, 10000) + chunky_parsed = self.chunkify(parsed_csv, 10000) for chunk in chunky_parsed: self.post_mysql(file, chunk) else: - chunky_parsed = self.chunkify( parsed_csv ) + chunky_parsed = self.chunkify(parsed_csv) for chunk in chunky_parsed: self.shorten_csv(file, chunk) - - chunky_parsed = self.chunkify( parsed_csv, 50000) - for i, chunk in enumerate(chunky_parsed): - self._make_sitemap(f'{file[:-4]}__{i}', chunk) - + + self._make_sitemap(file, parsed_csv) + def parse_csv(self, filename): """ Parse CSV file into yourls-friendly csv. @@ -335,7 +374,7 @@ def parse_csv(self, filename): :param filename: required, string. URL to be shortened. :return: list. Parsed csv. """ - _ = self._check_kwargs(('keyword', 'url', 'title')) + _ = self._check_kwargs(('keyword', 'long_url', 'title')) vals = {k: v for k, v in _} try: @@ -352,11 +391,12 @@ def parse_csv(self, filename): parsed_line = [] for k, v in vals.items(): try: - parsed_line.append( line[headers.index(v)].strip() ) + parsed_line.append(line[headers.index(v)].strip()) except (ValueError, IndexError): continue - _ = self._check_kwargs(['uri_stem',]) - ret_csv.append((','.join(parsed_line) + '\n').replace(*[v for k, v in _], '')) + _ = self._check_kwargs(['uri_stem', ]) + ret_csv.append( + (','.join(parsed_line) + '\n').replace(*[v for k, v in _], '')) if not r: fp.close() From f3d24ad505463dfecf1aaf8060957e36cb511a54 Mon Sep 17 00:00:00 2001 From: Benjamin Webb Date: Mon, 17 Jul 2023 14:04:18 -0400 Subject: [PATCH 3/3] Use time to last commit for sitemap lastmod time --- build/Dockerfile | 6 ++++++ build/requirements.txt | 1 + build/yourls_api.py | 26 ++++++++++++++++++++------ 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/build/Dockerfile b/build/Dockerfile index 740125d..ef8b487 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -1,5 +1,11 @@ FROM python:3.9.13-slim +RUN \ + apt-get update -y \ + && apt-get upgrade -y \ + && apt-get install -y git \ + && git clone -b master https://github.com/internetofwater/geoconnex.us.git /geoconnex.us + RUN mkdir /build COPY . /build/ diff --git a/build/requirements.txt b/build/requirements.txt index eaf4ea8..3dae4b0 100644 --- a/build/requirements.txt +++ b/build/requirements.txt @@ -1,3 +1,4 @@ click +GitPython pyourls3==1.0.1 mysql-connector diff --git a/build/yourls_api.py b/build/yourls_api.py index d58e4e8..af27fa3 100644 --- a/build/yourls_api.py +++ b/build/yourls_api.py @@ -29,6 +29,7 @@ import csv +from git import Repo import json from datetime import datetime as dt import mysql.connector @@ -91,9 +92,12 @@ def url_join(*parts): class yourls(Yourls): + geoconnex = Repo('/geoconnex.us') + def __init__(self, **kwargs): + self.tree = self.geoconnex.heads.master.commit.tree self.kwargs = kwargs - self.__to_db = kwargs.get('to_db',) + self.__to_db = kwargs.get('to_db', True) if self.__to_db: mydb, cursor = connection() sql_statement = 'DELETE FROM yourls_url WHERE ip = "0.0.0.0"' @@ -266,6 +270,7 @@ def _make_sitemap(self, filename, csv_=''): file = csv_ if csv_ else open(filename, 'r') chunky_parsed = self.chunkify(file, 50000) + file_time = self._get_filetime(filename) for i, chunk in enumerate(chunky_parsed): lines = chunk.split("\n") split_ = [line.split(',').pop(0) for line in lines[:-1]] @@ -278,19 +283,18 @@ def _make_sitemap(self, filename, csv_=''): if '$' in line: return - time_ = self._get_filetime(filename) url_ = url_join(URI_STEM, line) - t = URLSET_FOREACH.format(url_, time_) + t = URLSET_FOREACH.format( + url_, file_time.strftime('%Y-%m-%dT%H:%M:%SZ')) link_xml = ET.fromstring(t) sitemap.append(link_xml) # Write sitemap.xml fidx = f'{filename.stem}__{i}' - sitemap_time = os.path.getmtime(filename) sitemap_file = (filename.parent / fidx).with_suffix('.xml') tree.write(sitemap_file, **SITEMAP_ARGS) - os.utime(sitemap_file, (time.time(), sitemap_time)) + os.utime(sitemap_file, (time.time(), file_time.timestamp())) def make_sitemap(self, files): tree = ET.parse('./sitemap-schema.xml') @@ -315,7 +319,7 @@ def make_sitemap(self, files): copy2(f, fpath_) # create to link /sitemap/_sitemap.xml - time_ = self._get_filetime(fpath_) + time_ = self._get_filetime(fpath_).strftime('%Y-%m-%dT%H:%M:%SZ') url_ = url_join(URI_STEM, str(fpath_)) t = SITEMAP_FOREACH.format(url_, time_) @@ -329,6 +333,16 @@ def make_sitemap(self, files): def _get_filetime(self, fpath_): try: + path_ = fpath_.relative_to(SITEMAP) + except ValueError: + path_ = fpath_.relative_to("/build/namespaces") + + try: + blob = (self.tree / "namespaces" / f"{path_}") + commit = next(self.geoconnex.iter_commits( + paths=blob.path, max_count=1)) + time_ = commit.committed_datetime + except KeyError: _ = os.path.getmtime(fpath_) time_ = dt.fromtimestamp(_) except OSError: