nefino-geosync 0.2.1__py3-none-any.whl → 0.2.2__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.

@@ -1,18 +1,21 @@
1
1
  """This module handles the API client for the Nefino API.
2
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."""
3
+ you can use this client to interact with the API directly.
4
+ """
4
5
 
5
- from typing import Any, Dict, List
6
+ from .config import Config
6
7
  from .schema import GeoAnalysisInput, PlaceTypeGeo, schema
7
8
  from sgqlc.endpoint.http import HTTPEndpoint
8
9
  from sgqlc.operation import Operation
9
- from .config import Config
10
+ from typing import Any, Dict, List
10
11
 
11
- def get_client(api_host: str="https://api.nefino.li") -> HTTPEndpoint:
12
+
13
+ def get_client(api_host: str = 'https://api.nefino.li') -> HTTPEndpoint:
12
14
  """Returns an HTTP client for the Nefino API."""
13
15
  headers = {'Authorization': Config.singleton().api_key}
14
16
  return HTTPEndpoint(f'{api_host}/external', headers)
15
17
 
18
+
16
19
  def general_availability_operation() -> Operation:
17
20
  """Returns the general availability of layers and access permissions from Nefino API."""
18
21
  operation = Operation(schema.Query)
@@ -35,6 +38,7 @@ def general_availability_operation() -> Operation:
35
38
  layers.pre_buffer()
36
39
  return operation
37
40
 
41
+
38
42
  # any is the most specific type we can write for the results from the availability query
39
43
  # this is a limitation of sgqlc types
40
44
  # GitHub issue: https://github.com/profusion/sgqlc/issues/129
@@ -42,7 +46,9 @@ GeneralAvailabilityResult = Any
42
46
  LocalAvailabilityResult = Any
43
47
 
44
48
 
45
- def local_availability_operation(availability_result: GeneralAvailabilityResult) -> Operation:
49
+ def local_availability_operation(
50
+ availability_result: GeneralAvailabilityResult,
51
+ ) -> Operation:
46
52
  """Builds an operation to determine location-specific details of all layers."""
47
53
  operation = Operation(schema.Query)
48
54
  for state in build_states_list(availability_result):
@@ -50,32 +56,35 @@ def local_availability_operation(availability_result: GeneralAvailabilityResult)
50
56
  # if you request the same field multiple times with different arguments,
51
57
  # you need to give each copy a unique alias
52
58
  __alias__=f'regionalLayers_{state}',
53
- place_id=state, place_type=PlaceTypeGeo('FEDERAL_STATE_GEO'))
59
+ place_id=state,
60
+ place_type=PlaceTypeGeo('FEDERAL_STATE_GEO'),
61
+ )
54
62
  regional_layers.name()
55
63
  regional_layers.last_update()
56
64
  return operation
57
65
 
66
+
58
67
  def build_states_list(availability_result: GeneralAvailabilityResult) -> List[str]:
59
68
  """Returns a list of states from the availability result."""
60
69
  if availability_result.allowed_analysis_areas is None:
61
70
  return []
62
71
  if availability_result.allowed_analysis_areas.all_areas_enabled:
63
72
  # 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]
73
+ return [f'DE{i}' for i in list('123456789ABCDEFG')]
74
+ return [state.place_id for state in availability_result.allowed_analysis_areas.enabled_states]
75
+
67
76
 
68
77
  def start_analyses_operation(inputs: Dict[str, GeoAnalysisInput]) -> Operation:
69
78
  """Builds an operation to start analyses with the given inputs."""
70
79
  operation = Operation(schema.Mutation)
71
80
  for state, input_data in inputs.items():
72
- start_analysis = operation.start_analysis(inputs=input_data,
73
- __alias__=f'startAnalysis_{state}')
81
+ start_analysis = operation.start_analysis(inputs=input_data, __alias__=f'startAnalysis_{state}')
74
82
  start_analysis.pk()
75
83
  start_analysis.status()
76
84
  start_analysis.url()
77
85
  return operation
78
86
 
87
+
79
88
  def get_analyses_operation() -> Operation:
80
89
  """Builds an operation to get all analyses."""
81
90
  operation = Operation(schema.Query)
@@ -84,4 +93,27 @@ def get_analyses_operation() -> Operation:
84
93
  analyses.status()
85
94
  analyses.url()
86
95
  analyses.started_at()
87
- return operation
96
+ return operation
97
+
98
+
99
+ def layer_changelog_operation(timestamp_start: str = None) -> Operation:
100
+ """Builds an operation to get layer changelog entries."""
101
+ operation = Operation(schema.Query)
102
+
103
+ # Build the input object for the changelog query
104
+ changelog_input = {}
105
+ if timestamp_start:
106
+ changelog_input['timestampStart'] = timestamp_start
107
+
108
+ changelog = operation.layer_changelog(inputs=changelog_input)
109
+ changelog.layer_name()
110
+ changelog.timestamp()
111
+ changelog.action()
112
+ changelog.changed_fields()
113
+ changelog.attributes()
114
+ changelog.layer_id()
115
+ changelog.last_update()
116
+ changelog.cluster_name()
117
+ changelog.cluster_id()
118
+
119
+ return operation
@@ -1,68 +1,134 @@
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
1
  from .access_rule_filter import AccessRuleFilter
2
+ from .api_client import (
3
+ GeneralAvailabilityResult,
4
+ LocalAvailabilityResult,
5
+ build_states_list,
6
+ )
7
+ from .config import Config
8
+ from .journal import Journal
9
+ from .layer_changelog import LayerChangelogResult, layer_has_relevant_changes_in_changelog
10
+ from .parse_args import parse_args
11
+ from .schema import (
12
+ CoordinateInput,
13
+ GeoAnalysisInput,
14
+ GeoAnalysisLayerInput,
15
+ GeoAnalysisObjectInput,
16
+ GeoAnalysisOutputFormatInput,
17
+ GeoAnalysisRequestInput,
18
+ GeoAnalysisScopeInput,
19
+ ScopeType,
20
+ )
21
+ from typing import Dict, List, Set
7
22
 
8
23
  # Place analyses require a dummy coordinate. It will be ignored in calculations.
9
24
  DUMMY_COORDINATE = CoordinateInput(lon=9.0, lat=52.0)
10
25
  # The API requires input of combining operations, even if they are not used.
11
26
  DUMMY_OPERATIONS = []
12
27
 
13
- def compose_complete_requests(general_availability: GeneralAvailabilityResult,
14
- local_availability: LocalAvailabilityResult
15
- ) -> Dict[str, GeoAnalysisInput]:
28
+
29
+ def compose_complete_requests(
30
+ general_availability: GeneralAvailabilityResult,
31
+ local_availability: LocalAvailabilityResult,
32
+ changelog_result: LayerChangelogResult = None,
33
+ ) -> Dict[str, GeoAnalysisInput]:
16
34
  """Use fetched data to build the complete requests for all available layers."""
17
35
  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
36
 
23
- def compose_layer_inputs(layers: list, local_layers: Set[str], state: str) -> List[GeoAnalysisLayerInput]:
37
+ # Log the list of available federal states
38
+ if available_states:
39
+ print(f'📍 Checking {len(available_states)} available federal state(s): {", ".join(sorted(available_states))}')
40
+ else:
41
+ print('⚠️ No federal states available for your account')
42
+ return {}
43
+
44
+ requests_as_tuples = [
45
+ (state, compose_single_request(state, general_availability, local_availability, changelog_result))
46
+ for state in available_states
47
+ ]
48
+
49
+ # Filter out None requests and notify user about up-to-date states
50
+ result = {}
51
+ for state, request in requests_as_tuples:
52
+ if request is not None:
53
+ result[state] = request
54
+ else:
55
+ print(f'✅ {state} is up-to-date')
56
+
57
+ return result
58
+
59
+
60
+ def compose_layer_inputs(
61
+ layers: list, local_layers: Set[str], state: str, cluster_name: str, changelog_result: LayerChangelogResult = None
62
+ ) -> List[GeoAnalysisLayerInput]:
24
63
  """Build a list of layer inputs from output lists."""
64
+ args = parse_args()
25
65
  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:
66
+ updated_layers = []
67
+
68
+ print(f' 🔍 Checking layers in cluster {cluster_name} for {state}...')
69
+
70
+ for layer in layers:
71
+ # Check if layer should be processed
72
+ is_available = (not layer.is_regional) or (layer.name in local_layers)
73
+ needs_update = journal.is_newer_than_saved(layer.name, state, layer.last_update)
74
+ has_relevant_changes = layer_has_relevant_changes_in_changelog(changelog_result, layer.name, cluster_name)
75
+
76
+ if is_available and (needs_update or has_relevant_changes):
77
+ updated_layers.append(layer)
78
+ if args.verbose:
79
+ reason = 'last update' if needs_update else 'relevant changes'
80
+ print(f' 📄 {layer.name} needs update ({reason}: {layer.last_update})')
81
+
82
+ if updated_layers:
83
+ print(f' ⚡ Found {len(updated_layers)} in cluster {cluster_name} layers to update for {state}')
84
+ else:
85
+ print(f' ✅ All layers are up-to-date in cluster {cluster_name} for {state}')
86
+
87
+ return [GeoAnalysisLayerInput(layer_name=layer.name, buffer_m=[layer.pre_buffer]) for layer in updated_layers]
88
+
89
+
90
+ def compose_single_request(
91
+ state: str,
92
+ general_availability: GeneralAvailabilityResult,
93
+ local_availability: LocalAvailabilityResult,
94
+ changelog_result: LayerChangelogResult = None,
95
+ ) -> GeoAnalysisInput:
36
96
  """Build a single request for a given state."""
97
+ print(f'🔍 Checking layers for {state}...')
98
+
37
99
  config = Config.singleton()
38
100
  rules = AccessRuleFilter(general_availability.access_rules)
39
101
  # 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}']}
102
+ state_local_layers = {layer.name for layer in local_availability[f'regionalLayers_{state}']}
42
103
 
43
104
  for skip_layer in config.skip_layers:
44
105
  state_local_layers.discard(skip_layer)
45
106
 
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)]
107
+ requests_as_tuples = [
108
+ (cluster, compose_layer_inputs(cluster.layers, state_local_layers, state, cluster.name, changelog_result))
109
+ for cluster in general_availability.clusters
110
+ if cluster.has_access and rules.check(state, cluster.name)
111
+ ]
49
112
 
50
- requests =[GeoAnalysisRequestInput(cluster_name=cluster.name, layers=layers)
51
- for (cluster, layers) in requests_as_tuples if len(layers) > 0]
113
+ requests = [
114
+ GeoAnalysisRequestInput(cluster_name=cluster.name, layers=layers)
115
+ for (cluster, layers) in requests_as_tuples
116
+ if len(layers) > 0
117
+ ]
52
118
 
53
119
  if len(requests) == 0:
54
120
  return None
55
121
  # Specify the output format
56
122
  # TODO: this should be configurable
57
- output = GeoAnalysisOutputFormatInput(template_name='default',
58
- type=config.output_format,
59
- crs=config.crs)
123
+ output = GeoAnalysisOutputFormatInput(template_name='default', type=config.output_format, crs=config.crs)
60
124
  # specify where the analysis should be done
61
125
  scope = GeoAnalysisScopeInput(place=state, type=ScopeType('FEDERAL_STATE'))
62
126
  # 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)
127
+ spec = GeoAnalysisObjectInput(
128
+ coordinate=DUMMY_COORDINATE,
129
+ output=output,
130
+ scope=scope,
131
+ requests=requests,
132
+ operations=DUMMY_OPERATIONS,
133
+ )
134
+ return GeoAnalysisInput(name=f'sync_{state}', specs=spec)
@@ -14,12 +14,12 @@ def download_analysis(analysis: AnalysisResult) -> None:
14
14
  """Downloads the analysis to the local machine."""
15
15
  journal = Journal.singleton()
16
16
  download_dir = get_download_directory(analysis.pk)
17
- download_file = os.path.join(download_dir, "download.zip")
17
+ download_file = os.path.join(download_dir, 'download.zip')
18
18
  if os.path.exists(download_file):
19
19
  # remove any failed download
20
20
  os.remove(download_file)
21
- urlretrieve(analysis.url.replace(" ", "%20"), download_file)
22
- with zipfile.ZipFile(download_file, "r") as zip_ref:
21
+ urlretrieve(analysis.url.replace(' ', '%20'), download_file)
22
+ with zipfile.ZipFile(download_file, 'r') as zip_ref:
23
23
  zip_ref.extractall(download_dir)
24
24
  zip_root = get_zip_root(download_dir)
25
25
  unpack_items(zip_root, analysis.pk, analysis.started_at)
@@ -32,9 +32,7 @@ def get_zip_root(download_dir: str) -> str:
32
32
  return download_dir
33
33
 
34
34
 
35
- FILE_NAME_PATTERN = re.compile(
36
- r"(?P<layer>^.*?)(?P<buffer>__[0-9]+m)?(?P<ext>\..{3,4}$)"
37
- )
35
+ FILE_NAME_PATTERN = re.compile(r'(?P<layer>^.*?)(?P<buffer>__[0-9]+m)?(?P<ext>\..{3,4}$)')
38
36
 
39
37
 
40
38
  def unpack_items(zip_root: str, pk: str, started_at: datetime) -> None:
@@ -50,7 +48,7 @@ def unpack_items(zip_root: str, pk: str, started_at: datetime) -> None:
50
48
  config = Config.singleton()
51
49
 
52
50
  if pk not in journal.analysis_states:
53
- print(f"Analysis {pk} not found in journal; skipping download")
51
+ print(f'Analysis {pk} not found in journal; skipping download')
54
52
  return
55
53
 
56
54
  state = journal.get_state_for_analysis(pk)
@@ -58,12 +56,9 @@ def unpack_items(zip_root: str, pk: str, started_at: datetime) -> None:
58
56
 
59
57
  # Iterate through cluster folders inside the analysis subfolder
60
58
  for cluster in (
61
- f
62
- for f in os.listdir(base_path)
63
- if f != "analysis_area" and os.path.isdir(os.path.join(base_path, f))
59
+ f for f in os.listdir(base_path) if f != 'analysis_area' and os.path.isdir(os.path.join(base_path, f))
64
60
  ):
65
61
  cluster_dir = os.path.join(base_path, cluster)
66
- layers = set()
67
62
 
68
63
  for file in os.listdir(cluster_dir):
69
64
  if journal.is_newer_than_saved(file, state, started_at):
@@ -73,29 +68,27 @@ def unpack_items(zip_root: str, pk: str, started_at: datetime) -> None:
73
68
 
74
69
  file_path = os.path.join(cluster_dir, file)
75
70
  match = re.match(FILE_NAME_PATTERN, file)
76
- layer, ext = (match.group("layer"), match.group("ext"))
71
+ layer, ext = (match.group('layer'), match.group('ext'))
77
72
 
78
73
  # Remove any existing files for the same layer
79
74
  # this is important to avoid confusion if the pre-buffer changes
80
- for matching_file in (
81
- f for f in os.listdir(output_dir) if f.startswith(layer)
82
- ):
75
+ for matching_file in (f for f in os.listdir(output_dir) if f.startswith(layer)):
83
76
  output_match = re.match(FILE_NAME_PATTERN, matching_file)
84
77
  # only remove files that match the layer and extension
85
78
  # otherwise, only the last extension to be unpacked would survive
86
79
  # also, we are double-checking the layer name here in case we have
87
80
  # a layer name which starts with a different layer's name
88
- if (
89
- output_match.group("layer") == layer
90
- and output_match.group("ext") == ext
91
- ):
81
+ if output_match.group('layer') == layer and output_match.group('ext') == ext:
92
82
  os.remove(os.path.join(output_dir, matching_file))
93
83
 
94
84
  move(file_path, output_dir)
95
- layers.add(layer)
96
85
 
97
- journal.record_layers_unpacked(layers, state, started_at)
86
+ # Update the journal to mark layers as updated. We might have empty layers so we do set all requested layers as
87
+ # updated.
88
+ layers_to_mark_updated = journal.analysis_requested_layers[pk]
89
+ print(f'Recording {len(layers_to_mark_updated)} requested layers as updated for state {state}')
98
90
 
91
+ journal.record_layers_unpacked(layers_to_mark_updated, state, started_at)
99
92
  rmtree(zip_root)
100
93
 
101
94
 
@@ -116,11 +109,9 @@ def get_base_path(zip_root: str) -> str:
116
109
  Returns:
117
110
  str: Path to the directory containing the cluster folders and analysis_summary.xlsx
118
111
  """
