Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions scripts/constant.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
DELETE_ROOT_NETWORKS = STUDY_SERVER_URL + "/supervision/root-networks"

GET_DIRECTORY_ELEMENTS = DIRECTORY_SERVER_URL + "/supervision/elements"
GET_UNMODIFIED_DIRECTORY_ELEMENTS = DIRECTORY_SERVER_URL + "/supervision/elements/unmodified"
GET_SUPERVISION_STUDIES = STUDY_SERVER_URL + "/supervision/studies"
GET_DYNAMIC_MAPPING_FILTERS = DYNAMIC_MAPPING_SERVER_URL + "/supervision/filters"

Expand All @@ -122,6 +123,7 @@
RECREATE_STUDY_INDICES = STUDY_SERVER_URL + "/supervision/studies/indices"
DELETE_STUDY_INDEXED_EQUIPMENTS_BY_NETWORK_UUID = STUDY_SERVER_URL + "/supervision/studies/{networkUuid}/indexed-equipments-by-network-uuid"
DELETE_STUDY_NODES_BUILDS = STUDY_SERVER_URL + "/supervision/studies/{studyUuid}/nodes/builds"
INVALIDATE_STUDY = STUDY_SERVER_URL + "/supervision/studies/{studyUuid}/invalidate"
GET_STUDIES_INDEXED_STUDIES_COUNT = STUDY_SERVER_URL + "/supervision/studies/indexation-count"
GET_STUDIES_INDEXED_EQUIPMENTS_COUNT = STUDY_SERVER_URL + "/supervision/equipments/indexation-count"
GET_STUDIES_INDEXED_TOMBSTONED_EQUIPMENTS_COUNT = STUDY_SERVER_URL + "/supervision/tombstoned-equipments/indexation-count"
Expand Down
3 changes: 3 additions & 0 deletions scripts/functions/studies/studies.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,7 @@ def delete_indexed_equipments(networkUuid):

def invalidate_nodes_builds(study_uuid):
return requests.delete(constant.DELETE_STUDY_NODES_BUILDS.format(studyUuid = study_uuid))

def invalidate_study(study_uuid):
return requests.delete(constant.INVALIDATE_STUDY.format(studyUuid = study_uuid))

105 changes: 105 additions & 0 deletions scripts/invalidate_unmodified_studies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
#
# Copyright (c) 2026, RTE (http://www.rte-france.com)
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
#

import sys
import requests
import constant
from functions.studies.studies import invalidate_study
from tqdm import tqdm

#
# Invalidates built nodes and delete initial variant network for all studies that have not been modified since a given duration.
#
# Usage:
# python invalidate_unmodified_studies.py <duration> [--dry-run] [--limit <n>]
#
# Arguments:
# duration ISO 8601 duration (e.g. P365D for 1 year, P30D for 30 days, PT24H for 24 hours)
# --dry-run Optional flag to only list affected studies without performing any invalidation
# --limit <n> Optional maximum number of studies to process
#
# Example:
# python invalidate_unmodified_studies.py P365D --dry-run
# python invalidate_unmodified_studies.py P365D --limit 10 --dry-run
#

#
# @author Hugo Marcellin <hugo.marcellin_externe at rte-france.com>
#

def get_unmodified_studies(duration):
response = requests.get(constant.GET_UNMODIFIED_DIRECTORY_ELEMENTS, params={"elementType": "STUDY", "duration": duration})
response.raise_for_status()
Comment on lines +34 to +36
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find and examine the file
find . -name "unmount_unmodified_studies.py" -type f

Repository: gridsuite/admin-tools

Length of output: 104


🏁 Script executed:

#!/bin/bash
# Search for requests.get() calls in the codebase to understand the pattern
rg "requests\.get\(" --no-heading -B 2 -A 2

Repository: gridsuite/admin-tools

Length of output: 21172


🏁 Script executed:

#!/bin/bash
# Check if there's a constants or config file that might set default timeouts
fd -i "constant" --type f -x head -20 {}

Repository: gridsuite/admin-tools

Length of output: 697


🏁 Script executed:

#!/bin/bash
cat -n scripts/unmount_unmodified_studies.py

Repository: gridsuite/admin-tools

Length of output: 4391


🏁 Script executed:

#!/bin/bash
# Check if there's a pattern of timeout usage elsewhere in the codebase
rg "timeout\s*=" --type py -C 2

Repository: gridsuite/admin-tools

Length of output: 47


Add timeout to HTTP request for fetching unmodified studies.

The requests.get() call on line 34 lacks a timeout parameter and can hang indefinitely, causing the job to stall. Add timeout=30 to the request.

Proposed fix
 def get_unmodified_studies(duration):
-    response = requests.get(constant.GET_UNMODIFIED_DIRECTORY_ELEMENTS, params={"elementType": "STUDY", "duration": duration})
+    response = requests.get(
+        constant.GET_UNMODIFIED_DIRECTORY_ELEMENTS,
+        params={"elementType": "STUDY", "duration": duration},
+        timeout=30,
+    )
     response.raise_for_status()
     return response.json()
🧰 Tools
🪛 Ruff (0.15.10)

[error] 34-34: Probable use of requests call without timeout

