diff --git a/.gitignore b/.gitignore index 26b602e4..14535f82 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,14 @@ bundles-util.js qgis_resources.py venv .idea +# Nixos Direnv Dev Environment +.envrc +shell.nix +.vscode +.vscode-extensions +.venv +.env +.privoxy-config +.privoxy-cache/ca-key.pem +.privoxy-cache/ca-cert.pem +.privoxy.pid diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 298b6585..bb243015 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,7 +17,7 @@ repos: hooks: - id: black - repo: https://github.com/PyCQA/flake8 - rev: 3.9.2 + rev: 6.0.0 hooks: - id: flake8 language_version: python3 diff --git a/README.rst b/README.rst index 0c08ecb0..46c3886e 100644 --- a/README.rst +++ b/README.rst @@ -127,7 +127,11 @@ To install the latest version of the plugin: That will copy the code into your QGIS user plugin folder, or create a symlink in it, depending on your OS. - **NOTE**: This ``paver`` task only installs to the 'default' QGIS profile; so, you will have to ensure that is the active profile in order to see the plugin. You will also need to initially activate the plugin inside of the QGIS plugin manager. + **NOTE**: By default, this ``paver`` task installs the plugin to the 'default' QGIS profile. However, you can optionally specify a custom plugin path using the `--pluginpath` flag. For example: + + paver install --pluginpath=~/.local/share/QGIS/QGIS3/profiles/custom_profile/python/plugins + + If you specify a custom `pluginpath`, the plugin will be installed to the specified location. Ensure that the specified profile is active in QGIS to see the plugin. You will also need to activate the plugin inside the QGIS plugin manager. - To package the plugin (*not needed during development*), run @@ -136,3 +140,9 @@ To install the latest version of the plugin: Documentation will be built in the `docs` folder and added to the resulting zip file. It includes dependencies as well, but it will not download them, so the `setup` task has to be run before packaging. + +- NixOS users will find a flake in the root of the repository, which can be used to create a development shell with all dependencies installed. To use it, run: + + nix develop + + This will set up a development environment with all necessary dependencies for the plugin. diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..3b54e324 --- /dev/null +++ b/flake.lock @@ -0,0 +1,121 @@ +{ + "nodes": { + "flake-parts": { + "inputs": { + "nixpkgs-lib": "nixpkgs-lib" + }, + "locked": { + "lastModified": 1748821116, + "narHash": "sha256-F82+gS044J1APL0n4hH50GYdPRv/5JWm34oCJYmVKdE=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "49f0870db23e8c1ca0b5259734a02cd9e1e371a1", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "flake-utils": { + "locked": { + "lastModified": 1659877975, + "narHash": "sha256-zllb8aq3YO3h8B/U0/J1WBgAL8EX5yWf5pMj3G0NAmc=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "c0e246b9b83f637f4681389ecabcb2681b4f3af0", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "geospatial": { + "inputs": { + "flake-parts": "flake-parts", + "nixgl": "nixgl", + "nixpkgs": "nixpkgs" + }, + "locked": { + "lastModified": 1749111814, + "narHash": "sha256-QqriB8GtPn7hxAPyp7jIk3a2555p75/kmB90n2abu5g=", + "owner": "imincik", + "repo": "geospatial-nix.repo", + "rev": "41f92ade4891a67c44d06ac37dcc225a3b123160", + "type": "github" + }, + "original": { + "owner": "imincik", + "repo": "geospatial-nix.repo", + "type": "github" + } + }, + "nixgl": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": [ + "geospatial", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1713543440, + "narHash": "sha256-lnzZQYG0+EXl/6NkGpyIz+FEOc/DSEG57AP1VsdeNrM=", + "owner": "nix-community", + "repo": "nixGL", + "rev": "310f8e49a149e4c9ea52f1adf70cdc768ec53f8a", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixGL", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1748856973, + "narHash": "sha256-RlTsJUvvr8ErjPBsiwrGbbHYW8XbB/oek0Gi78XdWKg=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e4b09e47ace7d87de083786b404bf232eb6c89d8", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-lib": { + "locked": { + "lastModified": 1748740939, + "narHash": "sha256-rQaysilft1aVMwF14xIdGS3sj1yHlI6oKQNBRTF40cc=", + "owner": "nix-community", + "repo": "nixpkgs.lib", + "rev": "656a64127e9d791a334452c6b6606d17539476e2", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixpkgs.lib", + "type": "github" + } + }, + "root": { + "inputs": { + "geospatial": "geospatial", + "nixpkgs": [ + "geospatial", + "nixpkgs" + ] + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..b79735e0 --- /dev/null +++ b/flake.nix @@ -0,0 +1,160 @@ +{ + description = "NixOS developer environment for QGIS plugins."; + + inputs.geospatial.url = "github:imincik/geospatial-nix.repo"; + inputs.nixpkgs.follows = "geospatial/nixpkgs"; + + outputs = { self, geospatial, nixpkgs }: + let + system = "x86_64-linux"; + profileName = "PLANET"; + pkgs = import nixpkgs { + inherit system; + config = { allowUnfree = true; }; + }; + extraPythonPackages = ps: [ + ps.pyqtwebengine + ps.jsonschema + ps.debugpy + ps.future + ps.psutil + ]; + qgisWithExtras = geospatial.packages.${system}.qgis.override { + inherit extraPythonPackages; + }; + qgisLtrWithExtras = geospatial.packages.${system}.qgis-ltr.override { + inherit extraPythonPackages; + }; + in { + packages.${system} = { + default = qgisWithExtras; + qgis-ltr = qgisLtrWithExtras; + }; + + devShells.${system}.default = pkgs.mkShell { + packages = [ + pkgs.chafa + pkgs.ffmpeg + pkgs.gdb + pkgs.git + pkgs.glow # terminal markdown viewer + pkgs.gource # Software version control visualization + pkgs.gum + pkgs.gum # UX for TUIs + pkgs.jq + pkgs.libsForQt5.kcachegrind + pkgs.nixfmt-rfc-style + pkgs.pre-commit + pkgs.pyprof2calltree # needed to covert cprofile call trees into a format kcachegrind can read + pkgs.python3 + pkgs.qgis + pkgs.qt5.full # so we get designer + pkgs.qt5.qtbase + pkgs.qt5.qtlocation + pkgs.qt5.qtquickcontrols2 + pkgs.qt5.qtsvg + pkgs.qt5.qttools + pkgs.skate # Distributed key/value store + pkgs.vim + pkgs.virtualenv + pkgs.vscode + pkgs.privoxy + (pkgs.python3.withPackages (ps: [ + ps.python + ps.pip + ps.setuptools + ps.wheel + ps.pytest + ps.pytest-qt + ps.black + ps.click # needed by black + ps.jsonschema + ps.pandas + ps.odfpy + ps.psutil + ps.httpx + ps.toml + ps.typer + ps.paver + # For autocompletion in vscode + ps.pyqt5-stubs + ps.debugpy + ps.numpy + ps.gdal + ps.toml + ps.typer + ps.snakeviz # For visualising cprofiler outputs + ])) + + ]; + shellHook = '' + unset SOURCE_DATE_EPOCH + + # Create a virtual environment in .venv if it doesn't exist + if [ ! -d ".venv" ]; then + python -m venv .venv + fi + + # Activate the virtual environment + source .venv/bin/activate + + # Upgrade pip and install packages from requirements.txt if it exists + pip install --upgrade pip > /dev/null + if [ -f requirements.txt ]; then + echo "Installing Python requirements from requirements.txt..." + pip install -r requirements.txt > .pip-install.log 2>&1 + if [ $? -ne 0 ]; then + echo "❌ Pip install failed. See .pip-install.log for details." + fi + else + echo "No requirements.txt found, skipping pip install." + fi + + echo "-----------------------" + echo "🌈 Your Dev Environment is prepared." + echo "To run QGIS with your profile, use one of these commands:" + echo "" + echo " nix run .#qgis" + echo " nix run .#qgis-ltr" + echo "" + echo " Or use the helper script to launch it: " + echo " scripts/start_qgis.sh" + echo " scripts/start_qgis_ltr.sh" + echo "" + echo "πŸ“’ Note:" + echo "-----------------------" + echo "We provide a ready-to-use" + echo "VSCode environment which you" + echo "can start like this:" + echo "" + echo "scripts/vscode.sh" + echo "-----------------------" + echo "If you want to test the plugin behind an http proxy" + echo "we provide a script to run privoxy." + echo "πŸ›‘οΈ To start the proxy (Privoxy), run:" + echo " ./scripts/privoxy.sh start" + echo "πŸ›‘ To stop the proxy, run:" + echo " ./scripts/privoxy.sh stop" + echo "-----------------------" + echo "" + + pre-commit clean > /dev/null + pre-commit install --install-hooks > /dev/null + pre-commit run --all-files || true + ''; + }; + + apps.${system} = { + qgis = { + type = "app"; + program = "${qgisWithExtras}/bin/qgis"; + args = [ "--profile" "${profileName}" ]; + }; + qgis-ltr = { + type = "app"; + program = "${qgisLtrWithExtras}/bin/qgis"; + args = [ "--profile" "${profileName}" ]; + }; + }; + }; +} diff --git a/pavement.py b/pavement.py index a40043b6..4994fa45 100644 --- a/pavement.py +++ b/pavement.py @@ -26,7 +26,7 @@ import subprocess import sys import zipfile -from configparser import SafeConfigParser +from configparser import ConfigParser from io import StringIO from pathlib import Path @@ -83,9 +83,7 @@ def setup(): try: subprocess.check_call( [ - sys.executable, - "-m", - "pip", + "pip", # Explicitly use pip instead of relying on sys.executable "install", "--no-deps", "--upgrade", @@ -100,25 +98,33 @@ def setup(): @task +@cmdopts( + [ + ("pluginpath=", "p", "Custom path to install the plugin"), + ] +) def install(options): - """install plugin to qgis""" + """Install plugin to QGIS.""" plugin_name = options.plugin.name src = path(__file__).dirname() / plugin_name - if os.name == "nt": - default_profile_plugins = ( + + # Use the plugin path provided via the command-line flag, or fallback to default paths + if hasattr(options, "pluginpath") and options.pluginpath: + dst_plugins = path(options.pluginpath).expanduser() + elif os.name == "nt": + dst_plugins = path( "~/AppData/Roaming/QGIS/QGIS3/profiles/default/python/plugins" - ) + ).expanduser() elif sys.platform == "darwin": - default_profile_plugins = ( + dst_plugins = path( "~/Library/Application Support/QGIS/QGIS3" "/profiles/default/python/plugins" - ) + ).expanduser() else: - default_profile_plugins = ( + dst_plugins = path( "~/.local/share/QGIS/QGIS3/profiles/default/python/plugins" - ) + ).expanduser() - dst_plugins = path(default_profile_plugins).expanduser() if not dst_plugins.exists(): os.makedirs(dst_plugins, exist_ok=True) dst = dst_plugins / plugin_name diff --git a/planet_explorer/gui/pe_basemap_layer_widget.py b/planet_explorer/gui/pe_basemap_layer_widget.py index 447d43dd..d905c6e1 100644 --- a/planet_explorer/gui/pe_basemap_layer_widget.py +++ b/planet_explorer/gui/pe_basemap_layer_widget.py @@ -58,8 +58,7 @@ from ..planet_api import PlanetClient TILE_URL_TEMPLATE = ( - "https://tiles.planet.com/basemaps/v1/planet-tiles/" - "%s/gmap/{z}/{x}/{y}.png" + "https://tiles.planet.com/basemaps/v1/planet-tiles/" "%s/gmap/{z}/{x}/{y}.png" ) @@ -304,7 +303,7 @@ def __init__(self, layer): idx = 0 self.labelId = QLabel() self.labelId.setText( - f'{self.mosaicids[idx]}' + f'{self.mosaicids[idx]}' # noqa ) self.layout.addWidget(self.labelId) self.labelName = QLabel(current_mosaic_name) @@ -352,7 +351,7 @@ def is_planet_basemap(self): def on_value_changed(self, value): self.labelId.setText( - f'{self.mosaicids[value]}' + f'{self.mosaicids[value]}' # noqa ) self.labelName.setText(f"{self.mosaicnames[value]}") if not self.slider.isSliderDown(): @@ -368,8 +367,8 @@ def change_source(self): res = pattern.search(unquote(self.layer.source())) passed_api_key = res.groups()[0] if res.groups() else None - if '&' in passed_api_key: - passed_api_key = passed_api_key.split('&')[0] + if "&" in passed_api_key: + passed_api_key = passed_api_key.split("&")[0] has_api_key = PlanetClient.getInstance().has_api_key() @@ -381,7 +380,7 @@ def change_source(self): api_key = ( PlanetClient.getInstance().api_key() - if not passed_api_key or passed_api_key is '' + if not passed_api_key or passed_api_key == "" else passed_api_key ) @@ -391,9 +390,7 @@ def change_source(self): self.slider.setVisible(has_api_key) value = self.slider.value() if len(self.mosaics) > 1 else 0 name, mosaicid = self.mosaics[value] - tile_url = TILE_URL_TEMPLATE % ( - mosaicid, - ) + tile_url = TILE_URL_TEMPLATE % (mosaicid,) tile_url = f"{tile_url}?{quote(f'&api_key={str(api_key)}')}" diff --git a/planet_explorer/gui/pe_basemaps_list_widget.py b/planet_explorer/gui/pe_basemaps_list_widget.py index 41d54127..226c1f55 100644 --- a/planet_explorer/gui/pe_basemaps_list_widget.py +++ b/planet_explorer/gui/pe_basemaps_list_widget.py @@ -154,8 +154,8 @@ def __init__(self, mosaic): self.mosaic = mosaic title = mosaic_title(mosaic) self.nameLabel = QLabel( - f'{title}' - f'
{mosaic[NAME]}' + f'{title}' # noqa + f'
{mosaic[NAME]}' # noqa ) self.iconLabel = QLabel() self.toolsButton = QLabel() diff --git a/planet_explorer/gui/pe_basemaps_widget.py b/planet_explorer/gui/pe_basemaps_widget.py index a8104e24..8293ecd3 100644 --- a/planet_explorer/gui/pe_basemaps_widget.py +++ b/planet_explorer/gui/pe_basemaps_widget.py @@ -589,8 +589,8 @@ def show_order_streaming_page(self): name = selected[0][NAME] dates = date_interval_from_mosaics(selected) description = ( - f'{name}
' - f'{len(selected)} instances | {dates}' + f'{name}
' # noqa + f'{len(selected)} instances | {dates}' # noqa ) self.labelStreamingOrderDescription.setText(description) pixmap = QPixmap(PLACEHOLDER_THUMB, "SVG") @@ -639,8 +639,8 @@ def show_order_name_page(self): dates = date_interval_from_mosaics(selected) if self.radioDownloadComplete.isChecked(): description = ( - f'{name}
' - f'{len(selected)} instances | {dates}' + f'{name}
' # noqa + f'{len(selected)} instances | {dates}' # noqa ) title = "Order Complete Basemap" @@ -653,8 +653,8 @@ def show_order_name_page(self): numquads = len(selected_quads) title = "Order Partial Basemap" description = ( - f'{name}
' - f'{self._quads_summary()}' + f'{name}
' # noqa + f'{self._quads_summary()}' # noqa ) total_area = self._quads_quota() @@ -673,7 +673,7 @@ def show_order_name_page(self): size = numquads * QUAD_SIZE if quota is not None: self.labelOrderInfo.setText( - f"This Order will use {total_area:.2f} square km" + f"This Order will use {total_area:.2f} square km" # noqa f" of your remaining {quota} quota.\n\n" f"This Order's download size will be approximately {size} GB." ) diff --git a/planet_explorer/gui/pe_dailyimages_search_results_widget.py b/planet_explorer/gui/pe_dailyimages_search_results_widget.py index 90fc026f..5d11f14a 100644 --- a/planet_explorer/gui/pe_dailyimages_search_results_widget.py +++ b/planet_explorer/gui/pe_dailyimages_search_results_widget.py @@ -619,7 +619,7 @@ def update_for_children(self): self.children_count = size text = f"""{self.date}
{PlanetClient.getInstance().item_types_names()[self.properties[ITEM_TYPE]]}
- {size} images""" + {size} images""" # noqa self.nameLabel.setText(text) geoms = [] @@ -689,7 +689,7 @@ def update_for_children(self): ) self.children_count = size text = f""" Satellite {self.satellite} {self.instrument} - ({size} images)""" + ({size} images)""" # noqa self.nameLabel.setText(text) geoms = [] @@ -782,17 +782,15 @@ def _get_text(self): if value == PlanetNodeMetadata.AREA_COVER: area_coverage = area_coverage_for_image(self.image, self.request) if area_coverage is not None: - metadata += f"{value.value}:{area_coverage:.0f}{spacer}" + metadata += f"{value.value}:{area_coverage:.0f}{spacer}" # noqa else: - metadata += f"{value.value}:--{spacer}" + metadata += f"{value.value}:--{spacer}" # noqa else: - metadata += ( - f'{value.value}:{self.properties.get(value.value, "--")}{spacer}' - ) + metadata += f'{value.value}:{self.properties.get(value.value, "--")}{spacer}' # noqa text = f"""{self.date} {self.time} UTC
{PlanetClient.getInstance().item_types_names()[self.properties[ITEM_TYPE]]}
{metadata} - """ + """ # noqa return text diff --git a/planet_explorer/gui/pe_explorer_dockwidget.py b/planet_explorer/gui/pe_explorer_dockwidget.py index 60572d14..a9eb9b5b 100644 --- a/planet_explorer/gui/pe_explorer_dockwidget.py +++ b/planet_explorer/gui/pe_explorer_dockwidget.py @@ -211,7 +211,7 @@ def login(self, api_key=None): f"api_key = {self.p_client.api_key()}\n\n" f"user: {self.p_client.user()}\n\n" ) - log.debug(f"Login successful:\n{specs}") + log.debug(f"Login successful:\n{specs}") # noqa # Now switch panels self.p_client.loginChanged.emit(self.p_client.has_api_key()) diff --git a/planet_explorer/gui/pe_orders.py b/planet_explorer/gui/pe_orders.py index c3f17a0a..fb8a8f25 100644 --- a/planet_explorer/gui/pe_orders.py +++ b/planet_explorer/gui/pe_orders.py @@ -1056,7 +1056,7 @@ def _process_response(self, item_type: str, response: dict): self._log( f"Requesting {item_type} order failed: " "response data contains no Order ID.\n" - f"Order resp_data:\n{response}" + f"Order resp_data:\n{response}" # noqa ) return False diff --git a/planet_explorer/gui/pe_orders_monitor_dockwidget.py b/planet_explorer/gui/pe_orders_monitor_dockwidget.py index ad9f4373..8e8ca95f 100755 --- a/planet_explorer/gui/pe_orders_monitor_dockwidget.py +++ b/planet_explorer/gui/pe_orders_monitor_dockwidget.py @@ -230,7 +230,7 @@ def __init__(self, order, dialog): f"

