def new_config(config_path: Path, ) -> None: """ Create a new configuration file with some pre-filled data. To avoid data loss, this path must not exist. This command is interactive, and will ask you some questions about your project. This command targeted towards existing projects that want to start using wap or projects that want to migrate from another packager. More than likely, you will need to edit this configuration file to fit to your project. This just provides a starting point. """ if config_path.exists(): raise NewConfigException('Path "' + click.style(f"{config_path}", fg="green") + '" exists. Aborting to avoid data loss.') project_name = Path.cwd().name config = guide(project_dir_name=project_name) log.info('\nCreating config file at "' + click.style(f"{config_path}", fg="green")) log.info( click.style("Make sure to edit it to fit your project!", fg="yellow")) config.to_path(config_path)
def zip_addon(addon_name: str, version: str, wow_version: WoWVersion) -> Path: package_path = get_package_path(addon_name=addon_name, version=version, wow_version=wow_version) zip_path = get_zip_path(addon_name=addon_name, version=version, wow_version=wow_version) if zip_path.exists(): delete_path(zip_path) zip_path.parent.mkdir(parents=True, exist_ok=True) with ZipFile(zip_path, mode="w", compression=ZIP_DEFLATED) as zip_file: for path in package_path.rglob("*"): zip_file.write( filename=path, arcname=path.relative_to(package_path), ) log.info("Zipped addon " + click.style(f"{addon_name}", fg="blue") + " (" + click.style(f"{wow_version.type()}", fg="magenta") + ') at "' + click.style(f"{zip_path}", fg="green") + '"') return zip_path
def package_addon( config_path: Path, config: Config, addon_name: str, dir_configs: Sequence[AddonConfig], version: str, wow_version: WoWVersion, ) -> Path: package_path = get_package_path( addon_name=addon_name, version=version, wow_version=wow_version, ) if package_path.exists(): delete_path(package_path) package_path.mkdir(parents=True) for dir_config in dir_configs: src_dir = config_path.parent / dir_config.path if not src_dir.is_dir(): raise PackageException( f'Dir config has path "{src_dir}", but it is not a directory. ' "This path must point to a directory, must be relative to " f'the parent of the config file ("{config_path.resolve()}") and, ' 'if it is in a subdirectory, must only use forward slashes ("/").' ) dst_dir = package_path / src_dir.name shutil.copytree(src_dir, dst_dir) source_toc_path = src_dir / (src_dir.name + ".toc") toc_path = dst_dir / (src_dir.name + ".toc") if toc_path.exists(): log.warn(f'TOC file "{source_toc_path}" exists, and will ' "be overwritten with a generated one.") delete_path(toc_path) write_toc( config=config, toc_config=dir_config.toc_config, dir_path=src_dir, write_path=toc_path, addon_version=version, wow_version=wow_version, ) log.info("Built addon " + click.style(f"{addon_name}", fg="blue") + " (" + click.style(f"{wow_version.type()}", fg="magenta") + ') at "' + click.style(f"{package_path}", fg="green") + '"') return package_path
def _prompt_yes_no(text: str, **prompt_kwargs: Any) -> bool: yes_no_pattern = re.compile(r"^(y|(?:yes)|n|(?:no))$", flags=re.IGNORECASE) text = (click.style(text, fg="blue") + " <" + click.style("yes", fg="magenta") + "|" + click.style("y", fg="magenta") + "|" + click.style("no", fg="magenta") + "|" + click.style("n", fg="magenta") + ">") while True: value = cast(str, click.prompt(text=text, err=True, **prompt_kwargs)) if (match := yes_no_pattern.match(value)) is not None: return "y" in match[0].lower() log.info( f'Value "{value}" does not match pattern: {yes_no_pattern.pattern}' )
def upload_addon( *, addon_name: str, curseforge_config: CurseforgeConfig, changelog: Changelog, wow_version: WoWVersion, version: str, release_type: str, curseforge_api: CurseForgeAPI, ) -> str: cf_wow_version_id = curseforge_api.get_version_id( version=wow_version.dot_version()) zip_file_path = get_zip_path(addon_name=addon_name, version=version, wow_version=wow_version) if not zip_file_path.is_file(): log.error( "Expected zip file not found. Have you run `" + click.style(f'wap package --version "{version}"', fg="blue") + "` yet?") raise UploadException(f'Zip file "{zip_file_path}" not found.') with zip_file_path.open("rb") as package_archive_file: file_id = curseforge_api.upload_addon_file( project_id=curseforge_config.project_id, archive_file=package_archive_file, display_name=f"{addon_name}-{version}-{wow_version.type()}", changelog=changelog, wow_version_id=cf_wow_version_id, release_type=release_type, file_name=zip_file_path.name, ) url = curseforge_api.uploaded_file_url( slug=curseforge_config.project_slug, file_id=file_id, ) log.info("Uploaded " + click.style(f"{addon_name}", fg="blue") + " (" + click.style(f"{wow_version.type()}", fg="magenta") + ") to CurseForge at " + click.style(f"{url}", fg="green")) return url
def _prompt_until_matching( pattern: Pattern[str], text: str, default: Optional[str] = None, **prompt_kwargs: Any, ) -> re.Match[str]: text = click.style(text, fg="blue") if default is not None: text += " [" + click.style(default, fg="green") + "]" while True: value = cast( str, click.prompt(text, default=default, show_default=False, err=True, **prompt_kwargs), ) if (match := pattern.match(value)) is not None: return match log.info(f'Value "{value}" does not match pattern: {pattern.pattern}')
def watch_project(config_path: Path) -> Generator[set[FileState], None, None]: is_first_iter = True last_state: set[FileState] = set() log.info("Starting filesystem watcher. Press " + click.style("Ctrl-C", fg="red") + " at any time to stop.\n") while True: new_state = asyncio.run( _poll_for_change(config_path=config_path, last_state=last_state)) if new_state != last_state: time_str = arrow.now().isoformat() if is_first_iter: log.info( f"{time_str} - Running initial package and install...\n") is_first_iter = False else: log.info( f"{time_str} - Changes detected. Repackaging and installing...\n" ) diff = new_state - last_state last_state = new_state yield diff
def do_package_and_dev_install( ctx: click.Context, config_path: Path, version: str, wow_addons_path: Path, ) -> None: try: ctx.invoke( package, config_path=config_path, version=version, ) ctx.invoke( dev_install, config_path=config_path, version=version, wow_addons_path=wow_addons_path, ) log.info("") # separate runs from one another except WAPException as we: log.error(we.message + "\n") except click.ClickException as ce: log.error(ce.message + "\n")
def dev_install_addon( *, addon_name: str, version: str, wow_addons_path: Path, wow_version: WoWVersion, ) -> Sequence[Path]: installed_paths = [] package_path = get_package_path(addon_name=addon_name, version=version, wow_version=wow_version) if not package_path.is_dir(): log.error("Expected package directory not found. Have you run `" + click.style(f"wap package --version {version}", fg="blue") + "` yet?") raise DevInstallException( f'Package directory "{package_path}" not found.') for addon_path in package_path.iterdir(): install_addon_path = wow_addons_path / addon_path.name if install_addon_path.exists(): delete_path(install_addon_path) shutil.copytree(addon_path, install_addon_path) installed_paths.append(install_addon_path) log.info("Installed addon directory " + click.style(f"{addon_path.name}", fg="blue") + " (" + click.style(f"{wow_version.type()}", fg="magenta") + ') to "' + click.style(f"{install_addon_path}", fg="green") + '"') return installed_paths
def validate( config_path: Path, show_json: bool, ) -> None: """ Validates a wap configuration file. An exit code of 0 means the validation was successful. Otherwise, the error encountered is displayed and the exit code is non-zero. Successful validation does not indicate that you can use all the wap commands. It merely means that there were no errors parsing it. """ try: config = Config.from_path(config_path) log.info(f'✔️ "{config_path}" is valid') if show_json: click.echo(json.dumps(config.to_python_object(), indent=2)) except ConfigException: log.error(f'❌ "{config_path}" is not valid') raise
def base() -> None: """package and upload your WoW addons.""" # always print out version info on run (will help with issue reports) log.info((VERSION_STRING_TEMPLATE + "\n") % {"version": __version__})
def guide(project_dir_name: str) -> Config: addon_name_pattern = re.compile(r"\S+") author_pattern = re.compile(r".+") notes_pattern = re.compile(r".+") project_id_pattern = re.compile(r"\d+") curseforge_url_pattern = re.compile( r"https:\/\/www\.curseforge\.com\/wow\/addons\/(?P<addon_name>\S+)") log.info( "This command will guide you through creating your " + click.style(".wap.yml", fg="blue") + " config.\n", ) # addon name name = _prompt_until_matching( addon_name_pattern, text="Addon name", default=project_dir_name, )[0] # author for TOC author = _prompt_until_matching( author_pattern, text="Author name", )[0] # notes for TOC notes = _prompt_until_matching( notes_pattern, text="Addon description", )[0] toc_config = TocConfig( tags={ "Title": name, "Author": author, "Notes": notes }, files=[PurePosixPath("Init.lua")], ) addon_configs = [ AddonConfig(path=PurePosixPath(name), toc_config=toc_config) ] # wow versions supported wow_versions = [] if _prompt_yes_no(text="Will this addon support retail WoW?"): wow_versions.append(LATEST_RETAIL_VERSION) if _prompt_yes_no(text="Will this addon support classic WoW?"): wow_versions.append(LATEST_CLASSIC_VERSION) else: wow_versions.append(LATEST_CLASSIC_VERSION) log.info("If not supporting retail, must be supporting classic") # curseforge curseforge_config = None if _prompt_yes_no(text="Do you have a CurseForge project?"): project_id = _prompt_until_matching( project_id_pattern, text="CurseForge project id (found in top-right of addon page)", )[0] project_slug = _prompt_until_matching( curseforge_url_pattern, text="CurseForge URL", )["addon_name"] curseforge_config = CurseforgeConfig( project_id=project_id, changelog_path=DEFAULT_CHANGELOG_PATH, project_slug=project_slug, ) return Config( name=name, addon_configs=addon_configs, wow_versions=wow_versions, curseforge_config=curseforge_config, )
def quickstart(project_dir_path: Path, ) -> None: """ Creates a new addon project directory at PROJECT_DIR_PATH with a default structure. PROJECT_DIR_PATH must not exist. This command is interactive, and will ask you some questions about your project. If you later decide you want to change something about how you answered these question, edit your config file. For example, running: wap quickstart MyAddon will create a directory structure like the following: \b MyAddon ├── MyAddon │ └── Init.lua ├── CHANGELOG.md ├── README.md └── .wap.yml """ # the docstring above contains the \b escape. This prevents paragraph wrapping # https://click.palletsprojects.com/en/7.x/documentation/#preventing-rewrapping # i'm not sure why i had to adjust the indentation level to get it to line up # properly though if project_dir_path.exists(): raise QuickstartException( f"{project_dir_path} exists. Choose a path that does not exist for your " "new project.") config = guide(project_dir_name=project_dir_path.resolve().name) project_name = config.name log.info('\nCreating project directory at "' + click.style(f"{project_dir_path}", fg="green") + '"') project_dir_path.mkdir(parents=True) config_path = project_dir_path / DEFAULT_CONFIG_PATH log.info('Writing config file at "' + click.style(f"{config_path}", fg="green") + '"') config.to_path(config_path) log.info("Creating changelog file at '" + click.style(f"{DEFAULT_CHANGELOG_PATH}", fg="green") + '"') write_changelog(project_dir_path / DEFAULT_CHANGELOG_PATH, project_name) readme_path = project_dir_path / "README.md" log.info('Creating readme at "' + click.style(f"{readme_path}", fg="green")) write_readme(readme_path, project_name) # guided config puts 1 starter lua file in the toc config's files. dir_config = config.addon_configs[0] starter_lua_file = dir_config.toc_config.files[0] lua_file = project_dir_path / dir_config.path / starter_lua_file log.info("Creating starter lua file at '" + click.style(f"{lua_file}", fg="green") + '"') lua_file.parent.mkdir() write_lua_file(lua_file, project_name) log.info( click.style( "\nProject created! You can now begin developing your project.\n", fg="green", bold=True, )) log.info( "After you `" + click.style(f'cd "{project_dir_path}"', fg="magenta") + "`, you can get started running some wap commands immediately, such as:" ) log.info(" - " + click.style("wap package", fg="blue")) log.info(" - " + click.style( ("wap dev-install --wow-addons-path " f'"{default_wow_addons_path_for_system()}"'), fg="blue", )) if config.curseforge_config: log.info(" - " + click.style( 'wap upload --version "dev" --curseforge-token "<your-token>"', fg="blue", )) log.info("\nHave fun!")