Files
edx-platform/pavelib/assets.py
2024-05-07 08:40:40 -04:00

507 lines
17 KiB
Python

"""
Asset compilation and collection.
This entire module is DEPRECATED. In Redwood, it exists just as a collection of temporary compatibility wrappers.
In Sumac, this module will be deleted. To migrate, follow the advice in the printed warnings and/or read the
instructions on the DEPR ticket: https://github.com/openedx/edx-platform/issues/31895
"""
import argparse
import glob
import json
import shlex
import traceback
from functools import wraps
from threading import Timer
from paver import tasks
from paver.easy import call_task, cmdopts, consume_args, needs, no_help, sh, task
from watchdog.events import PatternMatchingEventHandler
from watchdog.observers import Observer # pylint: disable=unused-import # Used by Tutor. Remove after Sumac cut.
from .utils.cmd import django_cmd
from .utils.envs import Env
from .utils.timer import timed
SYSTEMS = {
'lms': 'lms',
'cms': 'cms',
'studio': 'cms',
}
WARNING_SYMBOLS = "⚠️ " * 50 # A row of 'warning' emoji to catch CLI users' attention
def run_deprecated_command_wrapper(*, old_command, ignored_old_flags, new_command):
"""
Run the new version of shell command, plus a warning that the old version is deprecated.
"""
depr_warning = (
"\n" +
f"{WARNING_SYMBOLS}\n" +
"\n" +
f"WARNING: '{old_command}' is DEPRECATED! It will be removed before Sumac.\n" +
"The command you ran is now just a temporary wrapper around a new,\n" +
"supported command, which you should use instead:\n" +
"\n" +
f"\t{new_command}\n" +
"\n" +
"Details: https://github.com/openedx/edx-platform/issues/31895\n" +
"".join(
f" WARNING: ignored deprecated paver flag '{flag}'\n"
for flag in ignored_old_flags
) +
f"{WARNING_SYMBOLS}\n" +
"\n"
)
# Print deprecation warning twice so that it's more likely to be seen in the logs.
print(depr_warning)
sh(new_command)
print(depr_warning)
def debounce(seconds=1):
"""
Prevents the decorated function from being called more than every `seconds`
seconds. Waits until calls stop coming in before calling the decorated
function.
This is DEPRECATED. It exists in Redwood just to ease the transition for Tutor.
"""
def decorator(func):
func.timer = None
@wraps(func)
def wrapper(*args, **kwargs):
def call():
func(*args, **kwargs)
func.timer = None
if func.timer:
func.timer.cancel()
func.timer = Timer(seconds, call)
func.timer.start()
return wrapper
return decorator
class SassWatcher(PatternMatchingEventHandler):
"""
Watches for sass file changes
This is DEPRECATED. It exists in Redwood just to ease the transition for Tutor.
"""
ignore_directories = True
patterns = ['*.scss']
def register(self, observer, directories):
"""
register files with observer
Arguments:
observer (watchdog.observers.Observer): sass file observer
directories (list): list of directories to be register for sass watcher.
"""
for dirname in directories:
paths = []
if '*' in dirname:
paths.extend(glob.glob(dirname))
else:
paths.append(dirname)
for obs_dirname in paths:
observer.schedule(self, obs_dirname, recursive=True)
@debounce()
def on_any_event(self, event):
print('\tCHANGED:', event.src_path)
try:
compile_sass() # pylint: disable=no-value-for-parameter
except Exception: # pylint: disable=broad-except
traceback.print_exc()
@task
@no_help
@cmdopts([
('system=', 's', 'The system to compile sass for (defaults to all)'),
('theme-dirs=', '-td', 'Theme dirs containing all themes (defaults to None)'),
('themes=', '-t', 'The theme to compile sass for (defaults to None)'),
('debug', 'd', 'Whether to use development settings'),
('force', '', 'DEPRECATED. Full recompilation is now always forced.'),
])
@timed
def compile_sass(options):
"""
Compile Sass to CSS. If command is called without any arguments, it will
only compile lms, cms sass for the open source theme. And none of the comprehensive theme's sass would be compiled.
If you want to compile sass for all comprehensive themes you will have to run compile_sass
specifying all the themes that need to be compiled..
The following is a list of some possible ways to use this command.
Command:
paver compile_sass
Description:
compile sass files for both lms and cms. If command is called like above (i.e. without any arguments) it will
only compile lms, cms sass for the open source theme. None of the theme's sass will be compiled.
Command:
paver compile_sass --theme-dirs /edx/app/edxapp/edx-platform/themes --themes=red-theme
Description:
compile sass files for both lms and cms for 'red-theme' present in '/edx/app/edxapp/edx-platform/themes'
Command:
paver compile_sass --theme-dirs=/edx/app/edxapp/edx-platform/themes --themes red-theme stanford-style
Description:
compile sass files for both lms and cms for 'red-theme' and 'stanford-style' present in
'/edx/app/edxapp/edx-platform/themes'.
Command:
paver compile_sass --system=cms
--theme-dirs /edx/app/edxapp/edx-platform/themes /edx/app/edxapp/edx-platform/common/test/
--themes red-theme stanford-style test-theme
Description:
compile sass files for cms only for 'red-theme', 'stanford-style' and 'test-theme' present in
'/edx/app/edxapp/edx-platform/themes' and '/edx/app/edxapp/edx-platform/common/test/'.
This is a DEPRECATED COMPATIBILITY WRAPPER. Use `npm run compile-sass` instead.
"""
systems = [SYSTEMS[sys] for sys in get_parsed_option(options, 'system', ['lms', 'cms'])] # normalize studio->cms
run_deprecated_command_wrapper(
old_command="paver compile_sass",
ignored_old_flags=(set(["--force"]) & set(options)),
new_command=shlex.join([
"npm",
"run",
("compile-sass-dev" if options.get("debug") else "compile-sass"),
"--",
*(["--dry"] if tasks.environment.dry_run else []),
*(["--skip-lms"] if "lms" not in systems else []),
*(["--skip-cms"] if "cms" not in systems else []),
*(
arg
for theme_dir in get_parsed_option(options, 'theme_dirs', [])
for arg in ["--theme-dir", str(theme_dir)]
),
*(
arg
for theme in get_parsed_option(options, "themes", [])
for arg in ["--theme", theme]
),
]),
)
def _compile_sass(system, theme, debug, force, _timing_info):
"""
This is a DEPRECATED COMPATIBILITY WRAPPER
It exists to ease the transition for Tutor in Redwood, which directly imported and used this function.
"""
run_deprecated_command_wrapper(
old_command="pavelib.assets:_compile_sass",
ignored_old_flags=(set(["--force"]) if force else set()),
new_command=[
"npm",
"run",
("compile-sass-dev" if debug else "compile-sass"),
"--",
*(["--dry"] if tasks.environment.dry_run else []),
*(
["--skip-default", "--theme-dir", str(theme.parent), "--theme", str(theme.name)]
if theme
else []
),
("--skip-cms" if system == "lms" else "--skip-lms"),
]
)
def process_npm_assets():
"""
Process vendor libraries installed via NPM.
This is a DEPRECATED COMPATIBILITY WRAPPER. It is now handled as part of `npm clean-install`.
If you need to invoke it explicitly, you can run `npm run postinstall`.
"""
run_deprecated_command_wrapper(
old_command="pavelib.assets:process_npm_assets",
ignored_old_flags=[],
new_command=shlex.join(["npm", "run", "postinstall"]),
)
@task
@no_help
def process_xmodule_assets():
"""
Process XModule static assets.
This is a DEPRECATED COMPATIBILITY STUB. Refrences to it should be deleted.
"""
print(
"\n" +
f"{WARNING_SYMBOLS}",
"\n" +
"WARNING: 'paver process_xmodule_assets' is DEPRECATED! It will be removed before Sumac.\n" +
"\n" +
"Starting with Quince, it is no longer necessary to post-process XModule assets, so \n" +
"'paver process_xmodule_assets' is a no-op. Please simply remove it from your build scripts.\n" +
"\n" +
"Details: https://github.com/openedx/edx-platform/issues/31895\n" +
f"{WARNING_SYMBOLS}",
)
def collect_assets(systems, settings, **kwargs):
"""
Collect static assets, including Django pipeline processing.
`systems` is a list of systems (e.g. 'lms' or 'studio' or both)
`settings` is the Django settings module to use.
`**kwargs` include arguments for using a log directory for collectstatic output. Defaults to /dev/null.
This is a DEPRECATED COMPATIBILITY WRAPPER
It exists to ease the transition for Tutor in Redwood, which directly imported and used this function.
"""
run_deprecated_command_wrapper(
old_command="pavelib.asset:collect_assets",
ignored_old_flags=[],
new_command=" && ".join(
"( " +
shlex.join(
["./manage.py", SYSTEMS[sys], f"--settings={settings}", "collectstatic", "--noinput"]
) + (
""
if "collect_log_dir" not in kwargs else
" > /dev/null"
if kwargs["collect_log_dir"] is None else
f"> {kwargs['collect_log_dir']}/{SYSTEMS[sys]}-collectstatic.out"
) +
" )"
for sys in systems
),
)
def execute_compile_sass(args):
"""
Construct django management command compile_sass (defined in theming app) and execute it.
Args:
args: command line argument passed via update_assets command
This is a DEPRECATED COMPATIBILITY WRAPPER. Use `npm run compile-sass` instead.
"""
for sys in args.system:
options = ""
options += " --theme-dirs " + " ".join(args.theme_dirs) if args.theme_dirs else ""
options += " --themes " + " ".join(args.themes) if args.themes else ""
options += " --debug" if args.debug else ""
sh(
django_cmd(
sys,
args.settings,
"compile_sass {system} {options}".format(
system='cms' if sys == 'studio' else sys,
options=options,
),
),
)
@task
@cmdopts([
('settings=', 's', "Django settings (defaults to devstack)"),
('watch', 'w', "DEPRECATED. This flag never did anything anyway."),
])
@timed
def webpack(options):
"""
Run a Webpack build.
This is a DEPRECATED COMPATIBILITY WRAPPER. Use `npm run webpack` instead.
"""
settings = getattr(options, 'settings', Env.DEVSTACK_SETTINGS)
result = Env.get_django_settings(['STATIC_ROOT', 'WEBPACK_CONFIG_PATH'], "lms", settings=settings)
static_root_lms, config_path = result
static_root_cms, = Env.get_django_settings(["STATIC_ROOT"], "cms", settings=settings)
js_env_extra_config_setting, = Env.get_django_json_settings(["JS_ENV_EXTRA_CONFIG"], "cms", settings=settings)
js_env_extra_config = json.dumps(js_env_extra_config_setting or "{}")
node_env = "development" if config_path == 'webpack.dev.config.js' else "production"
run_deprecated_command_wrapper(
old_command="paver webpack",
ignored_old_flags=(set(["watch"]) & set(options)),
new_command=' '.join([
f"WEBPACK_CONFIG_PATH={config_path}",
f"NODE_ENV={node_env}",
f"STATIC_ROOT_LMS={static_root_lms}",
f"STATIC_ROOT_CMS={static_root_cms}",
f"JS_ENV_EXTRA_CONFIG={js_env_extra_config}",
"npm",
"run",
"webpack",
]),
)
def get_parsed_option(command_opts, opt_key, default=None):
"""
Extract user command option and parse it.
Arguments:
command_opts: Command line arguments passed via paver command.
opt_key: name of option to get and parse
default: if `command_opt_value` not in `command_opts`, `command_opt_value` will be set to default.
Returns:
list or None
"""
command_opt_value = getattr(command_opts, opt_key, default)
if command_opt_value:
command_opt_value = listfy(command_opt_value)
return command_opt_value
def listfy(data):
"""
Check and convert data to list.
Arguments:
data: data structure to be converted.
"""
if isinstance(data, str):
data = data.split(',')
elif not isinstance(data, list):
data = [data]
return data
@task
@cmdopts([
('background', 'b', 'DEPRECATED. Use shell tools like & to run in background if needed.'),
('settings=', 's', "DEPRECATED. Django is not longer invoked to compile JS/Sass."),
('theme-dirs=', '-td', 'The themes dir containing all themes (defaults to None)'),
('themes=', '-t', 'DEPRECATED. All themes in --theme-dirs are now watched.'),
('wait=', '-w', 'DEPRECATED. Watchdog\'s default wait time is now used.'),
])
@timed
def watch_assets(options):
"""
Watch for changes to asset files, and regenerate js/css
This is a DEPRECATED COMPATIBILITY WRAPPER. Use `npm run watch` instead.
"""
# Don't watch assets when performing a dry run
if tasks.environment.dry_run:
return
theme_dirs = ':'.join(get_parsed_option(options, 'theme_dirs', []))
run_deprecated_command_wrapper(
old_command="paver watch_assets",
ignored_old_flags=(set(["debug", "themes", "settings", "background"]) & set(options)),
new_command=shlex.join([
*(
["env", f"COMPREHENSIVE_THEME_DIRS={theme_dirs}"]
if theme_dirs else []
),
"npm",
"run",
"watch",
]),
)
@task
@needs(
'pavelib.prereqs.install_node_prereqs',
'pavelib.prereqs.install_python_prereqs',
)
@consume_args
@timed
def update_assets(args):
"""
Compile Sass, then collect static assets.
This is a DEPRECATED COMPATIBILITY WRAPPER around other DEPRECATED COMPATIBILITY WRAPPERS.
The aggregate affect of this command can be achieved with this sequence of commands instead:
* pip install -r requirements/edx/assets.txt # replaces install_python_prereqs
* npm clean-install # replaces install_node_prereqs
* npm run build # replaces execute_compile_sass and webpack
* ./manage.py lms collectstatic --noinput # replaces collect_assets (for LMS)
* ./manage.py cms collectstatic --noinput # replaces collect_assets (for CMS)
"""
parser = argparse.ArgumentParser(prog='paver update_assets')
parser.add_argument(
'system', type=str, nargs='*', default=["lms", "studio"],
help="lms or studio",
)
parser.add_argument(
'--settings', type=str, default=Env.DEVSTACK_SETTINGS,
help="Django settings module",
)
parser.add_argument(
'--debug', action='store_true', default=False,
help="Enable all debugging",
)
parser.add_argument(
'--debug-collect', action='store_true', default=False,
help="Disable collect static",
)
parser.add_argument(
'--skip-collect', dest='collect', action='store_false', default=True,
help="Skip collection of static assets",
)
parser.add_argument(
'--watch', action='store_true', default=False,
help="Watch files for changes",
)
parser.add_argument(
'--theme-dirs', dest='theme_dirs', type=str, nargs='+', default=None,
help="base directories where themes are placed",
)
parser.add_argument(
'--themes', type=str, nargs='+', default=None,
help="list of themes to compile sass for. ignored when --watch is used; all themes are watched.",
)
parser.add_argument(
'--collect-log', dest="collect_log_dir", default=None,
help="When running collectstatic, direct output to specified log directory",
)
parser.add_argument(
'--wait', type=float, default=0.0,
help="DEPRECATED. Watchdog's default wait time is now used.",
)
args = parser.parse_args(args)
# Build Webpack
call_task('pavelib.assets.webpack', options={'settings': args.settings})
# Compile sass for themes and system
execute_compile_sass(args)
if args.collect:
if args.collect_log_dir:
collect_log_args = {"collect_log_dir": args.collect_log_dir}
elif args.debug or args.debug_collect:
collect_log_args = {"collect_log_dir": None}
else:
collect_log_args = {}
collect_assets(args.system, args.settings, **collect_log_args)
if args.watch:
call_task(
'pavelib.assets.watch_assets',
options={
'background': not args.debug,
'settings': args.settings,
'theme_dirs': args.theme_dirs,
'themes': args.themes,
'wait': [float(args.wait)]
},
)