Order {order.name()}

" f"Placed on: {order.date()}
" "Id: ' + f' href="https://www.planet.com/account/#/orders/{order.id()}">' # noqa f"{order.id()}
" f"Imagery source: {order.item_type()}
" # f'Assets ordered: {order.assets_ordered()}
' diff --git a/planet_explorer/gui/pe_planet_inspector_dockwidget.py b/planet_explorer/gui/pe_planet_inspector_dockwidget.py index e8628aaa..bc8a58e0 100644 --- a/planet_explorer/gui/pe_planet_inspector_dockwidget.py +++ b/planet_explorer/gui/pe_planet_inspector_dockwidget.py @@ -283,7 +283,7 @@ def __init__(self, scene): text = f"""{date} {time} UTC
{PlanetClient.getInstance().item_types_names()[self.properties['item_type']]} - """ + """ # noqa self.nameLabel = QLabel(text) self.iconLabel = QLabel() diff --git a/planet_explorer/gui/pe_quads_treewidget.py b/planet_explorer/gui/pe_quads_treewidget.py index 52743f04..f54aaa28 100644 --- a/planet_explorer/gui/pe_quads_treewidget.py +++ b/planet_explorer/gui/pe_quads_treewidget.py @@ -236,7 +236,7 @@ def __init__(self, quad): self.setMouseTracking(True) self.quad = quad self.nameLabel = QLabel( - f'{quad[ID]}
' + f'{quad[ID]}
' # noqa f"{quad[PERCENT_COVERED]} % covered" ) self.iconLabel = QLabel() diff --git a/planet_explorer/gui/pe_tasking_dockwidget.py b/planet_explorer/gui/pe_tasking_dockwidget.py index 888e00e6..a1f5d3da 100644 --- a/planet_explorer/gui/pe_tasking_dockwidget.py +++ b/planet_explorer/gui/pe_tasking_dockwidget.py @@ -126,7 +126,7 @@ def __init__(self, pt): and contact our sales team here.

 