119
- if "analysis_summary.xlsx" in os.listdir(zip_root):
112
+ if 'analysis_summary.xlsx' in os.listdir(zip_root):
120
113
  # Old structure - use zip_root
121
114
  return zip_root
122
115
  # Get the analysis subfolder name (first and only directory in zip_root)
123
- analysis_subfolder = next(
124
- f for f in os.listdir(zip_root) if os.path.isdir(os.path.join(zip_root, f))
125
- )
116
+ analysis_subfolder = next(f for f in os.listdir(zip_root) if os.path.isdir(os.path.join(zip_root, f)))
126
117
  return os.path.join(zip_root, analysis_subfolder)
@@ -1,19 +1,18 @@
1
- from .journal import Journal
2
- from .get_downloadable_analyses import get_downloadable_analyses
3
1
  from .download_analysis import download_analysis
4
- from sgqlc.endpoint.http import HTTPEndpoint
2
+ from .get_downloadable_analyses import get_downloadable_analyses
3
+ from .journal import Journal
5
4
  from .parse_args import parse_args
5
+ from sgqlc.endpoint.http import HTTPEndpoint
6
+
6
7
 
7
8
  def download_completed_analyses(client: HTTPEndpoint) -> None:
8
9
  """Downloads the analyses that have been completed."""
