From 00277b17bf2cab410c5cfc41dd876161b8438187 Mon Sep 17 00:00:00 2001 From: Poikilos <7557867+poikilos@users.noreply.github.com> Date: Sat, 7 Mar 2026 16:58:57 -0500 Subject: [PATCH] Fix: set conf values non-redundantly (and change BASE_ENLIVEN_CONF_SETTINGS from list to OrderedDict). Fix: Raise exception instead of skipping mod if missing 'name', or 'repo' for git mods. Add prestibags. Fix some repo URLs. Put mods in alphabetical order under each category. Change from Protector Redo to protect_block_area. --- EnlivenMinetest.code-workspace | 55 +++- buildenliven.py | 467 ++++++--------------------------- changelog.md | 20 ++ pyenliven/__init__.py | 11 +- pyenliven/gamebuilder.py | 205 +++++++++++++++ pyenliven/metadata.py | 354 +++++++++++++++++++++++++ 6 files changed, 713 insertions(+), 399 deletions(-) create mode 100644 pyenliven/gamebuilder.py create mode 100644 pyenliven/metadata.py diff --git a/EnlivenMinetest.code-workspace b/EnlivenMinetest.code-workspace index 1dd405a..d3979b3 100644 --- a/EnlivenMinetest.code-workspace +++ b/EnlivenMinetest.code-workspace @@ -6,9 +6,62 @@ ], "settings": { "cSpell.words": [ + "advancedban", + "bakedclay", + "caverealms", + "compassgps", + "digilines", + "dmobs", + "Facedeer", + "gamebuilder", + "gamespec", + "glooptest", + "hbarmor", + "hbhunger", + "hbsprint", + "homedecor", + "hudbars", + "intllib", + "invhack", + "ircpack", + "luanti", + "mapgen", + "mesecons", + "metatools", "minetest", + "Minetestforfun", "minetestmapper", - "pyenliven" + "modlist", + "modpack", + "monoids", + "moreblocks", + "moreores", + "moretrees", + "mymasonhammer", + "mywalls", + "nftools", + "pipeworks", + "placecraft", + "plantlife", + "playeranim", + "playereffects", + "Poikilos", + "prestibags", + "privs", + "pyenliven", + "railcorridors", + "sfinv", + "skinsdb", + "slimenodes", + "subgame", + "travelnet", + "trmp", + "unifieddyes", + "worldedge", + "worldedit", + "wulfsdad", + "Wuzzy", + "xban" ] } } \ No newline at end of file diff --git a/buildenliven.py b/buildenliven.py index ea23726..15dbc57 100755 --- a/buildenliven.py +++ b/buildenliven.py @@ -2,23 +2,35 @@ ''' ENLIVEN subgame builder - creates ENLIVEN based on minetest_game Merges mods & settings from old bash installer script + +mt_conf_by_mod settings should be placed in minetest.conf +but go in f"{destination}/minetest.conf" by default. See also: +- patches/subgame/minetest.conf + To define the game. +- patches/subgame/minetest.server-example.conf goes in the server only. + - Place the result in the game directory such as will result in + f"worlds/{world}/ENLIVEN/minetest.conf" +- patches/subgame/minetest.client-example.conf goes in clients only. ''' from __future__ import print_function +from collections import OrderedDict import sys import os import argparse # import configparser -import shutil -from typing import List, Dict, Union +import logging # Assuming these exist in your pyenliven module from pyenliven import ( echo0, # getSGPath, # not used here anymore # profile, - MODS_STOPGAP_DIR, ) +from pyenliven.gamebuilder import GameBuilder + +logger = logging.getLogger(os.path.split(__file__)[1]) + # ────────────────────────────────────────────────────────────── # M O D L I S T F R O M B A S H S C R I P T # ────────────────────────────────────────────────────────────── @@ -29,386 +41,6 @@ from pyenliven import ( # 'branch': optional branch name # 'stopgap_only': True → only use from MODS_STOPGAP_DIR, ignore repo -gamespec: Dict[str, any] = {} -gamespec['remove_mods'] = [ - "facade", # no recipes - "placecraft", # interferes with eating - "more_chests", # https://github.com/poikilos/EnlivenMinetest/issues/446 - "emeralds", # https://github.com/poikilos/EnlivenMinetest/issues/497 - "give_initial_stuff", - "xban2", - "dynamic_liquid", - "stamina", - "hbarmor", - "hbhunger", - "hudbars", - "hbsprint", - "dungeon_loot", # replaced by treasurer + trm_* - "helicopter", # known crash issues in older versions - # add others from issue #310 if desired -] - -gamespec['add_mods']: List[Dict[str, any]] = [ - - # ── Utility / Admin ──────────────────────────────────────── - {'name': 'invhack', 'repo': "https://github.com/salahzar/minetest-invhack.git"}, - {'name': 'worldedit', 'repo': "https://github.com/Uberi/MineTest-WorldEdit.git"}, - {'name': 'metatools', 'repo': "https://github.com/poikilos/metatools.git"}, - {'name': 'protector', 'repo': "https://notabug.org/TenPlus1/protector.git"}, - {'name': 'advancedban', 'repo': "https://github.com/srifqi/advancedban"}, - {'name': 'areas', 'repo': "https://github.com/ShadowNinja/areas.git"}, - {'name': 'whitelist', 'repo': "https://github.com/ShadowNinja/whitelist.git"}, - {'name': 'vote', 'repo': "https://github.com/minetest-mods/vote.git"}, - - # ── Mobs & Worldgen ──────────────────────────────────────── - {'name': 'worldedge', 'repo': "https://github.com/minetest-mods/worldedge.git"}, - {'name': 'mobs_redo', 'repo': "https://notabug.org/TenPlus1/mobs_redo.git"}, - {'name': 'mobs_monster', 'repo': "https://notabug.org/TenPlus1/mobs_monster.git"}, - {'name': 'mobs_animal', 'repo': "https://notabug.org/TenPlus1/mobs_animal.git"}, - {'name': 'mob_horse', 'repo': "https://notabug.org/tenplus1/mob_horse.git"}, - {'name': 'mobs_sky', 'repo': "https://github.com/poikilos/mobs_sky.git"}, - {'name': 'spawners', 'repo': "https://bitbucket.org/minetest_gamers/spawners.git"}, - {'name': 'tsm_pyramids', 'repo': "https://github.com/poikilos/tsm_pyramids.git"}, - {'name': 'tsm_chests_dungeon', 'repo': "http://repo.or.cz/minetest_tsm_chests_dungeon.git"}, - {'name': 'treasurer', 'repo': "http://repo.or.cz/minetest_treasurer.git"}, - {'name': 'trm_pyramids'}, # special – files copied directly in bash → handle manually or stopgap - {'name': 'moreblocks', 'repo': "https://github.com/minetest-mods/moreblocks.git"}, - {'name': 'plantlife_modpack','repo': "https://github.com/mt-mods/plantlife_modpack.git"}, - {'name': 'bushes_soil', 'repo': "https://github.com/poikilos/bushes_soil.git"}, - {'name': 'lapis', 'repo': "https://github.com/Napiophelios/LapisLazuli.git"}, - {'name': 'biome_lib', 'repo': "https://github.com/mt-mods/biome_lib.git"}, - {'name': 'moretrees', 'repo': "https://github.com/mt-mods/moretrees.git"}, - {'name': 'mesecons', 'repo': "https://github.com/minetest-mods/mesecons"}, - {'name': 'pipeworks', 'repo': "https://github.com/mt-mods/pipeworks.git"}, - {'name': 'technic', 'repo': "https://github.com/minetest-mods/technic.git"}, - {'name': 'technic_armor', 'repo': "https://github.com/stujones11/technic_armor.git"}, - {'name': 'mapgen_helper', 'repo': "https://github.com/minetest-mods/mapgen_helper.git"}, - {'name': 'subterrane', 'repo': "https://github.com/minetest-mods/subterrane.git"}, - {'name': 'caverealms', 'repo': "https://github.com/FaceDeer/minetest-caverealms.git"}, - {'name': 'moreores', 'repo': "https://github.com/minetest-mods/moreores.git"}, - {'name': 'tsm_mines', 'repo': "http://repo.or.cz/tsm_mines.git"}, - {'name': 'tsm_railcorridors','repo': "http://repo.or.cz/RailCorridors/tsm_railcorridors.git"}, - {'name': 'birthstones', 'repo': "https://github.com/poikilos/birthstones.git"}, - {'name': 'bakedclay', 'repo': "https://notabug.org/tenplus1/bakedclay.git"}, - {'name': 'quartz', 'repo': "https://github.com/minetest-mods/quartz"}, - {'name': 'magma_conduits', 'repo': "https://github.com/FaceDeer/magma_conduits.git"}, - {'name': 'boost_cart', 'repo': "https://github.com/SmallJoker/boost_cart.git"}, - - # ── Gameplay / Items ─────────────────────────────────────── - {'name': 'throwing', 'repo': "https://github.com/minetest-mods/throwing.git"}, - {'name': 'throwing_arrows', 'repo': "https://github.com/minetest-mods/throwing_arrows.git"}, - {'name': 'fishing', 'repo': "https://github.com/MinetestForFun/fishing.git", - 'issues': ["Make sure fishing rods recipe works"]}, - {'name': 'compassgps', 'repo': "https://github.com/poikilos/compassgps.git"}, - {'name': 'sounding_line', 'repo': "https://github.com/minetest-mods/sounding_line.git"}, - {'name': 'mywalls', 'repo': "https://github.com/minetest-mods/mywalls.git"}, - {'name': 'mymasonhammer', 'repo': "https://github.com/minetest-mods/mymasonhammer.git"}, - {'name': 'ts_furniture', 'repo': "https://github.com/minetest-mods/ts_furniture.git"}, - {'name': '3d_armor', 'repo': "https://github.com/stujones11/minetest-3d_armor.git"}, - {'name': 'basic_materials','repo': "https://github.com/mt-mods/basic_materials.git"}, - {'name': 'homedecor_modpack','repo': "https://github.com/mt-mods/homedecor_modpack.git"}, - {'name': 'homedecor_ua', 'repo': "https://github.com/poikilos/homedecor_ua.git"}, - {'name': 'unifieddyes', 'repo': "https://github.com/mt-mods/unifieddyes.git"}, - {'name': 'travelnet', 'repo': "https://github.com/Sokomine/travelnet.git"}, - {'name': 'anvil', 'repo': "https://github.com/minetest-mods/anvil.git"}, - {'name': 'sling', 'repo': "https://github.com/minetest-mods/sling.git"}, - {'name': 'signs_lib', 'repo': "https://github.com/mt-mods/signs_lib.git"}, - {'name': 'slimenodes', 'repo': "https://github.com/poikilos/slimenodes.git"}, - {'name': 'ropes', 'repo': "https://github.com/minetest-mods/ropes.git"}, - {'name': 'digilines', 'repo': "https://github.com/minetest-mods/digilines.git"}, - {'name': 'trmp_minetest_game','repo': "https://github.com/poikilos/trmp_minetest_game.git"}, - {'name': 'awards', 'repo': "https://gitlab.com/rubenwardy/awards.git"}, - {'name': 'awards_board', 'repo': "https://framagit.org/xisd-minetest/awards_board.git"}, - {'name': 'item_drop', 'repo': "https://github.com/minetest-mods/item_drop.git"}, - {'name': 'sponge', 'repo': "https://github.com/BenjieFiftysix/sponge"}, - - # ── Player UX ────────────────────────────────────────────── - {'name': 'money', 'repo': "https://notabug.org/TenPlus1/money"}, - {'name': 'lightning', 'repo': "https://github.com/minetest-mods/lightning.git"}, - {'name': 'unified_inventory','repo': [ - "https://github.com/minetest-mods/unified_inventory.git", # official - "https://github.com/MinetestForFun/unified_inventory" # old bash preference - ]}, - {'name': 'player_monoids', 'repo': "https://github.com/minetest-mods/player_monoids.git"}, - {'name': 'sprint', 'repo': "https://github.com/GunshipPenguin/sprint.git"}, - {'name': 'hunger_ng', 'repo': "https://gitlab.com/4w/hunger_ng.git"}, - {'name': 'playereffects', 'repo': "https://github.com/sys4-fr/playereffects"}, - {'name': 'ambience', 'repo': "https://notabug.org/tenplus1/ambience.git"}, - {'name': 'playeranim', 'repo': "https://github.com/minetest-mods/playeranim.git"}, - {'name': 'skinsdb', 'repo': "https://github.com/minetest-mods/skinsdb.git"}, - {'name': 'woodcutting', 'repo': "https://github.com/minetest-mods/woodcutting.git"}, - - # ── Legacy / Special ─────────────────────────────────────── - #{'name': 'animal_materials_legacy'}, - #{'name': 'elk_legacy'}, - #{'name': 'glooptest_missing'}, - #{'name': 'nftools_legacy'}, -] - -''' -mt_conf_by_mod settings should be placed in minetest.conf such as -/opt/minebest/mtworlds/center/ENLIVEN/minetest.conf -but for now just use -- patches/subgame/minetest.conf - To define the game. - If minebest is present, combine minetest.conf and - minetest.server-example.conf - but maybe make an alternate version with stuff that isn't in - world.conf. - For other conf settings: -- patches/subgame/minetest.server-example.conf goes in the server only. - - Place the result in the game directory such as will result in - /opt/minebest/mtworlds/center/ENLIVEN/minetest.conf -- patches/subgame/minetest.client-example.conf goes in clients only. -''' - -BASE_ENLIVEN_CONF_SETTINGS = [ - # General / map - "enable_lapis_mod_columns = true", - "map_generation_limit = 5000", - # Protector - "protector_radius = 7", - "protector_flip = true", - "protector_pvp = true", - "protector_pvp_spawn = 10", - "protector_drop = false", - "protector_hurt = 3", - # Other gameplay - "world_edge = 5000", - "default_privs = interact,shout,home", - "max_users = 50", - "motd = \"Actions and chat messages are logged. Use inventory to see recipes (use web for live map if available).\"", - "disallow_empty_passwords = true", - "secure.trusted_mods = advanced_npc", - "server_dedicated = false", - "bones_position_message = true", - # Sprint (GunshipPenguin sprint settings) - "sprint_speed = 2.25", - "sprint_jump = 1.25", - "sprint_stamina_drain = .5", -] - -# Per-mod overrides / extras -mt_conf_by_mod = { - 'item_drop': { - 'item_drop.pickup_radius': "1.425", - }, - 'throwing_arrows': { - 'throwing.enable_arrow': "true", - }, -} -why = {} -why["https://github.com/MinetestForFun/unified_inventory"] = ''' -This fork makes a "nicer interface". The fork hasn't been tested yet. -''' -# deprecates https://github.com/poikilos/vines.git fork of Facedeer's: -why["https://github.com/FaceDeer/vines.git"] = ''' -> I've finally done it, I've split this mod in twain. The new -> stand-alone ropes mod has no dependency on biome_lib and no vine -> content, though its crafting recipes remain compatible with the vines -> produced by this mod. -> -> My fork of this vines mod has had the rope-related content removed -> from it, leaving it as just a vines mod. Note that I haven't tested -> it extensively - I have to admit, I've mainly been in this for the -> ropes. :) I'll do what I can to maintain it, though, if anyone has -> bug reports or requests. -> -> I've added a node upgrade function to the new ropes mod that will -> convert the ropes from both my fork of the vines mod and the original -> version of the vines mod by bas080 to the new ropes mod's ropes. So -> if you wish to upgrade an existing world it should work. - -- FaceDeer on [[Mod] Vines and Rope [2.3] [vines]] - (https://forums.minetest.org/viewtopic.php?f=11&t=2344&start=50 - &sid=bf15c996963e891cd3f2460c2525044a) - -Note that vines requires: - -default -biome_lib -moretrees? -doc? -intllib? -mobs? -creatures? -''' -gamespec['disable_mobs'] = [ - "old_lady", -] - -""" -known_issues = {} -known_issues['fishing'] = "Make sure fishing rods recipe works" - - -server_only_mods = [ - 'ircpack', - 'chat3', # debatable – was removed in some places -] - -# ────────────────────────────────────────────────────────────── - -class GameBuilder: - def __init__(self, minetest_game_path: str, minetest_version: str = "5"): - self.source_game = os.path.realpath(minetest_game_path) - self.target_parent = os.path.dirname(self.source_game) - self.target_game = os.path.join(self.target_parent, "ENLIVEN") - self.mods_target = os.path.join(self.target_game, "mods") - self.minetest_version = minetest_version # "5" or "0.4" - - if minetest_version not in ("5", "0.4"): - raise ValueError("minetest_version must be '5' or '0.4'") - - if not os.path.isdir(self.source_game): - raise FileNotFoundError(f"minetest_game not found: {self.source_game}") - - echo0(f"Building ENLIVEN → {self.target_game}") - echo0(f" from base: {self.source_game}") - echo0(f" target Minetest compatibility: {self.minetest_version}") - - def prepare_target(self): - """Copy minetest_game → ENLIVEN if needed""" - if os.path.exists(self.target_game): - echo0(f"Target already exists: {self.target_game}") - echo0("→ delete it manually if you want fresh copy") - return - - echo0("Copying minetest_game → ENLIVEN ...") - shutil.copytree(self.source_game, self.target_game) - - def install_mod(self, entry: Dict[str, any]): - name = entry.get('name') - repo = entry.get('repo') - branch = entry.get('branch') - stopgap_only = entry.get('stopgap_only', False) - - if not name: - echo0("Skipping entry without 'name'") - return - - dest = os.path.join(self.mods_target, name) - - # 1. Prefer stopgap if exists - stopgap_src = os.path.join(MODS_STOPGAP_DIR, name) - - if os.path.isdir(stopgap_src): - echo0(f" [stopgap] {name}") - if os.path.exists(dest): - shutil.rmtree(dest) - shutil.copytree(stopgap_src, dest) - return - - # 2. Git clone if we have repo URL(s) - if repo and not stopgap_only: - urls = [repo] if isinstance(repo, str) else repo - cloned = False - - for url in urls: - echo0(f" trying → {url}") - # Here should be real git clone logic. - # For now just placeholder message. - # You can use subprocess.run(["git", "clone", ...]) - echo0(f" [TODO git clone] {url} → {dest}") - cloned = True # pretend success - break - - if cloned: - return - - echo0(f"→ WARNING: no source found for {name}") - - def remove_mod(self, modname: str): - path = os.path.join(self.mods_target, modname) - if os.path.isdir(path): - echo0(f" removing {modname}") - shutil.rmtree(path) - - def apply_remove_list(self): - for m in gamespec.get('remove_mods', []): - self.remove_mod(m) - - def install_all_mods(self): - for entry in gamespec.get('add_mods', []): - self.install_mod(entry) - - def write_game_conf(self): - path = os.path.join(self.target_game, "game.conf") - with open(path, "w", encoding="utf-8") as f: - f.write("name = ENLIVEN\n") - f.write("description = Enhanced minetest_game experience\n") - - def update_conf(self, path: str): - """Append settings only if not already present (line-based, stripped comparison)""" - os.makedirs(os.path.dirname(path), exist_ok=True) - - # Collect base settings - desired_lines = list(BASE_ENLIVEN_CONF_SETTINGS) - - # Add version-specific player animation setting - if self.minetest_version == "5": - desired_lines.append("playeranim.model_version = MTG_4_Nov_2017") - else: # "0.4" - desired_lines.append("playeranim.model_version = MTG_4_Jun_2017") - - # Add per-mod settings - for modname, settings in mt_conf_by_mod.items(): - desired_lines.append(f"# settings from {modname}") - for k, v in settings.items(): - desired_lines.append(f"{k} = {v}") - desired_lines.append("") - - if not desired_lines: - return - - # Normalize for comparison (strip, remove empty) - desired_set = {line.strip() for line in desired_lines if line.strip()} - - # Read existing content - existing_lines = [] - if os.path.isfile(path): - with open(path, "r", encoding="utf-8") as f: - existing_lines = [line.rstrip("\n") for line in f] - existing_set = {line.strip() for line in existing_lines if line.strip()} - else: - existing_set = set() - - # Find lines to add - to_add = [line for line in desired_lines if line.strip() and line.strip() not in existing_set] - - if not to_add: - echo0(f"minetest.conf already contains all desired ENLIVEN settings ({path})") - return - - # Write mode - mode = "a" if os.path.isfile(path) else "w" - with open(path, mode, encoding="utf-8") as f: - if mode == "w": - f.write(f"# ENLIVEN subgame recommended settings (Minetest {self.minetest_version} compatibility)\n\n") - for line in to_add: - f.write(line + "\n") - - echo0(f"Updated {path} — added {len(to_add)} new line(s)") - - def build(self, conf_path: str = None): - self.prepare_target() - self.apply_remove_list() - self.install_all_mods() - self.write_game_conf() - - if not conf_path: - conf_path = os.path.join(self.source_game, "minetest.conf.enliven") - - self.update_conf(conf_path) - - echo0("\nBuild finished.") - echo0(f"Game location: {self.target_game}") - echo0(f"Config : {conf_path}") - echo0("Next steps:") - echo0(" • review & edit the minetest.conf file") - echo0(" • test in Minetest") - - def main(): parser = argparse.ArgumentParser(description="Build ENLIVEN subgame from minetest_game") parser.add_argument("minetest_game_path", help="Path to minetest_game directory") @@ -418,20 +50,67 @@ def main(): parser.add_argument("--minetest-version", "-v", dest="minetest_version", choices=["0.4", "5"], default="5", help="Target Minetest version compatibility: '5' (default) or '0.4'") - + parser.add_argument("--offline", "-o", action="store_true", + help="Do not pull in repos (fail if not cloned yet).") + parser.add_argument("--delete", "-d", action="store_true", + help="Erase target completely first") + parser.add_argument("--no-pull", "-n", action="store_true", + help="Skip pull command on existing repos") args = parser.parse_args() - try: - builder = GameBuilder( - args.minetest_game_path, - minetest_version=args.minetest_version - ) - builder.build(conf_path=args.conf_path) - return 0 - except Exception as exc: - echo0(f"ERROR: {exc}", file=sys.stderr) - return 1 + # try: + builder = GameBuilder( + args.minetest_game_path, + minetest_version=args.minetest_version, + offline=args.offline, + delete=args.delete, + pull=args.no_pull + ) + builder.build(conf_path=args.conf_path) + return 0 + # except Exception as exc: + # echo0(f"ERROR: {exc}") + # return 1 + + +def detect_games(programs_dir, name="minetest_game"): + games = [] + if not os.path.isdir(programs_dir): + print(f"No {repr(programs_dir)}") + return games + for engine in os.listdir(programs_dir): + engine_path = os.path.join(programs_dir, engine) + # such as "luanti" + for version in os.listdir(engine_path): + # such as "luanti-5.15.1" + version_path = os.path.join(engine_path, version) + games_path = os.path.join(version_path, "games") + game_path = os.path.join(games_path, name) + if os.path.isdir(game_path): + games.append(OrderedDict( + name=name, + path=game_path, + )) + else: + print(f"No {repr(game_path)}") + return games if __name__ == "__main__": + base_game_name = "minetest_game" + programs_dir = os.path.expanduser("~/.config/EnlivenMinetest/versions") + games = detect_games(programs_dir) + seqCount = 0 + for argI in range(1, len(sys.argv)): + arg = sys.argv[argI] + if arg.startswith("-"): + continue + seqCount += 1 + if seqCount < 1: + if len(games) < 1: + raise ValueError(f"No {base_game_name} in {programs_dir}/<->/games. Download it there first.") + if len(games) > 1: + print(f"Detected {games}") + print(f"No base game folder specified. Using {games[0]}") + sys.argv.insert(1, games[0]['path']) sys.exit(main()) diff --git a/changelog.md b/changelog.md index 6e821ba..d824b1a 100644 --- a/changelog.md +++ b/changelog.md @@ -4,10 +4,30 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [git] - 2026-03-07 +### Added +- loot (adds loot API and dungeon treasure) +- open_ai (adds open_ai mobs API and creatures) +- prestibags +- `update_conf` and `encode_cv` methods for editing conf files without adding duplicate settings +- sponge + +### Changed +- Switch from Protector Redo to protect_block_area + +### Removed +- treasurer (and trm_*, trmp_*, and tsm_* mods) +- mobs redo (and mobs_* mods) + + +## [git] - 2026-03-06 +### Added +- buildenliven.py now has WIP version of most features and metadata (mod list) from install-ENLIVEN-minetest_game.sh ## [git] - 2024-01-06 ### Removed - Move mock_tnt to its own repo (https://github.com/Poikilos/mock_tnt). +- Usage of OldCoder code for ENLIVEN game. ## [git] - 2020-05-07 diff --git a/pyenliven/__init__.py b/pyenliven/__init__.py index 7bdf9aa..0e9d332 100644 --- a/pyenliven/__init__.py +++ b/pyenliven/__init__.py @@ -20,7 +20,8 @@ max_verbosity = 2 def echo0(*args, **kwargs): - print(*args, file=sys.stderr, **kwargs) + kwargs['file'] = sys.stderr + print(*args, **kwargs) def echo1(*args, **kwargs): @@ -52,9 +53,10 @@ def set_verbosity(level): "verbosity must be {} at maximum.".format(max_verbosity) ) - +enable_mtanalyze = False try: import mtanalyze + enable_mtanalyze = True except ModuleNotFoundError as ex: # tryMTA = os.path.join(profile, "git", "mtanalyze") moduleDir = os.path.dirname(os.path.realpath(__file__)) @@ -68,16 +70,17 @@ except ModuleNotFoundError as ex: import mtanalyze # ^ import mtanalyze/mtanalyze purposely since the main # mtanalyze/ directory is a setuptools package not a module. + enable_mtanalyze = True else: echo0("") - echo0("You must install mtanalyze alongside") + echo0("You can optionally install mtanalyze alongside") echo0("EnlivenMinetest such that ../mtanalize/mtanalize exists") echo0("such as via:") echo0(" git clone https://github.com/poikilos/mtanalyze {}" "".format(tryMTA)) echo0("") # raise tryMTA - exit(1) + # exit(1) # from mtanalyze import profile_path MY_MODULE_DIR = os.path.dirname(os.path.realpath(__file__)) diff --git a/pyenliven/gamebuilder.py b/pyenliven/gamebuilder.py new file mode 100644 index 0000000..4f99f1f --- /dev/null +++ b/pyenliven/gamebuilder.py @@ -0,0 +1,205 @@ +import shutil +import os + +from collections import OrderedDict +from logging import getLogger +from typing import Dict + +from git import Repo + +from pyenliven import MODS_STOPGAP_DIR, echo0 +from pyenliven.metadata import ( + BASE_ENLIVEN_CONF_SETTINGS, + gamespec, + update_conf, +) + + +logger = getLogger(__name__) + + +class GameBuilder: + """Manage creation of a game from scratch. + + Attributes: + more_conf (OrderedDict): minetest.conf settings collected from + entry(ies) such as from install_mod calls. + """ + def __init__(self, minetest_game_path: str, minetest_version: str = "5", + offline: bool = False, delete: bool = False, + pull: bool = True): + self.source_game = os.path.realpath(minetest_game_path) + self.target_parent = os.path.dirname(self.source_game) + self.target_game = os.path.join(self.target_parent, "ENLIVEN") + self.pull = pull + if os.path.exists(self.target_game): + if delete: + print(f"rm -rf {repr(self.target_game)}") + shutil.rmtree(self.target_game) + self.mods_target = os.path.join(self.target_game, "mods") + self.minetest_version = minetest_version # "5" or "0.4" + self.offline = offline + self.more_conf = OrderedDict() + self.meta = OrderedDict() + self.meta['mods'] = OrderedDict() + + if minetest_version not in ("5", "0.4"): + raise ValueError("minetest_version must be '5' or '0.4'") + + if not os.path.isdir(self.source_game): + raise FileNotFoundError(f"minetest_game not found: {self.source_game}") + if os.path.realpath(self.source_game) == os.path.realpath(self.target_game): + raise ValueError("source game and target game are both" + f" {repr(os.path.realpath(self.source_game))}") + echo0(f"Building ENLIVEN → {self.target_game}") + echo0(f" from base: {self.source_game}") + echo0(f" target Minetest compatibility: {self.minetest_version}") + echo0(f" offline mode: {self.offline}") + + def prepare_target(self): + """Copy minetest_game → ENLIVEN if needed""" + if os.path.exists(self.target_game): + raise FileExistsError(f"Target already exists: {self.target_game}") + # return + echo0("Copying minetest_game → ENLIVEN ...") + shutil.copytree(self.source_game, self.target_game) + + def install_mod(self, entry: Dict[str, any]): + name = entry.get('name') + repo = entry.get('repo') + branch = entry.get('branch') + stopgap_only = entry.get('stopgap_only', False) + settings = entry.get('settings') + if settings: + for k, v in settings.items(): + self.more_conf[k] = v + + if not name: + raise ValueError(f"Missing 'name' in {entry}") + if name in self.meta['mods']: + raise KeyError(f"Already installed a mod named {name}") + dest = os.path.join(self.mods_target, name) + + # 1. Prefer stopgap if exists + stopgap_src = os.path.join(MODS_STOPGAP_DIR, name) + + if os.path.isdir(stopgap_src): + echo0(f" [stopgap] {name}") + if os.path.exists(dest): + shutil.rmtree(dest) + shutil.copytree(stopgap_src, dest) + self.meta['mods'][name] = entry + return + + # 2. Git clone if we have repo URL(s) + if not repo: + raise ValueError(f"Missing 'repo' for {entry}") + urls = [repo] if isinstance(repo, str) else repo + url = urls[-1] # prefer last one + del urls + distributor = entry.get('distributor') + if not distributor: + distributor = url.split("/")[-2] + repo_name = url.split("/")[-1].replace(".git", "") + + git_local_path = os.path.expanduser(f"~/{repo_name}") + if os.path.isdir(git_local_path): + # Use the development copy on the computer + logger.warning(f"Using local git repo without update: {git_local_path}") + if os.path.exists(dest): + raise FileExistsError(f"Remove {dest} first.") + # shutil.rmtree(dest) + shutil.copytree(git_local_path, dest) + self.meta['mods'][name] = entry + return + + downloads_path = os.path.expanduser(f"~/Downloads/git/{distributor}/{repo_name}") + if os.path.isdir(downloads_path): + if not self.offline: + if self.pull: + echo0(f" pulling {downloads_path}") + git_repo = Repo(downloads_path) + git_repo.remotes.origin.pull() + else: + echo0(f" using existing {downloads_path}") + if os.path.exists(dest): + raise FileExistsError(f"Remove {dest} first.") + # shutil.rmtree(dest) + shutil.copytree(downloads_path, dest) + self.meta['mods'][name] = entry + return + else: + if self.offline: + raise FileNotFoundError(f"Mod {name} not found in offline mode: {downloads_path}") + else: + echo0(f" cloning {url} → {downloads_path}") + Repo.clone_from(url, downloads_path) + if os.path.exists(dest): + raise FileExistsError(f"Remove {dest} first.") + # shutil.rmtree(dest) + shutil.copytree(downloads_path, dest) + # dest_git = os.path.join(dest, ".git") + # if os.path.isdir(dest_git): + # shutil.rmtree(dest_git) + self.meta['mods'][name] = entry + return + + def remove_mod(self, name: str): + path = os.path.join(self.mods_target, name) + if os.path.isdir(path): + echo0(f" removing {name}") + shutil.rmtree(path) + + def apply_remove_list(self): + for m in gamespec.get('remove_mods', []): + self.remove_mod(m) + + def install_all_mods(self): + for entry in gamespec.get('add_mods', []): + self.install_mod(entry) + + def write_game_conf(self): + path = os.path.join(self.target_game, "game.conf") + with open(path, "w", encoding="utf-8") as f: + f.write("name = ENLIVEN\n") + f.write("description = For building immersive worlds using ramping, consistent style, and emphasizing world interaction over menus\n") + + def update_conf(self, path: str): + """Append settings only if not already present (line-based, stripped comparison)""" + os.makedirs(os.path.dirname(path), exist_ok=True) + + # Collect base settings + new_settings = BASE_ENLIVEN_CONF_SETTINGS.copy() + new_settings.update(self.more_conf) + + # Add version-specific player animation setting + if 'playeranim' in self.meta['mods']: + # TODO: Make version keys and values in gamespec + if self.minetest_version == "5": + new_settings['playeranim.model_version'] = "MTG_4_Nov_2017" + else: # "0.4" + new_settings['playeranim.model_version'] = "MTG_4_Jun_2017" + + if not new_settings: + return + update_conf(path, new_settings) + # desired_set = {line.strip() for line in desired_lines if line.strip()} + + def build(self, conf_path: str = None): + self.prepare_target() + self.apply_remove_list() + self.install_all_mods() + self.write_game_conf() + + # Default conf path if not provided + if not conf_path: + conf_path = os.path.join(self.source_game, "minetest.conf.enliven") + + self.update_conf(conf_path) + + echo0("\nBuild finished.") + echo0(f"Game location: {self.target_game}") + echo0(f"Config : {conf_path}") + echo0("Next steps:") + echo0(" • review & edit the minetest.conf file") + echo0(" • test in Minetest") diff --git a/pyenliven/metadata.py b/pyenliven/metadata.py new file mode 100644 index 0000000..541c4e9 --- /dev/null +++ b/pyenliven/metadata.py @@ -0,0 +1,354 @@ +import shutil +import os + +from collections import OrderedDict +from decimal import Decimal +from typing import Dict, List + + +gamespec: OrderedDict[str, any] = OrderedDict() +gamespec['remove_mods'] = [ + "facade", # no recipes + "placecraft", # interferes with eating + "more_chests", # https://github.com/poikilos/EnlivenMinetest/issues/446 + "emeralds", # https://github.com/poikilos/EnlivenMinetest/issues/497 + "give_initial_stuff", + "xban2", + "dynamic_liquid", + # "stamina", + "hbarmor", + "hbhunger", + "hudbars", + "hbsprint", + # "dungeon_loot", # or treasurer + trm_* + "helicopter", # known crash issues in older versions + # add others from issue #310 if desired +] + +gamespec['add_mods']: List[Dict[str, any]] = [ + + # ── Utility / Admin ──────────────────────────────────────── + # ^ See also inventory_admin mod which is purely command-based + {'name': "advancedban", 'repo': "https://github.com/srifqi/advancedban"}, + {'name': "areas", 'repo': "https://github.com/ShadowNinja/areas.git"}, + {'name': "invhack", 'repo': "https://github.com/salahzar/minetest-invhack.git", + 'privs': {'moderator': ['invhack']}, + 'items': {'moderator': ['invhack:tool']}}, + {'name': "metatools", 'repo': "https://github.com/poikilos/metatools.git"}, + {'name': "modlist", 'repo': "https://github.com/SkyBuilder1717/modlist.git"}, + # {'name': "protector", 'repo': ["https://notabug.org/TenPlus1/protector.git", + # "https://codeberg.org/tenplus1/protector.git"]}, + {'name': "protect_block_area", 'repo': "https://github.com/C-C-Minetest-Server/protect_block_area.git"}, + {'name': "vote", 'repo': "https://github.com/minetest-mods/vote.git"}, + {'name': "whitelist", 'repo': "https://github.com/ShadowNinja/whitelist.git"}, + {'name': "worldedit", 'repo': "https://github.com/Uberi/MineTest-WorldEdit.git"}, + + # Mobs + #{'name': "mobs_redo", 'repo': ["https://notabug.org/TenPlus1/mobs_redo.git", + # "https://codeberg.org/tenplus1/mobs_redo.git"]}, + #{'name': "mobs_monster", 'repo': ["https://notabug.org/TenPlus1/mobs_monster.git", + # "https://codeberg.org/tenplus1/mobs_monster.git"]}, + #{'name': "mobs_animal", 'repo': ["https://notabug.org/TenPlus1/mobs_animal.git", + # "https://codeberg.org/tenplus1/mobs_animal.git"]}, + #{'name': "mob_horse", 'repo': ["https://notabug.org/tenplus1/mob_horse.git", + # "https://codeberg.org/tenplus1/mob_horse.git"]}, + #{'name': "mobs_sky", 'repo': "https://github.com/poikilos/mobs_sky.git"}, + #{'name': "mobs_water", 'repo': "https://github.com/blert2112/mobs_water.git"}, + #{'name': "dmobs", 'repo': ["https://github.com/minetest-mobs-mods/dmobs.git", + # "https://codeberg.org/tenplus1/dmobs"]}, + # TODO: ^ Make open_ai mobs api bridge for these + {'name': "open_ai", 'repo': "https://github.com/Poikilos/open_ai.git"}, + {'name': "spawners", 'repo': "https://bitbucket.org/minetest_gamers/spawners.git"}, + + # Worldgen + {'name': "bakedclay", 'repo': ["https://notabug.org/tenplus1/bakedclay.git", + "https://codeberg.org/tenplus1/bakedclay.git"]}, + {'name': "biome_lib", 'repo': "https://github.com/mt-mods/biome_lib.git"}, + # {'name': "birthstones", 'repo': "https://github.com/poikilos/birthstones.git"}, # commented to reduce inventory overload + {'name': "bushes_soil", 'repo': "https://github.com/poikilos/bushes_soil.git"}, + {'name': "caverealms", 'repo': "https://github.com/FaceDeer/minetest-caverealms.git"}, + {'name': "lapis", 'repo': "https://github.com/Napiophelios/LapisLazuli.git", + 'settings': OrderedDict(enable_lapis_mod_columns=True), + 'why': "Unlike, minetest-mods/lapis, LapisLazuli has more things you can make."}, + {'name': "mapgen_helper", 'repo': "https://github.com/minetest-mods/mapgen_helper.git"}, + {'name': "mesecons", 'repo': "https://github.com/minetest-mods/mesecons"}, + {'name': "moreblocks", 'repo': "https://github.com/minetest-mods/moreblocks.git"}, + {'name': "moreores", 'repo': "https://github.com/minetest-mods/moreores.git"}, + {'name': "moretrees", 'repo': "https://github.com/mt-mods/moretrees.git"}, + {'name': "plantlife_modpack", 'repo': "https://github.com/mt-mods/plantlife_modpack.git"}, + {'name': "pipeworks", 'repo': "https://github.com/mt-mods/pipeworks.git"}, + {'name': "subterrane", 'repo': "https://github.com/minetest-mods/subterrane.git"}, + {'name': "technic", 'repo': "https://github.com/minetest-mods/technic.git"}, + {'name': "technic_armor", 'repo': "https://github.com/stujones11/technic_armor.git"}, + # {'name': "tsm_pyramids", 'repo': "https://github.com/poikilos/tsm_pyramids.git"}, + # {'name': "tsm_chests_dungeon", 'repo': "http://repo.or.cz/minetest_tsm_chests_dungeon.git"}, # see "loot" mod instead + # {'name': "treasurer", 'repo': "http://repo.or.cz/minetest_treasurer.git"}, + # {'name': "trm_pyramids"}, # special – files copied directly in bash → handle manually or stopgap + # {'name': "tsm_mines", 'repo': "http://repo.or.cz/tsm_mines.git"}, fork of BlockMen’s [Mines](https://forum.minetest.net/viewtopic.php?f=11&t=6307) replaced by tsm_railcorridors + # {'name': "tsm_railcorridors", 'repo': ["http://repo.or.cz/RailCorridors/tsm_railcorridors.git", + # "https://codeberg.org/Wuzzy/minetest_tsm_railcorridors.git"]}, + # TODO: ^ Make loot version of these + {'name': "loot", 'repo': "https://github.com/minetest-mods/loot.git", + 'why': "Defines loot API and adds loot to dungeons", + 'settings': OrderedDict(loot_dungeons=True)}, + # {'name': 'quartz', 'repo': "https://github.com/minetest-mods/quartz"}, # commented to reduce inventory overload + {'name': "magma_conduits", 'repo': "https://github.com/FaceDeer/magma_conduits.git"}, + {'name': "worldedge", 'repo': "https://github.com/minetest-mods/worldedge.git"}, + + # ── Gameplay / Items ─────────────────────────────────────── + {'name': "3d_armor", 'repo': "https://github.com/stujones11/minetest-3d_armor.git"}, + {'name': "anvil", 'repo': "https://github.com/minetest-mods/anvil.git"}, + {'name': "awards", 'repo': "https://gitlab.com/rubenwardy/awards.git"}, + {'name': "awards_board", 'repo': "https://framagit.org/xisd-minetest/awards_board.git"}, + {'name': "basic_materials",'repo': "https://github.com/mt-mods/basic_materials.git"}, + {'name': "boost_cart", 'repo': "https://github.com/SmallJoker/boost_cart.git"}, + {'name': "compassgps", 'repo': "https://github.com/poikilos/compassgps.git"}, + {'name': "digilines", 'repo': "https://github.com/minetest-mods/digilines.git"}, + {'name': "fishing", 'repo': "https://github.com/MinetestForFun/fishing.git", + 'issues': ["Make sure fishing rods recipe works"], + 'version-note': "Minetestforfun's (NOT wulfsdad's) fishing "}, + {'name': "homedecor_modpack", 'repo': "https://github.com/mt-mods/homedecor_modpack.git"}, + {'name': "homedecor_ua", 'repo': "https://github.com/poikilos/homedecor_ua.git"}, + {'name': "item_drop", 'repo': "https://github.com/minetest-mods/item_drop.git", + 'settings': {'item_drop.pickup_radius': "1.425"}}, + # {'name': 'mymasonhammer', 'repo': "https://github.com/minetest-mods/mymasonhammer.git", + # 'what': "A hammer that cuts stairs and ladders in blocks"}, + {'name': "mywalls", 'repo': "https://github.com/minetest-mods/mywalls.git", + 'what': "Adds more wall types for walls mod from minetest_game."}, + {'name': "ropes", 'repo': "https://github.com/minetest-mods/ropes.git"}, + {'name': "sling", 'repo': "https://github.com/minetest-mods/sling.git"}, + {'name': "signs_lib", 'repo': "https://github.com/mt-mods/signs_lib.git"}, + {'name': "slimenodes", 'repo': "https://github.com/poikilos/slimenodes.git"}, + {'name': "sounding_line", 'repo': "https://github.com/minetest-mods/sounding_line.git"}, + {'name': "stamina", 'repo': "https://codeberg.org/tenplus1/stamina.git", + 'comment': "Patches item_eat to affect saturation instead of health.", + 'help-dev': "Changes ItemStack before register_on_item_eat callbacks, but they can use 6th param for original ItemStack", + 'privs': {'invincible':['no_hunger']}}, + {'name': "sponge", 'repo': "https://github.com/BenjieFiftysix/sponge"}, + # TODO: ^ Test, make sure is in mapgen. Add underwater/sealife mod & integrate + {'name': "throwing", 'repo': "https://github.com/minetest-mods/throwing.git"}, + {'name': "throwing_arrows", 'repo': "https://github.com/minetest-mods/throwing_arrows.git", + 'settings': {'throwing.enable_arrow': "true"}}, + {'name': "travelnet", 'repo': "https://github.com/Sokomine/travelnet.git"}, + {'name': "ts_furniture", 'repo': "https://github.com/minetest-mods/ts_furniture.git"}, + # {'name': 'trmp_minetest_game','repo': "https://github.com/poikilos/trmp_minetest_game.git"}, + # TODO: ^ Make a version for loot + # {'name': 'unifieddyes', 'repo': "https://github.com/mt-mods/unifieddyes.git"}, + # TODO: ^ add Poikilos/dyed mod + + # ── Player UX ────────────────────────────────────────────── + {'name': "ambience", 'repo': ["https://notabug.org/tenplus1/ambience.git", + "https://codeberg.org/tenplus1/ambience.git"]}, + {'name': "hunger_ng", 'repo': "https://gitlab.com/4w/hunger_ng.git"}, + {'name': "lightning", 'repo': "https://github.com/minetest-mods/lightning.git"}, + # {'name': "money", 'repo': ["https://notabug.org/TenPlus1/money", + # "https://codeberg.org/tenplus1/money.git"], + # 'why-not': "This fork removes everything except barter stations"}, + {'name': "player_monoids", 'repo': "https://github.com/minetest-mods/player_monoids.git"}, + {'name': "playeranim", 'repo': "https://github.com/minetest-mods/playeranim.git", + 'what': "Makes the head, and the right arm when you're mining, face the way you're facing"}, + {'name': "playereffects", 'repo': "https://github.com/sys4-fr/playereffects"}, + # {'name': 'skinsdb', 'repo': "https://github.com/minetest-mods/skinsdb.git"}, + # {'name': "sprint", 'repo': "https://github.com/GunshipPenguin/sprint.git"}, + # TODO: ^ Make sure stamina mod fully implements sprint + # {'name': 'unified_inventory','repo': [ + # "https://github.com/minetest-mods/unified_inventory.git", + # "https://github.com/MinetestForFun/unified_inventory" # fork with "nicer interface" + # ]}, + {'name': "sfinv", 'repo': "https://github.com/rubenwardy/sfinv.git", + 'why': "Unified inventory is ugly and has a bloated API."}, + # {'name': "bags", 'repo': "https://github.com/cornernote/minetest-bags.git"}, + {'name': "prestibags", 'repo': "https://github.com/Poikilos/prestibags.git"}, + {'name': "woodcutting", 'repo': "https://github.com/minetest-mods/woodcutting.git"}, + + # ── Legacy / Special ─────────────────────────────────────── + # {'name': "animal_materials_legacy"}, + # {'name': "elk_legacy"}, + # {'name': "glooptest_missing"}, + # {'name': "nftools_legacy"}, +] + +# Preprocess add_mods for repo.or.cz +for entry in gamespec['add_mods']: + repos = entry.get('repo') + if isinstance(repos, str): + repos = [repos] + for repo in repos: + if repo and "repo.or.cz" in repo: + entry['distributor'] = "Wuzzy" + break + +# Per-mod overrides / extras +why = {} +why["https://github.com/MinetestForFun/unified_inventory"] = ''' +This fork makes a "nicer interface". The fork hasn't been tested yet. +''' +# deprecates https://github.com/poikilos/vines.git fork of Facedeer's: +why["https://github.com/FaceDeer/vines.git"] = ''' +> I've finally done it, I've split this mod in twain. The new +> stand-alone ropes mod has no dependency on biome_lib and no vine +> content, though its crafting recipes remain compatible with the vines +> produced by this mod. +> +> My fork of this vines mod has had the rope-related content removed +> from it, leaving it as just a vines mod. Note that I haven't tested +> it extensively - I have to admit, I've mainly been in this for the +> ropes. :) I'll do what I can to maintain it, though, if anyone has +> bug reports or requests. +> +> I've added a node upgrade function to the new ropes mod that will +> convert the ropes from both my fork of the vines mod and the original +> version of the vines mod by bas080 to the new ropes mod's ropes. So +> if you wish to upgrade an existing world it should work. + +- FaceDeer on [[Mod] Vines and Rope [2.3] [vines]] + (https://forums.minetest.org/viewtopic.php?f=11&t=2344&start=50 + &sid=bf15c996963e891cd3f2460c2525044a) + +Note that vines requires: + +default +biome_lib +moretrees? +doc? +intllib? +mobs? +creatures? +''' +gamespec['disable_mobs'] = [ + "old_lady", +] + + +server_only_mods = [ + 'ircpack', + 'chat3', +] + +# ────────────────────────────────────────────────────────────── + +# ────────────────────────────────────────────────────────────── +# Collected minetest.conf settings from the bash script +# ────────────────────────────────────────────────────────────── + +BASE_ENLIVEN_CONF_SETTINGS = OrderedDict( + # General / map + map_generation_limit=5000, + # HUD / Controls + # "stamina_disable_aux1 = true", # require double tap for run (Prevent stamina from taking up aux1 key) + # "stamina_hud_x =", + # "stamina_hud_y =", + # "stamina_double_tap_time =", # 0 to disable + # Protector + protector_radius=7, + protector_flip=True, + protector_pvp=True, + protector_pvp_spawn=10, + protector_drop=False, + protector_hurt=3, + # Other gameplay + world_edge=5000, + default_privs="interact,shout,home", + max_users=50, + motd="Actions and chat messages are logged. Use inventory to see recipes (use web for live map if available).", + disallow_empty_passwords=True, + server_dedicated=False, + bones_position_message=True, + # Sprint (GunshipPenguin sprint settings) + sprint_speed=2.25, + sprint_jump=1.25, + sprint_stamina_drain=.5, +) +BASE_ENLIVEN_CONF_SETTINGS['secure.trusted_mods'] = "advanced_npc" + + + +def encode_cv(v): + """Encode the value to minetest.conf syntax""" + if v is None: + return "" # results in "name =" which is valid syntax + if isinstance(v, bool): + return "true" if v else "false" + if isinstance(v, (int, float, Decimal)): + return str(v) + if isinstance(v, str): + return f'"{v}"' + raise TypeError( + "{} type is not implemented in pyenliven Luanti conf encoder" + .format(type(v).__name__)) + + +def update_conf(path, new_settings): + # Read existing content + existing_lines = [] + if os.path.isfile(path): + with open(path, "r", encoding="utf-8") as f: + existing_lines = [line.rstrip("\n") for line in f] + existing_set = {line.strip() for line in existing_lines if line.strip()} + else: + existing_set = set() + + tmp_path = path + ".tmp" + if os.path.isfile(tmp_path): + os.remove(tmp_path) + changed = 0 + added = 0 + same = 0 + if os.path.isfile(path): + lineN = 0 + with open(path, 'r', encoding="utf-8") as src: + with open(tmp_path, 'w', encoding="utf-8") as dst: + for rawL in src: + lineN += 1 + line = rawL.strip() + if line.startswith("#"): + dst.write(rawL) + continue + if not line: + dst.write(rawL) + continue + if "=" not in line: + logger.warning( + f"{path}, line {lineN}:" + f" No '=' in {rawL.rstrip()}") + dst.write(rawL) + continue + parts = line.split(line, 1) + parts[0] = parts[0].strip() + if not parts[0]: + logger.warning( + f"{path}, line {lineN}:" + f" No name before '=' in {rawL.rstrip()}") + dst.write(rawL) + continue + if len(parts) > 1: + parts[1] = parts[1].strip() + else: + # f"{name} =" is null value syntax for conf + parts[1] = None + if parts[0] in new_settings: + encoded = encode_cv(new_settings[parts[0]]) + if parts[1] == encoded: + # no change + same += 1 + dst.write(rawL) + continue + changed += 1 + if new_settings[parts[0]] is not None: + dst.write(f"{parts[0]} = {encoded}\n") + else: + dst.write(f"{parts[0]} =\n") + del new_settings[parts[0]] + if new_settings: + mode = "a" if os.path.isfile(tmp_path) else "w" + with open(tmp_path, mode, encoding="utf-8") as dst: + for k, v in new_settings.items(): + if v is not None: + dst.write(f"{k} = {encode_cv(v)}\n") + else: + dst.write(f"{k} =\n") # "{k} = " is null value syntax + if os.path.isfile(path): + os.remove(path) + shutil.move(tmp_path, path) + + echo0(f"Updated {path}: added {added} new line(s), changed {changed}, {same} value(s) already matched")