- Take me to the Tasking Dashboard 

""" + Take me to the Tasking Dashboard 

""" # noqa textbrowser.setHtml(text) layout.addWidget(textbrowser) self.setLayout(layout) @@ -135,9 +135,7 @@ def __init__(self, pt): def _link_clicked(self, url): if url.toString() == "dashboard": analytics_track(SKYSAT_TASK_CREATED) - url = ( - f"https://www.planet.com/tasking/orders/new/?geometry={self.pt.asWkt()}" - ) + url = f"https://www.planet.com/tasking/orders/new/?geometry={self.pt.asWkt()}" # noqa open_link_with_browser(url) self.close() else: @@ -194,11 +192,12 @@ def aoi_captured(self, rect, pt): transformed = transform.transform(pt) self.marker.setToGeometry(QgsGeometry.fromPointXY(transformed)) self._set_map_tool(False) - text = f""" -

Selected Point Coordinates

-

Latitude : {pt.y():.4f}

-

Longitude : {pt.x():.4f}

- """ + + text = ( + f"

Selected Point Coordinates

" + f'

Latitude : {pt.y():.4f}

' # noqa + f'

Longitude : {pt.x():.4f}

' # noqa + ) self.textBrowserPoint.setHtml(text) self.btnCancel.setEnabled(True) self.btnOpenDashboard.setEnabled(True) diff --git a/planet_explorer/pe_utils.py b/planet_explorer/pe_utils.py index ee969897..a0498708 100644 --- a/planet_explorer/pe_utils.py +++ b/planet_explorer/pe_utils.py @@ -226,8 +226,8 @@ def add_menu_section_action(text, menu, tag="b", pad=0.5): """ lbl = QLabel(f"<{tag}>{text}", menu) lbl.setStyleSheet( - f"QLabel {{ padding-left: {pad}em; padding-right: {pad}em; " - f"padding-top: {pad}ex; padding-bottom: {pad}ex;}}" + f"QLabel {{ padding-left: {pad}em; padding-right: {pad}em; " # noqa: E702 E201 + f"padding-top: {pad}ex; padding-bottom: {pad}ex; }}" # noqa: E702 E202 ) wa = QWidgetAction(menu) wa.setDefaultWidget(lbl) @@ -356,7 +356,7 @@ def create_preview_group( uri = tile_service_data_src_uri(item_ids, service=tile_service) if uri: - log.debug(f"Tile datasource URI:\n{uri}") + log.debug(f"Tile datasource URI: \n{uri}") rlayer = QgsRasterLayer(uri, "Image previews", "wms") rlayer.setCustomProperty(PLANET_PREVIEW_ITEM_IDS, json.dumps(item_ids)) @@ -625,4 +625,6 @@ def plugin_version(add_commit=False): def user_agent(): - return f"qgis-{Qgis.QGIS_VERSION};planet-explorer{plugin_version()}" + return ( + f"qgis-{Qgis.QGIS_VERSION};planet-explorer{plugin_version()}" # noqa: E702 E231 + ) diff --git a/planet_explorer/planet_api/p_client.py b/planet_explorer/planet_api/p_client.py index 549a0099..a76cba2b 100644 --- a/planet_explorer/planet_api/p_client.py +++ b/planet_explorer/planet_api/p_client.py @@ -120,7 +120,7 @@ def set_proxy_values(self): proxyHost = settings.value("proxy/proxyHost") proxyPort = settings.value("proxy/proxyPort") - url = f"{proxyHost}:{proxyPort}" + url = f"{proxyHost}:{proxyPort}" # noqa authid = settings.value("proxy/authcfg", "") if authid: authConfig = QgsAuthMethodConfig() @@ -135,7 +135,7 @@ def set_proxy_values(self): if username: tokens = url.split("://") - url = f"{tokens[0]}://{username}:{password}@{tokens[-1]}" + url = f"{tokens[0]}://{username}:{password}@{tokens[-1]}" # noqa: E231 self.dispatcher.session.proxies["http"] = url self.dispatcher.session.proxies["https"] = url @@ -228,7 +228,8 @@ def get_quads_for_mosaic(self, mosaic, bbox=None, minimal=False): mosaicid = mosaic["id"] url = self._url( - f"basemaps/v1/mosaics/{mosaicid}/quads?bbox={bbox[0]},{bbox[1]},{bbox[2]},{bbox[3]}" + f"basemaps/v1/mosaics/{mosaicid}/quads?bbox=" + f"{bbox[0]}, {bbox[1]}, {bbox[2]}, {bbox[3]}" ) if bbox is None: if isinstance(mosaic, str): @@ -357,7 +358,7 @@ def update_user_quota(self): ).get_body() resp_data = resp.get() - log.debug(f"resp_data:\n{resp_data}") + log.debug(f"resp_data:\n{resp_data}") # noqa: E231 if not resp_data: log.warning("No response data found for getting quota") return False @@ -503,8 +504,8 @@ def tile_service_hash(item_type_ids: List[str]) -> Optional[str]: return res_json["name"] else: log.debug( - f"Tile service hash request failed:\n" - f"status_code: {res.status_code}\n" + f"Tile service hash request failed:\n" # noqa: E231 + f"status_code: {res.status_code}\n" # noqa: E231 f"reason: {res.reason}" ) diff --git a/planet_explorer/tests/install_plugin.py b/planet_explorer/tests/install_plugin.py index 71191b8a..32c1bad5 100755 --- a/planet_explorer/tests/install_plugin.py +++ b/planet_explorer/tests/install_plugin.py @@ -89,7 +89,7 @@ def error_catcher(msg, tag, level): assert utils.startPlugin(PLUGIN_KEY), f"'{PLUGIN_KEY}' failed to start!" assert ( PLUGIN_KEY in utils.active_plugins - ), f"'{PLUGIN_KEY}' not found in active_plugins, found: {utils.active_plugins}" + ), f"'{PLUGIN_KEY}' not found in active_plugins, found: {utils.active_plugins}" # noqa # Unload the plugin assert utils.unloadPlugin(PLUGIN_KEY), "'planet_explorer' failed to unload" diff --git a/planet_explorer/tests/test_basemaps.py b/planet_explorer/tests/test_basemaps.py index e3f69642..a7c80d0c 100644 --- a/planet_explorer/tests/test_basemaps.py +++ b/planet_explorer/tests/test_basemaps.py @@ -247,7 +247,7 @@ def test_basemaps_order_partial( break assert any( order_name in o_name for o_name in order_names - ), f"New order not present in orders list: {order_names}" + ), f"New order not present in orders list: {order_names}" # noqa # Download the basemap item_widget.download(is_unit_test=True) # noqa diff --git a/planet_explorer/tests/test_orders.py b/planet_explorer/tests/test_orders.py index e698073b..9d6f3b9a 100755 --- a/planet_explorer/tests/test_orders.py +++ b/planet_explorer/tests/test_orders.py @@ -242,7 +242,7 @@ def _order_dialog_interact(): assert any( order_name in o_name for o_name in order_names - ), f"New order not present in orders list: {order_names}" + ), f"New order not present in orders list: {order_names}" # noqa # Check for all order metadata except QuadOrder orders order_metadata = [ diff --git a/scripts/privoxy.sh b/scripts/privoxy.sh new file mode 100755 index 00000000..c5452b3d --- /dev/null +++ b/scripts/privoxy.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash + +set -e + +CMD="${1:-start}" +PRIVOXY_CACHE_DIR="$(pwd)/.privoxy-cache" +PRIVOXY_CONFIG_FILE="$(pwd)/.privoxy-config" +PRIVOXY_PID_FILE="$(pwd)/.privoxy.pid" +PRIVOXY_CA_CERT_FILE="$PRIVOXY_CACHE_DIR/ca-cert.pem" +PRIVOXY_CA_KEY_FILE="$PRIVOXY_CACHE_DIR/ca-key.pem" + +mkdir -p "$PRIVOXY_CACHE_DIR" + +generate_ca() { + if [ ! -f "$PRIVOXY_CA_CERT_FILE" ] || [ ! -f "$PRIVOXY_CA_KEY_FILE" ]; then + echo "Generating CA certificate for HTTPS inspection..." + openssl req -x509 -newkey rsa:2048 -days 365 -nodes \ + -keyout "$PRIVOXY_CA_KEY_FILE" \ + -out "$PRIVOXY_CA_CERT_FILE" \ + -subj "/CN=Privoxy CA" + echo "CA certificate generated at: $PRIVOXY_CA_CERT_FILE" + else + echo "CA certificate already exists at: $PRIVOXY_CA_CERT_FILE" + fi +} + +show_help() { + echo "Usage: $0 {start|stop|restart|status|generate-ca|help}" + echo "" + echo "Commands:" + echo " start Start privoxy with HTTPS support" + echo " stop Stop privoxy" + echo " restart Restart privoxy" + echo " status Show privoxy status" + echo " generate-ca Generate a new CA certificate for HTTPS interception" + echo " help Show this help message" + echo "" + echo "The CA certificate is required for HTTPS interception. After running 'generate-ca'," + echo "import $PRIVOXY_CA_CERT_FILE into your browser/system to avoid HTTPS warnings." +} + +case "$CMD" in + start) + generate_ca + if [ ! -f "$PRIVOXY_CONFIG_FILE" ]; then + cat >"$PRIVOXY_CONFIG_FILE" </dev/null; then + privoxy --pidfile "$PRIVOXY_PID_FILE" "$PRIVOXY_CONFIG_FILE" & + echo $! >"$PRIVOXY_PID_FILE" + echo "Started privoxy proxy with HTTPS support on 127.0.0.1:8123 (logdir: $PRIVOXY_CACHE_DIR)" + else + echo "Privoxy already running (PID: $(cat "$PRIVOXY_PID_FILE"))" + fi + ;; + stop) + if [ -f "$PRIVOXY_PID_FILE" ]; then + kill "$(cat "$PRIVOXY_PID_FILE")" && rm "$PRIVOXY_PID_FILE" + echo "Stopped privoxy." + else + echo "Privoxy is not running." + fi + ;; + restart) + "$0" stop + sleep 1 + "$0" start + ;; + status) + if [ -f "$PRIVOXY_PID_FILE" ] && kill -0 "$(cat "$PRIVOXY_PID_FILE")" 2>/dev/null; then + echo "Privoxy is running (PID: $(cat "$PRIVOXY_PID_FILE"))" + else + echo "Privoxy is not running." + fi + ;; + generate-ca) + generate_ca + ;; + help | --help | -h) + show_help + ;; + *) + show_help + exit 1 + ;; +esac diff --git a/scripts/start_qgis.sh b/scripts/start_qgis.sh new file mode 100755 index 00000000..fdf50840 --- /dev/null +++ b/scripts/start_qgis.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +echo "πŸͺ› Running QGIS with the PLANET profile:" +echo "--------------------------------" +echo "Do you want to enable debug mode?" +choice=$(gum choose "πŸͺ² Yes" "🐞 No") +case $choice in + "πŸͺ² Yes") developer_mode=1 ;; + "🐞 No") developer_mode=0 ;; +esac + +# Running on local used to skip tests that will not work in a local dev env +PLANET_LOG=$HOME/PLANET.log +rm -f $PLANET_LOG +# This is the new way, using Ivan Mincis nix spatial project and a flake +# see flake.nix for implementation details +PLANET_LOG=${PLANET_LOG} \ + PLANET_DEBUG=${developer_mode} \ + RUNNING_ON_LOCAL=1 \ + nix run .#default -- --profile PLANET diff --git a/scripts/start_qgis_ltr.sh b/scripts/start_qgis_ltr.sh new file mode 100755 index 00000000..52330585 --- /dev/null +++ b/scripts/start_qgis_ltr.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +echo "πŸͺ› Running QGIS with the default profile:" +echo "--------------------------------" + +# This is the flake approach, using Ivan Mincis nix spatial project and a flake +# see flake.nix for implementation details +GEEST_LOG=${GEEST_LOG} \ + RUNNING_ON_LOCAL=1 \ + nix run .#qgis-ltr --profile QGIS diff --git a/scripts/vscode.sh b/scripts/vscode.sh new file mode 100755 index 00000000..079a9af8 --- /dev/null +++ b/scripts/vscode.sh @@ -0,0 +1,382 @@ +#!/usr/bin/env bash + +# ---------------------------------------------- +# User-adjustable parameters +# ---------------------------------------------- + +VSCODE_PROFILE="PLANET" +EXT_DIR=".vscode-extensions" +VSCODE_DIR=".vscode" +LOG_FILE="vscode.log" + +REQUIRED_EXTENSIONS=( + naumovs.color-highlight@2.8.0 + GitHub.copilot@1.277.0 + ms-python.vscode-pylance@2025.4.1 + KevinRose.vsc-python-indent@1.21.0 + lextudio.restructuredtext@190.4.10 + ms-python.python@2025.6.1 + GitHub.vscode-pull-request-github@0.108.0 + lextudio.restructuredtext-pack@1.0.3 + tht13.rst-vscode@3.0.1 + shd101wyy.markdown-preview-enhanced@0.8.18 + lextudio.iis@1.0.15 + donjayamanne.python-extension-pack@1.7.0 + timonwong.shellcheck@0.37.7 + batisteo.vscode-django@1.15.0 + ms-python.black-formatter@2025.2.0 + hbenl.vscode-test-explorer@2.22.1 + foxundermoon.shell-format@7.2.5 + aikebang.mkdocs-syntax-highlight@0.2.1 + leonhard-s.python-sphinx-highlight@0.3.0 + trond-snekvik.simple-rst@1.5.4 + littlefoxteam.vscode-python-test-adapter@0.8.2 + ms-python.debugpy@2025.8.0 + GitHub.copilot@1.331.0 + github.vscode-github-actions@0.27.1 + donjayamanne.python-environment-manager@1.2.7 + mkhl.direnv@0.17.0 + useblocks.sphinx-needs-vscode@0.3.2 + searKing.preview-vscode@2.3.12 + njpwerner.autodocstring@0.6.1 + ms-vscode.test-adapter-converter@0.2.1 + DavidAnson.vscode-markdownlint@0.60.0 + waderyan.gitblame@11.1.3 + #GitHub.copilot-chat@0.26.7 + VisualStudioExptTeam.intellicode-api-usage-examples@0.2.9 + wholroyd.jinja@0.0.8 + jamesqquick.python-class-generator@0.0.3 + yzhang.markdown-all-in-one@3.6.3 + VisualStudioExptTeam.vscodeintellicode@1.3.2 +) + +# ---------------------------------------------- +# Functions +# ---------------------------------------------- + +launch_vscode() { + code --user-data-dir="$VSCODE_DIR" \ + --profile="${VSCODE_PROFILE}" \ + --extensions-dir="$EXT_DIR" "$@" +} + +list_installed_extensions() { + find "$EXT_DIR" -maxdepth 1 -mindepth 1 -type d | while read -r dir; do + pkg="$dir/package.json" + if [[ -f "$pkg" ]]; then + name=$(jq -r '.name' <"$pkg") + publisher=$(jq -r '.publisher' <"$pkg") + version=$(jq -r '.version' <"$pkg") + echo "${publisher}.${name}@${version}" + fi + done +} + +clean() { + rm -rf .vscode .vscode-extensions +} +print_help() { + cat <"$LOG_FILE" + +# Locate QGIS binary +QGIS_BIN=$(which qgis) + +if [[ -z "$QGIS_BIN" ]]; then + echo "Error: QGIS binary not found!" + exit 1 +fi + +# Extract the Nix store path (removing /bin/qgis) +QGIS_PREFIX=$(dirname "$(dirname "$QGIS_BIN")") + +# Construct the correct QGIS Python path +QGIS_PYTHON_PATH="$QGIS_PREFIX/share/qgis/python" +# Needed for qgis processing module import +PROCESSING_PATH="$QGIS_PREFIX/share/qgis/python/qgis" + +# Check if the Python directory exists +if [[ ! -d "$QGIS_PYTHON_PATH" ]]; then + echo "Error: QGIS Python path not found at $QGIS_PYTHON_PATH" + exit 1 +fi + +# Create .env file for VSCode +ENV_FILE=".env" + +QTPOSITIONING="/nix/store/nb3gkbi161fna9fxh9g3bdgzxzpq34gf-python3.11-pyqt5-5.15.10/lib/python3.11/site-packages" + +echo "Creating VSCode .env file..." +cat <"$ENV_FILE" +PYTHONPATH=$QGIS_PYTHON_PATH:$QTPOSITIONING +# needed for launch.json +QGIS_EXECUTABLE=$QGIS_BIN +QGIS_PREFIX_PATH=$QGIS_PREFIX +PYQT5_PATH="$QGIS_PREFIX/share/qgis/python/PyQt" +QT_QPA_PLATFORM=offscreen +EOF + +echo "βœ… .env file created successfully!" +echo "Contents of .env:" +cat "$ENV_FILE" + +# Also set the python path in this shell in case we want to run tests etc from the command line +export PYTHONPATH=$PYTHONPATH:$QGIS_PYTHON_PATH + +echo "πŸ—¨οΈ Checking VSCode is installed ..." +if ! command -v code &>/dev/null; then + echo " ❌ 'code' CLI not found. Please install VSCode and add 'code' to your PATH." + exit 1 +else + echo " βœ… VSCode found ok." +fi + +# Ensure .vscode directory exists +echo "πŸ—¨οΈ Checking if VSCode has been run before..." +if [ ! -d .vscode ]; then + echo " πŸ”»πŸ”»πŸ”»πŸ”»πŸ”»πŸ”»πŸ”»πŸ”»πŸ”»πŸ”»πŸ”»πŸ”»πŸ”»πŸ”»πŸ”»πŸ”»πŸ”»πŸ”»πŸ”»πŸ”»πŸ”»πŸ”»πŸ”»πŸ”»πŸ”»πŸ”»πŸ”»πŸ”»πŸ”»" + echo " ⭐️ It appears you have not run vscode in this project before." + echo " After it opens, please close vscode and then rerun this script" + echo " so that the extensions directory initialises properly." + echo " πŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”ΊπŸ”Ί" + mkdir -p .vscode + mkdir -p .vscode-extensions + # Launch VSCode with the sandboxed environment + launch_vscode . + exit 1 +else + echo " βœ… VSCode directory found from previous runs of vscode." +fi + +#echo "πŸ—¨οΈ Checking mkdocs is installed ..." +#if ! command -v mkdocs &>/dev/null; then +# echo " ❌ 'mkdocs' CLI not found. Please install it and ensure you have permissions to use it." +# exit 1 +#else +# echo " βœ… mkdocs found ok." +#fi + +echo "πŸ—¨οΈ Checking if VSCode has been run before..." +if [ ! -d "$VSCODE_DIR" ]; then + echo " ⭐️ First-time VSCode run detected. Opening VSCode to initialize..." + mkdir -p "$VSCODE_DIR" + mkdir -p "$EXT_DIR" + launch_vscode . + exit 1 +else + echo " βœ… VSCode directory detected." +fi + +SETTINGS_FILE="$VSCODE_DIR/settings.json" + +echo "πŸ—¨οΈ Checking if settings.json exists..." +if [[ ! -f "$SETTINGS_FILE" ]]; then + echo "{}" >"$SETTINGS_FILE" + echo " πŸ”§ Created new settings.json" +else + echo " βœ… settings.json exists" +fi + +echo "πŸ—¨οΈ Updating git commit signing setting..." +jq '.["git.enableCommitSigning"] = true' "$SETTINGS_FILE" >"$SETTINGS_FILE.tmp" && mv "$SETTINGS_FILE.tmp" "$SETTINGS_FILE" +echo " πŸ”§ git.enableCommitSigning enabled" + +echo "πŸ—¨οΈ Ensuring markdown formatter is set..." +if ! jq -e '."[markdown]".editor.defaultFormatter' "$SETTINGS_FILE" >/dev/null; then + jq '."[markdown]" += {"editor.defaultFormatter": "DavidAnson.vscode-markdownlint"}' "$SETTINGS_FILE" >"$SETTINGS_FILE.tmp" && mv "$SETTINGS_FILE.tmp" "$SETTINGS_FILE" + echo " πŸ”§ Markdown formatter set" +else + echo " βœ… Markdown formatter already configured" +fi + +echo "πŸ—¨οΈ Ensuring shell script formatter and linter are set..." +if ! jq -e '."[shellscript]".editor.defaultFormatter' "$SETTINGS_FILE" >/dev/null; then + jq '."[shellscript]" += {"editor.defaultFormatter": "foxundermoon.shell-format", "editor.formatOnSave": true}' "$SETTINGS_FILE" >"$SETTINGS_FILE.tmp" && mv "$SETTINGS_FILE.tmp" "$SETTINGS_FILE" + echo " πŸ”§ Shell script formatter set to foxundermoon.shell-format, formatOnSave enabled" +else + echo " βœ… Shell script formatter already configured" +fi + +if ! jq -e '.["shellcheck.enable"]' "$SETTINGS_FILE" >/dev/null; then + jq '. + {"shellcheck.enable": true}' "$SETTINGS_FILE" >"$SETTINGS_FILE.tmp" && mv "$SETTINGS_FILE.tmp" "$SETTINGS_FILE" + echo " πŸ”§ ShellCheck linting enabled" +else + echo " βœ… ShellCheck linting already configured" +fi + +if ! jq -e '.["shellformat.flag"]' "$SETTINGS_FILE" >/dev/null; then + jq '. + {"shellformat.flag": "-i 4 -bn -ci"}' "$SETTINGS_FILE" >"$SETTINGS_FILE.tmp" && mv "$SETTINGS_FILE.tmp" "$SETTINGS_FILE" + echo " πŸ”§ Shell format flags set (-i 4 -bn -ci)" +else + echo " βœ… Shell format flags already configured" +fi +echo "πŸ—¨οΈ Ensuring global format-on-save is enabled..." +if ! jq -e '.["editor.formatOnSave"]' "$SETTINGS_FILE" >/dev/null; then + jq '. + {"editor.formatOnSave": true}' "$SETTINGS_FILE" >"$SETTINGS_FILE.tmp" && mv "$SETTINGS_FILE.tmp" "$SETTINGS_FILE" + echo " πŸ”§ Global formatOnSave enabled" +else + echo " βœ… Global formatOnSave already configured" +fi + +# Python formatter and linter +echo "πŸ—¨οΈ Ensuring Python formatter and linter are set..." +if ! jq -e '."[python]".editor.defaultFormatter' "$SETTINGS_FILE" >/dev/null; then + jq '."[python]" += {"editor.defaultFormatter": "ms-python.black-formatter"}' "$SETTINGS_FILE" >"$SETTINGS_FILE.tmp" && mv "$SETTINGS_FILE.tmp" "$SETTINGS_FILE" + echo " πŸ”§ Python formatter set to Black" +else + echo " βœ… Python formatter already configured" +fi + +if ! jq -e '.["python.linting.enabled"]' "$SETTINGS_FILE" >/dev/null; then + jq '. + {"python.linting.enabled": true, "python.linting.pylintEnabled": true}' "$SETTINGS_FILE" >"$SETTINGS_FILE.tmp" && mv "$SETTINGS_FILE.tmp" "$SETTINGS_FILE" + echo " πŸ”§ Python linting enabled (pylint)" +else + echo " βœ… Python linting already configured" +fi + +echo "πŸ—¨οΈ Ensuring Python Testing Env is set..." +if ! jq -e '."[python]".editor.pytestArgs' "$SETTINGS_FILE" >/dev/null; then + jq '."[python]" += {"editor.pytestArgs": "test"}' "$SETTINGS_FILE" >"$SETTINGS_FILE.tmp" && mv "$SETTINGS_FILE.tmp" "$SETTINGS_FILE" + echo " πŸ”§ Python test set up" +else + echo " βœ… Python tests already configured" +fi +if ! jq -e '."[python]".testing.unittestEnabled' "$SETTINGS_FILE" >/dev/null; then + jq '. + {"python.editor.unittestEnabled": false, "python.testing.pytestEnabled": true}' "$SETTINGS_FILE" >"$SETTINGS_FILE.tmp" && mv "$SETTINGS_FILE.tmp" "$SETTINGS_FILE" + echo " πŸ”§ Python unit test set up" +else + echo " βœ… Python unit tests already configured" +fi +echo "πŸ—¨οΈ Ensuring Python Env File is set..." +if ! jq -e '."[python]".envFile' "$SETTINGS_FILE" >/dev/null; then + jq '."[python]" += {"envFile": "${workspaceFolder}/.env"}' "$SETTINGS_FILE" >"$SETTINGS_FILE.tmp" && mv "$SETTINGS_FILE.tmp" "$SETTINGS_FILE" + echo " πŸ”§ Python Env file set up" +else + echo " βœ… Python Env File already configured" +fi + +# TODO +# "python.analysis.extraPaths": [ +## "/nix/store/1lzzg2pl8h9ji0ks8nd2viyxgif9can7-qgis-3.38.3/share/qgis/python", +# ], +# "terminal.integrated.env.linux": { +# "PYTHONPATH": "/nix/store/1lzzg2pl8h9ji0ks8nd2viyxgif9can7-qgis-3.38.3/share/qgis/python" + +if [[ " $* " == *" --verbose "* ]]; then + echo "πŸ—¨οΈ Final settings.json contents:" + cat "$SETTINGS_FILE" +fi + +# Add VSCode runner configuration + +cat <.vscode/launch.json +{ + "version": "0.2.0", + "configurations": [ + { + "name": "QGIS Plugin Debug", + "type": "debugpy", + "request": "launch", + "program": "${env:QGIS_EXECUTABLE}", // Set the QGIS executable path from an environment variable + //"program": "/usr/bin/qgis", // Replace with the actual QGIS executable path + "args": ["--project", "${workspaceFolder}/GEEST.qgs"], // Optional QGIS project + "console": "integratedTerminal", + "env": { + "PYTHONPATH": "${workspaceFolder}/planet_explorer" + } + }, + { + "name": "Python: Remote Attach 9000", + "type": "debugpy", + "request": "attach", + "connect": { + "host": "localhost", + "port": 9000 + }, + "pathMappings": [ + { + "localRoot": "${workspaceFolder}/planet_explorer", // Local path on your machine + "remoteRoot": "${env:HOME}/.local/share/QGIS/QGIS3/profiles/PLANET/python/plugins/planet_explorer" // Uses $HOME instead of hardcoding username + } + ] + } + ] +} +EOF + +echo "πŸ—¨οΈ Installing required extensions..." +for ext in "${REQUIRED_EXTENSIONS[@]}"; do + if echo "$installed_exts" | grep -q "^${ext}$"; then + echo " βœ… Extension ${ext} already installed." + else + echo " πŸ“¦ Installing ${ext}..." + # Capture both stdout and stderr to log file + if launch_vscode --install-extension "${ext}" >>"$LOG_FILE" 2>&1; then + # Refresh installed_exts after install + installed_exts=$(list_installed_extensions) + if echo "$installed_exts" | grep -q "^${ext}$"; then + echo " βœ… Successfully installed ${ext}." + else + echo " ❌ Failed to install ${ext} (not found after install)." + exit 1 + fi + else + echo " ❌ Failed to install ${ext} (error during install). Check $LOG_FILE for details." + exit 1 + fi + fi +done + +echo "πŸ—¨οΈ Launching VSCode..." +launch_vscode .