9
10
  journal = Journal.singleton()
10
11
  args = parse_args()
11
12
  for analysis in get_downloadable_analyses(client):
12
- if not analysis.pk in journal.synced_analyses:
13
+ if analysis.pk not in journal.synced_analyses:
13
14
  if analysis.pk in journal.analysis_states:
14
15
  download_analysis(analysis)
15
- print(f"Downloaded analysis {analysis.pk}")
16
- else:
17
- print(f"Analysis {analysis.pk} missing metadata; skipping download")
16
+ print(f'Downloaded analysis {analysis.pk}')
18
17
  elif args.verbose:
19
- print(f"Analysis {analysis.pk} already downloaded")
18
+ print(f'Analysis {analysis.pk} already downloaded')
@@ -1,10 +1,11 @@
1
- from time import sleep
2
- from typing import Generator, Protocol
3
1
  from .api_client import get_analyses_operation
4
- from .schema import DateTime, Status
5
2
  from .graphql_errors import check_errors
6
- from sgqlc.endpoint.http import HTTPEndpoint
7
3
  from .parse_args import parse_args
4
+ from .schema import DateTime, Status
5
+ from sgqlc.endpoint.http import HTTPEndpoint
6
+ from time import sleep
7
+ from typing import Generator, Protocol
8
+
8
9
 
