Skip to content

Commit e9f57c9

Browse files
authored
Merge aecc3d2 into 5a93f09
2 parents 5a93f09 + aecc3d2 commit e9f57c9

4 files changed

Lines changed: 93 additions & 20 deletions

File tree

source/_addonStore/models/status.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,8 @@ class AvailableAddonStatus(DisplayStringEnum):
7272
INCOMPATIBLE_DISABLED = enum.auto() # disabled due to being incompatible
7373
PENDING_DISABLE = enum.auto() # disabled after restart
7474
DISABLED = enum.auto()
75-
PENDING_INCOMPATIBLE_ENABLED = enum.auto() # overriden incompatible, enabled after restart
76-
INCOMPATIBLE_ENABLED = enum.auto() # enabled, overriden incompatible
75+
PENDING_INCOMPATIBLE_ENABLED = enum.auto() # overridden incompatible, enabled after restart
76+
INCOMPATIBLE_ENABLED = enum.auto() # enabled, overridden incompatible
7777
PENDING_ENABLE = enum.auto() # enabled after restart
7878
ENABLED = enum.auto() # enabled but not running (e.g. all add-ons are disabled).
7979
RUNNING = enum.auto() # enabled and active.

source/_addonStore/models/version.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,10 @@ def enableCompatibilityOverride(self):
6161
and when this add-on is updated, disabled or removed.
6262
"""
6363
from addonHandler import AddonStateCategory, state
64-
overiddenAddons = state[AddonStateCategory.OVERRIDE_COMPATIBILITY]
65-
assert self.name not in overiddenAddons, f"{self.name}, {overiddenAddons}"
64+
overriddenAddons = state[AddonStateCategory.OVERRIDE_COMPATIBILITY]
65+
assert self.name not in overriddenAddons, f"{self.name}, {overriddenAddons}"
6666
assert self.canOverrideCompatibility
67-
overiddenAddons.add(self.name)
67+
overriddenAddons.add(self.name)
6868
state[AddonStateCategory.BLOCKED].discard(self.name)
6969
state[AddonStateCategory.DISABLED].discard(self.name)
7070

source/addonHandler/__init__.py

Lines changed: 68 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
Set,
2929
TYPE_CHECKING,
3030
Tuple,
31+
Union,
3132
)
3233
import zipfile
3334
from configobj import ConfigObj
@@ -43,7 +44,7 @@
4344
from types import ModuleType
4445

4546
from _addonStore.models.status import AddonStateCategory, SupportsAddonState
46-
from _addonStore.models.version import SupportsVersionCheck
47+
from _addonStore.models.version import MajorMinorPatch, SupportsVersionCheck
4748
import extensionPoints
4849
from utils.caseInsensitiveCollections import CaseInsensitiveSet
4950

@@ -96,6 +97,7 @@ def _generateDefaultStateContent() -> Dict[AddonStateCategory, CaseInsensitiveSe
9697
}
9798

9899
data: Dict[AddonStateCategory, CaseInsensitiveSet[str]]
100+
manualOverridesAPIVersion: MajorMinorPatch
99101

100102
@property
101103
def statePath(self) -> os.PathLike:
@@ -109,11 +111,7 @@ def load(self) -> None:
109111
try:
110112
# #9038: Python 3 requires binary format when working with pickles.
111113
with open(self.statePath, "rb") as f:
112-
pickledState: Dict[str, Set[str]] = pickle.load(f)
113-
for category in pickledState:
114-
# Make pickles case insensitive
115-
state[AddonStateCategory(category)] = CaseInsensitiveSet(pickledState[category])
116-
self.update(state)
114+
pickledState: Dict[str, Union[Set[str], addonAPIVersion.AddonApiVersionT]] = pickle.load(f)
117115
except FileNotFoundError:
118116
pass # Clean config - no point logging in this case
119117
except IOError:
@@ -122,8 +120,36 @@ def load(self) -> None:
122120
log.debugWarning("Failed to unpickle state", exc_info=True)
123121
except Exception:
124122
log.exception()
123+
else:
124+
if "backCompatToAPIVersion" not in pickledState:
125+
# The ability to override add-ons only appeared in 2023.2,
126+
# where the BACK_COMPAT_TO API version was 2023.1.0.
127+
self.manualOverridesAPIVersion = MajorMinorPatch(2023, 1, 0)
128+
else:
129+
self.manualOverridesAPIVersion = MajorMinorPatch(*pickledState["backCompatToAPIVersion"])
130+
for category in AddonStateCategory:
131+
# Make pickles case insensitive
132+
state[AddonStateCategory(category)] = CaseInsensitiveSet(pickledState.get(category, set()))
133+
if self.manualOverridesAPIVersion != addonAPIVersion.BACK_COMPAT_TO:
134+
log.debug(
135+
"BACK_COMPAT_TO API version for manual compatibility overrides has changed. "
136+
f"NVDA API has been upgraded: from {self.manualOverridesAPIVersion} to {addonAPIVersion.BACK_COMPAT_TO}"
137+
)
138+
if self.manualOverridesAPIVersion < addonAPIVersion.BACK_COMPAT_TO:
139+
# Reset compatibility overrides as the API version has upgraded.
140+
# For the installer, this is not written to disk.
141+
# Portable/temporary copies will write this on the first run.
142+
# Mark overridden compatible add-ons as blocked.
143+
state[AddonStateCategory.BLOCKED].update(state[AddonStateCategory.OVERRIDE_COMPATIBILITY])
144+
# Reset overridden compatibility for add-ons that were overridden by older versions of NVDA.
145+
state[AddonStateCategory.OVERRIDE_COMPATIBILITY].clear()
146+
self.manualOverridesAPIVersion = MajorMinorPatch(*addonAPIVersion.BACK_COMPAT_TO)
147+
self.update(state)
125148

126149
def removeStateFile(self) -> None:
150+
if not NVDAState.shouldWriteToDisk():
151+
log.debugWarning("NVDA should not write to disk from secure mode or launcher", stack_info=True)
152+
return
127153
try:
128154
os.remove(self.statePath)
129155
except FileNotFoundError:
@@ -138,16 +164,17 @@ def save(self) -> None:
138164
return
139165

140166
if any(self.values()):
167+
# We cannot pickle instance of `AddonsState` directly
168+
# since older versions of NVDA aren't aware about this class and they're expecting
169+
# the state to be using inbuilt data types only.
170+
picklableState: Dict[str, Union[Set[str], addonAPIVersion.AddonApiVersionT]] = dict()
171+
for category in self.data:
172+
picklableState[category.value] = set(self.data[category])
173+
picklableState["backCompatToAPIVersion"] = self.manualOverridesAPIVersion
141174
try:
142175
# #9038: Python 3 requires binary format when working with pickles.
143176
with open(self.statePath, "wb") as f:
144-
# We cannot pickle instance of `AddonsState` directly
145-
# since older versions of NVDA aren't aware about this class and they're expecting
146-
# the state to be using inbuilt data types only.
147-
pickleableState: Dict[str, Set[str]] = dict()
148-
for category in self.data:
149-
pickleableState[category.value] = set(self.data[category])
150-
pickle.dump(pickleableState, f, protocol=0)
177+
pickle.dump(picklableState, f, protocol=0)
151178
except (IOError, pickle.PicklingError):
152179
log.debugWarning("Error saving state", exc_info=True)
153180
else:
@@ -166,6 +193,23 @@ def cleanupRemovedDisabledAddons(self) -> None:
166193
log.debug(f"Discarding {disabledAddonName} from disabled add-ons as it has been uninstalled.")
167194
self[AddonStateCategory.DISABLED].discard(disabledAddonName)
168195

196+
def cleanupCompatibleAddonsFromDowngrade(self) -> None:
197+
installedAddons = {a.name: a for a in getAvailableAddons()}
198+
for blockedAddon in CaseInsensitiveSet(
199+
self[AddonStateCategory.BLOCKED].union(
200+
self[AddonStateCategory.OVERRIDE_COMPATIBILITY]
201+
)
202+
):
203+
# Iterate over copy of set to prevent updating the set while iterating over it.
204+
if blockedAddon not in installedAddons:
205+
log.debug(f"Discarding {blockedAddon} from blocked add-ons as it has been uninstalled.")
206+
self[AddonStateCategory.BLOCKED].discard(blockedAddon)
207+
self[AddonStateCategory.OVERRIDE_COMPATIBILITY].discard(blockedAddon)
208+
elif installedAddons[blockedAddon].isCompatible:
209+
log.debug(f"Discarding {blockedAddon} from blocked add-ons as it has become compatible.")
210+
self[AddonStateCategory.BLOCKED].discard(blockedAddon)
211+
self[AddonStateCategory.OVERRIDE_COMPATIBILITY].discard(blockedAddon)
212+
169213

170214
state: AddonsState[AddonStateCategory, CaseInsensitiveSet[str]] = AddonsState()
171215

@@ -188,8 +232,16 @@ def getIncompatibleAddons(
188232
addon,
189233
currentAPIVersion=currentAPIVersion,
190234
backwardsCompatToVersion=backCompatToAPIVersion
235+
)
236+
and (
237+
# Add-ons that override incompatibility are not considered incompatible.
238+
not addon.overrideIncompatibility
239+
# If we are upgrading NVDA API versions,
240+
# then the add-on compatibility override will be reset
241+
or backCompatToAPIVersion > addonAPIVersion.BACK_COMPAT_TO
242+
)
191243
)
192-
))
244+
)
193245

194246

195247
def removeFailedDeletion(path: os.PathLike):
@@ -206,7 +258,7 @@ def disableAddonsIfAny():
206258
# Pull in and enable add-ons that should be disabled and enabled, respectively.
207259
state[AddonStateCategory.DISABLED] |= state[AddonStateCategory.PENDING_DISABLE]
208260
state[AddonStateCategory.DISABLED] -= state[AddonStateCategory.PENDING_ENABLE]
209-
# Remove disabled add-ons from having overriden compatibility
261+
# Remove disabled add-ons from having overridden compatibility
210262
state[AddonStateCategory.OVERRIDE_COMPATIBILITY] -= state[AddonStateCategory.DISABLED]
211263
# Clear pending disables and enables
212264
state[AddonStateCategory.PENDING_DISABLE].clear()
@@ -224,6 +276,7 @@ def initialize():
224276
getAvailableAddons(refresh=True, isFirstLoad=True)
225277
if NVDAState.shouldWriteToDisk():
226278
state.cleanupRemovedDisabledAddons()
279+
state.cleanupCompatibleAddonsFromDowngrade()
227280
state.save()
228281
initializeModulePackagePaths()
229282

tests/manual/addonStore.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,3 +203,23 @@ For "Action" button and "Other details" text field controls:
203203
1. Tab to another control and check that `alt+<letter>` allows to move the focus back to the control.
204204
1. From another control tab to the control and check that `alt+<letter>` is reported.
205205
1. In the control, check that `shift+numpad2` reports the shortcut key.
206+
207+
## Updating NVDA
208+
209+
### Updating NVDA with incompatible add-ons
210+
211+
There are several scenarios which need to be tested for updating NVDA with incompatible add-ons.
212+
This is an advanced test scenario which requires 3 versions of NVDA to test with.
213+
Typically, this requires a contributor creating 3 different versions of the same patch of NVDA, with different versions of `addonAPIVersion.CURRENT` and `addonAPIVersion.BACK_COMPAT_TO`
214+
- X.1 e.g `CURRENT=2023.1`, `BACK_COMPAT_TO=2023.1`
215+
- X.2 e.g `CURRENT=2023.2`, `BACK_COMPAT_TO=2023.1`
216+
- (X+1).1 e.g `CURRENT=2024.1`, `BACK_COMPAT_TO=2024.1`
217+
218+
219+
| Test Name | Upgrade from | Upgrade to | Test notes |
220+
|---|---|---|---|
221+
| Upgrade to different NVDA version in the same API breaking release cycle | X.1 | X.1 | Add-ons which remain incompatible are listed as incompatible on upgrading. Preserves state of enabled incompatible add-ons |
222+
| Upgrade to a different but compatible API version | X.1 | X.2 | Add-ons which remain incompatible are listed as incompatible on upgrading. Preserves state of enabled incompatible add-ons |
223+
| Downgrade to a different but compatible API version | X.2 | X.1 | Add-ons which remain incompatible are listed as incompatible on upgrading. Preserves state of enabled incompatible add-ons |
224+
| Upgrade to an API breaking version | X.1 | (X+1).1 | All incompatible add-ons are listed as incompatible on upgrading, overridden compatibility is reset. |
225+
| Downgrade to an API breaking version | (X+1).1 | X.1 | Add-ons which remain incompatible listed as incompatible on upgrading. Preserves state of enabled incompatible add-ons. Add-ons which are now compatible are re-enabled. |

0 commit comments

Comments
 (0)