(S113)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/unmount_unmodified_studies.py` around lines 33 - 35, The requests.get
call inside get_unmodified_studies should include a timeout to avoid hanging;
modify the call to requests.get(constant.GET_UNMODIFIED_DIRECTORY_ELEMENTS,
params={"elementType": "STUDY", "duration": duration}, timeout=30) so the HTTP
request to constant.GET_UNMODIFIED_DIRECTORY_ELEMENTS has a 30-second timeout
and still calls response.raise_for_status() afterwards.

return response.json()

def invalidate_unmodified_studies(duration, dry_run=False, limit=None):
if constant.DEV:
print(f"\nDEV={str(constant.DEV)} -> hostnames configured for a local execution (172.17.0.1:xxxx)")

print(f"Fetching studies not modified since {duration}...")
studies = get_unmodified_studies(duration)

if not studies:
print("No unmodified studies found.")
return

print(f"Found {len(studies)} unmodified study/studies.")

if limit is not None and limit < len(studies):
print(f"Limit applied: processing {limit} out of {len(studies)} studies.")
studies = studies[:limit]

print("Selected studies:")
for study in studies:
print(f" - {study['elementUuid']} | {study['elementName']} | last modified: {study['lastModificationDate']}")

if dry_run:
print("\nDry run mode: no study will be invalidated.")
return
Comment thread
flomillot marked this conversation as resolved.

print("\nUnmounting studies...")
success_count = 0
failure_count = 0
for study in tqdm(studies):
try:
study_uuid = study["elementUuid"]
result = invalidate_study(study_uuid)
result.raise_for_status()
success_count += 1
except Exception as e:
failure_count += 1
tqdm.write(f" FAILED - {study_uuid} (error: {str(e)})")
if isinstance(e, requests.exceptions.RequestException) and e.response is not None:
Comment on lines +68 to +76
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

cat -n scripts/invalidate_unmodified_studies.py | sed -n '60,85p'

Repository: gridsuite/admin-tools

Length of output: 1312


Fix unsafe exception handling: study_uuid undefined if line 69 fails.

If line 69 raises an exception (e.g., missing "elementUuid" key), study_uuid is never assigned but line 75 references it in the except block, causing a NameError that crashes the script. Move the assignment outside the try block or use .get() with a default value to ensure study_uuid is always defined before the except handler executes.

Proposed fix
-    for study in tqdm(studies):
-        try:
-            study_uuid = study["elementUuid"]
+    for study in tqdm(studies):
+        study_uuid = study.get("elementUuid", "<missing-elementUuid>")
+        try:
+            if study_uuid == "<missing-elementUuid>":
+                raise KeyError("elementUuid")
             result = invalidate_study(study_uuid)
             result.raise_for_status()
             success_count += 1
-        except Exception as e:
+        except requests.exceptions.RequestException as e:
             failure_count += 1
             tqdm.write(f"  FAILED - {study_uuid} (error: {str(e)})")
-            if isinstance(e, requests.exceptions.RequestException) and e.response is not None:
+            if e.response is not None:
                 tqdm.write("Response body: " + repr(e.response.text)) # repr for cheap escaping
             tqdm.write("") # emtpy newline between errors for legibility
+        except KeyError as e:
+            failure_count += 1
+            tqdm.write(f"  FAILED - {study_uuid} (missing field: {str(e)})")
+            tqdm.write("")
🧰 Tools
🪛 Ruff (0.15.12)

[warning] 73-73: Do not catch blind exception: Exception

(BLE001)


[warning] 75-75: Use explicit conversion flag

Replace with conversion flag

(RUF010)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/invalidate_unmodified_studies.py` around lines 68 - 76, The except
block can reference study_uuid before it's assigned if study["elementUuid"]
throws; ensure study_uuid is defined before the try by extracting it with a safe
accessor (e.g., use study.get("elementUuid", "<unknown>") or assign study_uuid =
study.get("elementUuid") above the try), then wrap only the network call and
result.raise_for_status() in the try; keep references to invalidate_study and
result.raise_for_status() inside the try so exceptions from those are still
caught but the except handler can safely log study_uuid.

tqdm.write("Response body: " + repr(e.response.text)) # repr for cheap escaping
tqdm.write("") # emtpy newline between errors for legibility

print(f"\nDone. {success_count} succeeded, {failure_count} failed.")


if len(sys.argv) < 2:
print("Usage: python invalidate_unmodified_studies.py <duration> [--dry-run] [--limit <n>]")
print("Example: python invalidate_unmodified_studies.py P365D --limit 10 --dry-run")
sys.exit(1)

duration_arg = sys.argv[1]
dry_run_arg = "--dry-run" in sys.argv

limit_arg = None
if "--limit" in sys.argv:
limit_index = sys.argv.index("--limit")
if limit_index + 1 >= len(sys.argv):
print("Error: --limit requires a numeric value.")
sys.exit(1)
try:
limit_arg = int(sys.argv[limit_index + 1])
if limit_arg <= 0:
raise ValueError
except ValueError:
print("Error: --limit must be a positive integer.")
sys.exit(1)

invalidate_unmodified_studies(duration_arg, dry_run=dry_run_arg, limit=limit_arg)
Loading