<NME> frontend.ts
<BEF> /**
 * @license
 * Copyright 2016 Google Inc.
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

/**
 * @file
 * Support for Python integration.
 */

import {ChunkManager, WithParameters} from 'neuroglancer/chunk_manager/frontend';
import {DataSource} from 'neuroglancer/datasource';
import {MeshSourceParameters, SkeletonSourceParameters, VolumeChunkEncoding, VolumeChunkSourceParameters} from 'neuroglancer/datasource/python/base';
import {MeshSource} from 'neuroglancer/mesh/frontend';
import {VertexAttributeInfo} from 'neuroglancer/skeleton/base';
import {SkeletonSource} from 'neuroglancer/skeleton/frontend';
import {DataType, DEFAULT_MAX_VOXELS_PER_CHUNK_LOG2, getNearIsotropicBlockSize, getTwoDimensionalBlockSize} from 'neuroglancer/sliceview/base';
import {VolumeChunkSpecification, VolumeSourceOptions, VolumeType} from 'neuroglancer/sliceview/volume/base';
import {VolumeChunkSource, MultiscaleVolumeChunkSource as GenericMultiscaleVolumeChunkSource} from 'neuroglancer/sliceview/volume/frontend';
import {mat4, vec3} from 'neuroglancer/util/geom';
import {openHttpRequest, sendHttpRequest} from 'neuroglancer/util/http_request';
import {parseArray, parseFixedLengthArray, verify3dDimensions, verify3dScale, verify3dVec, verifyEnumString, verifyObject, verifyObjectAsMap, verifyObjectProperty, verifyPositiveInt, verifyString} from 'neuroglancer/util/json';

class PythonVolumeChunkSource extends
(WithParameters(VolumeChunkSource, VolumeChunkSourceParameters)) {}
class PythonMeshSource extends
(WithParameters(MeshSource, MeshSourceParameters)) {}

interface ScaleInfo {
  key: string;
class PythonVolumeChunkSource extends
(WithPythonDataSource(WithParameters(VolumeChunkSource, VolumeChunkSourceParameters))) {
}
class PythonMeshSource extends
(WithPythonDataSource(WithParameters(MeshSource, MeshSourceParameters))) {}

export function computeNearIsotropicDownsamplingLevels(
    shape: Float32Array, downsampleDims: readonly number[], effectiveVoxelSize: Float32Array,
    maxDownsampling: number, maxDownsamplingScales: number, maxDownsampledSize: number) {
  const rank = shape.length;
  const curDownsampleFactors = new Float32Array(rank);
  curDownsampleFactors.fill(1);
  const downsampleLevels: Float32Array[] = [curDownsampleFactors.slice()];
  let curDownsampleProduct = 1;
  const numDownsampleDims = downsampleDims.length;
  if (numDownsampleDims === 0) return downsampleLevels;
  while (true) {
    if (downsampleLevels.length >= maxDownsamplingScales) break;
    if (curDownsampleProduct >= maxDownsampling) break;
    if (curDownsampleFactors.every((f, i) => shape[i] / f <= maxDownsampledSize)) break;
    let curSmallestScaleDim = downsampleDims[0];
  volumeType: VolumeType;
  encoding: VolumeChunkEncoding;
  scales: ScaleInfo[][];

  constructor(public chunkManager: ChunkManager, public key: string, public response: any) {
    verifyObject(response);
    this.dataType = verifyObjectProperty(response, 'dataType', x => verifyEnumString(x, DataType));
    this.volumeType =
        verifyObjectProperty(response, 'volumeType', x => verifyEnumString(x, VolumeType));
    this.numChannels = verifyObjectProperty(response, 'numChannels', verifyPositiveInt);
    this.encoding =
        verifyObjectProperty(response, 'encoding', x => verifyEnumString(x, VolumeChunkEncoding));
    let maxVoxelsPerChunkLog2 = verifyObjectProperty(
        response, 'maxVoxelsPerChunkLog2',
        x => x === undefined ? DEFAULT_MAX_VOXELS_PER_CHUNK_LOG2 : verifyPositiveInt(x));
    }
    downsampleLevels.push(new Float32Array(curDownsampleFactors));
  }
  return downsampleLevels;
}

function parseCoordinateSpaceAndVoxelOffset(response: any) {
  verifyObject(response);
  const baseModelSpace: CoordinateSpace = {
    ...verifyObjectProperty(response, 'coordinateSpace', coordinateSpaceFromJson),
    valid: true
  };
  const {rank} = baseModelSpace;
  // Mark all coordinate arrays as implicit, since they are obtained from the data source and need
  // not be preserved in the Neuroglancer JSON state.
  baseModelSpace.coordinateArrays.forEach(coordinateArray => {
    if (coordinateArray === undefined) return;
    coordinateArray.explicit = false;
  });
  const subsourceToModelTransform =
      matrix.identity(new Float32Array((rank + 1) * (rank + 1)), rank + 1, rank + 1);

  const voxelOffset = verifyObjectProperty(
      response, 'voxelOffset',
      x => parseFixedLengthArray(new Float64Array(rank), x, verifyFiniteFloat));
  for (let i = 0; i < rank; ++i) {
    subsourceToModelTransform[(rank + 1) * rank + i] = voxelOffset[i];
  }
  return {subsourceToModelTransform, baseModelSpace, voxelOffset};
}

export class PythonMultiscaleVolumeChunkSource extends MultiscaleVolumeChunkSource {
  dataType: DataType;
  volumeType: VolumeType;
  encoding: VolumeChunkEncoding;
  generation: number;
  modelSpace: CoordinateSpace;
  downsamplingLayout: ChunkLayoutPreference;
  chunkLayoutPreference: ChunkLayoutPreference;
  shape: Float32Array;
  maxDownsampling: number;
  maxDownsampledSize: number;
  maxDownsamplingScales: number;
  maxVoxelsPerChunkLog2: number;
  subsourceToModelTransform: Float32Array;

