From f61870108f65dec23f645f0b94ab8f80c138747e Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Tue, 14 Apr 2026 19:39:08 +0200 Subject: [PATCH 1/5] Update flynt --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c833b268..131e332d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ repos: # Can run individually with `flynt [file]` or `flynt [source]` - repo: https://github.com/ikamensh/flynt - rev: '1.0.1' + rev: '1.0.6' hooks: - id: flynt args: ["--fail-on-change", "--verbose"] From 7f3e02389285a452b44b7455a96fe9e58dc82e15 Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Tue, 14 Apr 2026 19:39:20 +0200 Subject: [PATCH 2/5] Handle unknown user in provenance --- geometric_features/utils.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/geometric_features/utils.py b/geometric_features/utils.py index 0cb8e6cb..6488e24a 100644 --- a/geometric_features/utils.py +++ b/geometric_features/utils.py @@ -42,8 +42,7 @@ def write_feature_names_and_tags(cacheLocation='./geometry_data', quiet=False): allFeaturesAndTags[componentName] = OrderedDict() if objectType not in allFeaturesAndTags[componentName]: allFeaturesAndTags[componentName][objectType] = OrderedDict() - allFeaturesAndTags[componentName][objectType][featureName] = \ - tags + allFeaturesAndTags[componentName][objectType][featureName] = tags outFile = open(outFileName, 'w') @@ -59,7 +58,13 @@ def provenance_command(): # Phillip J. Wolfram, Xylar Asay-Davis cwd = os.getcwd() - user = os.getenv('USER') + user = ( + os.getenv('USER') + or os.getenv('LOGNAME') + or os.getenv('LNAME') + or os.getenv('USERNAME') + or 'unknown-user' + ) curtime = datetime.datetime.now().strftime('%m/%d/%y %H:%M') call = ' '.join(sys.argv) host = socket.gethostname() From cb6862376f658ed166e7f86c54d90b4607b71600 Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Tue, 14 Apr 2026 19:40:51 +0200 Subject: [PATCH 3/5] Add a test for unknown user support --- .../test/test_feature_collection.py | 205 +++++++++--------- 1 file changed, 108 insertions(+), 97 deletions(-) diff --git a/geometric_features/test/test_feature_collection.py b/geometric_features/test/test_feature_collection.py index 75b5e794..0bc543cb 100644 --- a/geometric_features/test/test_feature_collection.py +++ b/geometric_features/test/test_feature_collection.py @@ -5,14 +5,16 @@ import shapely import shapely.geometry -from geometric_features import (FeatureCollection, GeometricFeatures, - read_feature_collection) +from geometric_features import ( + FeatureCollection, + GeometricFeatures, + read_feature_collection, +) from geometric_features.test import TestCase, loaddatadir # noqa: F401 @pytest.mark.usefixtures('loaddatadir') class TestFeatureCollection(TestCase): - @staticmethod def read_feature(region='Adriatic_Sea'): """ @@ -39,8 +41,9 @@ def read_feature(region='Adriatic_Sea'): return fc @staticmethod - def check_feature(feature, expected_name='Adriatic Sea', - expected_type='Polygon'): + def check_feature( + feature, expected_name='Adriatic Sea', expected_type='Polygon' + ): """ Check some properties of the feature @@ -73,8 +76,9 @@ def test_copy_features(self): Test copying the features in a feature collection """ fc = self.read_feature() - other = FeatureCollection(features=fc.features, - otherProperties=fc.otherProperties) + other = FeatureCollection( + features=fc.features, otherProperties=fc.otherProperties + ) assert len(other.features) == 1 feature = other.features[0] @@ -123,8 +127,10 @@ def test_add_tag(self): fc = self.read_feature(region='Adriatic_Sea') fc.tag(tags=['tag1', 'tag2', 'Mediterranean_Basin']) - assert (fc.features[0]['properties']['tags'] == - 'Adriatic_Sea;Mediterranean_Basin;tag1;tag2') + assert ( + fc.features[0]['properties']['tags'] + == 'Adriatic_Sea;Mediterranean_Basin;tag1;tag2' + ) self.check_feature(fc.features[0]) @@ -135,13 +141,17 @@ def test_remove_tag(self): fc = self.read_feature(region='Adriatic_Sea') fc.tag(tags=['Mediterranean_Basin', 'tag1'], remove=True) - assert (fc.features[0]['properties']['tags'] == 'Adriatic_Sea') + assert fc.features[0]['properties']['tags'] == 'Adriatic_Sea' self.check_feature(fc.features[0]) - def test_set_group_name(self, componentName='ocean', objectType='region', - featureName='Celtic Sea', - groupName='testGroupName'): + def test_set_group_name( + self, + componentName='ocean', + objectType='region', + featureName='Celtic Sea', + groupName='testGroupName', + ): """ Write example file to test groupName functionality. @@ -169,17 +179,20 @@ def test_set_group_name(self, componentName='ocean', objectType='region', def verify_groupName(destfile, groupName): with open(destfile) as f: filevals = json.load(f) - assert 'groupName' in filevals, \ + assert 'groupName' in filevals, ( f'groupName does not exist in {destfile}' - assert filevals['groupName'] == groupName, \ - 'Incorrect groupName of {} specified instead of ' \ + ) + assert filevals['groupName'] == groupName, ( + 'Incorrect groupName of {} specified instead of ' '{}.'.format(filevals['groupName'], groupName) + ) gf = GeometricFeatures() fc = gf.read(componentName, objectType, [featureName]) fc.set_group_name(groupName) - assert fc.otherProperties['groupName'] == groupName, \ + assert fc.otherProperties['groupName'] == groupName, ( 'groupName not assigned to FeatureCollection' + ) destfile = str(self.datadir.join('test.geojson')) fc.to_geojson(destfile) verify_groupName(destfile, groupName) @@ -194,8 +207,11 @@ def test_combine(self): name = 'Weird Disjoint Regions' combined = fc1.combine(name) assert len(combined.features) == 1 - self.check_feature(combined.features[0], expected_name=name, - expected_type='MultiPolygon') + self.check_feature( + combined.features[0], + expected_name=name, + expected_type='MultiPolygon', + ) def test_difference(self): """ @@ -205,9 +221,11 @@ def test_difference(self): mask = self.read_feature() difference = fc.difference(maskingFC=mask) assert len(difference.features) == 1 - self.check_feature(difference.features[0], - expected_name='Global Ocean', - expected_type='Polygon') + self.check_feature( + difference.features[0], + expected_name='Global Ocean', + expected_type='Polygon', + ) # make sure the original global ocean and mask have no holes for fc_test in [fc, mask]: @@ -236,52 +254,31 @@ def test_fix_antimeridian(self): 'tags': '', 'object': 'region', 'component': 'ocean', - 'author': 'Xylar Asay-Davis' + 'author': 'Xylar Asay-Davis', }, 'geometry': { 'type': 'Polygon', 'coordinates': [ [ - [ - 190.000000, - -70.000000 - ], - [ - 190.000000, - -90.000000 - ], - [ - 180.000000, - -90.000000 - ], - [ - 170.000000, - -90.000000 - ], - [ - 170.000000, - -70.000000 - ], - [ - 180.000000, - -70.000000 - ], - [ - 190.000000, - -70.000000 - ] + [190.000000, -70.000000], + [190.000000, -90.000000], + [180.000000, -90.000000], + [170.000000, -90.000000], + [170.000000, -70.000000], + [180.000000, -70.000000], + [190.000000, -70.000000], ] - ] - } + ], + }, } fc.add_feature(feature=feature) - self.check_feature(fc.features[0], - expected_name=name, - expected_type='Polygon') + self.check_feature( + fc.features[0], expected_name=name, expected_type='Polygon' + ) fixed = fc.fix_antimeridian() - self.check_feature(fixed.features[0], - expected_name=name, - expected_type='MultiPolygon') + self.check_feature( + fixed.features[0], expected_name=name, expected_type='MultiPolygon' + ) geom = globe.features[0]['geometry'] globe_shape = shapely.geometry.shape(geom) @@ -304,49 +301,28 @@ def test_simplify(self): 'tags': '', 'object': 'region', 'component': 'ocean', - 'author': 'Xylar Asay-Davis' + 'author': 'Xylar Asay-Davis', }, 'geometry': { 'type': 'Polygon', 'coordinates': [ [ - [ - 90.000000, - -70.000000 - ], - [ - 90.000000, - -80.000000 - ], - [ - 90.000000, - -80.000000 - ], - [ - 70.000000, - -80.000000 - ], - [ - 70.000000, - -70.000000 - ], - [ - 90.000000, - -70.000000 - ], - [ - 90.000000, - -70.000000 - ] + [90.000000, -70.000000], + [90.000000, -80.000000], + [90.000000, -80.000000], + [70.000000, -80.000000], + [70.000000, -70.000000], + [90.000000, -70.000000], + [90.000000, -70.000000], ] - ] - } + ], + }, } fc = FeatureCollection() fc.add_feature(feature=feature) - self.check_feature(fc.features[0], - expected_name=name, - expected_type='Polygon') + self.check_feature( + fc.features[0], expected_name=name, expected_type='Polygon' + ) # verify that the original shape has 7 coordinates (with 2 redundant # points) @@ -385,16 +361,51 @@ def test_to_geojson(self): fc_check = read_feature_collection(dest_filename) self.check_feature(fc_check.features[0]) + def test_to_geojson_without_user_env(self): + """ + Test writing a feature when username environment variables are unset + """ + fc = self.read_feature() + dest_filename = str(self.datadir.join('test_no_user.geojson')) + + saved_env = {} + for env_var in ['USER', 'LOGNAME', 'LNAME', 'USERNAME']: + saved_env[env_var] = os.environ.get(env_var) + + try: + for env_var in saved_env: + os.environ.pop(env_var, None) + fc.to_geojson(dest_filename) + finally: + for env_var, value in saved_env.items(): + if value is None: + os.environ.pop(env_var, None) + else: + os.environ[env_var] = value + + fc_check = read_feature_collection(dest_filename) + self.check_feature(fc_check.features[0]) + history = fc_check.features[0]['properties']['history'] + assert 'unknown-user' in history + def test_plot(self): fc = self.read_feature() - colors = ['#7fc97f', '#beaed4', '#fdc086', '#ffff99', '#386cb0', - '#f0027f', '#bf5b17'] + colors = [ + '#7fc97f', + '#beaed4', + '#fdc086', + '#ffff99', + '#386cb0', + '#f0027f', + '#bf5b17', + ] projection = 'cyl' - fig = fc.plot(projection, maxLength=4.0, figsize=(12, 12), - colors=colors, dpi=200) + fig = fc.plot( + projection, maxLength=4.0, figsize=(12, 12), colors=colors, dpi=200 + ) dest_filename = str(self.datadir.join('plot.png')) fig.savefig(dest_filename) From d0b87708347d44c03bd3352a69430e7ecf496764 Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Tue, 14 Apr 2026 19:47:00 +0200 Subject: [PATCH 4/5] Update to v1.6.2 --- geometric_features/version.py | 2 +- recipe/meta.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/geometric_features/version.py b/geometric_features/version.py index 0c2894a3..32af917a 100644 --- a/geometric_features/version.py +++ b/geometric_features/version.py @@ -1,2 +1,2 @@ -__version_info__ = (1, 6, 1) +__version_info__ = (1, 6, 2) __version__ = '.'.join(str(vi) for vi in __version_info__) diff --git a/recipe/meta.yaml b/recipe/meta.yaml index 03dc751f..bd6bf6ba 100644 --- a/recipe/meta.yaml +++ b/recipe/meta.yaml @@ -1,5 +1,5 @@ {% set name = "geometric_features" %} -{% set version = "1.6.1" %} +{% set version = "1.6.2" %} {% set build = 0 %} package: From 563308aa77a42f37745d4152f6084dc873f32add Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Tue, 14 Apr 2026 19:55:47 +0200 Subject: [PATCH 5/5] Fix looking up geometric_data in CI --- geometric_features/geometric_features.py | 92 ++++++++---- .../test/test_geometric_features.py | 132 +++++++++++++----- 2 files changed, 165 insertions(+), 59 deletions(-) diff --git a/geometric_features/geometric_features.py b/geometric_features/geometric_features.py index bfbf6ecd..3a8ed9e5 100644 --- a/geometric_features/geometric_features.py +++ b/geometric_features/geometric_features.py @@ -3,8 +3,10 @@ from importlib.resources import files as imp_res_files from geometric_features.download import download_files -from geometric_features.feature_collection import (FeatureCollection, - read_feature_collection) +from geometric_features.feature_collection import ( + FeatureCollection, + read_feature_collection, +) from geometric_features.version import __version__ @@ -25,6 +27,7 @@ class GeometricFeatures(object): from if files are missing from the local cache """ + # Authors # ------- # Xylar Asay-Davis @@ -47,10 +50,7 @@ def __init__(self, cacheLocation=None, remoteBranchOrTag=None): """ if cacheLocation is None: - if 'GEOMETRIC_DATA_DIR' in os.environ: - self.cacheLocation = os.environ['GEOMETRIC_DATA_DIR'] - else: - self.cacheLocation = './geometric_data' + self.cacheLocation = _get_default_cache_location() else: self.cacheLocation = cacheLocation if remoteBranchOrTag is None: @@ -58,13 +58,20 @@ def __init__(self, cacheLocation=None, remoteBranchOrTag=None): else: self.remoteBranch = remoteBranchOrTag - features_file = (imp_res_files('geometric_features') / - 'features_and_tags.json') + features_file = ( + imp_res_files('geometric_features') / 'features_and_tags.json' + ) with features_file.open('r') as file: self.allFeaturesAndTags = json.load(file) - def read(self, componentName, objectType, featureNames=None, tags=None, - allTags=True): + def read( + self, + componentName, + objectType, + featureNames=None, + tags=None, + allTags=True, + ): """ Read one or more features from the cached collection of geometric features. If any of the requested features have not been cached, they @@ -102,10 +109,12 @@ def read(self, componentName, objectType, featureNames=None, tags=None, # ------- # Xylar Asay-Davis - featureNames = self._get_feature_names(componentName, objectType, - featureNames, tags, allTags) - fileList = self._download_geometric_features(componentName, objectType, - featureNames) + featureNames = self._get_feature_names( + componentName, objectType, featureNames, tags, allTags + ) + fileList = self._download_geometric_features( + componentName, objectType, featureNames + ) fc = FeatureCollection() for fileName in fileList: @@ -144,8 +153,9 @@ def split(self, fc, destinationDir=None): componentName = feature['properties']['component'] objectType = feature['properties']['object'] - relativePath = _get_file_name(componentName, objectType, - featureName) + relativePath = _get_file_name( + componentName, objectType, featureName + ) fullPath = os.path.join(destinationDir, relativePath) path, file = os.path.split(fullPath) @@ -160,8 +170,9 @@ def split(self, fc, destinationDir=None): singleFC.to_geojson(fullPath, stripHistory=True) - def _download_geometric_features(self, componentName, objectType, - featureNames): + def _download_geometric_features( + self, componentName, objectType, featureNames + ): """ Determine a list of requested files and download the any that are missing from the repo @@ -197,23 +208,28 @@ def _download_geometric_features(self, componentName, objectType, fileList = [] filesToDownload = [] for featureName in featureNames: - relativePath = _get_file_name(componentName, objectType, - featureName) + relativePath = _get_file_name( + componentName, objectType, featureName + ) fullPath = os.path.join(self.cacheLocation, relativePath) fileList.append(fullPath) if not os.path.exists(fullPath): filesToDownload.append(relativePath) if len(filesToDownload) > 0: - baseURL = 'https://raw.githubusercontent.com/MPAS-Dev/' \ + baseURL = ( + 'https://raw.githubusercontent.com/MPAS-Dev/' 'geometric_features/{}/geometric_data'.format( - self.remoteBranch) + self.remoteBranch + ) + ) download_files(filesToDownload, baseURL, self.cacheLocation) return fileList - def _get_feature_names(self, componentName, objectType, featureNames, - tags, allTags): + def _get_feature_names( + self, componentName, objectType, featureNames, tags, allTags + ): """ Find features by name or tags, reporting errors in the process @@ -260,7 +276,8 @@ def _get_feature_names(self, componentName, objectType, featureNames, if objectType not in component: raise KeyError( - f'invalid object {objectType} in component {componentName}') + f'invalid object {objectType} in component {componentName}' + ) availableFeaturesAndTags = component[objectType] @@ -318,7 +335,28 @@ def _get_file_name(componentName, objectType, featureName): featureDir = featureName.strip().replace(' ', '_').strip('.') for char in badCharacters: featureDir = featureDir.replace(char, '') - fileName = os.path.join(componentName, objectType, featureDir, - f'{objectType}.geojson') + fileName = os.path.join( + componentName, objectType, featureDir, f'{objectType}.geojson' + ) return fileName + + +def _get_default_cache_location(): + """ + Get the default location of the local geometric features cache. + + Returns + ------- + cache_location : str + The default cache location + """ + if 'GEOMETRIC_DATA_DIR' in os.environ: + return os.environ['GEOMETRIC_DATA_DIR'] + + package_dir = os.path.dirname(os.path.abspath(__file__)) + repo_cache = os.path.join(os.path.dirname(package_dir), 'geometric_data') + if os.path.isdir(repo_cache): + return repo_cache + + return './geometric_data' diff --git a/geometric_features/test/test_geometric_features.py b/geometric_features/test/test_geometric_features.py index 4c21c634..428ef380 100644 --- a/geometric_features/test/test_geometric_features.py +++ b/geometric_features/test/test_geometric_features.py @@ -8,11 +8,13 @@ @pytest.mark.usefixtures('loaddatadir') class TestGeometricFeatures(TestCase): - @staticmethod - def check_feature(feature, expected_name='Celtic Sea', - expected_component='ocean', - expected_type='region'): + def check_feature( + feature, + expected_name='Celtic Sea', + expected_component='ocean', + expected_type='region', + ): """ Check some properties of the feature @@ -35,8 +37,9 @@ def check_feature(feature, expected_name='Celtic Sea', assert feature['properties']['component'] == expected_component assert feature['properties']['object'] == expected_type - def test_read_by_name(self, component='ocean', object_type='region', - feature='Celtic Sea'): + def test_read_by_name( + self, component='ocean', object_type='region', feature='Celtic Sea' + ): """ Read an example feature by name and test for a few expected attributes. @@ -54,14 +57,21 @@ def test_read_by_name(self, component='ocean', object_type='region', The name of a geometric feature to read """ gf = GeometricFeatures() - fc = gf.read(componentName=component, objectType=object_type, - featureNames=[feature]) - self.check_feature(fc.features[0], expected_name=feature, - expected_component=component, - expected_type=object_type) - - def test_read_by_tag(self, component='ocean', object_type='region', - tag='Adriatic_Sea'): + fc = gf.read( + componentName=component, + objectType=object_type, + featureNames=[feature], + ) + self.check_feature( + fc.features[0], + expected_name=feature, + expected_component=component, + expected_type=object_type, + ) + + def test_read_by_tag( + self, component='ocean', object_type='region', tag='Adriatic_Sea' + ): """ Read an example feature by name and test for a few expected attributes. @@ -79,14 +89,22 @@ def test_read_by_tag(self, component='ocean', object_type='region', The name of a tag to read """ gf = GeometricFeatures() - fc = gf.read(componentName=component, objectType=object_type, - tags=[tag]) - self.check_feature(fc.features[0], expected_name='Adriatic Sea', - expected_component=component, - expected_type=object_type) - - def test_read_all_tag(self, component='ocean', object_type='region', - tags=('Adriatic_Sea', 'Mediterranean_Basin')): + fc = gf.read( + componentName=component, objectType=object_type, tags=[tag] + ) + self.check_feature( + fc.features[0], + expected_name='Adriatic Sea', + expected_component=component, + expected_type=object_type, + ) + + def test_read_all_tag( + self, + component='ocean', + object_type='region', + tags=('Adriatic_Sea', 'Mediterranean_Basin'), + ): """ Read an example feature by name and test for a few expected attributes. @@ -104,15 +122,26 @@ def test_read_all_tag(self, component='ocean', object_type='region', The names of tags to read """ gf = GeometricFeatures() - fc = gf.read(componentName=component, objectType=object_type, - tags=tags, allTags=True) + fc = gf.read( + componentName=component, + objectType=object_type, + tags=tags, + allTags=True, + ) assert len(fc.features) == 1 - self.check_feature(fc.features[0], expected_name='Adriatic Sea', - expected_component=component, - expected_type=object_type) - - def test_split(self, component='ocean', object_type='region', - tag='Mediterranean_Basin'): + self.check_feature( + fc.features[0], + expected_name='Adriatic Sea', + expected_component=component, + expected_type=object_type, + ) + + def test_split( + self, + component='ocean', + object_type='region', + tag='Mediterranean_Basin', + ): """ Read an example feature by name and test for a few expected attributes. @@ -130,8 +159,9 @@ def test_split(self, component='ocean', object_type='region', The name of a tag to read """ gf = GeometricFeatures() - fc = gf.read(componentName=component, objectType=object_type, - tags=[tag]) + fc = gf.read( + componentName=component, objectType=object_type, tags=[tag] + ) gf.split(fc, destinationDir=self.datadir) @@ -140,3 +170,41 @@ def test_split(self, component='ocean', object_type='region', subdir = name.replace(' ', '_') path = f'{self.datadir}/{component}/{object_type}/{subdir}/{object_type}.geojson' # noqa: E501 assert os.path.exists(path) + + def test_read_by_name_from_outside_repo_data_dir( + self, component='ocean', object_type='region', feature='Celtic Sea' + ): + """ + Read a feature while the current working directory is outside the repo. + + Parameters + ---------- + component : str, optional + The component from which to retrieve the feature + + object_type : {'point', 'transect', 'region'}, optional + The type of geometry to load, a point (0D), transect (1D) or region + (2D) + + feature : str, optional + The name of a geometric feature to read + """ + cwd = os.getcwd() + os.chdir(self.datadir) + + try: + gf = GeometricFeatures() + fc = gf.read( + componentName=component, + objectType=object_type, + featureNames=[feature], + ) + finally: + os.chdir(cwd) + + self.check_feature( + fc.features[0], + expected_name=feature, + expected_component=component, + expected_type=object_type, + )