diff --git a/CHANGELOG.md b/CHANGELOG.md
index b2f2e0f2..becf7f5b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,18 @@
+# pypa:pywwt 0.23.0 (2023-11-28)
+
+- Update the Jupyter widget implementation to use ES6 class syntax (#368,
+ @Carifio24). This should fix up compatibility with the 8.x series of
+ `ipywidgets`.
+
+We have temporarily disabled DOI registration with this release (#369, @pkgw).
+The associated release artifacts will report (obviously) bogus DOIs. This is due
+to a recent major update to Zenodo, which seems to have broken their APIs. Our
+Cranko release automation tool has not yet been updated to handle the new
+system. We intend to address that soon, but we have internal motivations to get
+this release out soon (see above), so we are going ahead without the Zenodo
+deposition.
+
+
# pypa:pywwt 0.22.0 (2023-09-15)
- When using the bundled version of the WWT research app, make sure that we pull
diff --git a/ci/azure-build-and-test.yml b/ci/azure-build-and-test.yml
index 962bb45f..777de085 100644
--- a/ci/azure-build-and-test.yml
+++ b/ci/azure-build-and-test.yml
@@ -213,11 +213,31 @@ jobs:
parameters:
setupBuild: true
+ # We install some extra deps here to make the docs build happy (it needs to
+ # import everything) while avoiding clashes between Conda and pip.
- bash: |
set -euo pipefail
source activate-conda.sh
set -x
- \conda create -y -n build setuptools matplotlib pip pyopengl 'pyqt>=5.12' pyqtwebengine python=3.9 qtpy scipy shapely
+ \conda create -y -n build \
+ astropy \
+ astropy-sphinx-theme \
+ ipykernel \
+ jupyter_sphinx \
+ matplotlib \
+ nbclassic \
+ numpydoc \
+ pip \
+ pyopengl \
+ 'pyqt>=5.12' \
+ pyqtwebengine \
+ python=3.9 \
+ qtpy \
+ scipy \
+ setuptools \
+ shapely \
+ sphinx \
+ sphinx-automodapi
conda activate build
pip install $BASH_WORKSPACE/sdist/*.tar.gz
displayName: Install from sdist
@@ -230,7 +250,6 @@ jobs:
source activate-conda.sh
conda activate build
set -x
- \conda install -y astropy astropy-sphinx-theme ipykernel jupyter_sphinx nbclassic numpydoc sphinx sphinx-automodapi
cd docs
make html
displayName: Build docs
diff --git a/ci/azure-deployment.yml b/ci/azure-deployment.yml
index 23725ba3..9bf955a7 100644
--- a/ci/azure-deployment.yml
+++ b/ci/azure-deployment.yml
@@ -111,33 +111,35 @@ jobs:
- bash: shred ~/.npmrc
displayName: Clean up credentials
- - job: zenodo_publish
- pool:
- vmImage: ubuntu-latest
- variables:
- - group: Deployment Credentials
-
- steps:
- - template: azure-job-setup.yml
- parameters:
- setupCranko: true
-
- - bash: |
- set -xeuo pipefail
-
- if cranko show if-released --exit-code pypa:pywwt ; then
- cranko zenodo upload-artifacts --metadata=ci/zenodo.json5 $BASH_WORKSPACE/sdist/*.tar.gz
- fi
- displayName: Upload source tarball
- env:
- ZENODO_TOKEN: $(ZENODO_TOKEN)
-
- - bash: |
- set -xeuo pipefail
-
- if cranko show if-released --exit-code pypa:pywwt ; then
- cranko zenodo publish --metadata=ci/zenodo.json5
- fi
- displayName: Publish to Zenodo
- env:
- ZENODO_TOKEN: $(ZENODO_TOKEN)
+ # 2023 Nov: temporarily disabling Zenodo; they have just updated their API and broken
+ # everything, and we want to ge a release out.
+ #- job: zenodo_publish
+ # pool:
+ # vmImage: ubuntu-latest
+ # variables:
+ # - group: Deployment Credentials
+ #
+ # steps:
+ # - template: azure-job-setup.yml
+ # parameters:
+ # setupCranko: true
+ #
+ # - bash: |
+ # set -xeuo pipefail
+ #
+ # if cranko show if-released --exit-code pypa:pywwt ; then
+ # cranko zenodo upload-artifacts --metadata=ci/zenodo.json5 $BASH_WORKSPACE/sdist/*.tar.gz
+ # fi
+ # displayName: Upload source tarball
+ # env:
+ # ZENODO_TOKEN: $(ZENODO_TOKEN)
+ #
+ # - bash: |
+ # set -xeuo pipefail
+ #
+ # if cranko show if-released --exit-code pypa:pywwt ; then
+ # cranko zenodo publish --metadata=ci/zenodo.json5
+ # fi
+ # displayName: Publish to Zenodo
+ # env:
+ # ZENODO_TOKEN: $(ZENODO_TOKEN)
diff --git a/ci/azure-sdist.yml b/ci/azure-sdist.yml
index 859ebdc6..ea790a7a 100644
--- a/ci/azure-sdist.yml
+++ b/ci/azure-sdist.yml
@@ -29,12 +29,14 @@ jobs:
- bash: cranko release-workflow apply-versions
displayName: Apply Cranko versions
- - bash: |
- cranko zenodo preregister --metadata=ci/zenodo.json5 pypa:pywwt pywwt/_version.py CHANGELOG.md
- displayName: "Preregister Zenodo DOI"
- ${{ if and(eq(variables['Build.SourceBranchName'], 'rc'), ne(variables['build.reason'], 'PullRequest')) }}:
- env:
- ZENODO_TOKEN: $(ZENODO_TOKEN)
+ # 2023 Nov: temporarily disabling Zenodo; they have just updated their API and broken
+ # everything, and we want to ge a release out.
+ # - bash: |
+ # cranko zenodo preregister --metadata=ci/zenodo.json5 pypa:pywwt pywwt/_version.py CHANGELOG.md
+ # displayName: "Preregister Zenodo DOI"
+ # ${{ if and(eq(variables['Build.SourceBranchName'], 'rc'), ne(variables['build.reason'], 'PullRequest')) }}:
+ # env:
+ # ZENODO_TOKEN: $(ZENODO_TOKEN)
- bash: |
set -xeuo pipefail
diff --git a/ci/zenodo.json5 b/ci/zenodo.json5
index 4f177a5d..4fd5ed97 100644
--- a/ci/zenodo.json5
+++ b/ci/zenodo.json5
@@ -1,73 +1,85 @@
+// See https://pkgw.github.io/cranko/book/latest/integrations/zenodo.html
+// and https://developers.zenodo.org/#representation
+
{
- "conceptrecid": "7164147",
- "metadata": {
- "access_right": "open",
- "creators": [
+ conceptrecid: '7164147',
+
+ metadata: {
+ upload_type: 'software',
+ language: 'eng',
+
+ // ** Keep this alphabetical by family name!!! **
+ creators: [
{
- "affiliation": "Center for Astrophysics | Harvard & Smithsonian",
- "name": "Carifio, Jonathan",
- "orcid": "0000-0002-7759-2601"
+ affiliation: 'Center for Astrophysics | Harvard & Smithsonian',
+ name: 'Carifio, Jonathan',
+ orcid: '0000-0002-7759-2601',
},
{
- "affiliation": "Heidelberg Institute for Theoretical Studies",
- "name": "Gaibler, Volker",
- "orcid": "0000-0001-7581-7574"
+ affiliation: 'Heidelberg Institute for Theoretical Studies',
+ name: 'Gaibler, Volker',
+ orcid: '0000-0001-7581-7574',
},
{
- "affiliation": "Aperio Software",
- "name": "Homeier, Derek",
- "orcid": "0000-0002-8546-9128"
+ affiliation: 'Aperio Software',
+ name: 'Homeier, Derek',
+ orcid: '0000-0002-8546-9128',
},
{
- "affiliation": "Winter Way",
- "name": "Norman, Henrik",
- "orcid": "0000-0003-4189-3450"
+ affiliation: 'Winter Way',
+ name: 'Norman, Henrik',
+ orcid: '0000-0003-4189-3450',
},
{
- "affiliation": "Space Telescope Science Institute",
- "name": "Otor, O. Justin",
- "orcid": "0000-0002-4679-5692"
+ affiliation: 'Space Telescope Science Institute',
+ name: 'Otor, O. Justin',
+ orcid: '0000-0002-4679-5692',
},
{
- "affiliation": "Aperio Software",
- "name": "Robitaille, Thomas P.",
- "orcid": "0000-0002-8642-1329"
+ affiliation: 'Aperio Software',
+ name: 'Robitaille, Thomas P.',
+ orcid: '0000-0002-8642-1329',
},
{
- "name": "Subbarao, Jeffrey"
+ name: 'Subbarao, Jeffrey',
},
{
- "affiliation": "Center for Astrophysics | Harvard & Smithsonian",
- "name": "Williams, Peter K. G.",
- "orcid": "0000-0003-3734-3587"
+ affiliation: 'Center for Astrophysics | Harvard & Smithsonian',
+ name: 'Williams, Peter K. G.',
+ orcid: '0000-0003-3734-3587',
},
{
- "affiliation": "Center for Astrophysics | Harvard & Smithsonian",
- "name": "ZuHone, John",
- "orcid": "0000-0003-3175-2347"
- }
+ affiliation: 'Center for Astrophysics | Harvard & Smithsonian',
+ name: 'ZuHone, John',
+ orcid: '0000-0003-3175-2347',
+ },
],
- "description": "pywwt is the official toolkit for accessing WorldWide Telescope (WWT) from Python. Learn more at the pywwt website.",
- "grants": [
- {
- "id": "10.13039/100000001::1550701"
- }
+
+ // Subset of HTML allowed here:
+ description: 'pywwt is the official toolkit for accessing WorldWide \
+Telescope (WWT) from Python. Learn more at the pywwt website.',
+
+ access_right: 'open',
+ license: 'BSD-3-Clause', // see https://spdx.org/licenses/
+
+ grants: [
+ // National Science Foundation:
+ {id: '10.13039/100000001::1550701'},
+
+ // As of 2022 August these grants are not in the OpenAIRE "Research Graph"
+ // and so Zenodo won't let us record them here. Test existence by
+ // GET'ing `https://zenodo.org/api/grants/$id`.
+ //
+ //{id: '10.13039/100000001::1642446'},
+ //{id: '10.13039/100000001::2004840'},
],
- "keywords": [
- "Astronomy",
- "Python",
- "Visualization",
- "WorldWide Telescope"
+
+ // Keywords use an uncontrolled vocabulary (and so are of limited usefulness):
+ keywords: [
+ 'Astronomy',
+ 'Python',
+ 'Visualization',
+ 'WorldWide Telescope',
],
- "language": "eng",
- "license": "BSD-3-Clause",
- "publication_date": "2023-09-15",
- "title": "pypa:pywwt 0.22.0",
- "upload_type": "software",
- "version": "0.22.0"
},
- "conceptdoi": "10.5281/zenodo.7164147",
- "record_id": "8350627",
- "doi": "10.5281/zenodo.8350627",
- "bucket_link": "https://zenodo.org/api/files/4d1eeef7-6d48-44b1-a8eb-ee7127b1eba4"
}
diff --git a/frontend/CHANGELOG.md b/frontend/CHANGELOG.md
index 3be11fa0..388fb452 100644
--- a/frontend/CHANGELOG.md
+++ b/frontend/CHANGELOG.md
@@ -1,3 +1,10 @@
+# npm:pywwt 1.7.0 (2023-11-28)
+
+- Update the Jupyter widget implementation to use ES6 class syntax (#368,
+ @Carifio24). This should fix up compatibility with the 8.x series of
+ `ipywidgets`.
+
+
# npm:pywwt 1.6.0 (2023-09-15)
- Require the new ESM-based engine through the 0.16.x series of the research app
diff --git a/frontend/lib/widget.js b/frontend/lib/widget.js
index 44377918..d10b27f5 100644
--- a/frontend/lib/widget.js
+++ b/frontend/lib/widget.js
@@ -67,21 +67,24 @@ var version = require('./index').version;
// Which is a hairy busines, because not only does our one widget potentially
// have multiple views, but there are also potentially multiple active widgets,
// and we'll see all of their messages.
-var WWTModel = widgets.DOMWidgetModel.extend({
- defaults: _.extend(widgets.DOMWidgetModel.prototype.defaults(), {
- _model_name: 'WWTModel',
- _model_module: 'pywwt',
- _model_module_version: version,
-
- _view_name: 'WWTView',
- _view_module: 'pywwt',
- _view_module_version: version,
-
- _appUrl: ''
- }),
+class WWTModel extends widgets.DOMWidgetModel {
+ defaults() {
+ return {
+ ...super.defaults(),
+ _model_name: 'WWTModel',
+ _model_module: 'pywwt',
+ _model_module_version: version,
+
+ _view_name: 'WWTView',
+ _view_module: 'pywwt',
+ _view_module_version: version,
+
+ _appUrl: ''
+ }
+ }
- initialize: function () {
- WWTModel.__super__.initialize.apply(this, arguments);
+ initialize() {
+ widgets.DOMWidgetModel.prototype.initialize.apply(this, arguments);
// NOTE: we deliberately call the following twice to make sure that it
// is properly set, due to a caching bug in some versions of JupyterLab.
@@ -112,12 +115,12 @@ var WWTModel = widgets.DOMWidgetModel.extend({
function (event) { self.processDomWindowMessage(event); },
false
);
- },
+ }
// The kernel can generate partial URLs, but doesn't (and can't) know the
// full URL where data are ultimately exposed. So in various places we need
// to edit URLs emerging from the client to make them complete.
- canonicalizeUrl: function (url) {
+ canonicalizeUrl(url) {
// Sketchy heuristic to deal with the Jupyter "base URL", which still
// isn't an absolute URL. It's a URL path used by multi-user Jupyter
// servers and the like. The Python kernel code can determine the base
@@ -132,19 +135,19 @@ var WWTModel = widgets.DOMWidgetModel.extend({
}
return new URL(url, location.toString()).toString();
- },
+ }
// Get a unique ID and sequence number for distinguishing views. Note that
// while each model might have multiple views, there might also be multiple
// widget models too, and we have to distinguish them all.
- mintViewIds: function() {
+ mintViewIds() {
var seq = this._nextViewSeqNumber;
this._nextViewSeqNumber++;
return [this.model_id + "v" + seq, seq];
- },
+ }
// Called by a widget view when the "liveness" state of its app changes.
- onViewStatusChange: function(view, alive) {
+ onViewStatusChange(view, alive) {
if (alive) {
// Should this view become the current view?
//
@@ -187,14 +190,14 @@ var WWTModel = widgets.DOMWidgetModel.extend({
}
}
}
- },
+ }
// Relay a message from the kernel to the active view. In order to keep
// things tractable, we only route messages to the "current" view. For
// instance, if the client were to issue a data-request message and we
// routed it to multiple views, we'd get multiple responses, with no
// sensible way to know which to prefer.
- processIpyWidgetsMessage: function (msg) {
+ processIpyWidgetsMessage(msg) {
if (this._currentView === null) {
// We could queue up messages here. The kernel "shouldn't" send us
// any messages until a view is ready, but it's always possible that
@@ -224,7 +227,7 @@ var WWTModel = widgets.DOMWidgetModel.extend({
}
this._currentView.relayIpyWidgetsMessage(msg);
- },
+ }
// Process messages from the WWT apps, potentially relaying them to the
// kernel.
@@ -234,7 +237,7 @@ var WWTModel = widgets.DOMWidgetModel.extend({
//
// The message is relayed to the kernel using ipywidgets "custom" messages,
// which is basically trivial once we've dealt with the above.
- processDomWindowMessage: function (event) {
+ processDomWindowMessage(event) {
var payload = event.data;
if (event.origin !== this._appOrigin)
@@ -270,7 +273,7 @@ var WWTModel = widgets.DOMWidgetModel.extend({
payload['_pywwtExpedite'] = true;
this.send(payload);
}
-});
+}
// The pywwt ipywidget view implementation.
//
@@ -282,8 +285,8 @@ var WWTModel = widgets.DOMWidgetModel.extend({
// destroy the element. However, re-adding an iframe to the DOM causes it to
// reload, so hiding and re-showing a WWT view causes its internal state to be
// reset :-(
-var WWTView = widgets.DOMWidgetView.extend({
- render: function () {
+class WWTView extends widgets.DOMWidgetView {
+ render() {
this._appUrl = this.model.canonicalizeUrl(this.model.get('_appUrl'));
this._appOrigin = new URL(this._appUrl).origin;
@@ -324,9 +327,9 @@ var WWTView = widgets.DOMWidgetView.extend({
);
setInterval(function () { self.checkApp(); }, 1000);
- },
+ }
- checkApp: function() {
+ checkApp() {
// Send our next ping ...
var window = this.tryGetWindow();
@@ -346,13 +349,13 @@ var WWTView = widgets.DOMWidgetView.extend({
this._alive = alive;
this.model.onViewStatusChange(this, alive);
}
- },
+ }
// Process a message sent to the browser window. This function's only job is
// to look for responses to our pings. It will be called for messages from
// all views of all widgets, though, so it needs to be careful about which
// messages to process.
- processDomWindowMessage: function (event) {
+ processDomWindowMessage(event) {
var payload = event.data;
if (event.origin !== this._appOrigin)
@@ -365,12 +368,12 @@ var WWTView = widgets.DOMWidgetView.extend({
this._lastPongTimestamp = ts;
}
}
- },
+ }
// Called by the model when there's a message from the kernel that should go
// to this view. The model "shouldn't" give us any messages if/when our
// window is nonfunctional, but the window might always die underneath us.
- relayIpyWidgetsMessage: function (msg) {
+ relayIpyWidgetsMessage(msg) {
var window = this.tryGetWindow();
if (!window) {
// TODO? Tell the model that we failed?
@@ -378,38 +381,34 @@ var WWTView = widgets.DOMWidgetView.extend({
}
window.postMessage(msg, this._appUrl);
- },
+ }
// Note: processPhosphorMessage is needed for Jupyter Lab <2 and
// processLuminoMessage is needed for Jupyter Lab 2.0+
- processPhosphorMessage: function (msg) {
+ // See https://ipywidgets.readthedocs.io/en/latest/migration_guides.html#phosphor-lumino
+ _processLuminoMessage(msg, _super) {
// We listen for phosphor resize events so that when Jupyter Lab is
// used, we adjust the canvas size to the tab/panel in Jupyter Lab.
// See relayout for more details.
- WWTView.__super__.processPhosphorMessage.apply(this, arguments);
+ _super.call(this, msg);
switch (msg.type) {
case 'resize':
case 'after-show':
this.relayout();
break;
}
- },
+ }
- processLuminoMessage: function (msg) {
- // We listen for lumino resize events so that when Jupyter Lab is
- // used, we adjust the canvas size to the tab/panel in Jupyter Lab.
- // See relayout for more details.
- WWTView.__super__.processLuminoMessage.apply(this, arguments);
- switch (msg.type) {
- case 'resize':
- case 'after-show':
- this.relayout();
- break;
- }
- },
+ processPhosphorMessage(msg) {
+ this._processLuminoMessage(msg, super.processPhosphorMessage);
+ }
- relayout: function () {
+ processLuminoMessage(msg) {
+ this._processLuminoMessage(msg, super.processLuminoMessage);
+ }
+
+ relayout() {
// Only do resizing if we are not in the notebook context but in a
// split panel context. We find this out by checking if one of the
// parents of the current element has the jp-MainAreaWidget class --
@@ -459,13 +458,13 @@ var WWTView = widgets.DOMWidgetView.extend({
// need to find a better solution in the long term.
iframe.width = width - 10;
iframe.height = height - 10;
- },
+ }
// Get the WWT window, if it is actually fully initialized. Note that in
// JupyterLab if this widget view is hidden and then re-shown, the iframe
// will reload, and the contentWindow will acquire a new value. So we can't
// cache too aggressively.
- tryGetWindow: function () {
+ tryGetWindow() {
var iframe = this.el.getElementsByTagName('iframe')[0];
if (!iframe)
return null;
@@ -476,7 +475,7 @@ var WWTView = widgets.DOMWidgetView.extend({
return window;
}
-});
+}
module.exports = {
WWTModel: WWTModel,
diff --git a/frontend/package.json b/frontend/package.json
index dc028255..5e217c1d 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -42,5 +42,5 @@
"clean": "shx rm -rf dist",
"pywwt-export": "npm run build && npm pack && node pywwt-export.js"
},
- "version": "1.6.0"
+ "version": "1.7.0"
}
diff --git a/pywwt/_version.py b/pywwt/_version.py
index 64ad4248..5d52bd0a 100644
--- a/pywwt/_version.py
+++ b/pywwt/_version.py
@@ -1,4 +1,4 @@
-version_info = (0, 22, 0, 'final', 0) # cranko project-version tuple
+version_info = (0, 23, 0, 'final', 0) # cranko project-version tuple
_specifier_ = {
"alpha": ".a",
@@ -18,5 +18,5 @@
)
# The strings are auto-updated by Cranko during formal releases:
-version_doi = "10.5281/zenodo.8350627"
-concept_doi = "10.5281/zenodo.7164147"
+version_doi = "xx.xxxx/dev-build.pypa:pywwt.version"
+concept_doi = "xx.xxxx/dev-build.pypa:pywwt.concept"
diff --git a/pywwt/core.py b/pywwt/core.py
index cc29fd7e..87b2fc60 100644
--- a/pywwt/core.py
+++ b/pywwt/core.py
@@ -610,8 +610,9 @@ def instruments(self):
).tag(wwt="altAzGridColor", wwt_reset=True)
alt_az_text = Bool(
- False, help='Whether to show labels for the altitude-azimuth grid\'s text ' '(`bool`)'
- ).tag(wwt='showAltAzGridText', wwt_reset=True)
+ False,
+ help="Whether to show labels for the altitude-azimuth grid's text " "(`bool`)",
+ ).tag(wwt="showAltAzGridText", wwt_reset=True)
background = Unicode(
"Hydrogen Alpha Full Sky Map",
@@ -651,13 +652,15 @@ def instruments(self):
).tag(wwt="showConstellationSelection", wwt_reset=True)
constellation_pictures = Bool(
- False, help='Whether to show pictures of the constellations\' '
- 'mythological representations ' '(`bool`)'
- ).tag(wwt='showConstellationPictures', wwt_reset=True)
+ False,
+ help="Whether to show pictures of the constellations' "
+ "mythological representations "
+ "(`bool`)",
+ ).tag(wwt="showConstellationPictures", wwt_reset=True)
constellation_labels = Bool(
- False, help='Whether to show labels for constellations ' '(`bool`)'
- ).tag(wwt='showConstellationLabels', wwt_reset=True)
+ False, help="Whether to show labels for constellations " "(`bool`)"
+ ).tag(wwt="showConstellationLabels", wwt_reset=True)
crosshairs = Bool(
False, help="Whether to show crosshairs at the center of " "the field (`bool`)"
@@ -711,8 +714,8 @@ def instruments(self):
).tag(wwt="galacticGridColor", wwt_reset=True)
galactic_text = Bool(
- False, help='Whether to show labels for the galactic grid\'s text ' '(`bool`)'
- ).tag(wwt='showGalacticGridText', wwt_reset=True)
+ False, help="Whether to show labels for the galactic grid's text " "(`bool`)"
+ ).tag(wwt="showGalacticGridText", wwt_reset=True)
grid = Bool(False, help="Whether to show the equatorial grid " "(`bool`)").tag(
wwt="showGrid", wwt_reset=True
@@ -871,11 +874,13 @@ def center_on_coordinates(self, coord, fov=60 * u.deg, roll=None, instant=True):
desired location.
"""
coord_icrs = coord.icrs
- msg = dict(event="center_on_coordinates",
- ra=coord_icrs.ra.deg,
- dec=coord_icrs.dec.deg,
- fov=fov.to(u.deg).value,
- instant=instant)
+ msg = dict(
+ event="center_on_coordinates",
+ ra=coord_icrs.ra.deg,
+ dec=coord_icrs.dec.deg,
+ fov=fov.to(u.deg).value,
+ instant=instant,
+ )
if roll is not None:
msg["roll"] = roll.to(u.deg).value
self._send_msg(**msg)
@@ -1069,11 +1074,11 @@ def load_image_collection(self, url, recursive=False, remote_only=False):
nest_asyncio.apply()
loop = asyncio.get_event_loop()
- loop.run_until_complete(self._send_into_future(
- event="load_image_collection",
- url=url,
- loadChildFolders=recursive
- ))
+ loop.run_until_complete(
+ self._send_into_future(
+ event="load_image_collection", url=url, loadChildFolders=recursive
+ )
+ )
@property
def available_layers(self):
@@ -1341,7 +1346,7 @@ def _serialize_state(self, title, max_width, max_height):
"ra": center.icrs.ra.deg,
"dec": center.icrs.dec.deg,
"fov": fov.to_value(u.deg),
- "roll": roll.to_value(u.deg)
+ "roll": roll.to_value(u.deg),
}
state["foreground_settings"] = {