diff --git a/data/actionsmap/yunohost.yml b/data/actionsmap/yunohost.yml index cf98ca8c8..3a4c9db97 100644 --- a/data/actionsmap/yunohost.yml +++ b/data/actionsmap/yunohost.yml @@ -551,7 +551,10 @@ app: full: --full help: Display all details, including the app manifest and various other infos action: store_true - + -c: + full: --with-categories + help: Also return a list of app categories + action: store_true ### app_list() list: diff --git a/src/yunohost/app.py b/src/yunohost/app.py index 1c4073893..a54a18c6f 100644 --- a/src/yunohost/app.py +++ b/src/yunohost/app.py @@ -57,7 +57,7 @@ APP_TMP_FOLDER = INSTALL_TMP + '/from_file' APPS_CATALOG_CACHE = '/var/cache/yunohost/repo' APPS_CATALOG_CONF = '/etc/yunohost/apps_catalog.yml' APPS_CATALOG_CRON_PATH = "/etc/cron.daily/yunohost-fetch-apps-catalog" -APPS_CATALOG_API_VERSION = 1 +APPS_CATALOG_API_VERSION = 2 APPS_CATALOG_DEFAULT_URL = "https://app.yunohost.org/default" re_github_repo = re.compile( @@ -71,7 +71,7 @@ re_app_instance_name = re.compile( ) -def app_catalog(full=False, all=False): +def app_catalog(full=False, with_categories=False): """ Return a dict of apps available to installation from Yunohost's app catalog """ @@ -80,16 +80,32 @@ def app_catalog(full=False, all=False): catalog = _load_apps_catalog() installed_apps = set(_installed_apps()) - for app, infos in catalog.items(): + # Trim info for apps if not using --full + for app, infos in catalog["apps"].items(): infos["installed"] = app in installed_apps + infos["manifest"]["description"] = _value_for_locale(infos['manifest']['description']) + if not full: - catalog[app] = { - "description": _value_for_locale(infos['manifest']['description']), + catalog["apps"][app] = { + "description": infos['manifest']['description'], "level": infos["level"], } - return {"apps": catalog} + # Trim info for categories if not using --full + for category in catalog["categories"]: + category["title"] = _value_for_locale(category["title"]) + category["description"] = _value_for_locale(category["description"]) + + if not full: + catalog["categories"] = [{"id": c["id"], + "description": c["description"]} + for c in catalog["categories"]] + + if not with_categories: + return {"apps": catalog["apps"]} + else: + return {"apps": catalog["apps"], "categories": catalog["categories"]} def app_list(full=False): @@ -107,12 +123,7 @@ def app_list(full=False): def app_info(app, full=False): """ - Get app info - - Keyword argument: - app -- Specific app ID - raw -- Return the full app_dict - + Get info for a specific app """ if not _is_installed(app): raise YunohostError('app_not_installed', app=app, all_apps=_get_all_installed_apps_id()) @@ -133,7 +144,7 @@ def app_info(app, full=False): ret['settings'] = settings absolute_app_name = app if "__" not in app else app[:app.index('__')] # idk this is the name of the app even for multiinstance apps (so wordpress__2 -> wordpress) - ret["from_catalog"] = _load_apps_catalog().get(absolute_app_name, {}) + ret["from_catalog"] = _load_apps_catalog()["apps"].get(absolute_app_name, {}) ret['upgradable'] = _app_upgradable(ret) ret['supports_change_url'] = os.path.exists(os.path.join(APPS_SETTING_PATH, app, "scripts", "change_url")) ret['supports_backup_restore'] = (os.path.exists(os.path.join(APPS_SETTING_PATH, app, "scripts", "backup")) and @@ -609,7 +620,7 @@ def app_install(operation_logger, app, label=None, args=None, no_remove_on_failu if answer.upper() != "Y": raise YunohostError("aborting") - raw_app_list = _load_apps_catalog() + raw_app_list = _load_apps_catalog()["apps"] if app in raw_app_list or ('@' in app) or ('http://' in app) or ('https://' in app): @@ -2108,7 +2119,7 @@ def _fetch_app_from_git(app): else: manifest['remote']['revision'] = revision else: - app_dict = _load_apps_catalog() + app_dict = _load_apps_catalog()["apps"] if app not in app_dict: raise YunohostError('app_unknown') @@ -2645,11 +2656,14 @@ def _update_apps_catalog(): def _load_apps_catalog(): """ - Read all the apps catalog cache files and build a single dict (app_dict) - corresponding to all known apps in all indexes + Read all the apps catalog cache files and build a single dict (merged_catalog) + corresponding to all known apps and categories """ - app_dict = {} + merged_catalog = { + "apps": {}, + "categories": [] + } for apps_catalog_id in [L["id"] for L in _read_apps_catalog_list()]: @@ -2672,18 +2686,22 @@ def _load_apps_catalog(): del apps_catalog_content["from_api_version"] # Add apps from this catalog to the output - for app, info in apps_catalog_content.items(): + for app, info in apps_catalog_content["apps"].items(): # (N.B. : there's a small edge case where multiple apps catalog could be listing the same apps ... # in which case we keep only the first one found) - if app in app_dict: - logger.warning("Duplicate app %s found between apps catalog %s and %s" % (app, apps_catalog_id, app_dict[app]['repository'])) + if app in merged_catalog["apps"]: + logger.warning("Duplicate app %s found between apps catalog %s and %s" + % (app, apps_catalog_id, merged_catalog["apps"][app]['repository'])) continue info['repository'] = apps_catalog_id - app_dict[app] = info + merged_catalog["apps"][app] = info - return app_dict + # Annnnd categories + merged_catalog["categories"] += apps_catalog_content["categories"] + + return merged_catalog # # ############################### # diff --git a/src/yunohost/tests/test_appscatalog.py b/src/yunohost/tests/test_appscatalog.py index 613b59012..39a0be206 100644 --- a/src/yunohost/tests/test_appscatalog.py +++ b/src/yunohost/tests/test_appscatalog.py @@ -14,6 +14,7 @@ from yunohost.app import (_initialize_apps_catalog_system, _update_apps_catalog, _actual_apps_catalog_api_url, _load_apps_catalog, + app_catalog, logger, APPS_CATALOG_CACHE, APPS_CATALOG_CONF, @@ -25,8 +26,14 @@ APPS_CATALOG_DEFAULT_URL_FULL = _actual_apps_catalog_api_url(APPS_CATALOG_DEFAUL CRON_FOLDER, CRON_NAME = APPS_CATALOG_CRON_PATH.rsplit("/", 1) DUMMY_APP_CATALOG = """{ - "foo": {"id": "foo", "level": 4}, - "bar": {"id": "bar", "level": 7} + "apps": { + "foo": {"id": "foo", "level": 4, "category": "yolo", "manifest":{"description": "Foo"}}, + "bar": {"id": "bar", "level": 7, "category": "swag", "manifest":{"description": "Bar"}} + }, + "categories": [ + {"id": "yolo", "description": "YoLo"}, + {"id": "swag", "description": "sWaG"} + ] } """ @@ -107,7 +114,7 @@ def test_apps_catalog_emptylist(): assert not len(apps_catalog_list) -def test_apps_catalog_update_success(mocker): +def test_apps_catalog_update_nominal(mocker): # Initialize ... _initialize_apps_catalog_system() @@ -130,9 +137,16 @@ def test_apps_catalog_update_success(mocker): # Cache shouldn't be empty anymore empty assert glob.glob(APPS_CATALOG_CACHE + "/*") - app_dict = _load_apps_catalog() - assert "foo" in app_dict.keys() - assert "bar" in app_dict.keys() + # And if we load the catalog, we sould find + # - foo and bar as apps (unordered), + # - yolo and swag as categories (ordered) + catalog = app_catalog(with_categories=True) + + assert "apps" in catalog + assert set(catalog["apps"].keys()) == set(["foo", "bar"]) + + assert "categories" in catalog + assert [c["id"] for c in catalog["categories"]] == ["yolo", "swag"] def test_apps_catalog_update_404(mocker): @@ -219,7 +233,7 @@ def test_apps_catalog_load_with_empty_cache(mocker): # Try to load the apps catalog # This should implicitly trigger an update in the background mocker.spy(m18n, "n") - app_dict = _load_apps_catalog() + app_dict = _load_apps_catalog()["apps"] m18n.n.assert_any_call("apps_catalog_obsolete_cache") m18n.n.assert_any_call("apps_catalog_update_success") @@ -252,7 +266,7 @@ def test_apps_catalog_load_with_conflicts_between_lists(mocker): # Try to load the apps catalog # This should implicitly trigger an update in the background mocker.spy(logger, "warning") - app_dict = _load_apps_catalog() + app_dict = _load_apps_catalog()["apps"] logger.warning.assert_any_call(AnyStringWith("Duplicate")) # Cache shouldn't be empty anymore empty @@ -291,7 +305,7 @@ def test_apps_catalog_load_with_oudated_api_version(mocker): m.register_uri("GET", APPS_CATALOG_DEFAULT_URL_FULL, text=DUMMY_APP_CATALOG) mocker.spy(m18n, "n") - app_dict = _load_apps_catalog() + app_dict = _load_apps_catalog()["apps"] m18n.n.assert_any_call("apps_catalog_update_success") assert "foo" in app_dict.keys() @@ -329,7 +343,7 @@ def test_apps_catalog_migrate_legacy_explicitly(): assert cron_job_is_there() # Reading the apps_catalog should work - app_dict = _load_apps_catalog() + app_dict = _load_apps_catalog()["apps"] assert "foo" in app_dict.keys() assert "bar" in app_dict.keys() @@ -343,7 +357,7 @@ def test_apps_catalog_migrate_legacy_implicitly(): with requests_mock.Mocker() as m: m.register_uri("GET", APPS_CATALOG_DEFAULT_URL_FULL, text=DUMMY_APP_CATALOG) - app_dict = _load_apps_catalog() + app_dict = _load_apps_catalog()["apps"] assert "foo" in app_dict.keys() assert "bar" in app_dict.keys()