9
10
  # Let's give a quick description of what we want to be fetching.
10
11
  # This does depend on what get_analysis_operation() actually does.
@@ -15,31 +16,33 @@ class AnalysisResult(Protocol):
15
16
  started_at: DateTime
16
17
 
17
18
 
18
- def get_downloadable_analyses(client: HTTPEndpoint) -> Generator[AnalysisResult, None, None]:
19
+ def get_downloadable_analyses(
20
+ client: HTTPEndpoint,
21
+ ) -> Generator[AnalysisResult, None, None]:
19
22
  """Yields analyses that are available for download.
20
23
  Polls for more analyses and yields them until no more are available.
21
24
  """
22
25
  verbose = parse_args().verbose
23
26
  op = get_analyses_operation()
24
27
  reported_pks = set()
25
- print("Checking for analyses to download...")
28
+ print('Checking for analyses to download...')
26
29
  while True:
27
30
  data = client(op)
28
- check_errors(data)
31
+ check_errors(data, 'Failed to fetch analysis status')
29
32
  analyses = op + data
30
33
  found_outstanding_analysis = False
31
34
 
32
35
  for analysis in analyses.analysis_metadata:
33
- if analysis.status == Status("PENDING") or analysis.status == Status("RUNNING"):
36
+ if analysis.status == Status('PENDING') or analysis.status == Status('RUNNING'):
34
37
  if verbose:
