Repository: biolab/orange3

Issue:
Table: copy does not guarantee unlock to suceed
<!-- 
Thanks for taking the time to report a bug!
If you're raising an issue about an add-on (i.e., installed via Options > Add-ons), raise an issue in the relevant add-on's issue tracker instead. See: https://github.com/biolab?q=orange3
To fix the bug, we need to be able to reproduce it. Please answer the following questions to the best of your ability.
-->

**What's wrong?**
<!-- Be specific, clear, and concise. Include screenshots if relevant. -->
<!-- If you're getting an error message, copy it, and enclose it with three backticks (```). -->
Unlocking sparse data does not work.


**How can we reproduce the problem?** 
<!-- Upload a zip with the .ows file and data. -->
<!-- Describe the steps (open this widget, click there, then add this...) -->

Any of the following results in an error (ValueError: 'metas'/'X' is a view into another table and cannot be unlocked)
```
from Orange.data import Table

Table.LOCKING = True
data = Table("zoo")
data = data.to_sparse()
with data.unlocked():
    pass
```

```
from Orange.data import Table

Table.LOCKING = True
data = Table("iris")
data = data.to_sparse()
with data.unlocked():
    pass
data = data.copy()
with data.unlocked():
    pass
```

**What's your environment?**
<!-- To find your Orange version, see "Help → About → Version" or `Orange.version.full_version` in code -->
- Operating system: macOS Catalina
- Orange version: master
- How you installed Orange: pip



Discussion:
I see that to_sparse goes through `transform()` which does not have any unlocking guarantees.

As for copy, that one should always produce unlockable domain, but does not in some cases. This is a bug:

```
from Orange.data import Table
Table.LOCKING = True

data = Table("iris")
data = data.to_sparse()
with data.unlocked():  # OK
    pass
data = data.copy()
with data.unlocked():  # FAILS
    pass
```

Resolving patch:
diff --git a/Orange/data/table.py b/Orange/data/table.py
--- a/Orange/data/table.py
+++ b/Orange/data/table.py
@@ -1465,9 +1465,10 @@ def ensure_copy(self):
         """
 
         def is_view(x):
-            # Sparse matrices don't have views like numpy arrays. Since indexing on
-            # them creates copies in constructor we can skip this check here.
-            return not sp.issparse(x) and x.base is not None
+            if not sp.issparse(x):
+                return x.base is not None
+            else:
+                return x.data.base is not None
 
         if is_view(self._X):
             self._X = self._X.copy()


Test patch:
diff --git a/Orange/tests/test_table.py b/Orange/tests/test_table.py
--- a/Orange/tests/test_table.py
+++ b/Orange/tests/test_table.py
@@ -495,9 +495,13 @@ def test_copy_sparse(self):
         self.assertNotEqual(id(t.metas), id(copy.metas))
 
         # ensure that copied sparse arrays do not share data
+        # and that both are unlockable
         with t.unlocked():
             t.X[0, 0] = 42
         self.assertEqual(copy.X[0, 0], 5.1)
+        with copy.unlocked():
+            copy.X[0, 0] = 43
+        self.assertEqual(t.X[0, 0], 42)
 
     def test_concatenate(self):
         d1 = data.Domain(