mirror of
https://github.com/PlaneQuery/OpenAirframes.git
synced 2026-04-25 20:46:08 +02:00
OpenAirframes 1.0
This commit is contained in:
@@ -27,7 +27,7 @@ from src.adsb.compress_adsb_to_aircraft_data import compress_multi_icao_df, COLU
|
||||
|
||||
|
||||
DEFAULT_CHUNK_DIR = os.path.join(OUTPUT_DIR, "adsb_chunks")
|
||||
FINAL_OUTPUT_DIR = "./data/planequery_aircraft"
|
||||
FINAL_OUTPUT_DIR = "./data/openairframes"
|
||||
os.makedirs(FINAL_OUTPUT_DIR, exist_ok=True)
|
||||
|
||||
|
||||
@@ -36,8 +36,13 @@ def get_target_day() -> datetime:
|
||||
return datetime.utcnow() - timedelta(days=1)
|
||||
|
||||
|
||||
def process_single_chunk(chunk_path: str) -> pl.DataFrame:
|
||||
"""Load and compress a single chunk parquet file."""
|
||||
def process_single_chunk(chunk_path: str, delete_after_load: bool = False) -> pl.DataFrame:
|
||||
"""Load and compress a single chunk parquet file.
|
||||
|
||||
Args:
|
||||
chunk_path: Path to parquet file
|
||||
delete_after_load: If True, delete the parquet file after loading to free disk space
|
||||
"""
|
||||
print(f"Processing {os.path.basename(chunk_path)}... | {get_resource_usage()}")
|
||||
|
||||
# Load chunk - only columns we need
|
||||
@@ -45,6 +50,14 @@ def process_single_chunk(chunk_path: str) -> pl.DataFrame:
|
||||
df = pl.read_parquet(chunk_path, columns=needed_columns)
|
||||
print(f" Loaded {len(df)} rows")
|
||||
|
||||
# Delete file immediately after loading to free disk space
|
||||
if delete_after_load:
|
||||
try:
|
||||
os.remove(chunk_path)
|
||||
print(f" Deleted {chunk_path} to free disk space")
|
||||
except Exception as e:
|
||||
print(f" Warning: Failed to delete {chunk_path}: {e}")
|
||||
|
||||
# Compress to aircraft records (one per ICAO) using shared function
|
||||
compressed = compress_multi_icao_df(df, verbose=True)
|
||||
print(f" Compressed to {len(compressed)} aircraft records")
|
||||
@@ -72,12 +85,12 @@ def combine_compressed_chunks(compressed_dfs: list[pl.DataFrame]) -> pl.DataFram
|
||||
|
||||
def download_and_merge_base_release(compressed_df: pl.DataFrame) -> pl.DataFrame:
|
||||
"""Download base release and merge with new data."""
|
||||
from src.get_latest_planequery_aircraft_release import download_latest_aircraft_adsb_csv
|
||||
from src.get_latest_release import download_latest_aircraft_adsb_csv
|
||||
|
||||
print("Downloading base ADS-B release...")
|
||||
try:
|
||||
base_path = download_latest_aircraft_adsb_csv(
|
||||
output_dir="./data/planequery_aircraft_base"
|
||||
output_dir="./data/openairframes_base"
|
||||
)
|
||||
print(f"Download returned: {base_path}")
|
||||
|
||||
@@ -156,16 +169,17 @@ def main():
|
||||
parser.add_argument("--chunks-dir", type=str, default=DEFAULT_CHUNK_DIR, help="Directory containing chunk parquet files")
|
||||
parser.add_argument("--skip-base", action="store_true", help="Skip downloading and merging base release")
|
||||
parser.add_argument("--keep-chunks", action="store_true", help="Keep chunk files after merging")
|
||||
parser.add_argument("--stream", action="store_true", help="Delete parquet files immediately after loading to save disk space")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Determine output ID and filename based on mode
|
||||
if args.start_date and args.end_date:
|
||||
# Historical mode
|
||||
output_id = f"{args.start_date}_{args.end_date}"
|
||||
output_filename = f"planequery_aircraft_adsb_{args.start_date}_{args.end_date}.csv"
|
||||
output_filename = f"openairframes_adsb_{args.start_date}_{args.end_date}.csv"
|
||||
print(f"Combining chunks for date range: {args.start_date} to {args.end_date}")
|
||||
else:
|
||||
# Daily mode
|
||||
# Daily mode - use same date for start and end
|
||||
if args.date:
|
||||
target_day = datetime.strptime(args.date, "%Y-%m-%d")
|
||||
else:
|
||||
@@ -173,7 +187,7 @@ def main():
|
||||
|
||||
date_str = target_day.strftime("%Y-%m-%d")
|
||||
output_id = date_str
|
||||
output_filename = f"planequery_aircraft_adsb_{date_str}.csv"
|
||||
output_filename = f"openairframes_adsb_{date_str}_{date_str}.csv"
|
||||
print(f"Combining chunks for {date_str}")
|
||||
|
||||
chunks_dir = args.chunks_dir
|
||||
@@ -190,9 +204,10 @@ def main():
|
||||
print(f"Found {len(chunk_files)} chunk files")
|
||||
|
||||
# Process each chunk separately to save memory
|
||||
# With --stream, delete parquet files immediately after loading to save disk space
|
||||
compressed_chunks = []
|
||||
for chunk_path in chunk_files:
|
||||
compressed = process_single_chunk(chunk_path)
|
||||
compressed = process_single_chunk(chunk_path, delete_after_load=args.stream)
|
||||
compressed_chunks.append(compressed)
|
||||
gc.collect()
|
||||
|
||||
|
||||
@@ -253,7 +253,7 @@ def concat_compressed_dfs(df_base, df_new):
|
||||
|
||||
def get_latest_aircraft_adsb_csv_df():
|
||||
"""Download and load the latest ADS-B CSV from GitHub releases."""
|
||||
from get_latest_planequery_aircraft_release import download_latest_aircraft_adsb_csv
|
||||
from get_latest_release import download_latest_aircraft_adsb_csv
|
||||
import re
|
||||
|
||||
csv_path = download_latest_aircraft_adsb_csv()
|
||||
@@ -264,8 +264,8 @@ def get_latest_aircraft_adsb_csv_df():
|
||||
if df[col].dtype == pl.Utf8:
|
||||
df = df.with_columns(pl.col(col).fill_null(""))
|
||||
|
||||
# Extract start date from filename pattern: planequery_aircraft_adsb_{start_date}_{end_date}.csv
|
||||
match = re.search(r"planequery_aircraft_adsb_(\d{4}-\d{2}-\d{2})_", str(csv_path))
|
||||
# Extract start date from filename pattern: openairframes_adsb_{start_date}_{end_date}.csv
|
||||
match = re.search(r"openairframes_adsb_(\d{4}-\d{2}-\d{2})_", str(csv_path))
|
||||
if not match:
|
||||
raise ValueError(f"Could not extract date from filename: {csv_path.name}")
|
||||
|
||||
|
||||
@@ -82,7 +82,8 @@ def fetch_releases(version_date: str) -> list:
|
||||
if version_date == "v2024.12.31":
|
||||
year = "2025"
|
||||
BASE_URL = f"https://api.github.com/repos/adsblol/globe_history_{year}/releases"
|
||||
PATTERN = f"{version_date}-planes-readsb-prod-0"
|
||||
# Match exact release name, exclude tmp releases
|
||||
PATTERN = rf"^{re.escape(version_date)}-planes-readsb-prod-\d+$"
|
||||
releases = []
|
||||
page = 1
|
||||
|
||||
@@ -187,19 +188,23 @@ def extract_split_archive(file_paths: list, extract_dir: str) -> bool:
|
||||
cat_proc = subprocess.Popen(
|
||||
["cat"] + file_paths,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
tar_cmd = ["tar", "xf", "-", "-C", extract_dir, "--strip-components=1"]
|
||||
subprocess.run(
|
||||
result = subprocess.run(
|
||||
tar_cmd,
|
||||
stdin=cat_proc.stdout,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=True
|
||||
)
|
||||
cat_proc.stdout.close()
|
||||
cat_stderr = cat_proc.stderr.read().decode() if cat_proc.stderr else ""
|
||||
cat_proc.wait()
|
||||
|
||||
if cat_stderr:
|
||||
print(f"cat stderr: {cat_stderr}")
|
||||
|
||||
print(f"Successfully extracted archive to {extract_dir}")
|
||||
|
||||
# Delete tar files immediately after extraction
|
||||
@@ -217,7 +222,10 @@ def extract_split_archive(file_paths: list, extract_dir: str) -> bool:
|
||||
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
stderr_output = e.stderr.decode() if e.stderr else ""
|
||||
print(f"Failed to extract split archive: {e}")
|
||||
if stderr_output:
|
||||
print(f"tar stderr: {stderr_output}")
|
||||
return False
|
||||
|
||||
|
||||
|
||||
+2
-2
@@ -76,8 +76,8 @@ def main():
|
||||
print(f"After dedup: {df_accumulated.height} rows")
|
||||
|
||||
# Write and upload final result
|
||||
output_name = f"planequery_aircraft_adsb_{global_start}_{global_end}.csv.gz"
|
||||
csv_output = Path(f"/tmp/planequery_aircraft_adsb_{global_start}_{global_end}.csv")
|
||||
output_name = f"openairframes_adsb_{global_start}_{global_end}.csv.gz"
|
||||
csv_output = Path(f"/tmp/openairframes_adsb_{global_start}_{global_end}.csv")
|
||||
gz_output = Path(f"/tmp/{output_name}")
|
||||
|
||||
df_accumulated.write_csv(csv_output)
|
||||
|
||||
@@ -21,12 +21,14 @@ import urllib.request
|
||||
import urllib.error
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .schema import extract_json_from_issue_body, extract_contributor_name_from_issue_body, parse_and_validate
|
||||
from .schema import extract_json_from_issue_body, extract_contributor_name_from_issue_body, parse_and_validate, load_schema, SCHEMAS_DIR
|
||||
from .contributor import (
|
||||
generate_contributor_uuid,
|
||||
generate_submission_filename,
|
||||
compute_content_hash,
|
||||
)
|
||||
from .update_schema import generate_updated_schema, check_for_new_tags, get_existing_tag_definitions
|
||||
from .read_community_data import build_tag_type_registry
|
||||
|
||||
|
||||
def github_api_request(
|
||||
@@ -54,7 +56,11 @@ def github_api_request(
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req) as response:
|
||||
return json.loads(response.read())
|
||||
response_body = response.read()
|
||||
# DELETE requests return empty body (204 No Content)
|
||||
if not response_body:
|
||||
return {}
|
||||
return json.loads(response_body)
|
||||
except urllib.error.HTTPError as e:
|
||||
error_body = e.read().decode() if e.fp else ""
|
||||
print(f"GitHub API error: {e.code} {e.reason}: {error_body}", file=sys.stderr)
|
||||
@@ -94,14 +100,30 @@ def create_branch(branch_name: str, sha: str) -> None:
|
||||
raise
|
||||
|
||||
|
||||
def get_file_sha(path: str, branch: str) -> str | None:
|
||||
"""Get the SHA of an existing file, or None if it doesn't exist."""
|
||||
try:
|
||||
response = github_api_request("GET", f"/contents/{path}?ref={branch}")
|
||||
return response.get("sha")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def create_or_update_file(path: str, content: str, message: str, branch: str) -> None:
|
||||
"""Create or update a file in the repository."""
|
||||
content_b64 = base64.b64encode(content.encode()).decode()
|
||||
github_api_request("PUT", f"/contents/{path}", {
|
||||
payload = {
|
||||
"message": message,
|
||||
"content": content_b64,
|
||||
"branch": branch,
|
||||
})
|
||||
}
|
||||
|
||||
# If file exists, we need to include its SHA to update it
|
||||
sha = get_file_sha(path, branch)
|
||||
if sha:
|
||||
payload["sha"] = sha
|
||||
|
||||
github_api_request("PUT", f"/contents/{path}", payload)
|
||||
|
||||
|
||||
def create_pull_request(title: str, head: str, base: str, body: str) -> dict:
|
||||
@@ -144,21 +166,19 @@ def process_submission(
|
||||
return False
|
||||
|
||||
data, errors = parse_and_validate(json_str)
|
||||
if errors:
|
||||
error_list = "\n".join(f"- {e}" for e in errors)
|
||||
if errors or data is None:
|
||||
error_list = "\n".join(f"- {e}" for e in errors) if errors else "Unknown error"
|
||||
add_issue_comment(issue_number, f"❌ **Validation Failed**\n\n{error_list}")
|
||||
return False
|
||||
|
||||
# Normalize to list
|
||||
submissions = data if isinstance(data, list) else [data]
|
||||
submissions: list[dict] = data if isinstance(data, list) else [data]
|
||||
|
||||
# Generate contributor UUID from GitHub ID
|
||||
contributor_uuid = generate_contributor_uuid(author_id)
|
||||
|
||||
# Extract contributor name from issue form (or default to GitHub username)
|
||||
# Extract contributor name from issue form (None means user opted out of attribution)
|
||||
contributor_name = extract_contributor_name_from_issue_body(issue_body)
|
||||
if not contributor_name:
|
||||
contributor_name = f"@{author_username}"
|
||||
|
||||
# Add metadata to each submission
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -167,14 +187,15 @@ def process_submission(
|
||||
|
||||
for submission in submissions:
|
||||
submission["contributor_uuid"] = contributor_uuid
|
||||
submission["contributor_name"] = contributor_name
|
||||
if contributor_name:
|
||||
submission["contributor_name"] = contributor_name
|
||||
submission["creation_timestamp"] = timestamp_str
|
||||
|
||||
# Generate unique filename
|
||||
content_json = json.dumps(submissions, indent=2, sort_keys=True)
|
||||
content_hash = compute_content_hash(content_json)
|
||||
filename = generate_submission_filename(author_username, date_str, content_hash)
|
||||
file_path = f"community/{filename}"
|
||||
file_path = f"community/{date_str}/{filename}"
|
||||
|
||||
# Create branch
|
||||
branch_name = f"community-submission-{issue_number}"
|
||||
@@ -185,14 +206,53 @@ def process_submission(
|
||||
commit_message = f"Add community submission from @{author_username} (closes #{issue_number})"
|
||||
create_or_update_file(file_path, content_json, commit_message, branch_name)
|
||||
|
||||
# Update schema with any new tags (modifies v1 in place)
|
||||
schema_updated = False
|
||||
new_tags = []
|
||||
try:
|
||||
# Build tag registry from new submissions
|
||||
tag_registry = build_tag_type_registry(submissions)
|
||||
|
||||
# Get current schema and merge existing tags
|
||||
current_schema = load_schema()
|
||||
existing_tags = get_existing_tag_definitions(current_schema)
|
||||
|
||||
# Merge existing tags into registry
|
||||
for tag_name, tag_def in existing_tags.items():
|
||||
if tag_name not in tag_registry:
|
||||
tag_type = tag_def.get("type", "string")
|
||||
tag_registry[tag_name] = tag_type
|
||||
|
||||
# Check for new tags
|
||||
new_tags = check_for_new_tags(tag_registry, current_schema)
|
||||
|
||||
if new_tags:
|
||||
# Generate updated schema
|
||||
updated_schema = generate_updated_schema(current_schema, tag_registry)
|
||||
schema_json = json.dumps(updated_schema, indent=2) + "\n"
|
||||
|
||||
create_or_update_file(
|
||||
"schemas/community_submission.v1.schema.json",
|
||||
schema_json,
|
||||
f"Update schema with new tags: {', '.join(new_tags)}",
|
||||
branch_name
|
||||
)
|
||||
schema_updated = True
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not update schema: {e}", file=sys.stderr)
|
||||
|
||||
# Create PR
|
||||
schema_note = ""
|
||||
if schema_updated:
|
||||
schema_note = f"\n**Schema Updated:** Added new tags: `{', '.join(new_tags)}`\n"
|
||||
|
||||
pr_body = f"""## Community Submission
|
||||
|
||||
Adds {len(submissions)} submission(s) from @{author_username}.
|
||||
|
||||
**File:** `{file_path}`
|
||||
**Contributor UUID:** `{contributor_uuid}`
|
||||
|
||||
{schema_note}
|
||||
Closes #{issue_number}
|
||||
|
||||
---
|
||||
|
||||
@@ -17,7 +17,7 @@ import pandas as pd
|
||||
|
||||
|
||||
COMMUNITY_DIR = Path(__file__).parent.parent.parent / "community"
|
||||
OUT_ROOT = Path("data/planequery_aircraft")
|
||||
OUT_ROOT = Path("data/openairframes")
|
||||
|
||||
|
||||
def read_all_submissions(community_dir: Path) -> list[dict]:
|
||||
@@ -47,7 +47,7 @@ def submissions_to_dataframe(submissions: list[dict]) -> pd.DataFrame:
|
||||
- creation_timestamp (first)
|
||||
- transponder_code_hex
|
||||
- registration_number
|
||||
- planequery_airframe_id
|
||||
- openairframes_id
|
||||
- contributor_name
|
||||
- [other columns alphabetically]
|
||||
- contributor_uuid (last)
|
||||
@@ -62,7 +62,7 @@ def submissions_to_dataframe(submissions: list[dict]) -> pd.DataFrame:
|
||||
"creation_timestamp",
|
||||
"transponder_code_hex",
|
||||
"registration_number",
|
||||
"planequery_airframe_id",
|
||||
"openairframes_id",
|
||||
"contributor_name",
|
||||
"contributor_uuid",
|
||||
]
|
||||
@@ -78,7 +78,7 @@ def submissions_to_dataframe(submissions: list[dict]) -> pd.DataFrame:
|
||||
"creation_timestamp",
|
||||
"transponder_code_hex",
|
||||
"registration_number",
|
||||
"planequery_airframe_id",
|
||||
"openairframes_id",
|
||||
"contributor_name",
|
||||
]
|
||||
last_cols = ["contributor_uuid"]
|
||||
@@ -108,7 +108,7 @@ def main():
|
||||
"creation_timestamp",
|
||||
"transponder_code_hex",
|
||||
"registration_number",
|
||||
"planequery_airframe_id",
|
||||
"openairframes_id",
|
||||
"contributor_name",
|
||||
"tags",
|
||||
"contributor_uuid",
|
||||
@@ -127,7 +127,7 @@ def main():
|
||||
|
||||
# Output
|
||||
OUT_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
output_file = OUT_ROOT / f"planequery_aircraft_community_{start_date_str}_{date_str}.csv"
|
||||
output_file = OUT_ROOT / f"openairframes_community_{start_date_str}_{date_str}.csv"
|
||||
|
||||
df.to_csv(output_file, index=False)
|
||||
|
||||
|
||||
@@ -30,7 +30,8 @@ def read_all_submissions(community_dir: Path | None = None) -> list[dict]:
|
||||
|
||||
all_submissions = []
|
||||
|
||||
for json_file in sorted(community_dir.glob("*.json")):
|
||||
# Search both root directory and date subdirectories (e.g., 2026-02-12/)
|
||||
for json_file in sorted(community_dir.glob("**/*.json")):
|
||||
try:
|
||||
with open(json_file) as f:
|
||||
data = json.load(f)
|
||||
@@ -50,6 +51,52 @@ def read_all_submissions(community_dir: Path | None = None) -> list[dict]:
|
||||
return all_submissions
|
||||
|
||||
|
||||
def get_python_type_name(value) -> str:
|
||||
"""Get a normalized type name for a value."""
|
||||
if value is None:
|
||||
return "null"
|
||||
if isinstance(value, bool):
|
||||
return "boolean"
|
||||
if isinstance(value, int):
|
||||
return "integer"
|
||||
if isinstance(value, float):
|
||||
return "number"
|
||||
if isinstance(value, str):
|
||||
return "string"
|
||||
if isinstance(value, list):
|
||||
return "array"
|
||||
if isinstance(value, dict):
|
||||
return "object"
|
||||
return type(value).__name__
|
||||
|
||||
|
||||
def build_tag_type_registry(submissions: list[dict]) -> dict[str, str]:
|
||||
"""
|
||||
Build a registry of tag names to their expected types from existing submissions.
|
||||
|
||||
Args:
|
||||
submissions: List of existing submission dictionaries
|
||||
|
||||
Returns:
|
||||
Dict mapping tag name to expected type (e.g., {"internet": "string", "year_built": "integer"})
|
||||
"""
|
||||
tag_types = {}
|
||||
|
||||
for submission in submissions:
|
||||
tags = submission.get("tags", {})
|
||||
if not isinstance(tags, dict):
|
||||
continue
|
||||
|
||||
for key, value in tags.items():
|
||||
inferred_type = get_python_type_name(value)
|
||||
|
||||
if key not in tag_types:
|
||||
tag_types[key] = inferred_type
|
||||
# If there's a conflict, keep the first type (it's already in use)
|
||||
|
||||
return tag_types
|
||||
|
||||
|
||||
def group_by_identifier(submissions: list[dict]) -> dict[str, list[dict]]:
|
||||
"""
|
||||
Group submissions by their identifier (registration, transponder, or airframe ID).
|
||||
@@ -65,8 +112,8 @@ def group_by_identifier(submissions: list[dict]) -> dict[str, list[dict]]:
|
||||
key = f"reg:{submission['registration_number']}"
|
||||
elif "transponder_code_hex" in submission:
|
||||
key = f"icao:{submission['transponder_code_hex']}"
|
||||
elif "planequery_airframe_id" in submission:
|
||||
key = f"id:{submission['planequery_airframe_id']}"
|
||||
elif "openairframes_id" in submission:
|
||||
key = f"id:{submission['openairframes_id']}"
|
||||
else:
|
||||
key = "_unknown"
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regenerate schema for a PR branch after main has been merged in.
|
||||
This script looks at the submission files in this branch and updates
|
||||
the schema if new tags were introduced.
|
||||
|
||||
Usage: python -m src.contributions.regenerate_pr_schema
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent to path for imports when running as script
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from src.contributions.read_community_data import read_all_submissions, build_tag_type_registry
|
||||
from src.contributions.update_schema import (
|
||||
get_existing_tag_definitions,
|
||||
check_for_new_tags,
|
||||
generate_updated_schema,
|
||||
)
|
||||
from src.contributions.schema import load_schema, SCHEMAS_DIR
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
# Load current schema
|
||||
current_schema = load_schema()
|
||||
|
||||
# Get existing tag definitions from schema
|
||||
existing_tags = get_existing_tag_definitions(current_schema)
|
||||
|
||||
# Read all submissions (including ones from this PR branch)
|
||||
submissions = read_all_submissions()
|
||||
|
||||
if not submissions:
|
||||
print("No submissions found")
|
||||
return
|
||||
|
||||
# Build tag registry from all submissions
|
||||
tag_registry = build_tag_type_registry(submissions)
|
||||
|
||||
# Check for new tags not in the current schema
|
||||
new_tags = check_for_new_tags(tag_registry, current_schema)
|
||||
|
||||
if new_tags:
|
||||
print(f"Found new tags: {new_tags}")
|
||||
print("Updating schema...")
|
||||
|
||||
# Generate updated schema
|
||||
updated_schema = generate_updated_schema(current_schema, tag_registry)
|
||||
|
||||
# Write updated schema (in place)
|
||||
schema_path = SCHEMAS_DIR / "community_submission.v1.schema.json"
|
||||
with open(schema_path, 'w') as f:
|
||||
json.dump(updated_schema, f, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
print(f"Updated {schema_path}")
|
||||
else:
|
||||
print("No new tags found, schema is up to date")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+116
-8
@@ -10,12 +10,59 @@ except ImportError:
|
||||
Draft202012Validator = None
|
||||
|
||||
|
||||
SCHEMA_PATH = Path(__file__).parent.parent.parent / "schemas" / "community_submission.v1.schema.json"
|
||||
SCHEMAS_DIR = Path(__file__).parent.parent.parent / "schemas"
|
||||
|
||||
# For backwards compatibility
|
||||
SCHEMA_PATH = SCHEMAS_DIR / "community_submission.v1.schema.json"
|
||||
|
||||
|
||||
def load_schema() -> dict:
|
||||
"""Load the community submission schema."""
|
||||
with open(SCHEMA_PATH) as f:
|
||||
def get_latest_schema_version() -> int:
|
||||
"""
|
||||
Find the latest schema version number.
|
||||
|
||||
Returns:
|
||||
Latest version number (e.g., 1, 2, 3)
|
||||
"""
|
||||
import re
|
||||
pattern = re.compile(r"community_submission\.v(\d+)\.schema\.json$")
|
||||
max_version = 0
|
||||
|
||||
for path in SCHEMAS_DIR.glob("community_submission.v*.schema.json"):
|
||||
match = pattern.search(path.name)
|
||||
if match:
|
||||
version = int(match.group(1))
|
||||
max_version = max(max_version, version)
|
||||
|
||||
return max_version
|
||||
|
||||
|
||||
def get_schema_path(version: int | None = None) -> Path:
|
||||
"""
|
||||
Get path to a specific schema version, or latest if version is None.
|
||||
|
||||
Args:
|
||||
version: Schema version number, or None for latest
|
||||
|
||||
Returns:
|
||||
Path to schema file
|
||||
"""
|
||||
if version is None:
|
||||
version = get_latest_schema_version()
|
||||
return SCHEMAS_DIR / f"community_submission.v{version}.schema.json"
|
||||
|
||||
|
||||
def load_schema(version: int | None = None) -> dict:
|
||||
"""
|
||||
Load the community submission schema.
|
||||
|
||||
Args:
|
||||
version: Schema version to load. If None, loads the latest version.
|
||||
|
||||
Returns:
|
||||
Schema dict
|
||||
"""
|
||||
schema_path = get_schema_path(version)
|
||||
with open(schema_path) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
@@ -50,11 +97,36 @@ def validate_submission(data: dict | list, schema: dict | None = None) -> list[s
|
||||
return errors
|
||||
|
||||
|
||||
def download_github_attachment(url: str) -> str | None:
|
||||
"""
|
||||
Download content from a GitHub attachment URL.
|
||||
|
||||
Args:
|
||||
url: GitHub attachment URL (e.g., https://github.com/user-attachments/files/...)
|
||||
|
||||
Returns:
|
||||
File content as string, or None if download failed
|
||||
"""
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "OpenAirframes-Bot"})
|
||||
with urllib.request.urlopen(req, timeout=30) as response:
|
||||
return response.read().decode("utf-8")
|
||||
except (urllib.error.URLError, urllib.error.HTTPError, UnicodeDecodeError) as e:
|
||||
print(f"Failed to download attachment from {url}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def extract_json_from_issue_body(body: str) -> str | None:
|
||||
"""
|
||||
Extract JSON from GitHub issue body.
|
||||
|
||||
Looks for JSON in the 'Submission JSON' section wrapped in code blocks.
|
||||
Looks for JSON in the 'Submission JSON' section, either:
|
||||
- A GitHub file attachment URL (drag-and-drop .json file)
|
||||
- Wrapped in code blocks (```json ... ``` or ``` ... ```)
|
||||
- Or raw JSON after the header
|
||||
|
||||
Args:
|
||||
body: The issue body text
|
||||
@@ -62,13 +134,49 @@ def extract_json_from_issue_body(body: str) -> str | None:
|
||||
Returns:
|
||||
Extracted JSON string or None if not found
|
||||
"""
|
||||
# Match JSON in "### Submission JSON" section
|
||||
pattern = r"### Submission JSON\s*\n\s*```(?:json)?\s*\n([\s\S]*?)\n\s*```"
|
||||
match = re.search(pattern, body)
|
||||
# Try: GitHub attachment URL in the Submission JSON section
|
||||
# Format: [filename.json](https://github.com/user-attachments/files/...)
|
||||
# Or just the raw URL
|
||||
pattern_attachment = r"### Submission JSON\s*\n[\s\S]*?(https://github\.com/(?:user-attachments/files|.*?/files)/[^\s\)\]]+\.json)"
|
||||
match = re.search(pattern_attachment, body)
|
||||
if match:
|
||||
url = match.group(1)
|
||||
content = download_github_attachment(url)
|
||||
if content:
|
||||
return content.strip()
|
||||
|
||||
# Also check for GitHub user-attachments URL anywhere in submission section
|
||||
pattern_attachment_alt = r"\[.*?\.json\]\((https://github\.com/[^\)]+)\)"
|
||||
match = re.search(pattern_attachment_alt, body)
|
||||
if match:
|
||||
url = match.group(1)
|
||||
if ".json" in url or "user-attachments" in url:
|
||||
content = download_github_attachment(url)
|
||||
if content:
|
||||
return content.strip()
|
||||
|
||||
# Try: JSON in code blocks after "### Submission JSON"
|
||||
pattern_codeblock = r"### Submission JSON\s*\n\s*```(?:json)?\s*\n([\s\S]*?)\n\s*```"
|
||||
match = re.search(pattern_codeblock, body)
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
|
||||
# Try: Raw JSON after "### Submission JSON" until next section or end
|
||||
pattern_raw = r"### Submission JSON\s*\n\s*([\[{][\s\S]*?[\]}])(?=\n###|\n\n###|$)"
|
||||
match = re.search(pattern_raw, body)
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
|
||||
# Try: Any JSON object/array in the body (fallback)
|
||||
pattern_any = r"([\[{][\s\S]*?[\]}])"
|
||||
for match in re.finditer(pattern_any, body):
|
||||
candidate = match.group(1).strip()
|
||||
# Validate it looks like JSON
|
||||
if candidate.startswith('{') and candidate.endswith('}'):
|
||||
return candidate
|
||||
if candidate.startswith('[') and candidate.endswith(']'):
|
||||
return candidate
|
||||
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Update the schema with tag type definitions from existing submissions.
|
||||
|
||||
This script reads all community submissions and generates a new schema version
|
||||
that includes explicit type definitions for all known tags.
|
||||
|
||||
When new tags are introduced, a new schema version is created (e.g., v1 -> v2 -> v3).
|
||||
|
||||
Usage:
|
||||
python -m src.contributions.update_schema
|
||||
python -m src.contributions.update_schema --check # Check if update needed
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .read_community_data import read_all_submissions, build_tag_type_registry
|
||||
from .schema import SCHEMAS_DIR, get_latest_schema_version, get_schema_path, load_schema
|
||||
|
||||
|
||||
def get_existing_tag_definitions(schema: dict) -> dict[str, dict]:
|
||||
"""Extract existing tag property definitions from schema."""
|
||||
tags_props = schema.get("properties", {}).get("tags", {}).get("properties", {})
|
||||
return tags_props
|
||||
|
||||
|
||||
def type_name_to_json_schema(type_name: str) -> dict:
|
||||
"""Convert a type name to a JSON Schema type definition."""
|
||||
type_map = {
|
||||
"string": {"type": "string"},
|
||||
"integer": {"type": "integer"},
|
||||
"number": {"type": "number"},
|
||||
"boolean": {"type": "boolean"},
|
||||
"null": {"type": "null"},
|
||||
"array": {"type": "array", "items": {"$ref": "#/$defs/tagScalar"}},
|
||||
"object": {"type": "object", "additionalProperties": {"$ref": "#/$defs/tagScalar"}},
|
||||
}
|
||||
return type_map.get(type_name, {"$ref": "#/$defs/tagValue"})
|
||||
|
||||
|
||||
def generate_updated_schema(base_schema: dict, tag_registry: dict[str, str]) -> dict:
|
||||
"""
|
||||
Generate an updated schema with explicit tag definitions.
|
||||
|
||||
Args:
|
||||
base_schema: The current schema to update
|
||||
tag_registry: Dict mapping tag name to type name
|
||||
|
||||
Returns:
|
||||
Updated schema dict
|
||||
"""
|
||||
schema = json.loads(json.dumps(base_schema)) # Deep copy
|
||||
|
||||
# Build tag properties with explicit types
|
||||
tag_properties = {}
|
||||
for tag_name, type_name in sorted(tag_registry.items()):
|
||||
tag_properties[tag_name] = type_name_to_json_schema(type_name)
|
||||
|
||||
# Only add/update the properties key within tags, preserve everything else
|
||||
if "properties" in schema and "tags" in schema["properties"]:
|
||||
schema["properties"]["tags"]["properties"] = tag_properties
|
||||
|
||||
return schema
|
||||
|
||||
|
||||
def check_for_new_tags(tag_registry: dict[str, str], current_schema: dict) -> list[str]:
|
||||
"""
|
||||
Check which tags in the registry are not yet defined in the schema.
|
||||
|
||||
Returns:
|
||||
List of new tag names
|
||||
"""
|
||||
existing_tags = get_existing_tag_definitions(current_schema)
|
||||
return [tag for tag in tag_registry if tag not in existing_tags]
|
||||
|
||||
|
||||
def update_schema_file(
|
||||
tag_registry: dict[str, str],
|
||||
check_only: bool = False
|
||||
) -> tuple[bool, list[str]]:
|
||||
"""
|
||||
Update the v1 schema file with new tag definitions.
|
||||
|
||||
Args:
|
||||
tag_registry: Dict mapping tag name to type name
|
||||
check_only: If True, only check if update is needed without writing
|
||||
|
||||
Returns:
|
||||
Tuple of (was_updated, list_of_new_tags)
|
||||
"""
|
||||
current_schema = load_schema()
|
||||
|
||||
# Find new tags
|
||||
new_tags = check_for_new_tags(tag_registry, current_schema)
|
||||
|
||||
if not new_tags:
|
||||
return False, []
|
||||
|
||||
if check_only:
|
||||
return True, new_tags
|
||||
|
||||
# Generate and write updated schema (in place)
|
||||
updated_schema = generate_updated_schema(current_schema, tag_registry)
|
||||
schema_path = get_schema_path()
|
||||
|
||||
with open(schema_path, "w") as f:
|
||||
json.dump(updated_schema, f, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
return True, new_tags
|
||||
|
||||
|
||||
def update_schema_from_submissions(check_only: bool = False) -> tuple[bool, list[str]]:
|
||||
"""
|
||||
Read all submissions and update the schema if needed.
|
||||
|
||||
Args:
|
||||
check_only: If True, only check if update is needed without writing
|
||||
|
||||
Returns:
|
||||
Tuple of (was_updated, list_of_new_tags)
|
||||
"""
|
||||
submissions = read_all_submissions()
|
||||
tag_registry = build_tag_type_registry(submissions)
|
||||
return update_schema_file(tag_registry, check_only)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Update schema with tag definitions")
|
||||
parser.add_argument("--check", action="store_true", help="Check if update needed without writing")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
was_updated, new_tags = update_schema_from_submissions(check_only=args.check)
|
||||
|
||||
if args.check:
|
||||
if was_updated:
|
||||
print(f"Schema update needed. New tags: {', '.join(new_tags)}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("Schema is up to date")
|
||||
sys.exit(0)
|
||||
else:
|
||||
if was_updated:
|
||||
print(f"Updated {get_schema_path()}")
|
||||
print(f"Added tags: {', '.join(new_tags)}")
|
||||
else:
|
||||
print("No update needed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -7,6 +7,7 @@ submissions when issues are opened or edited.
|
||||
|
||||
Usage:
|
||||
python -m src.contributions.validate_submission --issue-body "..."
|
||||
python -m src.contributions.validate_submission --issue-body-file /path/to/body.txt
|
||||
python -m src.contributions.validate_submission --file submission.json
|
||||
echo '{"registration_number": "N12345"}' | python -m src.contributions.validate_submission --stdin
|
||||
|
||||
@@ -23,6 +24,7 @@ import urllib.request
|
||||
import urllib.error
|
||||
|
||||
from .schema import extract_json_from_issue_body, parse_and_validate, load_schema
|
||||
from .read_community_data import read_all_submissions, build_tag_type_registry, get_python_type_name
|
||||
|
||||
|
||||
def github_api_request(method: str, endpoint: str, data: dict | None = None) -> dict:
|
||||
@@ -65,6 +67,40 @@ def remove_issue_label(issue_number: int, label: str) -> None:
|
||||
pass # Label might not exist
|
||||
|
||||
|
||||
def validate_tag_consistency(data: dict | list, tag_registry: dict[str, str]) -> list[str]:
|
||||
"""
|
||||
Check that tag types in new submissions match existing tag types.
|
||||
|
||||
Args:
|
||||
data: Single submission dict or list of submissions
|
||||
tag_registry: Dict mapping tag name to expected type
|
||||
|
||||
Returns:
|
||||
List of error messages. Empty list means validation passed.
|
||||
"""
|
||||
errors = []
|
||||
submissions = data if isinstance(data, list) else [data]
|
||||
|
||||
for i, submission in enumerate(submissions):
|
||||
prefix = f"[{i}] " if len(submissions) > 1 else ""
|
||||
tags = submission.get("tags", {})
|
||||
|
||||
if not isinstance(tags, dict):
|
||||
continue
|
||||
|
||||
for key, value in tags.items():
|
||||
actual_type = get_python_type_name(value)
|
||||
|
||||
if key in tag_registry:
|
||||
expected_type = tag_registry[key]
|
||||
if actual_type != expected_type:
|
||||
errors.append(
|
||||
f"{prefix}tags.{key}: expected type '{expected_type}', got '{actual_type}'"
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def validate_and_report(json_str: str, issue_number: int | None = None) -> bool:
|
||||
"""
|
||||
Validate JSON and optionally report to GitHub issue.
|
||||
@@ -90,6 +126,33 @@ def validate_and_report(json_str: str, issue_number: int | None = None) -> bool:
|
||||
|
||||
return False
|
||||
|
||||
# Check tag type consistency against existing submissions
|
||||
if data is not None:
|
||||
try:
|
||||
existing_submissions = read_all_submissions()
|
||||
tag_registry = build_tag_type_registry(existing_submissions)
|
||||
tag_errors = validate_tag_consistency(data, tag_registry)
|
||||
|
||||
if tag_errors:
|
||||
error_list = "\n".join(f"- {e}" for e in tag_errors)
|
||||
message = (
|
||||
f"❌ **Tag Type Mismatch**\n\n"
|
||||
f"Your submission uses tags with types that don't match existing submissions:\n\n"
|
||||
f"{error_list}\n\n"
|
||||
f"Please use the same type as existing tags, or use a different tag name."
|
||||
)
|
||||
|
||||
print(message, file=sys.stderr)
|
||||
|
||||
if issue_number:
|
||||
add_issue_comment(issue_number, message)
|
||||
remove_issue_label(issue_number, "validated")
|
||||
|
||||
return False
|
||||
except Exception as e:
|
||||
# Don't fail validation if we can't read existing submissions
|
||||
print(f"Warning: Could not check tag consistency: {e}", file=sys.stderr)
|
||||
|
||||
count = len(data) if isinstance(data, list) else 1
|
||||
message = f"✅ **Validation Passed**\n\n{count} submission(s) validated successfully against the schema.\n\nA maintainer can approve this submission by adding the `approved` label."
|
||||
|
||||
@@ -106,6 +169,7 @@ def main():
|
||||
parser = argparse.ArgumentParser(description="Validate community submission JSON")
|
||||
source_group = parser.add_mutually_exclusive_group(required=True)
|
||||
source_group.add_argument("--issue-body", help="Issue body text containing JSON")
|
||||
source_group.add_argument("--issue-body-file", help="File containing issue body text")
|
||||
source_group.add_argument("--file", help="JSON file to validate")
|
||||
source_group.add_argument("--stdin", action="store_true", help="Read JSON from stdin")
|
||||
|
||||
@@ -125,6 +189,20 @@ def main():
|
||||
"Please ensure your JSON is in the 'Submission JSON' field wrapped in code blocks."
|
||||
)
|
||||
sys.exit(1)
|
||||
elif args.issue_body_file:
|
||||
with open(args.issue_body_file) as f:
|
||||
issue_body = f.read()
|
||||
json_str = extract_json_from_issue_body(issue_body)
|
||||
if not json_str:
|
||||
print("❌ Could not extract JSON from issue body", file=sys.stderr)
|
||||
print(f"Issue body:\n{issue_body}", file=sys.stderr)
|
||||
if args.issue_number:
|
||||
add_issue_comment(
|
||||
args.issue_number,
|
||||
"❌ **Validation Failed**\n\nCould not extract JSON from submission. "
|
||||
"Please ensure your JSON is in the 'Submission JSON' field."
|
||||
)
|
||||
sys.exit(1)
|
||||
elif args.file:
|
||||
with open(args.file) as f:
|
||||
json_str = f.read()
|
||||
|
||||
+2
-2
@@ -74,10 +74,10 @@ if __name__ == '__main__':
|
||||
)
|
||||
|
||||
# Save the result
|
||||
OUT_ROOT = Path("data/planequery_aircraft")
|
||||
OUT_ROOT = Path("data/openairframes")
|
||||
OUT_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
output_file = OUT_ROOT / f"planequery_aircraft_adsb_{start_date_str}_{date_str}.csv"
|
||||
output_file = OUT_ROOT / f"openairframes_adsb_{start_date_str}_{date_str}.csv"
|
||||
df_combined.write_csv(output_file)
|
||||
|
||||
print(f"Saved: {output_file}")
|
||||
@@ -0,0 +1,49 @@
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Create daily FAA release")
|
||||
parser.add_argument("--date", type=str, help="Date to process (YYYY-MM-DD format, default: today)")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.date:
|
||||
date_str = args.date
|
||||
else:
|
||||
date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||
|
||||
out_dir = Path("data/faa_releasable")
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
zip_name = f"ReleasableAircraft_{date_str}.zip"
|
||||
|
||||
zip_path = out_dir / zip_name
|
||||
if not zip_path.exists():
|
||||
# URL and paths
|
||||
url = "https://registry.faa.gov/database/ReleasableAircraft.zip"
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
req = Request(
|
||||
url,
|
||||
headers={"User-Agent": "Mozilla/5.0"},
|
||||
method="GET",
|
||||
)
|
||||
|
||||
with urlopen(req, timeout=120) as r:
|
||||
body = r.read()
|
||||
zip_path.write_bytes(body)
|
||||
|
||||
OUT_ROOT = Path("data/openairframes")
|
||||
OUT_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
from derive_from_faa_master_txt import convert_faa_master_txt_to_df, concat_faa_historical_df
|
||||
from get_latest_release import get_latest_aircraft_faa_csv_df
|
||||
df_new = convert_faa_master_txt_to_df(zip_path, date_str)
|
||||
|
||||
try:
|
||||
df_base, start_date_str = get_latest_aircraft_faa_csv_df()
|
||||
df_base = concat_faa_historical_df(df_base, df_new)
|
||||
assert df_base['download_date'].is_monotonic_increasing, "download_date is not monotonic increasing"
|
||||
except Exception as e:
|
||||
print(f"No existing FAA release found, using only new data: {e}")
|
||||
df_base = df_new
|
||||
start_date_str = date_str
|
||||
|
||||
df_base.to_csv(OUT_ROOT / f"openairframes_faa_{start_date_str}_{date_str}.csv", index=False)
|
||||
@@ -1,33 +0,0 @@
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||
|
||||
out_dir = Path("data/faa_releasable")
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
zip_name = f"ReleasableAircraft_{date_str}.zip"
|
||||
|
||||
zip_path = out_dir / zip_name
|
||||
if not zip_path.exists():
|
||||
# URL and paths
|
||||
url = "https://registry.faa.gov/database/ReleasableAircraft.zip"
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
req = Request(
|
||||
url,
|
||||
headers={"User-Agent": "Mozilla/5.0"},
|
||||
method="GET",
|
||||
)
|
||||
|
||||
with urlopen(req, timeout=120) as r:
|
||||
body = r.read()
|
||||
zip_path.write_bytes(body)
|
||||
|
||||
OUT_ROOT = Path("data/planequery_aircraft")
|
||||
OUT_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
from derive_from_faa_master_txt import convert_faa_master_txt_to_df, concat_faa_historical_df
|
||||
from get_latest_planequery_aircraft_release import get_latest_aircraft_faa_csv_df
|
||||
df_new = convert_faa_master_txt_to_df(zip_path, date_str)
|
||||
df_base, start_date_str = get_latest_aircraft_faa_csv_df()
|
||||
df_base = concat_faa_historical_df(df_base, df_new)
|
||||
assert df_base['download_date'].is_monotonic_increasing, "download_date is not monotonic increasing"
|
||||
df_base.to_csv(OUT_ROOT / f"planequery_aircraft_faa_{start_date_str}_{date_str}.csv", index=False)
|
||||
@@ -29,8 +29,8 @@ def convert_faa_master_txt_to_df(zip_path: Path, date: str):
|
||||
certification = pd.json_normalize(df["certification"].where(df["certification"].notna(), {})).add_prefix("certificate_")
|
||||
df = df.drop(columns="certification").join(certification)
|
||||
|
||||
# Create planequery_airframe_id
|
||||
df["planequery_airframe_id"] = (
|
||||
# Create openairframes_id
|
||||
df["openairframes_id"] = (
|
||||
normalize(df["aircraft_manufacturer"])
|
||||
+ "|"
|
||||
+ normalize(df["aircraft_model"])
|
||||
@@ -38,11 +38,11 @@ def convert_faa_master_txt_to_df(zip_path: Path, date: str):
|
||||
+ normalize(df["serial_number"])
|
||||
)
|
||||
|
||||
# Move planequery_airframe_id to come after registration_number
|
||||
# Move openairframes_id to come after registration_number
|
||||
cols = df.columns.tolist()
|
||||
cols.remove("planequery_airframe_id")
|
||||
cols.remove("openairframes_id")
|
||||
reg_idx = cols.index("registration_number")
|
||||
cols.insert(reg_idx + 1, "planequery_airframe_id")
|
||||
cols.insert(reg_idx + 1, "openairframes_id")
|
||||
df = df[cols]
|
||||
|
||||
# Convert all NaN to empty strings
|
||||
|
||||
@@ -9,7 +9,7 @@ import urllib.error
|
||||
import json
|
||||
|
||||
|
||||
REPO = "PlaneQuery/planequery-aircraft"
|
||||
REPO = "PlaneQuery/openairframes"
|
||||
LATEST_RELEASE_URL = f"https://api.github.com/repos/{REPO}/releases/latest"
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ def get_latest_release_assets(repo: str = REPO, github_token: Optional[str] = No
|
||||
url = f"https://api.github.com/repos/{repo}/releases/latest"
|
||||
headers = {
|
||||
"Accept": "application/vnd.github+json",
|
||||
"User-Agent": "planequery-aircraft-downloader/1.0",
|
||||
"User-Agent": "openairframes-downloader/1.0",
|
||||
}
|
||||
if github_token:
|
||||
headers["Authorization"] = f"Bearer {github_token}"
|
||||
@@ -80,7 +80,7 @@ def download_asset(asset: ReleaseAsset, out_path: Path, github_token: Optional[s
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
headers = {
|
||||
"User-Agent": "planequery-aircraft-downloader/1.0",
|
||||
"User-Agent": "openairframes-downloader/1.0",
|
||||
"Accept": "application/octet-stream",
|
||||
}
|
||||
if github_token:
|
||||
@@ -109,7 +109,7 @@ def download_latest_aircraft_csv(
|
||||
repo: str = REPO,
|
||||
) -> Path:
|
||||
"""
|
||||
Download the latest planequery_aircraft_faa_*.csv file from the latest GitHub release.
|
||||
Download the latest openairframes_faa_*.csv file from the latest GitHub release.
|
||||
|
||||
Args:
|
||||
output_dir: Directory to save the downloaded file (default: "downloads")
|
||||
@@ -121,10 +121,10 @@ def download_latest_aircraft_csv(
|
||||
"""
|
||||
assets = get_latest_release_assets(repo, github_token=github_token)
|
||||
try:
|
||||
asset = pick_asset(assets, name_regex=r"^planequery_aircraft_faa_.*\.csv$")
|
||||
asset = pick_asset(assets, name_regex=r"^openairframes_faa_.*\.csv$")
|
||||
except FileNotFoundError:
|
||||
# Fallback to old naming pattern
|
||||
asset = pick_asset(assets, name_regex=r"^planequery_aircraft_\d{4}-\d{2}-\d{2}_.*\.csv$")
|
||||
asset = pick_asset(assets, name_regex=r"^openairframes_\d{4}-\d{2}-\d{2}_.*\.csv$")
|
||||
saved_to = download_asset(asset, output_dir / asset.name, github_token=github_token)
|
||||
print(f"Downloaded: {asset.name} ({asset.size} bytes) -> {saved_to}")
|
||||
return saved_to
|
||||
@@ -136,11 +136,11 @@ def get_latest_aircraft_faa_csv_df():
|
||||
'unique_regulatory_id': str,
|
||||
'registrant_county': str})
|
||||
df = df.fillna("")
|
||||
# Extract start date from filename pattern: planequery_aircraft_faa_{start_date}_{end_date}.csv
|
||||
match = re.search(r"planequery_aircraft_faa_(\d{4}-\d{2}-\d{2})_", str(csv_path))
|
||||
# Extract start date from filename pattern: openairframes_faa_{start_date}_{end_date}.csv
|
||||
match = re.search(r"openairframes_faa_(\d{4}-\d{2}-\d{2})_", str(csv_path))
|
||||
if not match:
|
||||
# Fallback to old naming pattern: planequery_aircraft_{start_date}_{end_date}.csv
|
||||
match = re.search(r"planequery_aircraft_(\d{4}-\d{2}-\d{2})_", str(csv_path))
|
||||
# Fallback to old naming pattern: openairframes_{start_date}_{end_date}.csv
|
||||
match = re.search(r"openairframes_(\d{4}-\d{2}-\d{2})_", str(csv_path))
|
||||
if not match:
|
||||
raise ValueError(f"Could not extract date from filename: {csv_path.name}")
|
||||
|
||||
@@ -154,7 +154,7 @@ def download_latest_aircraft_adsb_csv(
|
||||
repo: str = REPO,
|
||||
) -> Path:
|
||||
"""
|
||||
Download the latest planequery_aircraft_adsb_*.csv file from the latest GitHub release.
|
||||
Download the latest openairframes_adsb_*.csv file from the latest GitHub release.
|
||||
|
||||
Args:
|
||||
output_dir: Directory to save the downloaded file (default: "downloads")
|
||||
@@ -165,7 +165,7 @@ def download_latest_aircraft_adsb_csv(
|
||||
Path to the downloaded file
|
||||
"""
|
||||
assets = get_latest_release_assets(repo, github_token=github_token)
|
||||
asset = pick_asset(assets, name_regex=r"^planequery_aircraft_adsb_.*\.csv$")
|
||||
asset = pick_asset(assets, name_regex=r"^openairframes_adsb_.*\.csv$")
|
||||
saved_to = download_asset(asset, output_dir / asset.name, github_token=github_token)
|
||||
print(f"Downloaded: {asset.name} ({asset.size} bytes) -> {saved_to}")
|
||||
return saved_to
|
||||
@@ -176,8 +176,8 @@ def get_latest_aircraft_adsb_csv_df():
|
||||
import pandas as pd
|
||||
df = pd.read_csv(csv_path)
|
||||
df = df.fillna("")
|
||||
# Extract start date from filename pattern: planequery_aircraft_adsb_{start_date}_{end_date}.csv
|
||||
match = re.search(r"planequery_aircraft_adsb_(\d{4}-\d{2}-\d{2})_", str(csv_path))
|
||||
# Extract start date from filename pattern: openairframes_adsb_{start_date}_{end_date}.csv
|
||||
match = re.search(r"openairframes_adsb_(\d{4}-\d{2}-\d{2})_", str(csv_path))
|
||||
if not match:
|
||||
raise ValueError(f"Could not extract date from filename: {csv_path.name}")
|
||||
|
||||
Reference in New Issue
Block a user