35
- print(f"Analysis {analysis.pk} is still pending or running.")
38
+ print(f'Analysis {analysis.pk} is still pending or running.')
36
39
  found_outstanding_analysis = True
37
- if analysis.status == Status("SUCCESS") and analysis.pk not in reported_pks:
40
+ if analysis.status == Status('SUCCESS') and analysis.pk not in reported_pks:
38
41
  reported_pks.add(analysis.pk)
39
42
  yield analysis
40
43
 
41
44
  if not found_outstanding_analysis:
42
45
  break
43
46
  if verbose:
44
- print("Waiting for more analyses to finish...")
47
+ print('Waiting for more analyses to finish...')
45
48
  sleep(10)
@@ -1,40 +1,73 @@
1
+ import html
2
+ import json
3
+ import re
4
+ import sys
5
+ from .parse_args import parse_args
6
+ from datetime import datetime
1
7
  from prompt_toolkit import print_formatted_text
2
8
  from prompt_toolkit.formatted_text import HTML
3
- from .parse_args import parse_args
4
- import json
5
- import html
6
9
 
7
- def check_errors(data: dict) -> None:
10
+
11
+ def check_errors(data: dict, context: str = None) -> None:
8
12
  """Check for errors in a GraphQL response."""
9
13
  args = parse_args()
