#!/usr/bin/env python3 """ Terminology migration script: Merchant/Store -> Merchant/Store. Performs bulk find-and-replace across the entire codebase. Replacements are ordered longest-first to avoid partial match issues. Usage: python scripts/rename_terminology.py [--dry-run] """ import os import sys # Root of the project ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # Directories/files to skip SKIP_DIRS = { ".git", "__pycache__", "venv", ".venv", "node_modules", ".mypy_cache", ".pytest_cache", ".ruff_cache", "alembic/versions", # Don't touch old migrations "storage", "htmlcov", # Generated coverage reports ".aider.tags.cache.v3", } SKIP_FILES = { "rename_terminology.py", # Don't modify this script "TERMINOLOGY.md", # Don't modify the terminology doc "t001_rename_merchant_store_to_merchant_store.py", # Don't modify the new migration ".aider.chat.history.md", # Aider chat history ".aider.input.history", } # File extensions to process EXTENSIONS = {".py", ".html", ".js", ".css", ".json", ".yml", ".yaml", ".md", ".txt", ".env", ".cfg", ".ini", ".toml", ".sh", ".mak"} # Also process Makefile (no extension) EXACT_NAMES = {"Makefile", ".env.example", "Dockerfile", "docker-compose.yml"} # ============================================================================ # PROTECTED PATTERNS - these must NOT be renamed # ============================================================================ # These are address/billing fields where "merchant" means "business name on address", # not our Merchant/Merchant entity. We guard them with placeholders before replacements. GUARDS = [ ("ship_company", "ship_company"), ("bill_company", "bill_company"), # The 'merchant' column on customer_addresses (billing address business name) # We need to protect standalone 'merchant' only in specific contexts # Pattern: 'merchant = Column(' or '"merchant"' as dict key for address fields # We'll handle this with a targeted guard: ("company = Column(String(200))", "company = Column(String(200))"), ('["company"]', "["company"]"), ('buyer_details["company"]', "buyer_details["company"]"), ('"company": None', ""company": None"), ('"merchant": order.bill_company', ""company": order.bill_company"), # Protect letzshop URL paths containing /stores/ (external URLs) ("letzshop.lu/vendors/", "letzshop.lu/vendors/"), # Protect 'merchant' as a Pydantic field name for addresses ("company: str", "company: str"), ("company: Optional", "company: Optional"), # Protect .merchant attribute access in address context ("address.company", "address.company"), ("billing.company", "billing.company"), ("shipping_address.company", "shipping_address.company"), ] # ============================================================================ # REPLACEMENT RULES # ============================================================================ # Order matters! Longest/most specific patterns first to avoid partial matches. # Each tuple: (old_string, new_string) REPLACEMENTS = [ # === COMPOUND CLASS NAMES (longest first) === # Store compound class names -> Store ("StoreLetzshopCredentials", "StoreLetzshopCredentials"), ("StoreDirectProductCreate", "StoreDirectProductCreate"), ("StoreProductCreateResponse", "StoreProductCreateResponse"), ("StoreProductListResponse", "StoreProductListResponse"), ("StoreProductListItem", "StoreProductListItem"), ("StoreProductDetail", "StoreProductDetail"), ("StoreProductCreate", "StoreProductCreate"), ("StoreProductUpdate", "StoreProductUpdate"), ("StoreProductStats", "StoreProductStats"), ("StoreProductService", "StoreProductService"), ("StoreInvoiceSettings", "StoreInvoiceSettings"), ("StoreEmailTemplate", "StoreEmailTemplate"), ("StoreEmailSettings", "StoreEmailSettings"), ("StoreSubscription", "StoreSubscription"), ("StoreNotFoundException", "StoreNotFoundException"), ("StoreOnboarding", "StoreOnboarding"), ("StoreUserType", "StoreUserType"), ("StorePlatform", "StorePlatform"), ("StoreDomain", "StoreDomain"), ("StoreAddOn", "StoreAddOn"), ("StoreTheme", "StoreTheme"), ("StoreUser", "StoreUser"), # Letzshop-specific store class names ("LetzshopStoreDirectorySyncResponse", "LetzshopStoreDirectorySyncResponse"), ("LetzshopStoreDirectoryStatsResponse", "LetzshopStoreDirectoryStatsResponse"), ("LetzshopStoreDirectoryStats", "LetzshopStoreDirectoryStats"), ("LetzshopCreateStoreFromCacheResponse", "LetzshopCreateStoreFromCacheResponse"), ("LetzshopCachedStoreListResponse", "LetzshopCachedStoreListResponse"), ("LetzshopCachedStoreDetail", "LetzshopCachedStoreDetail"), ("LetzshopCachedStoreDetailResponse", "LetzshopCachedStoreDetailResponse"), ("LetzshopCachedStoreItem", "LetzshopCachedStoreItem"), ("LetzshopStoreSyncService", "LetzshopStoreSyncService"), ("LetzshopStoreListResponse", "LetzshopStoreListResponse"), ("LetzshopStoreOverview", "LetzshopStoreOverview"), ("LetzshopStoreInfo", "LetzshopStoreInfo"), ("LetzshopStoreCache", "LetzshopStoreCache"), # Service class names ("StoreDomainService", "StoreDomainService"), ("StoreTeamService", "StoreTeamService"), ("StoreService", "StoreService"), # Catalog-specific ("CatalogStoresResponse", "CatalogStoresResponse"), ("CatalogStore", "CatalogStore"), # Marketplace-specific ("CopyToStoreResponse", "CopyToStoreResponse"), ("CopyToStoreRequest", "CopyToStoreRequest"), ("StoresResponse", "StoresResponse"), # Merchant compound class names -> Merchant ("MerchantLoyaltySettings", "MerchantLoyaltySettings"), ("MerchantProfileStepStatus", "MerchantProfileStepStatus"), ("MerchantProfileResponse", "MerchantProfileResponse"), ("MerchantProfileRequest", "MerchantProfileRequest"), ("MerchantListResponse", "MerchantListResponse"), ("MerchantService", "MerchantService"), ("MerchantResponse", "MerchantResponse"), ("MerchantCreate", "MerchantCreate"), ("MerchantUpdate", "MerchantUpdate"), ("MerchantDetail", "MerchantDetail"), ("MerchantSchema", "MerchantSchema"), # === STANDALONE CLASS NAMES === # Must come after all compound names # "Merchant" -> "Merchant" (class name, used in imports, relationships, etc.) ("Merchant", "Merchant"), # "Store" -> "Store" (class name) ("Store", "Store"), # === IDENTIFIER PATTERNS (snake_case) === # Longest/most specific first # File/module paths in imports ("store_product_service", "store_product_service"), ("store_product", "store_product"), ("store_email_settings_service", "store_email_settings_service"), ("store_email_template", "store_email_template"), ("store_email_settings", "store_email_settings"), ("store_sync_service", "store_sync_service"), ("store_domain_service", "store_domain_service"), ("store_team_service", "store_team_service"), ("store_letzshop_credentials", "store_letzshop_credentials"), ("store_invoice_settings", "store_invoice_settings"), ("store_subscriptions", "store_subscriptions"), ("store_onboarding", "store_onboarding"), ("store_platforms", "store_platforms"), ("store_platform", "store_platform"), ("store_context", "store_context"), ("store_domains", "store_domains"), ("store_domain", "store_domain"), ("store_addons", "store_addons"), ("store_themes", "store_themes"), ("store_theme", "store_theme"), ("store_users", "store_users"), ("store_service", "store_service"), ("store_memberships", "store_memberships"), ("store_auth", "store_auth"), ("store_team", "store_team"), ("store_profile", "store_profile"), # Database column/field identifiers ("letzshop_store_slug", "letzshop_store_slug"), ("letzshop_store_id", "letzshop_store_id"), ("letzshop_store_cache", "letzshop_store_cache"), ("claimed_by_store_id", "claimed_by_store_id"), ("enrolled_at_store_id", "enrolled_at_store_id"), ("store_code", "store_code"), ("store_name", "store_name"), ("store_id", "store_id"), ("store_count", "store_count"), # Merchant identifiers ("merchant_loyalty_settings", "merchant_loyalty_settings"), ("merchant_service", "merchant_service"), ("merchant_id", "merchant_id"), # Route/URL path segments ("admin_stores", "admin_stores"), ("admin_store_domains", "admin_store_domains"), ("admin_merchants", "admin_merchants"), # Generic store/merchant identifiers (MUST be last) ("owned_merchants", "owned_merchants"), ("active_store_count", "active_store_count"), # === DISPLAY TEXT / COMMENTS / STRINGS === # These handle common text patterns in templates, docs, comments ("stores", "stores"), ("store", "store"), ("Stores", "Stores"), ("merchants", "merchants"), ("merchant", "merchant"), ("Merchants", "Merchants"), # Title case for display ("STORE", "STORE"), ("MERCHANT", "MERCHANT"), ] def should_skip(filepath: str) -> bool: """Check if file should be skipped.""" rel = os.path.relpath(filepath, ROOT) # Skip specific files basename = os.path.basename(filepath) if basename in SKIP_FILES: return True # Skip directories parts = rel.split(os.sep) for part in parts: if part in SKIP_DIRS: return True # Check for alembic/versions specifically if "alembic" + os.sep + "versions" in rel: return True return False def should_process(filepath: str) -> bool: """Check if file should be processed based on extension.""" basename = os.path.basename(filepath) if basename in EXACT_NAMES: return True _, ext = os.path.splitext(filepath) return ext in EXTENSIONS def apply_replacements(content: str) -> str: """Apply all replacements to content with guard protection.""" # Step 1: Apply guards to protect patterns that must NOT change for original, guard in GUARDS: content = content.replace(original, guard) # Step 2: Apply main replacements for old, new in REPLACEMENTS: content = content.replace(old, new) # Step 3: Restore guarded patterns for original, guard in GUARDS: content = content.replace(guard, original) return content def process_file(filepath: str, dry_run: bool = False) -> tuple[bool, int]: """ Process a single file. Returns (changed: bool, num_replacements: int) """ try: with open(filepath, "r", encoding="utf-8", errors="replace") as f: original = f.read() except (OSError, UnicodeDecodeError): return False, 0 modified = apply_replacements(original) if modified != original: changes = 0 for old, new in REPLACEMENTS: count = original.count(old) if count > 0: changes += count if not dry_run: with open(filepath, "w", encoding="utf-8") as f: f.write(modified) return True, changes return False, 0 def rename_files_and_dirs(dry_run: bool = False) -> list[tuple[str, str]]: """ Rename files and directories that contain 'store' or 'merchant' in their names. Returns list of (old_path, new_path) tuples. """ renames = [] # Collect all files/dirs that need renaming (process deepest first for dirs) items_to_rename = [] for dirpath, dirnames, filenames in os.walk(ROOT): if should_skip(dirpath): dirnames.clear() continue # Skip hidden dirs and common non-project dirs dirnames[:] = [d for d in dirnames if not d.startswith('.') and d not in SKIP_DIRS] # Check files for fname in filenames: if fname in SKIP_FILES: continue if "store" in fname or "merchant" in fname: old_path = os.path.join(dirpath, fname) new_name = fname new_name = new_name.replace("store_product_service", "store_product_service") new_name = new_name.replace("store_product", "store_product") new_name = new_name.replace("store_email_settings_service", "store_email_settings_service") new_name = new_name.replace("store_email_template", "store_email_template") new_name = new_name.replace("store_email_settings", "store_email_settings") new_name = new_name.replace("store_sync_service", "store_sync_service") new_name = new_name.replace("store_domain_service", "store_domain_service") new_name = new_name.replace("store_team_service", "store_team_service") new_name = new_name.replace("store_letzshop_credentials", "store_letzshop_credentials") new_name = new_name.replace("store_invoice_settings", "store_invoice_settings") new_name = new_name.replace("store_subscriptions", "store_subscriptions") new_name = new_name.replace("store_onboarding", "store_onboarding") new_name = new_name.replace("store_platforms", "store_platforms") new_name = new_name.replace("store_platform", "store_platform") new_name = new_name.replace("store_context", "store_context") new_name = new_name.replace("store_domains", "store_domains") new_name = new_name.replace("store_domain", "store_domain") new_name = new_name.replace("store_addons", "store_addons") new_name = new_name.replace("store_themes", "store_themes") new_name = new_name.replace("store_theme", "store_theme") new_name = new_name.replace("store_users", "store_users") new_name = new_name.replace("store_service", "store_service") new_name = new_name.replace("store_auth", "store_auth") new_name = new_name.replace("store_team", "store_team") new_name = new_name.replace("store_profile", "store_profile") new_name = new_name.replace("store", "store") new_name = new_name.replace("merchant_service", "merchant_service") new_name = new_name.replace("merchant_settings", "merchant_settings") new_name = new_name.replace("merchant", "merchant") if new_name != fname: new_path = os.path.join(dirpath, new_name) items_to_rename.append((old_path, new_path)) # Check directories for dname in dirnames: if "store" in dname or "merchant" in dname: # We'll handle directory renames after files pass # Perform file renames for old_path, new_path in items_to_rename: rel_old = os.path.relpath(old_path, ROOT) rel_new = os.path.relpath(new_path, ROOT) if not dry_run: os.rename(old_path, new_path) renames.append((rel_old, rel_new)) return renames def main(): dry_run = "--dry-run" in sys.argv if dry_run: print("=== DRY RUN MODE ===\n") # Phase 1: Apply text replacements to all files print("Phase 1: Applying text replacements...") total_files = 0 changed_files = 0 total_replacements = 0 for dirpath, dirnames, filenames in os.walk(ROOT): if should_skip(dirpath): dirnames.clear() continue dirnames[:] = [d for d in dirnames if not d.startswith('.') and d not in SKIP_DIRS] for fname in filenames: filepath = os.path.join(dirpath, fname) if not should_process(filepath): continue total_files += 1 changed, num = process_file(filepath, dry_run) if changed: changed_files += 1 total_replacements += num rel = os.path.relpath(filepath, ROOT) if dry_run: print(f" WOULD CHANGE: {rel} ({num} replacements)") print(f"\n Files scanned: {total_files}") print(f" Files {'would be ' if dry_run else ''}changed: {changed_files}") print(f" Total replacements: {total_replacements}") # Phase 2: Rename files print("\nPhase 2: Renaming files...") renames = rename_files_and_dirs(dry_run) for old, new in renames: action = "WOULD RENAME" if dry_run else "RENAMED" print(f" {action}: {old} -> {new}") print(f"\n Files {'would be ' if dry_run else ''}renamed: {len(renames)}") print("\nDone!") if __name__ == "__main__": main()