nefino-geosync 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of nefino-geosync might be problematic. Click here for more details.
- nefino_geosync/__init__.py +0 -0
- nefino_geosync/access_rule_filter.py +11 -0
- nefino_geosync/api_client.py +87 -0
- nefino_geosync/compose_requests.py +68 -0
- nefino_geosync/config.py +82 -0
- nefino_geosync/download_analysis.py +70 -0
- nefino_geosync/download_completed_analyses.py +19 -0
- nefino_geosync/get_downloadable_analyses.py +45 -0
- nefino_geosync/graphql_errors.py +40 -0
- nefino_geosync/journal.py +120 -0
- nefino_geosync/parse_args.py +15 -0
- nefino_geosync/run.py +28 -0
- nefino_geosync/schema.json +2309 -0
- nefino_geosync/schema.py +220 -0
- nefino_geosync/start_analyses.py +48 -0
- nefino_geosync/storage.py +40 -0
- nefino_geosync-0.1.0.dist-info/METADATA +271 -0
- nefino_geosync-0.1.0.dist-info/RECORD +21 -0
- nefino_geosync-0.1.0.dist-info/WHEEL +4 -0
- nefino_geosync-0.1.0.dist-info/entry_points.txt +2 -0
- nefino_geosync-0.1.0.dist-info/licenses/LICENSE +201 -0
|
File without changes
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
|
|
2
|
+
class AccessRuleFilter:
|
|
3
|
+
def __init__(self, access_rules):
|
|
4
|
+
self.access_rules = access_rules
|
|
5
|
+
|
|
6
|
+
def check(self, place, cluster):
|
|
7
|
+
for access_rule in self.access_rules:
|
|
8
|
+
if place in access_rule.places:
|
|
9
|
+
if access_rule.all_clusters_enabled or cluster in access_rule.clusters:
|
|
10
|
+
return True
|
|
11
|
+
return False
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""This module handles the API client for the Nefino API.
|
|
2
|
+
If you want to use the Nefino API for something other than fetching the latest geodata,
|
|
3
|
+
you can use this client to interact with the API directly."""
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List
|
|
6
|
+
from .schema import GeoAnalysisInput, PlaceTypeGeo, schema
|
|
7
|
+
from sgqlc.endpoint.http import HTTPEndpoint
|
|
8
|
+
from sgqlc.operation import Operation
|
|
9
|
+
from .config import Config
|
|
10
|
+
|
|
11
|
+
def get_client(api_host: str="https://api.nefino.li") -> HTTPEndpoint:
|
|
12
|
+
"""Returns an HTTP client for the Nefino API."""
|
|
13
|
+
headers = {'Authorization': Config.singleton().api_key}
|
|
14
|
+
return HTTPEndpoint(f'{api_host}/external', headers)
|
|
15
|
+
|
|
16
|
+
def general_availability_operation() -> Operation:
|
|
17
|
+
"""Returns the general availability of layers and access permissions from Nefino API."""
|
|
18
|
+
operation = Operation(schema.Query)
|
|
19
|
+
analysis_areas = operation.allowed_analysis_areas()
|
|
20
|
+
analysis_areas.all_areas_enabled()
|
|
21
|
+
analysis_areas.enabled_states().place_id()
|
|
22
|
+
|
|
23
|
+
access_rules = operation.access_rules()
|
|
24
|
+
access_rules.all_clusters_enabled()
|
|
25
|
+
access_rules.clusters()
|
|
26
|
+
access_rules.places()
|
|
27
|
+
|
|
28
|
+
clusters = operation.clusters()
|
|
29
|
+
clusters.name()
|
|
30
|
+
clusters.has_access()
|
|
31
|
+
layers = clusters.layers()
|
|
32
|
+
layers.name()
|
|
33
|
+
layers.last_update()
|
|
34
|
+
layers.is_regional()
|
|
35
|
+
layers.pre_buffer()
|
|
36
|
+
return operation
|
|
37
|
+
|
|
38
|
+
# any is the most specific type we can write for the results from the availability query
|
|
39
|
+
# this is a limitation of sgqlc types
|
|
40
|
+
# GitHub issue: https://github.com/profusion/sgqlc/issues/129
|
|
41
|
+
GeneralAvailabilityResult = Any
|
|
42
|
+
LocalAvailabilityResult = Any
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def local_availability_operation(availability_result: GeneralAvailabilityResult) -> Operation:
|
|
46
|
+
"""Builds an operation to determine location-specific details of all layers."""
|
|
47
|
+
operation = Operation(schema.Query)
|
|
48
|
+
for state in build_states_list(availability_result):
|
|
49
|
+
regional_layers = operation.regional_layers(
|
|
50
|
+
# if you request the same field multiple times with different arguments,
|
|
51
|
+
# you need to give each copy a unique alias
|
|
52
|
+
__alias__=f'regionalLayers_{state}',
|
|
53
|
+
place_id=state, place_type=PlaceTypeGeo('FEDERAL_STATE_GEO'))
|
|
54
|
+
regional_layers.name()
|
|
55
|
+
regional_layers.last_update()
|
|
56
|
+
return operation
|
|
57
|
+
|
|
58
|
+
def build_states_list(availability_result: GeneralAvailabilityResult) -> List[str]:
|
|
59
|
+
"""Returns a list of states from the availability result."""
|
|
60
|
+
if availability_result.allowed_analysis_areas is None:
|
|
61
|
+
return []
|
|
62
|
+
if availability_result.allowed_analysis_areas.all_areas_enabled:
|
|
63
|
+
# DE1 to DEG are the place_ids for the German states (EU scheme)
|
|
64
|
+
return [f'DE{i}' for i in list("123456789ABCDEFG")]
|
|
65
|
+
return [state.place_id for state in
|
|
66
|
+
availability_result.allowed_analysis_areas.enabled_states]
|
|
67
|
+
|
|
68
|
+
def start_analyses_operation(inputs: Dict[str, GeoAnalysisInput]) -> Operation:
|
|
69
|
+
"""Builds an operation to start analyses with the given inputs."""
|
|
70
|
+
operation = Operation(schema.Mutation)
|
|
71
|
+
for state, input_data in inputs.items():
|
|
72
|
+
start_analysis = operation.start_analysis(inputs=input_data,
|
|
73
|
+
__alias__=f'startAnalysis_{state}')
|
|
74
|
+
start_analysis.pk()
|
|
75
|
+
start_analysis.status()
|
|
76
|
+
start_analysis.url()
|
|
77
|
+
return operation
|
|
78
|
+
|
|
79
|
+
def get_analyses_operation() -> Operation:
|
|
80
|
+
"""Builds an operation to get all analyses."""
|
|
81
|
+
operation = Operation(schema.Query)
|
|
82
|
+
analyses = operation.analysis_metadata()
|
|
83
|
+
analyses.pk()
|
|
84
|
+
analyses.status()
|
|
85
|
+
analyses.url()
|
|
86
|
+
analyses.started_at()
|
|
87
|
+
return operation
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from typing import Dict, List, Set
|
|
2
|
+
from .schema import CoordinateInput, GeoAnalysisInput, GeoAnalysisLayerInput, GeoAnalysisObjectInput, GeoAnalysisOutputFormatInput, GeoAnalysisRequestInput, GeoAnalysisScopeInput, ScopeType
|
|
3
|
+
from .api_client import GeneralAvailabilityResult, LocalAvailabilityResult, build_states_list
|
|
4
|
+
from .journal import Journal
|
|
5
|
+
from .config import Config
|
|
6
|
+
from .access_rule_filter import AccessRuleFilter
|
|
7
|
+
|
|
8
|
+
# Place analyses require a dummy coordinate. It will be ignored in calculations.
|
|
9
|
+
DUMMY_COORDINATE = CoordinateInput(lon=9.0, lat=52.0)
|
|
10
|
+
# The API requires input of combining operations, even if they are not used.
|
|
11
|
+
DUMMY_OPERATIONS = []
|
|
12
|
+
|
|
13
|
+
def compose_complete_requests(general_availability: GeneralAvailabilityResult,
|
|
14
|
+
local_availability: LocalAvailabilityResult
|
|
15
|
+
) -> Dict[str, GeoAnalysisInput]:
|
|
16
|
+
"""Use fetched data to build the complete requests for all available layers."""
|
|
17
|
+
available_states = build_states_list(general_availability)
|
|
18
|
+
requests_as_tuples = [(state, compose_single_request(state, general_availability, local_availability))
|
|
19
|
+
for state in available_states]
|
|
20
|
+
return {state: request for (state, request) in requests_as_tuples
|
|
21
|
+
if request is not None}
|
|
22
|
+
|
|
23
|
+
def compose_layer_inputs(layers: list, local_layers: Set[str], state: str) -> List[GeoAnalysisLayerInput]:
|
|
24
|
+
"""Build a list of layer inputs from output lists."""
|
|
25
|
+
journal = Journal.singleton()
|
|
26
|
+
return [GeoAnalysisLayerInput(layer_name=layer['name'],
|
|
27
|
+
buffer_m=[layer['pre_buffer']])
|
|
28
|
+
for layer in layers
|
|
29
|
+
if ((not layer.is_regional) or (layer.name in local_layers))
|
|
30
|
+
and journal.is_newer_than_saved(layer.name, state, layer.last_update)]
|
|
31
|
+
|
|
32
|
+
def compose_single_request(state: str,
|
|
33
|
+
general_availability: GeneralAvailabilityResult,
|
|
34
|
+
local_availability: LocalAvailabilityResult
|
|
35
|
+
) -> GeoAnalysisInput:
|
|
36
|
+
"""Build a single request for a given state."""
|
|
37
|
+
config = Config.singleton()
|
|
38
|
+
rules = AccessRuleFilter(general_availability.access_rules)
|
|
39
|
+
# specify the data we want to add to the analysis
|
|
40
|
+
state_local_layers = {layer.name for layer in
|
|
41
|
+
local_availability[f'regionalLayers_{state}']}
|
|
42
|
+
|
|
43
|
+
for skip_layer in config.skip_layers:
|
|
44
|
+
state_local_layers.discard(skip_layer)
|
|
45
|
+
|
|
46
|
+
requests_as_tuples = [(cluster, compose_layer_inputs(cluster.layers, state_local_layers, state))
|
|
47
|
+
for cluster in general_availability.clusters
|
|
48
|
+
if cluster.has_access and rules.check(state, cluster.name)]
|
|
49
|
+
|
|
50
|
+
requests =[GeoAnalysisRequestInput(cluster_name=cluster.name, layers=layers)
|
|
51
|
+
for (cluster, layers) in requests_as_tuples if len(layers) > 0]
|
|
52
|
+
|
|
53
|
+
if len(requests) == 0:
|
|
54
|
+
return None
|
|
55
|
+
# Specify the output format
|
|
56
|
+
# TODO: this should be configurable
|
|
57
|
+
output = GeoAnalysisOutputFormatInput(template_name='default',
|
|
58
|
+
type=config.output_format,
|
|
59
|
+
crs=config.crs)
|
|
60
|
+
# specify where the analysis should be done
|
|
61
|
+
scope = GeoAnalysisScopeInput(place=state, type=ScopeType('FEDERAL_STATE'))
|
|
62
|
+
# put everything together into a specification for an analysis
|
|
63
|
+
spec = GeoAnalysisObjectInput(coordinate=DUMMY_COORDINATE,
|
|
64
|
+
output=output,
|
|
65
|
+
scope=scope,
|
|
66
|
+
requests=requests,
|
|
67
|
+
operations=DUMMY_OPERATIONS)
|
|
68
|
+
return GeoAnalysisInput(name=f'sync_{state}', specs=[spec])
|
nefino_geosync/config.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from typing import List
|
|
4
|
+
|
|
5
|
+
import questionary
|
|
6
|
+
from .schema import CRSType, OutputObjectType
|
|
7
|
+
from .storage import get_app_directory
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Config:
|
|
11
|
+
"""This class handles storing and retrieving user preferences."""
|
|
12
|
+
# This is a singleton class. There should only be one instance of Config.
|
|
13
|
+
_instance = None
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
def singleton(cls):
|
|
17
|
+
"""Returns the singleton instance of Journal."""
|
|
18
|
+
if not cls._instance:
|
|
19
|
+
cls._instance = Config()
|
|
20
|
+
return cls._instance
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def _config_file_path(self) -> str:
|
|
24
|
+
"""Returns the path to the config file."""
|
|
25
|
+
return os.path.join(get_app_directory(), "config.json")
|
|
26
|
+
|
|
27
|
+
def __init__(self):
|
|
28
|
+
if Config._instance:
|
|
29
|
+
raise Exception("Config is a singleton class. Use Config.singleton() to get the instance.")
|
|
30
|
+
self.already_prompted = False
|
|
31
|
+
if not os.path.exists(self._config_file_path):
|
|
32
|
+
self.run_config_prompts(missing_config=True)
|
|
33
|
+
else:
|
|
34
|
+
with open(self._config_file_path, "r") as f:
|
|
35
|
+
config = json.load(f)
|
|
36
|
+
self.output_path: str = config['output_path']
|
|
37
|
+
self.output_format: OutputObjectType = OutputObjectType(
|
|
38
|
+
config['output_format'])
|
|
39
|
+
self.crs: CRSType = CRSType(config['crs'])
|
|
40
|
+
self.skip_layers: List[str] = config['skip_layers']
|
|
41
|
+
self.api_key: str = config['api_key']
|
|
42
|
+
|
|
43
|
+
def save(self):
|
|
44
|
+
"""Saves the config to a file."""
|
|
45
|
+
with open(self._config_file_path, "w") as f:
|
|
46
|
+
json.dump({
|
|
47
|
+
'output_path': self.output_path,
|
|
48
|
+
'output_format': self.output_format,
|
|
49
|
+
'skip_layers': self.skip_layers,
|
|
50
|
+
'api_key': self.api_key,
|
|
51
|
+
'crs': self.crs
|
|
52
|
+
}, f)
|
|
53
|
+
|
|
54
|
+
def run_config_prompts(self, missing_config: bool = False):
|
|
55
|
+
"""Runs the configuration wizard."""
|
|
56
|
+
self.output_path = questionary.text(
|
|
57
|
+
"Where do you want to collect downloaded geodata files?",
|
|
58
|
+
default=os.path.join(get_app_directory(), "newestData") \
|
|
59
|
+
if missing_config else self.output_path).ask()
|
|
60
|
+
self.output_format = OutputObjectType(
|
|
61
|
+
questionary.select(
|
|
62
|
+
"What format do you want to use for the output files?",
|
|
63
|
+
instruction="Changing this value after first run will require wiping the downloaded data.",
|
|
64
|
+
choices=['GPKG', 'SHP'],
|
|
65
|
+
default='GPKG' if missing_config else self.output_format
|
|
66
|
+
).ask())
|
|
67
|
+
self.crs = CRSType(
|
|
68
|
+
questionary.select(
|
|
69
|
+
"What coordinate reference system do you want to use?",
|
|
70
|
+
choices=[crs for crs in CRSType],
|
|
71
|
+
default='EPSG_4326' if missing_config else self.crs
|
|
72
|
+
).ask())
|
|
73
|
+
self.api_key = questionary.text(
|
|
74
|
+
"Enter your API key:",
|
|
75
|
+
default="" if missing_config else self.api_key).ask()
|
|
76
|
+
skip_layer_string = questionary.text(
|
|
77
|
+
"Enter the names of any layers you want to skip downloading, separated by commas.",
|
|
78
|
+
instruction="Layer names can be found on https://docs.nefino.li/geo.",
|
|
79
|
+
default="" if missing_config else ",".join(self.skip_layers)).ask()
|
|
80
|
+
self.skip_layers = [] if skip_layer_string == "" else skip_layer_string.split(",")
|
|
81
|
+
self.save()
|
|
82
|
+
self.already_prompted = True
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
from shutil import move, rmtree
|
|
4
|
+
from urllib.request import urlretrieve
|
|
5
|
+
import zipfile
|
|
6
|
+
from .get_downloadable_analyses import AnalysisResult
|
|
7
|
+
from .journal import Journal
|
|
8
|
+
from .config import Config
|
|
9
|
+
from .storage import get_download_directory
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
|
|
12
|
+
def download_analysis(analysis: AnalysisResult) -> None:
|
|
13
|
+
"""Downloads the analysis to the local machine."""
|
|
14
|
+
journal = Journal.singleton()
|
|
15
|
+
download_dir = get_download_directory(analysis.pk)
|
|
16
|
+
download_file = os.path.join(download_dir, "download.zip")
|
|
17
|
+
if not os.path.exists(download_file):
|
|
18
|
+
urlretrieve(analysis.url.replace(" ", "%20"), download_file)
|
|
19
|
+
with zipfile.ZipFile(download_file, 'r') as zip_ref:
|
|
20
|
+
zip_ref.extractall(download_dir)
|
|
21
|
+
zip_root = get_zip_root(download_dir)
|
|
22
|
+
unpack_items(zip_root, analysis.pk, analysis.started_at)
|
|
23
|
+
journal.record_analysis_synced(analysis.pk)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_zip_root(download_dir: str) -> str:
|
|
27
|
+
"""Returns the root directory of the extracted zip file."""
|
|
28
|
+
# earlier we had a heavily nested structure
|
|
29
|
+
return download_dir
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
FILE_NAME_PATTERN = re.compile(r"(?P<layer>^.*?)(?P<buffer>__[0-9]+m)?(?P<ext>\..{3,4}$)")
|
|
33
|
+
|
|
34
|
+
def unpack_items(zip_root: str, pk: str, started_at: datetime) -> None:
|
|
35
|
+
"""Unpacks the layers from the zip file."""
|
|
36
|
+
journal = Journal.singleton()
|
|
37
|
+
config = Config.singleton()
|
|
38
|
+
if pk not in journal.analysis_states:
|
|
39
|
+
print(f"Analysis {pk} not found in journal; skipping download")
|
|
40
|
+
return
|
|
41
|
+
state = journal.get_state_for_analysis(pk)
|
|
42
|
+
for cluster in (f for f in os.listdir(zip_root)
|
|
43
|
+
if f != 'analysis_area'
|
|
44
|
+
and os.path.isdir(os.path.join(zip_root, f))):
|
|
45
|
+
cluster_dir = os.path.join(zip_root, cluster)
|
|
46
|
+
layers = set()
|
|
47
|
+
for file in os.listdir(cluster_dir):
|
|
48
|
+
if journal.is_newer_than_saved(file, state, started_at):
|
|
49
|
+
output_dir = os.path.join(config.output_path, state)
|
|
50
|
+
if not os.path.exists(output_dir):
|
|
51
|
+
os.makedirs(output_dir)
|
|
52
|
+
file_path = os.path.join(cluster_dir, file)
|
|
53
|
+
match = re.match(FILE_NAME_PATTERN, file)
|
|
54
|
+
layer, ext = (match.group("layer"), match.group("ext"))
|
|
55
|
+
# remove any existing files for the same layer
|
|
56
|
+
# this is important to avoid confusion if the pre-buffer changes
|
|
57
|
+
for matching_file in (f for f in os.listdir(output_dir)
|
|
58
|
+
if f.startswith(layer)):
|
|
59
|
+
output_match = re.match(FILE_NAME_PATTERN, matching_file)
|
|
60
|
+
# only remove files that match the layer and extension
|
|
61
|
+
# otherwise, only the last extension to be unpacked would survive
|
|
62
|
+
# also, we are double-checking the layer name here in case we have
|
|
63
|
+
# a layer name which starts with a different layer's name
|
|
64
|
+
if output_match.group("layer") == layer and \
|
|
65
|
+
output_match.group("ext") == ext:
|
|
66
|
+
os.remove(os.path.join(output_dir, matching_file))
|
|
67
|
+
move(file_path, output_dir)
|
|
68
|
+
layers.add(layer)
|
|
69
|
+
journal.record_layers_unpacked(layers, state, started_at)
|
|
70
|
+
rmtree(zip_root)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from .journal import Journal
|
|
2
|
+
from .get_downloadable_analyses import get_downloadable_analyses
|
|
3
|
+
from .download_analysis import download_analysis
|
|
4
|
+
from sgqlc.endpoint.http import HTTPEndpoint
|
|
5
|
+
from .parse_args import parse_args
|
|
6
|
+
|
|
7
|
+
def download_completed_analyses(client: HTTPEndpoint) -> None:
|
|
8
|
+
"""Downloads the analyses that have been completed."""
|
|
9
|
+
journal = Journal.singleton()
|
|
10
|
+
args = parse_args()
|
|
11
|
+
for analysis in get_downloadable_analyses(client):
|
|
12
|
+
if not analysis.pk in journal.synced_analyses:
|
|
13
|
+
if analysis.pk in journal.analysis_states:
|
|
14
|
+
download_analysis(analysis)
|
|
15
|
+
print(f"Downloaded analysis {analysis.pk}")
|
|
16
|
+
else:
|
|
17
|
+
print(f"Analysis {analysis.pk} missing metadata; skipping download")
|
|
18
|
+
elif args.verbose:
|
|
19
|
+
print(f"Analysis {analysis.pk} already downloaded")
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from time import sleep
|
|
2
|
+
from typing import Generator, Protocol
|
|
3
|
+
from .api_client import get_analyses_operation
|
|
4
|
+
from .schema import DateTime, Status
|
|
5
|
+
from .graphql_errors import check_errors
|
|
6
|
+
from sgqlc.endpoint.http import HTTPEndpoint
|
|
7
|
+
from .parse_args import parse_args
|
|
8
|
+
|
|
9
|
+
# Let's give a quick description of what we want to be fetching.
|
|
10
|
+
# This does depend on what get_analysis_operation() actually does.
|
|
11
|
+
class AnalysisResult(Protocol):
|
|
12
|
+
status: Status
|
|
13
|
+
pk: str
|
|
14
|
+
url: str
|
|
15
|
+
started_at: DateTime
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_downloadable_analyses(client: HTTPEndpoint) -> Generator[AnalysisResult, None, None]:
|
|
19
|
+
"""Yields analyses that are available for download.
|
|
20
|
+
Polls for more analyses and yields them until no more are available.
|
|
21
|
+
"""
|
|
22
|
+
verbose = parse_args().verbose
|
|
23
|
+
op = get_analyses_operation()
|
|
24
|
+
reported_pks = set()
|
|
25
|
+
print("Checking for analyses to download...")
|
|
26
|
+
while True:
|
|
27
|
+
data = client(op)
|
|
28
|
+
check_errors(data)
|
|
29
|
+
analyses = op + data
|
|
30
|
+
found_outstanding_analysis = False
|
|
31
|
+
|
|
32
|
+
for analysis in analyses.analysis_metadata:
|
|
33
|
+
if analysis.status == Status("PENDING") or analysis.status == Status("RUNNING"):
|
|
34
|
+
if verbose:
|
|
35
|
+
print(f"Analysis {analysis.pk} is still pending or running.")
|
|
36
|
+
found_outstanding_analysis = True
|
|
37
|
+
if analysis.status == Status("SUCCESS") and analysis.pk not in reported_pks:
|
|
38
|
+
reported_pks.add(analysis.pk)
|
|
39
|
+
yield analysis
|
|
40
|
+
|
|
41
|
+
if not found_outstanding_analysis:
|
|
42
|
+
break
|
|
43
|
+
if verbose:
|
|
44
|
+
print("Waiting for more analyses to finish...")
|
|
45
|
+
sleep(10)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from prompt_toolkit import print_formatted_text
|
|
2
|
+
from prompt_toolkit.formatted_text import HTML
|
|
3
|
+
from .parse_args import parse_args
|
|
4
|
+
import json
|
|
5
|
+
import html
|
|
6
|
+
|
|
7
|
+
def check_errors(data: dict) -> None:
|
|
8
|
+
"""Check for errors in a GraphQL response."""
|
|
9
|
+
args = parse_args()
|
|
10
|
+
if 'errors' in data:
|
|
11
|
+
if args.verbose:
|
|
12
|
+
pp("<b>GraphQL operation with errors:</b> " + html.escape(json.dumps(data, indent=4)))
|
|
13
|
+
|
|
14
|
+
if is_token_invalid(data):
|
|
15
|
+
pp('<b fg="red">ERROR:</b> Invalid token. Please run <b>nefino-geosync --configure</b> and double-check your API key.')
|
|
16
|
+
else:
|
|
17
|
+
if not args.verbose:
|
|
18
|
+
try:
|
|
19
|
+
pp("<b>Received GraphQL error from server:</b> " + html.escape(json.dumps(data['errors'], indent=4)))
|
|
20
|
+
except Exception as e:
|
|
21
|
+
print(e)
|
|
22
|
+
print(data["errors"])
|
|
23
|
+
pp("""<b fg="red">ERROR:</b> A GraphQL error occurred. Run with <b>--verbose</b> to see more information.
|
|
24
|
+
Exiting due to the above error.""")
|
|
25
|
+
if args.verbose:
|
|
26
|
+
pp('<b fg="red">ERROR:</b> A GraphQL error occurred. Exiting due to the above error.')
|
|
27
|
+
|
|
28
|
+
exit(1)
|
|
29
|
+
|
|
30
|
+
def pp(to_print: str):
|
|
31
|
+
print_formatted_text(HTML(to_print))
|
|
32
|
+
|
|
33
|
+
def is_token_invalid(data: dict) -> bool:
|
|
34
|
+
"""Check if the token is invalid."""
|
|
35
|
+
try:
|
|
36
|
+
if data['errors'][0]['extensions']['nefino_type'] == "AuthTokenInvalid":
|
|
37
|
+
return True
|
|
38
|
+
except KeyError:
|
|
39
|
+
return False
|
|
40
|
+
return False
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Dict, Set
|
|
6
|
+
from .storage import get_app_directory
|
|
7
|
+
|
|
8
|
+
class Journal:
|
|
9
|
+
"""Handles metadata about analyses for efficient downloading."""
|
|
10
|
+
|
|
11
|
+
# This is a singleton class. There should only be one instance of Journal.
|
|
12
|
+
_instance = None
|
|
13
|
+
|
|
14
|
+
@classmethod
|
|
15
|
+
def singleton(cls):
|
|
16
|
+
"""Returns the singleton instance of Journal."""
|
|
17
|
+
if not cls._instance:
|
|
18
|
+
cls._instance = Journal()
|
|
19
|
+
return cls._instance
|
|
20
|
+
|
|
21
|
+
def __init__(self):
|
|
22
|
+
if Journal._instance:
|
|
23
|
+
raise Exception("Journal is a singleton class. Use Journal.singleton() to get the instance.")
|
|
24
|
+
# Mapping from analysis pk to the state where the analysis was started
|
|
25
|
+
self.analysis_states: Dict[str, str] = dict()
|
|
26
|
+
|
|
27
|
+
# Mapping from layer name to where it was last updated and when
|
|
28
|
+
self.layer_last_updates: Dict[str, Dict[str, datetime]] = dict()
|
|
29
|
+
|
|
30
|
+
# Record which analyses have been successfully started
|
|
31
|
+
self.synced_analyses: Set[str] = set()
|
|
32
|
+
|
|
33
|
+
self.load_analysis_states()
|
|
34
|
+
self.load_layer_last_updates()
|
|
35
|
+
self.load_synced_analyses()
|
|
36
|
+
|
|
37
|
+
def save_analysis_states(self):
|
|
38
|
+
"""Saves the analysis states to a file."""
|
|
39
|
+
with open(os.path.join(get_app_directory(), "analysis_states.json"), "w") as f:
|
|
40
|
+
json.dump(self.analysis_states, f)
|
|
41
|
+
|
|
42
|
+
def load_analysis_states(self):
|
|
43
|
+
"""Loads the analysis states from a file."""
|
|
44
|
+
try:
|
|
45
|
+
with open(os.path.join(get_app_directory(), "analysis_states.json"), "r") as f:
|
|
46
|
+
self.analysis_states = json.load(f)
|
|
47
|
+
except FileNotFoundError:
|
|
48
|
+
# we already have an empty dictionary as the field value
|
|
49
|
+
print("No saved analysis states found.")
|
|
50
|
+
|
|
51
|
+
def save_layer_last_updates(self):
|
|
52
|
+
"""Saves the layer last updates to a file."""
|
|
53
|
+
with open(os.path.join(get_app_directory(), "layer_last_updates.json"), "w") as f:
|
|
54
|
+
json.dump(self.layer_last_updates, f, default=lambda x: x.isoformat())
|
|
55
|
+
|
|
56
|
+
def load_layer_last_updates(self):
|
|
57
|
+
"""Loads the layer last updates from a file."""
|
|
58
|
+
try:
|
|
59
|
+
with open(os.path.join(get_app_directory(), "layer_last_updates.json"), "r") as f:
|
|
60
|
+
self.layer_last_updates = json.load(f)
|
|
61
|
+
for cluster in self.layer_last_updates.values():
|
|
62
|
+
for state, timestamp in cluster.items():
|
|
63
|
+
cluster[state] = datetime.fromisoformat(timestamp) if timestamp else None
|
|
64
|
+
except FileNotFoundError:
|
|
65
|
+
# we already have an empty dictionary as the field value
|
|
66
|
+
print("No saved layer last updates found.")
|
|
67
|
+
|
|
68
|
+
def save_synced_analyses(self):
|
|
69
|
+
"""Saves the list of processed analyses to a file."""
|
|
70
|
+
with open(os.path.join(get_app_directory(), "synced_analyses.json"), "w") as f:
|
|
71
|
+
json.dump(list(self.synced_analyses), f)
|
|
72
|
+
|
|
73
|
+
def load_synced_analyses(self):
|
|
74
|
+
"""Loads the list of processed analyses from a file."""
|
|
75
|
+
try:
|
|
76
|
+
with open(os.path.join(get_app_directory(), "synced_analyses.json"), "r") as f:
|
|
77
|
+
self.synced_analyses = set(json.load(f))
|
|
78
|
+
except FileNotFoundError:
|
|
79
|
+
# we already have an empty set as the field value
|
|
80
|
+
print("No saved downloaded analyses found.")
|
|
81
|
+
|
|
82
|
+
def record_analyses_requested(self, start_analyses_result):
|
|
83
|
+
"""Records the analyses that have been started, and where they were started."""
|
|
84
|
+
pattern = r"^startAnalysis_(?P<state>DE[1-9A-G])$"
|
|
85
|
+
for alias, analysis_metadata in start_analyses_result.__dict__.items():
|
|
86
|
+
match = re.match(pattern, alias)
|
|
87
|
+
if not match:
|
|
88
|
+
continue
|
|
89
|
+
state = match.group("state")
|
|
90
|
+
# record where the analysis was started
|
|
91
|
+
self.analysis_states[analysis_metadata.pk] = state
|
|
92
|
+
self.save_analysis_states()
|
|
93
|
+
|
|
94
|
+
def record_layers_unpacked(self, layers: Set[str], state: str, started_at: datetime):
|
|
95
|
+
"""Records the layers that have been unpacked, and when they were last updated."""
|
|
96
|
+
print(f"Recording layers {layers} as unpacked for state {state}")
|
|
97
|
+
for layer in layers:
|
|
98
|
+
if layer not in self.layer_last_updates:
|
|
99
|
+
self.layer_last_updates[layer] = dict()
|
|
100
|
+
self.layer_last_updates[layer][state] = started_at
|
|
101
|
+
self.save_layer_last_updates()
|
|
102
|
+
|
|
103
|
+
def get_state_for_analysis(self, pk: str) -> str:
|
|
104
|
+
"""Returns the state where the analysis was started."""
|
|
105
|
+
return self.analysis_states[pk]
|
|
106
|
+
|
|
107
|
+
def is_newer_than_saved(self, layer: str, state: str, timestamp: datetime) -> bool:
|
|
108
|
+
"""Checks if the layer needs to be unpacked."""
|
|
109
|
+
if layer not in self.layer_last_updates:
|
|
110
|
+
return True
|
|
111
|
+
if state not in self.layer_last_updates[layer]:
|
|
112
|
+
return True
|
|
113
|
+
if not self.layer_last_updates[layer][state]:
|
|
114
|
+
return True
|
|
115
|
+
return self.layer_last_updates[layer][state] < timestamp
|
|
116
|
+
|
|
117
|
+
def record_analysis_synced(self, pk: str):
|
|
118
|
+
"""Records that the analysis has been downloaded and unpacked."""
|
|
119
|
+
self.synced_analyses.add(pk)
|
|
120
|
+
self.save_synced_analyses()
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
|
|
3
|
+
def parse_args(cached=[]):
|
|
4
|
+
if len(cached) > 0:
|
|
5
|
+
return cached[0]
|
|
6
|
+
parser = argparse.ArgumentParser(
|
|
7
|
+
prog="Nefino GeoSync",
|
|
8
|
+
description='Download available geodata from the Nefino API.',
|
|
9
|
+
epilog='If you have further questions please reach out to us! The maintainers for this tool can be found on https://github.com/nefino/geosync-py.')
|
|
10
|
+
parser.add_argument('-c', '--configure', action='store_true', help='Edit your existing configuration. The first-run wizard will be shown again, with your existing configuration pre-filled.')
|
|
11
|
+
parser.add_argument('-r', '--resume', action='store_true', help='Resume checking for completed analyses and downloading them. This will skip the analysis start step.')
|
|
12
|
+
parser.add_argument('-v', '--verbose', action='store_true', help='Print more information to the console.')
|
|
13
|
+
args = parser.parse_args()
|
|
14
|
+
cached.append(args)
|
|
15
|
+
return args
|
nefino_geosync/run.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""This is the main entry point of the application."""
|
|
2
|
+
import os
|
|
3
|
+
from .api_client import get_client
|
|
4
|
+
from .start_analyses import start_analyses
|
|
5
|
+
from .download_completed_analyses import download_completed_analyses
|
|
6
|
+
from .config import Config
|
|
7
|
+
from .parse_args import parse_args
|
|
8
|
+
|
|
9
|
+
def main():
|
|
10
|
+
args = parse_args()
|
|
11
|
+
|
|
12
|
+
if args.configure:
|
|
13
|
+
config = Config.singleton()
|
|
14
|
+
# if you are running with --configure on the first run (you don't need to)
|
|
15
|
+
# you will be prompted to configure the app by the config singleton init.
|
|
16
|
+
# In that case, don't prompt the user again.
|
|
17
|
+
if not config.already_prompted:
|
|
18
|
+
config.run_config_prompts()
|
|
19
|
+
|
|
20
|
+
client = get_client(api_host=os.getenv("NEFINO_API_HOST", default="https://api.nefino.li"))
|
|
21
|
+
|
|
22
|
+
if not args.resume:
|
|
23
|
+
start_analyses(client)
|
|
24
|
+
else:
|
|
25
|
+
download_completed_analyses(client)
|
|
26
|
+
|
|
27
|
+
if __name__ == "__main__":
|
|
28
|
+
main()
|