From 14acfa0f29f18585a64a5b25a836e83395f86411 Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Thu, 27 Aug 2026 17:28:05 -1000 Subject: [PATCH 1/4] KeySignature.sharps is always an int; isNonTraditional is a settable attribute `sharps` was `int|None`, with `None` meaning "non-traditional key signature." That made every caller either handle a None it would never see or paper over it (`self.sharps or 0` in asKey), and it left `isNonTraditional` as a derived read-only property. Now `sharps` is always an int and `isNonTraditional` is a plain bool attribute that users set directly. The property was declared as `sharps = property(_getSharps, _setSharps, ...)`, which mypy cannot see through -- `ks.sharps` revealed as `Any`, so annotating the getter alone bought nothing. Converted to the decorator form, and `reveal_type(ks.sharps)` is now `int`. `KeySignature(sharps=None)` still works for this cycle: it warns Music21DeprecationWarning and sets sharps=0, isNonTraditional=True. mypy flags it at the call site, which is the intended pressure during the deprecation. Only MusicXML supports non-traditional key signatures; MEI, ABC, humdrum, capella, musedata, noteworthy and romanText all build from an int. Annotating nonTraditionalKeySignature surfaced three latent errors in its body, where Element.text is str|None: an unannotated accidentals list, float(c.text), and a list[Pitch] assigned through the invariant alteredPitches setter (that setter now takes an Iterable). Also drops the unreachable "sharps None" braille message and the None fixup in sharpsToPitch. Version bumped for the pickle cache: the cache is version-keyed, and a master checkout reading b8 pickles written by this code fails 123 tests with "property 'isNonTraditional' of 'Key' object has no setter". Separately, fixes music21/duration.py's TupletFixer.fixBrokenTupletDuration docstring, which set humdrum.spineParser.flavors['JRP'] = True and never restored it. `flavors` is a module-level dict, so the leak was global and permanent for the process: under pytest's alphabetical order duration.py runs before humdrum/tests.py, and testSingleNote then parsed `40..` in JRP flavor, putting the two dots on the note (dots == 2) instead of the tuplet's durationNormal (dots == 0). CI never saw it because testSingleCoreAll sorts modules by mtime, and on a fresh clone all mtimes tie and it falls back to reverse-alphabetical, running humdrum before duration -- but any PR touching duration.py would have pushed it to the front and tripped the failure. AI-assisted (Claude) --- music21/_version.py | 2 +- music21/base.py | 2 +- music21/braille/basic.py | 2 +- music21/duration.py | 2 + music21/key.py | 144 +++++++++++++++++------------------ music21/musicxml/m21ToXml.py | 5 +- music21/musicxml/xmlToM21.py | 17 +++-- 7 files changed, 87 insertions(+), 87 deletions(-) diff --git a/music21/_version.py b/music21/_version.py index 240ab9a1f..d411b2944 100644 --- a/music21/_version.py +++ b/music21/_version.py @@ -47,7 +47,7 @@ ''' from __future__ import annotations -__version__ = '11.0.0b8' +__version__ = '11.0.0b9' def get_version_tuple(vv): v = vv.split('.') diff --git a/music21/base.py b/music21/base.py index 17d9f0a97..c00c214eb 100644 --- a/music21/base.py +++ b/music21/base.py @@ -26,7 +26,7 @@ >>> music21.VERSION_STR -'11.0.0b8' +'11.0.0b9' Alternatively, after doing a complete import, these classes are available under the module "base": diff --git a/music21/braille/basic.py b/music21/braille/basic.py index 7eb02e4ef..22d600e97 100644 --- a/music21/braille/basic.py +++ b/music21/braille/basic.py @@ -453,7 +453,7 @@ def keySigToBraille(music21KeySignature, outgoingKeySig=None): f'Incoming Key Signature {music21KeySignature} cannot be transcribed to braille.' ) music21KeySignature.editorial.brailleEnglish.append( - f'Key Signature {music21KeySignature} sharps None' + f'Key Signature {music21KeySignature} cannot be transcribed' ) return symbols['basic_exception'] diff --git a/music21/duration.py b/music21/duration.py index 811087db9..38afca337 100644 --- a/music21/duration.py +++ b/music21/duration.py @@ -3452,6 +3452,7 @@ def fixBrokenTupletDuration(self, tupletGroup: list[note.GeneralNote]) -> None: There is a side format of humdrum that the Josquin Research Project uses for long notes like the 3%2. + >>> saved_JRP_flavor = humdrum.spineParser.flavors['JRP'] #_DOCS_HIDE >>> humdrum.spineParser.flavors['JRP'] = True Since Humdrum parsing is going to apply TupletFixer, we will temporarily @@ -3475,6 +3476,7 @@ def fixBrokenTupletDuration(self, tupletGroup: list[note.GeneralNote]) -> None: {10.6667 - 12.0} >>> duration.TupletFixer.fixBrokenTupletDuration = saved_fixed_broken + >>> humdrum.spineParser.flavors['JRP'] = saved_JRP_flavor #_DOCS_HIDE >>> tf = duration.TupletFixer(m1) >>> tupletGroups = tf.findTupletGroups(incorporateGroupings=True) >>> tupletGroups diff --git a/music21/key.py b/music21/key.py index 6085d9a4d..05a2d69a0 100644 --- a/music21/key.py +++ b/music21/key.py @@ -85,7 +85,7 @@ def convertKeyStringToMusic21KeyString(textString): return textString -def sharpsToPitch(sharpCount): +def sharpsToPitch(sharpCount: int) -> pitch.Pitch: ''' Given a positive/negative number of sharps, return a Pitch object set to the appropriate major key value. @@ -122,9 +122,6 @@ def sharpsToPitch(sharpCount): >>> key._sharpsToPitchCache[1] ''' - if sharpCount is None: - sharpCount = 0 # fix for C major - if sharpCount in _sharpsToPitchCache: # return a deepcopy of the pitch return copy.deepcopy(_sharpsToPitchCache[sharpCount]) @@ -302,28 +299,16 @@ class KeySignature(base.Music21Object): >>> legal - To set a non-traditional Key Signature, create a KeySignature object - with `sharps=None`, and then set the `alteredPitches` list: - - >>> unusual = key.KeySignature(sharps=None) - >>> unusual.alteredPitches = ['E-', 'G#'] - >>> unusual - - >>> unusual.isNonTraditional - True - - To set a pitch as displayed in a particular octave, create a non-traditional - KeySignature and then set pitches with octaves: + To set a non-traditional Key Signature, set `isNonTraditional` to True and + give the `alteredPitches` list. A pitch with an octave is displayed in that + octave; set `accidentalsApplyOnlyToOctave` to True if it should affect only + that octave: - >>> unusual = key.KeySignature(sharps=None) - >>> unusual.alteredPitches = ['F#4'] + >>> unusual = key.KeySignature() + >>> unusual.isNonTraditional = True + >>> unusual.alteredPitches = ['E-', 'G#4'] >>> unusual - - - If the accidental applies to all octaves but is being displayed differently - then you are done, but if you want them to apply only to the octave displayed - in then set `.accidentalsApplyOnlyToOctave` to `True`: - + >>> unusual.accidentalsApplyOnlyToOctave False >>> unusual.accidentalsApplyOnlyToOctave = True @@ -343,9 +328,11 @@ class KeySignature(base.Music21Object): * Changed in v7: `sharps` defaults to 0 (key of no flats/sharps) rather than `None` for nontraditional keys. + * Changed in v11: `sharps` is always an int; `isNonTraditional` is a + settable attribute. Passing `sharps=None` is deprecated. ''' _styleClass = style.TextStyle - equalityAttributes = ('sharps',) + equalityAttributes = ('sharps', 'isNonTraditional') # note that musicxml permits non-traditional keys by specifying # one or more altered tones; these are given as pairs of @@ -353,12 +340,20 @@ class KeySignature(base.Music21Object): classSortOrder = 2 - def __init__(self, sharps: int|None = 0, **keywords): + def __init__(self, sharps: int = 0, **keywords): super().__init__(**keywords) - # position on the circle of fifths, where 1 is one sharp, -1 is one flat + self.isNonTraditional: bool = False + + if sharps is None: + warnings.warn( + 'sharps=None is deprecated: set isNonTraditional to True instead.', + exceptions21.Music21DeprecationWarning, + stacklevel=2) + sharps = 0 + self.isNonTraditional = True try: - if sharps is not None and (sharps != int(sharps)): + if sharps != int(sharps): raise KeySignatureException( f'Cannot get a KeySignature from this "number" of sharps: {sharps!r}; ' + 'did you mean to use a key.Key() object instead?') @@ -368,6 +363,7 @@ def __init__(self, sharps: int|None = 0, **keywords): + 'did you mean to use a key.Key() object instead?' ) from ve + # position on the circle of fifths, where 1 is one sharp, -1 is one flat self._sharps = sharps # need to store a list of pitch objects, used for creating a # non-traditional key @@ -379,7 +375,7 @@ def __init__(self, sharps: int|None = 0, **keywords): def _strDescription(self): output = '' ns = self.sharps - if ns is None: + if self.isNonTraditional: output = 'pitches: [' + ', '.join([str(p) for p in self.alteredPitches]) + ']' elif ns > 1: output = f'{ns} sharps' @@ -396,7 +392,7 @@ def _strDescription(self): def _reprInternal(self): return 'of ' + self._strDescription() - def asKey(self, mode: str|None = None, tonic: str|None = None): + def asKey(self, mode: str|None = None, tonic: str|None = None) -> Key: ''' Return a `key.Key` object representing this KeySignature object as a key in the given mode or in the given tonic. If `mode` is None, and `tonic` is not provided, @@ -429,7 +425,6 @@ def asKey(self, mode: str|None = None, tonic: str|None = None): * New in v7: `tonic` argument to solve for mode. ''' - our_sharps = self.sharps or 0 # || 0 in case of None -- non-standard key-signature if mode is not None and tonic is not None: warnings.warn(f'ignoring provided tonic: {tonic}', KeyWarning, stacklevel=2) if mode is None and tonic is None: @@ -438,7 +433,7 @@ def asKey(self, mode: str|None = None, tonic: str|None = None): majorSharpsToMode = {v: k for k, v in modeSharpsAlter.items()} majorSharps = pitchToSharps(tonic) try: - mode = majorSharpsToMode[our_sharps - majorSharps] + mode = majorSharpsToMode[self.sharps - majorSharps] except KeyError as ke: raise KeyException( f'Could not solve for mode from sharps={self.sharps}, tonic={tonic}') from ke @@ -448,7 +443,7 @@ def asKey(self, mode: str|None = None, tonic: str|None = None): raise KeyException(f'Mode {mode} is unknown') sharpAlterationFromMajor = modeSharpsAlter[mode] - pitchObj = sharpsToPitch(our_sharps - sharpAlterationFromMajor) + pitchObj = sharpsToPitch(self.sharps - sharpAlterationFromMajor) return Key(pitchObj.name, mode) @@ -491,7 +486,8 @@ def alteredPitches(self) -> list[pitch.Pitch]: Non-standard, non-traditional key signatures can set their own altered pitches cache. - >>> nonTrad = key.KeySignature(sharps=None) + >>> nonTrad = key.KeySignature() + >>> nonTrad.isNonTraditional = True >>> nonTrad.alteredPitches = ['B-', 'F#', 'E-', 'G#'] >>> nonTrad.alteredPitches [, @@ -503,7 +499,8 @@ def alteredPitches(self) -> list[pitch.Pitch]: Ensure at least something is provided when the user hasn't provided enough info: - >>> nonTrad2 = key.KeySignature(sharps=None) + >>> nonTrad2 = key.KeySignature() + >>> nonTrad2.isNonTraditional = True >>> nonTrad2.alteredPitches [] @@ -512,7 +509,7 @@ def alteredPitches(self) -> list[pitch.Pitch]: return self._alteredPitches post: list[pitch.Pitch] = [] - if self.sharps is None: + if self.isNonTraditional: return post if self.sharps > 0: @@ -536,7 +533,7 @@ def alteredPitches(self) -> list[pitch.Pitch]: return post @alteredPitches.setter - def alteredPitches(self, newAlteredPitches: list[str|pitch.Pitch|note.Note] + def alteredPitches(self, newAlteredPitches: t.Iterable[str|pitch.Pitch|note.Note] ) -> None: self.clearCache() newList: list[pitch.Pitch] = [] @@ -549,31 +546,6 @@ def alteredPitches(self, newAlteredPitches: list[str|pitch.Pitch|note.Note] newList.append(copy.deepcopy(p.pitch)) self._alteredPitches = newList - @property - def isNonTraditional(self) -> bool: - ''' - Returns bool if this is a non-traditional KeySignature: - - >>> g = key.KeySignature(3) - >>> g.isNonTraditional - False - - >>> g = key.KeySignature(sharps=None) - >>> g.alteredPitches = [pitch.Pitch('E`')] - >>> g.isNonTraditional - True - - >>> g - - - >>> g.accidentalByStep('E') - - ''' - if self.sharps is None and self.alteredPitches: - return True - else: - return False - def accidentalByStep(self, step: StepName) -> pitch.Accidental|None: ''' Given a step (C, D, E, F, etc.) return the accidental @@ -844,16 +816,9 @@ def getScale(self, mode='major'): # -------------------------------------------------------------------------- # properties - def _getSharps(self) -> int|None: - return self._sharps - - def _setSharps(self, value: int|None): - if value != self._sharps: - self._sharps = value - self.clearCache() - - sharps = property(_getSharps, _setSharps, - doc=''' + @property + def sharps(self) -> int: + ''' Get or set the number of sharps. If the number is negative then it sets the number of flats. Equivalent to musicxml's 'fifths' attribute. @@ -864,9 +829,14 @@ def _setSharps(self, value: int|None): >>> ks1.sharps = -4 >>> ks1 + ''' + return self._sharps - Can be set to None for a non-traditional key signature - ''') + @sharps.setter + def sharps(self, value: int) -> None: + if value != self._sharps: + self._sharps = value + self.clearCache() class Key(KeySignature, scale.DiatonicScale): @@ -1416,6 +1386,32 @@ def testAsKey(self): # test exception chained from KeyError self.assertIsInstance(cm.exception.__cause__, KeyError) + def testNonTraditional(self): + ''' + AI-assisted (Claude). + ''' + ks = KeySignature(3) + self.assertFalse(ks.isNonTraditional) + + ks = KeySignature() + ks.isNonTraditional = True + ks.alteredPitches = [pitch.Pitch('E`')] + self.assertEqual(repr(ks), '') + self.assertEqual(ks.accidentalByStep('E'), pitch.Accidental('half-flat')) + + # a non-traditional key signature is not equal to the C-major signature + # it shares a `sharps` count with. + self.assertNotEqual(ks, KeySignature()) + + def testSharpsNoneDeprecated(self): + ''' + AI-assisted (Claude). + ''' + with self.assertWarns(exceptions21.Music21DeprecationWarning): + ks = KeySignature(sharps=None) + self.assertEqual(ks.sharps, 0) + self.assertTrue(ks.isNonTraditional) + # ------------------------------------------------------------------------------ # define presented order in documentation diff --git a/music21/musicxml/m21ToXml.py b/music21/musicxml/m21ToXml.py index 034c21557..d859d1ff6 100644 --- a/music21/musicxml/m21ToXml.py +++ b/music21/musicxml/m21ToXml.py @@ -7340,7 +7340,7 @@ def timeSignatureToXml(self, ts: meter.TimeSignature|meter.SenzaMisuraTimeSignat self.setPrintObject(mxTime, ts) return mxTime - def keySignatureToXml(self, keyOrKeySignature): + def keySignatureToXml(self, keyOrKeySignature: key.KeySignature) -> Element: # noinspection PyShadowingNames ''' returns a key tag from a music21 @@ -7365,7 +7365,8 @@ def keySignatureToXml(self, keyOrKeySignature): major - >>> ksNonTrad = key.KeySignature(sharps=None) + >>> ksNonTrad = key.KeySignature() + >>> ksNonTrad.isNonTraditional = True >>> ksNonTrad.alteredPitches = ['C#', 'E-4'] >>> ksNonTrad diff --git a/music21/musicxml/xmlToM21.py b/music21/musicxml/xmlToM21.py index fdc7b0a22..8c0970be6 100644 --- a/music21/musicxml/xmlToM21.py +++ b/music21/musicxml/xmlToM21.py @@ -6250,7 +6250,7 @@ def mxKeyOctaves(self, mxKey, ks): ks.alteredPitches = alteredPitches - def nonTraditionalKeySignature(self, mxKey): + def nonTraditionalKeySignature(self, mxKey: ET.Element) -> key.KeySignature: # noinspection PyShadowingNames ''' Returns a KeySignature object that represents a nonTraditional Key Signature @@ -6279,17 +6279,17 @@ def nonTraditionalKeySignature(self, mxKey): children = list(mxKey) lastTag = None - steps = [] - alters = [] - accidentals = [] + steps: list[str] = [] + alters: list[float] = [] + accidentals: list[str|None] = [] for c in children: tag = c.tag if lastTag == 'key-alter' and tag == 'key-step': accidentals.append(None) - if tag == 'key-step': + if tag == 'key-step' and c.text: steps.append(c.text) - elif tag == 'key-alter': + elif tag == 'key-alter' and c.text: alters.append(float(c.text)) elif tag == 'key-accidental': accidentals.append(c.text) @@ -6301,9 +6301,10 @@ def nonTraditionalKeySignature(self, mxKey): raise MusicXMLImportException( 'For non traditional signatures each step must have an alter') - ks = key.KeySignature(sharps=None) + ks = key.KeySignature() + ks.isNonTraditional = True - alteredPitches = [] + alteredPitches: list[pitch.Pitch] = [] for step, alter, accidental in zip(steps, alters, accidentals): p = pitch.Pitch(step) if accidental is not None: From 15c08545d98ead8621c75b589109112bad0d7ec1 Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Thu, 27 Aug 2026 17:43:50 -1000 Subject: [PATCH 2/4] Don't assert a fully-qualified repr in testNonTraditional The CI runner (testSingleCoreAll) imports every module through ModuleGather under its short name, so the class repr is `` there and `` under pytest. Assert only the description. AI-assisted (Claude) --- music21/key.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/music21/key.py b/music21/key.py index 05a2d69a0..f50006099 100644 --- a/music21/key.py +++ b/music21/key.py @@ -1396,7 +1396,7 @@ def testNonTraditional(self): ks = KeySignature() ks.isNonTraditional = True ks.alteredPitches = [pitch.Pitch('E`')] - self.assertEqual(repr(ks), '') + self.assertIn('of pitches: [E`]', repr(ks)) self.assertEqual(ks.accidentalByStep('E'), pitch.Accidental('half-flat')) # a non-traditional key signature is not equal to the C-major signature From 008dd6bf7b9ca2e5c3db3a8df69ccc940e9b4bd1 Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Thu, 27 Aug 2026 17:48:13 -1000 Subject: [PATCH 3/4] Test runner: import modules by package name, not as top-level copies ModuleGather.getModule loaded each file with load_source() under its bare name ('key' for music21/key.py), which re-executed the file as a separate top-level module. Every unittest the single-core runner ran therefore tested a shadow copy of its module: `mod.KeySignature is not music21.key.KeySignature`, and `isinstance(ks, music21.key.KeySignature)` was False. It also gave those classes a `__module__` of 'key', so `repr()` came out as `` and any test asserting a full repr failed under CI while passing under pytest. Import by fully-qualified name instead, which returns the module music21 already imported. load_source() is now unused and removed. multiprocessTest was unaffected -- it uses getModuleWithoutImp, which walks the real package tree. Restores the exact repr assertion in key.Test.testNonTraditional. AI-assisted (Claude) --- music21/key.py | 2 +- music21/test/commonTest.py | 28 ++-------------------------- 2 files changed, 3 insertions(+), 27 deletions(-) diff --git a/music21/key.py b/music21/key.py index f50006099..05a2d69a0 100644 --- a/music21/key.py +++ b/music21/key.py @@ -1396,7 +1396,7 @@ def testNonTraditional(self): ks = KeySignature() ks.isNonTraditional = True ks.alteredPitches = [pitch.Pitch('E`')] - self.assertIn('of pitches: [E`]', repr(ks)) + self.assertEqual(repr(ks), '') self.assertEqual(ks.accidentalByStep('E'), pitch.Accidental('half-flat')) # a non-traditional key signature is not equal to the C-major signature diff --git a/music21/test/commonTest.py b/music21/test/commonTest.py index d473a02ab..64e06d18c 100644 --- a/music21/test/commonTest.py +++ b/music21/test/commonTest.py @@ -16,9 +16,7 @@ import copy import doctest import importlib -import importlib.util import os -import sys import typing import types import unittest.runner @@ -61,28 +59,6 @@ def testCopyAll(testInstance: unittest.TestCase, globals_: typing.Dict[str, typi testInstance.fail(f'Could not deepcopy obj {part}: {e}') -def load_source(name: str, path: str) -> types.ModuleType: - ''' - Replacement for deprecated imp.load_source() - - Thanks to: - https://github.com/epfl-scitas/spack for pointing out the - important missing "spec.loader.exec_module(module)" line. - ''' - spec = importlib.util.spec_from_file_location(name, path) - if spec is None or spec.loader is None: - raise FileNotFoundError(f'No such file or directory: {path!r}') - if name in sys.modules: - module = sys.modules[name] - else: - module = importlib.util.module_from_spec(spec) - if module is None: - raise FileNotFoundError(f'No such file or directory: {path!r}') - sys.modules[name] = module - spec.loader.exec_module(module) - - return module - # noinspection PyPackageRequirements def testImports(): ''' @@ -417,11 +393,11 @@ def getModule(self, fp, restoreEnvironmentDefaults=False): if skip: return None - name = self._getNamePeriod(fp, addM21=False) + name = self._getNamePeriod(fp, addM21=True) try: with warnings.catch_warnings(): - mod = load_source(name, fp) + mod = importlib.import_module(name) except Exception as excp: # pylint: disable=broad-exception-caught environLocal.warn(['failed import:', name, '\t', fp, '\n', '\tEXCEPTION:', str(excp).strip()]) From 934cc1a35d06cff6611cce6687d6ba4c237e94bd Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Thu, 27 Aug 2026 17:55:12 -1000 Subject: [PATCH 4/4] Revert the test-runner change out of this PR; import key inside the test The runner fix does not belong in a KeySignature PR. Reverts commonTest.py to master and instead follows the convention used elsewhere: import the module inside the test method, so the test uses music21.key.KeySignature and its repr is fully qualified under both runners. AI-assisted (Claude) --- music21/key.py | 8 +++++--- music21/test/commonTest.py | 28 ++++++++++++++++++++++++++-- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/music21/key.py b/music21/key.py index 05a2d69a0..b23054f51 100644 --- a/music21/key.py +++ b/music21/key.py @@ -1390,10 +1390,12 @@ def testNonTraditional(self): ''' AI-assisted (Claude). ''' - ks = KeySignature(3) + from music21 import key + + ks = key.KeySignature(3) self.assertFalse(ks.isNonTraditional) - ks = KeySignature() + ks = key.KeySignature() ks.isNonTraditional = True ks.alteredPitches = [pitch.Pitch('E`')] self.assertEqual(repr(ks), '') @@ -1401,7 +1403,7 @@ def testNonTraditional(self): # a non-traditional key signature is not equal to the C-major signature # it shares a `sharps` count with. - self.assertNotEqual(ks, KeySignature()) + self.assertNotEqual(ks, key.KeySignature()) def testSharpsNoneDeprecated(self): ''' diff --git a/music21/test/commonTest.py b/music21/test/commonTest.py index 64e06d18c..d473a02ab 100644 --- a/music21/test/commonTest.py +++ b/music21/test/commonTest.py @@ -16,7 +16,9 @@ import copy import doctest import importlib +import importlib.util import os +import sys import typing import types import unittest.runner @@ -59,6 +61,28 @@ def testCopyAll(testInstance: unittest.TestCase, globals_: typing.Dict[str, typi testInstance.fail(f'Could not deepcopy obj {part}: {e}') +def load_source(name: str, path: str) -> types.ModuleType: + ''' + Replacement for deprecated imp.load_source() + + Thanks to: + https://github.com/epfl-scitas/spack for pointing out the + important missing "spec.loader.exec_module(module)" line. + ''' + spec = importlib.util.spec_from_file_location(name, path) + if spec is None or spec.loader is None: + raise FileNotFoundError(f'No such file or directory: {path!r}') + if name in sys.modules: + module = sys.modules[name] + else: + module = importlib.util.module_from_spec(spec) + if module is None: + raise FileNotFoundError(f'No such file or directory: {path!r}') + sys.modules[name] = module + spec.loader.exec_module(module) + + return module + # noinspection PyPackageRequirements def testImports(): ''' @@ -393,11 +417,11 @@ def getModule(self, fp, restoreEnvironmentDefaults=False): if skip: return None - name = self._getNamePeriod(fp, addM21=True) + name = self._getNamePeriod(fp, addM21=False) try: with warnings.catch_warnings(): - mod = importlib.import_module(name) + mod = load_source(name, fp) except Exception as excp: # pylint: disable=broad-exception-caught environLocal.warn(['failed import:', name, '\t', fp, '\n', '\tEXCEPTION:', str(excp).strip()])