10
14
  if 'errors' in data:
11
15
  if args.verbose:
12
- pp("<b>GraphQL operation with errors:</b> " + html.escape(json.dumps(data, indent=4)))
16
+ pp('<b>GraphQL operation with errors:</b> ' + html.escape(json.dumps(data, indent=4)))
13
17
 
14
18
  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.')
19
+ pp(
20
+ '<b fg="red">ERROR:</b> Invalid token. Please run <b>nefino-geosync --configure</b> and double-check your API key.'
21
+ )
16
22
  else:
17
23
  if not args.verbose:
18
24
  try:
19
- pp("<b>Received GraphQL error from server:</b> " + html.escape(json.dumps(data['errors'], indent=4)))
25
+ pp(
26
+ '<b>Received GraphQL error from server:</b> '
27
+ + html.escape(json.dumps(data['errors'], indent=4))
28
+ )
20
29
  except Exception as e:
21
30
  print(e)
22
- print(data["errors"])
31
+ print(data['errors'])
32
+
33
+ # Add context information if provided
34
+ if context:
35
+ pp(f'<b fg="red">Context:</b> {context}')
36
+
37
+ if not args.verbose:
23
38
  pp("""<b fg="red">ERROR:</b> A GraphQL error occurred. Run with <b>--verbose</b> to see more information.
39
+ If this error persists, please contact Nefino support: https://www.nefino.de/kontakt
24
40
  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.')
41
+ else:
42
+ pp('<b fg="red">ERROR:</b> A GraphQL error occurred.')
43
+ pp(
44
+ '<b fg="red">If this error persists, please contact Nefino support: https://www.nefino.de/kontakt</b>'
45
+ )
46
+ pp('<b fg="red">Exiting due to the above error.</b>')
47
+
48
+ sys.exit(1)
27
49
 
28
- exit(1)
29
50
 
30
- def pp(to_print: str):
51
+ def pp(to_print: str) -> None:
52
+ # Display formatted text in console
31
53
  print_formatted_text(HTML(to_print))
32
54
 
55
+ # For logging: check if stdout has been replaced by TeeStream
56
+ # If so, write plain text directly to the log file to avoid duplication
57
+ if hasattr(sys.stdout, 'log_file'):
58
+ # Remove HTML tags for plain text logging
59
+ plain_text = re.sub(r'<[^>]+>', '', to_print)
60
+
61
+ timestamp = datetime.now().strftime('%H:%M:%S')
62
+ sys.stdout.log_file.write(f'[{timestamp}] [STDOUT] {plain_text}\n')
63
+ sys.stdout.log_file.flush()
64
+
65
+
33
66
  def is_token_invalid(data: dict) -> bool:
34
67
  """Check if the token is invalid."""
35
68
  try:
36
- if data['errors'][0]['extensions']['nefino_type'] == "AuthTokenInvalid":
69
+ if data['errors'][0]['extensions']['nefino_type'] == 'AuthTokenInvalid':
37
70
  return True
38
71
  except KeyError:
39
72
  return False
40
- return False
73
+ return False