  get rank() {
    return this.modelSpace.rank;
  }
  skeletonVertexAttributes: Map<string, VertexAttributeInfo>|undefined;

  // TODO(jbms): Properly handle reference counting of `dataSource`.
  constructor(
      public dataSource: Borrowed<PythonDataSource>, chunkManager: ChunkManager, public key: string,
      public response: any) {
    super(chunkManager);
    const {baseModelSpace, subsourceToModelTransform, voxelOffset} =
        parseCoordinateSpaceAndVoxelOffset(response);
    this.dataType = verifyObjectProperty(response, 'dataType', x => verifyEnumString(x, DataType));
    this.volumeType =
        verifyObjectProperty(response, 'volumeType', x => verifyEnumString(x, VolumeType));
    this.encoding =
        verifyObjectProperty(response, 'encoding', x => verifyEnumString(x, VolumeChunkEncoding));
    const rank = baseModelSpace.rank;
    this.subsourceToModelTransform = subsourceToModelTransform;
    const shape = verifyObjectProperty(
        response, 'shape',
        x => parseFixedLengthArray(new Float32Array(rank), x, verifyPositiveInt));
    this.shape = shape;

    this.maxDownsampling = verifyObjectProperty(
        response, 'maxDownsampling', x => x === null ? Number.POSITIVE_INFINITY : verifyInt(x));
    this.maxDownsampledSize = verifyObjectProperty(
        response, 'maxDownsampledSize', x => x === null ? Number.POSITIVE_INFINITY : verifyInt(x));
        chunkDataSize: scaleInfo.chunkDataSize!,
        volumeSourceOptions,
      });
      return this.chunkManager.getChunkSource(
          PythonVolumeChunkSource,
          {spec, parameters: {key: `${this.key}/${scaleInfo.key}`, encoding: encoding}});
    }));
  }

  getMeshSource() {
    return this.chunkManager.getChunkSource(PythonMeshSource, {
      parameters: {
        key: this.key,
      }
      units: baseModelSpace.units,
      boundingBoxes: [makeIdentityTransformedBoundingBox(box)],
}

export class PythonSkeletonSource extends
(WithParameters(SkeletonSource, SkeletonSourceParameters)) {
  get skeletonVertexCoordinatesInVoxels() {
    return false;
  }

  getSources(volumeSourceOptions: VolumeSourceOptions) {
    const downsampleDims: number[] = [];
    const {rank, volumeType, dataType, shape, encoding} = this;
    const effectiveDisplayScales = new Float32Array(rank);
    const {multiscaleToViewTransform, modelChannelDimensionIndices} = volumeSourceOptions;
    for (let modelDim = 0; modelDim < rank; ++modelDim) {
      let factor = 0;
      for (let viewDim = 0; viewDim < 3; ++viewDim) {
        const c = multiscaleToViewTransform[modelDim * 3 + viewDim];
        factor += c * c;
      }
      if (factor !== 0) downsampleDims.push(modelDim);
      effectiveDisplayScales[modelDim] = Math.sqrt(factor);
  return verifyObjectAsMap(JSON.parse(spec), parseVertexAttributeInfo);
}

export function getSkeletonSource(chunkManager: ChunkManager, key: string) {
  const skeletonKeyPattern = /^([^\/?]+)\?(.*)$/;

  let match = key.match(skeletonKeyPattern);
  if (match === null) {
    throw new Error(`Invalid python volume path: ${JSON.stringify(key)}`);
  }
  return chunkManager.getChunkSource(PythonSkeletonSource, {
    parameters: {
      key: match[1],
      vertexAttributes: parseSkeletonVertexAttributes(match[2]),
    }
  });
}

export function getVolume(chunkManager: ChunkManager, key: string) {
  return chunkManager.memoize.getUncounted(
      {'type': 'python:MultiscaleVolumeChunkSource', key},
      () => sendHttpRequest(openHttpRequest(`/neuroglancer/info/${key}`), 'json')
                .then(
                    response =>
                        new MultiscaleVolumeChunkSource(chunkManager, key, response)));
}

export class PythonDataSource extends DataSource {
  get description() {
    return 'Python-served volume';
  }
  getVolume(chunkManager: ChunkManager, key: string) {
    return getVolume(chunkManager, key);
  }
  getSkeletonSource(chunkManager: ChunkManager, key: string) {
    return getSkeletonSource(chunkManager, key);
  }
}

  getMeshSource() {
    const {skeletonVertexAttributes} = this;
    if (skeletonVertexAttributes !== undefined) {
      return this.chunkManager.getChunkSource(PythonSkeletonSource, {
        dataSource: this.dataSource,
        generation: this.generation,
        parameters: {
          key: this.key,
          vertexAttributes: skeletonVertexAttributes,
        }
      });
    }
    return this.chunkManager.getChunkSource(PythonMeshSource, {
      dataSource: this.dataSource,
      generation: this.generation,
      parameters: {
        key: this.key,
      }
    });
  }
}

export class PythonSkeletonSource extends
(WithPythonDataSource(WithParameters(SkeletonSource, SkeletonSourceParameters))) {
  get vertexAttributes() {
    return this.parameters.vertexAttributes;
  }
}

function parseVertexAttributeInfo(x: any): VertexAttributeInfo {
  verifyObject(x);
  return {
    dataType: verifyObjectProperty(x, 'dataType', y => verifyEnumString(y, DataType)),
    numComponents: verifyObjectProperty(x, 'numComponents', verifyPositiveInt),
  };
}

function getVolumeDataSource(
    dataSourceProvider: PythonDataSource, options: GetDataSourceOptions, key: string) {
  return options.chunkManager.memoize.getUncounted(
      {'type': 'python:VolumeDataSource', key}, async () => {
        const response = await (await fetchOk(`../../neuroglancer/info/${key}`)).json();
        const volume = new PythonMultiscaleVolumeChunkSource(
            dataSourceProvider, options.chunkManager, key, response);
        const dataSource: DataSource = {
          modelTransform: makeIdentityTransform(volume.modelSpace),
          subsources: [
            {
              id: 'default',
              default: true,
              subsource: {volume},
              subsourceToModelSubspaceTransform: volume.subsourceToModelTransform,
            },
            {
              id: 'bounds',
              default: true,
              subsource: {
                staticAnnotations: makeDataBoundsBoundingBoxAnnotationSet(volume.modelSpace.bounds)
              },
            },
          ],
        };
        if (volume.rank === 3 && volume.dataType !== DataType.FLOAT32) {
          const subsourceToModelSubspaceTransform =
              new Float32Array(volume.subsourceToModelTransform);
          const {scales, rank} = volume.modelSpace;
          for (let i = 0; i < rank; ++i) {
            subsourceToModelSubspaceTransform[(rank + 2) * i] = 1 / scales[i];
          }
          dataSource.subsources.push({
            id: 'meshes',
            default: true,
            subsourceToModelSubspaceTransform,
            subsource: {
              mesh: options.chunkManager.getChunkSource(PythonMeshSource, {
                dataSource: dataSourceProvider,
                generation: volume.generation,
                parameters: {
                  key: key,
                }
              })
            },
          });
        }
        return dataSource;
      });
}

function getSkeletonDataSource(
    dataSourceProvider: PythonDataSource, options: GetDataSourceOptions, key: string) {
  return options.chunkManager.memoize.getUncounted(
      {'type': 'python:SkeletonDataSource', key}, async () => {
        const response = await (await fetchOk(`../../neuroglancer/skeletoninfo/${key}`)).json();
        const {baseModelSpace, subsourceToModelTransform} =
            parseCoordinateSpaceAndVoxelOffset(response);
        const vertexAttributes = verifyObjectProperty(
            response, 'attributes', x => verifyObjectAsMap(x, parseVertexAttributeInfo));
        const generation = verifyObjectProperty(response, 'generation', x => x);
        const skeletonSource = options.chunkManager.getChunkSource(PythonSkeletonSource, {
          dataSource: dataSourceProvider,
          generation,
          parameters: {
            key,
            vertexAttributes,
          }
        });
        const dataSource: DataSource = {
          modelTransform: makeIdentityTransform(baseModelSpace),
          subsources: [
            {
              id: 'default',
              subsourceToModelSubspaceTransform: subsourceToModelTransform,
              default: true,
              subsource: {
                mesh: skeletonSource,
              },
            },
          ],
        };
        return dataSource;
      });
}

export class PythonDataSource extends DataSourceProvider {
  private sources = new Map<string, Set<PythonChunkSource>>();
  sourceGenerations = new Map<string, number>();

  registerSource(key: string, source: PythonChunkSource) {
    let existingSet = this.sources.get(key);
    if (existingSet === undefined) {
      existingSet = new Set();
      this.sources.set(key, existingSet);
    }
    const generation = this.sourceGenerations.get(key);
    if (generation !== undefined) {
      source.generation = generation;
    }
    existingSet.add(source);
    source.registerDisposer(() => {
      existingSet!.delete(source);
      if (existingSet!.size === 0) {
        this.sources.delete(key);
      }
    });
  }

  setSourceGeneration(key: string, generation: number) {
    const {sourceGenerations} = this;
    if (sourceGenerations.get(key) === generation) {
      return;
    }
    sourceGenerations.set(key, generation);
    const sources = this.sources.get(key);
    if (sources !== undefined) {
      for (const source of sources) {
        if (source.generation !== generation) {
          source.generation = generation;
          source.invalidateCache();
        }
      }
    }
  }

  deleteSourceGeneration(key: string) {
    this.sourceGenerations.delete(key);
  }

  get description() {
    return 'Python-served volume';
  }
  get(options: GetDataSourceOptions): Promise<DataSource> {
    const m = options.providerUrl.match(`^(volume|skeleton)/(.*)$`);
    if (m === null) {
      throw new Error(`Invalid Python data source URL: ${JSON.stringify(options.providerUrl)}`);
    }
    const key = m[2];
    if (m[1] === 'volume') {
      return getVolumeDataSource(this, options, key);
    } else {
      return getSkeletonDataSource(this, options, key);
    }
  }
}

<MSG> feat(python): support invalidating (forcing client to refresh) a LocalVolume

Currently the invalidation applies to the entire volume; there is no way to invalidate just a
subregion.

<DFF> @@ -19,23 +19,59 @@
  * Support for Python integration.
  */
 
-import {ChunkManager, WithParameters} from 'neuroglancer/chunk_manager/frontend';
+import {ChunkManager, ChunkSource, ChunkSourceConstructor, WithParameters} from 'neuroglancer/chunk_manager/frontend';
 import {DataSource} from 'neuroglancer/datasource';
-import {MeshSourceParameters, SkeletonSourceParameters, VolumeChunkEncoding, VolumeChunkSourceParameters} from 'neuroglancer/datasource/python/base';
+import {MeshSourceParameters, PythonSourceParameters, SkeletonSourceParameters, VolumeChunkEncoding, VolumeChunkSourceParameters} from 'neuroglancer/datasource/python/base';
 import {MeshSource} from 'neuroglancer/mesh/frontend';
 import {VertexAttributeInfo} from 'neuroglancer/skeleton/base';
 import {SkeletonSource} from 'neuroglancer/skeleton/frontend';
 import {DataType, DEFAULT_MAX_VOXELS_PER_CHUNK_LOG2, getNearIsotropicBlockSize, getTwoDimensionalBlockSize} from 'neuroglancer/sliceview/base';
 import {VolumeChunkSpecification, VolumeSourceOptions, VolumeType} from 'neuroglancer/sliceview/volume/base';
-import {VolumeChunkSource, MultiscaleVolumeChunkSource as GenericMultiscaleVolumeChunkSource} from 'neuroglancer/sliceview/volume/frontend';
+import {MultiscaleVolumeChunkSource as GenericMultiscaleVolumeChunkSource, VolumeChunkSource} from 'neuroglancer/sliceview/volume/frontend';
+import {Borrowed, Owned} from 'neuroglancer/util/disposable';
 import {mat4, vec3} from 'neuroglancer/util/geom';
 import {openHttpRequest, sendHttpRequest} from 'neuroglancer/util/http_request';
 import {parseArray, parseFixedLengthArray, verify3dDimensions, verify3dScale, verify3dVec, verifyEnumString, verifyObject, verifyObjectAsMap, verifyObjectProperty, verifyPositiveInt, verifyString} from 'neuroglancer/util/json';
+import {getObjectId} from 'neuroglancer/util/object_id';
+
+interface PythonChunkSource extends ChunkSource {
+  dataSource: PythonDataSource;
+  generation: number;
+}
+
+function WithPythonDataSource<BaseOptions extends {parameters: PythonSourceParameters}, TBase extends ChunkSourceConstructor<BaseOptions>>(
+    Base: TBase) {
+  type Options = BaseOptions&{
+    dataSource: Borrowed<PythonDataSource>;
+    generation: number;
+  };
+  class C extends Base {
+    dataSource: Owned<PythonDataSource>;
+    generation: number;
+    parameters: PythonSourceParameters;
+    constructor(...args: any[]) {
+      super(...args);
+      const options: Options = args[1];
+      const dataSource = this.dataSource = this.registerDisposer(options.dataSource.addRef());
+      this.generation = options.generation;
+      const key = options.parameters.key;
+      dataSource.registerSource(key, this);
+    }
+    static encodeOptions(options: Options) {
+      const encoding = super.encodeOptions(options);
+      // `generation` is not encoded in cache key, since it is not fixed.
+      encoding['dataSource'] = getObjectId(options.dataSource);
+      return encoding;
+    }
+  }
+  return C;
+}
 
 class PythonVolumeChunkSource extends
-(WithParameters(VolumeChunkSource, VolumeChunkSourceParameters)) {}
+(WithPythonDataSource(WithParameters(VolumeChunkSource, VolumeChunkSourceParameters))) {
+}
 class PythonMeshSource extends
-(WithParameters(MeshSource, MeshSourceParameters)) {}
+(WithPythonDataSource(WithParameters(MeshSource, MeshSourceParameters))) {}
 
 interface ScaleInfo {
   key: string;
@@ -63,15 +99,18 @@ export class MultiscaleVolumeChunkSource implements GenericMultiscaleVolumeChunk
   volumeType: VolumeType;
   encoding: VolumeChunkEncoding;
   scales: ScaleInfo[][];
+  generation: number;
 
-  constructor(public chunkManager: ChunkManager, public key: string, public response: any) {
+  // TODO(jbms): Properly handle reference counting of `dataSource`.
+  constructor(public dataSource: Borrowed<PythonDataSource>, public chunkManager: ChunkManager, public key: string, public response: any) {
     verifyObject(response);
     this.dataType = verifyObjectProperty(response, 'dataType', x => verifyEnumString(x, DataType));
     this.volumeType =
         verifyObjectProperty(response, 'volumeType', x => verifyEnumString(x, VolumeType));
     this.numChannels = verifyObjectProperty(response, 'numChannels', verifyPositiveInt);
     this.encoding =
-        verifyObjectProperty(response, 'encoding', x => verifyEnumString(x, VolumeChunkEncoding));
+      verifyObjectProperty(response, 'encoding', x => verifyEnumString(x, VolumeChunkEncoding));
+    this.generation = verifyObjectProperty(response, 'generation', x => x);
     let maxVoxelsPerChunkLog2 = verifyObjectProperty(
         response, 'maxVoxelsPerChunkLog2',
         x => x === undefined ? DEFAULT_MAX_VOXELS_PER_CHUNK_LOG2 : verifyPositiveInt(x));
@@ -152,14 +191,19 @@ export class MultiscaleVolumeChunkSource implements GenericMultiscaleVolumeChunk
         chunkDataSize: scaleInfo.chunkDataSize!,
         volumeSourceOptions,
       });
-      return this.chunkManager.getChunkSource(
-          PythonVolumeChunkSource,
-          {spec, parameters: {key: `${this.key}/${scaleInfo.key}`, encoding: encoding}});
+      return this.chunkManager.getChunkSource(PythonVolumeChunkSource, {
+        spec,
+        dataSource: this.dataSource,
+        generation: this.generation,
+        parameters: {key: this.key, scaleKey: scaleInfo.key, encoding: encoding}
+      });
     }));
   }
 
   getMeshSource() {
     return this.chunkManager.getChunkSource(PythonMeshSource, {
+      dataSource: this.dataSource,
+      generation: this.generation,
       parameters: {
         key: this.key,
       }
@@ -168,7 +212,7 @@ export class MultiscaleVolumeChunkSource implements GenericMultiscaleVolumeChunk
 }
 
 export class PythonSkeletonSource extends
-(WithParameters(SkeletonSource, SkeletonSourceParameters)) {
+(WithPythonDataSource(WithParameters(SkeletonSource, SkeletonSourceParameters))) {
   get skeletonVertexCoordinatesInVoxels() {
     return false;
   }
@@ -189,38 +233,75 @@ function parseSkeletonVertexAttributes(spec: string): Map<string, VertexAttribut
   return verifyObjectAsMap(JSON.parse(spec), parseVertexAttributeInfo);
 }
 
-export function getSkeletonSource(chunkManager: ChunkManager, key: string) {
-  const skeletonKeyPattern = /^([^\/?]+)\?(.*)$/;
+export class PythonDataSource extends DataSource {
+  private sources = new Map<string, Set<PythonChunkSource>>();
+  sourceGenerations = new Map<string, number>();
 
-  let match = key.match(skeletonKeyPattern);
-  if (match === null) {
-    throw new Error(`Invalid python volume path: ${JSON.stringify(key)}`);
+  registerSource(key: string, source: PythonChunkSource) {
+    let existingSet = this.sources.get(key);
+    if (existingSet === undefined) {
+      existingSet = new Set();
+      this.sources.set(key, existingSet);
+    }
+    const generation = this.sourceGenerations.get(key);
+    if (generation !== undefined) {
+      source.generation = generation;
+    }
+    existingSet.add(source);
+    source.registerDisposer(() => {
+      existingSet!.delete(source);
+      if (existingSet!.size === 0) {
+        this.sources.delete(key);
+      }
+    });
   }
-  return chunkManager.getChunkSource(PythonSkeletonSource, {
-    parameters: {
-      key: match[1],
-      vertexAttributes: parseSkeletonVertexAttributes(match[2]),
+
+  setSourceGeneration(key: string, generation: number) {
+    const {sourceGenerations} = this;
+    if (sourceGenerations.get(key) === generation) {
+      return;
     }
-  });
-}
+    sourceGenerations.set(key, generation);
+    const sources = this.sources.get(key);
+    if (sources !== undefined) {
+      for (const source of sources) {
+        if (source.generation !== generation) {
+          source.generation = generation;
+          source.invalidateCache();
+        }
+      }
+    }
+  }
 
-export function getVolume(chunkManager: ChunkManager, key: string) {
-  return chunkManager.memoize.getUncounted(
-      {'type': 'python:MultiscaleVolumeChunkSource', key},
-      () => sendHttpRequest(openHttpRequest(`/neuroglancer/info/${key}`), 'json')
-                .then(
-                    response =>
-                        new MultiscaleVolumeChunkSource(chunkManager, key, response)));
-}
+  deleteSourceGeneration(key: string) {
+    this.sourceGenerations.delete(key);
+  }
 
-export class PythonDataSource extends DataSource {
   get description() {
     return 'Python-served volume';
   }
   getVolume(chunkManager: ChunkManager, key: string) {
-    return getVolume(chunkManager, key);
+    return chunkManager.memoize.getUncounted(
+        {'type': 'python:MultiscaleVolumeChunkSource', key},
+        () => sendHttpRequest(openHttpRequest(`/neuroglancer/info/${key}`), 'json')
+                  .then(
+                      response =>
+                          new MultiscaleVolumeChunkSource(this, chunkManager, key, response)));
   }
   getSkeletonSource(chunkManager: ChunkManager, key: string) {
-    return getSkeletonSource(chunkManager, key);
+    const skeletonKeyPattern = /^([^\/?]+)\?(.*)$/;
+
+    let match = key.match(skeletonKeyPattern);
+    if (match === null) {
+      throw new Error(`Invalid python volume path: ${JSON.stringify(key)}`);
+    }
+    return chunkManager.getChunkSource(PythonSkeletonSource, {
+      dataSource: this,
+      generation: -1,
+      parameters: {
+        key: match[1],
+        vertexAttributes: parseSkeletonVertexAttributes(match[2]),
+      }
+    });
   }
 }

feat(python): support invalidating (forcing client to refresh) a LocalVolume