Repository: frictionlessdata/frictionless-py

Issue:
Core functionality missing
# Overview

Hi there! 👋🏻 

While using frictionless we found out, we are missing few core functionalities:
* `field-merge`
* `field-pack`
* `field-rename`
Especially first is cumbersome, as we wanted to create `geometry` column by merging teo columns. Workaround is to `field-add` and use formula to do so, however this is not working in declarative way (that's is our desired way how to have it in our infrastructure).
* `Package`  functionality (write package = write all resources that it has, do something to all Resources, ....)

And one question - when we can expect Storage API?
> The Storage concpet is responsible for reading and writing data package from dataset source like CKAN, SQL, or others. Currently, the Storage API is not yet finished so you can try reading the codebase and implement your own storage but you need to be ready for some changes to the API that might come.

---

Please preserve this line to notify @roll (lead of this repository)


Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d5f30769..5e3b367c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,22 @@
 
 Here described only the breaking and most significant changes. The full changelog and documentation for all released versions could be found in nicely formatted [commit history](https://github.com/frictionlessdata/frictionless-py/commits/main).
 
+## v4.34.0
+
+- Implemented package actions:
+  - `Package.describe` (will replace `package_describe` in v5)
+  - `package.extract` (will replace `package_extract` in v5)
+  - `package.validate` (will replace `package_validate` in v5)
+  - `package.transform` (will replace `package_transform` in v5)
+
+## v4.33.0
+
+- Implemented resource actions:
+  - `Resource.describe` (will replace `resource_describe` in v5)
+  - `resource.extract` (will replace `resource_extract` in v5)
+  - `resource.validate` (will replace `resource_validate` in v5)
+  - `resource.transform` (will replace `resource_transform` in v5)
+
 ## v4.32.0
 
 - Added to_markdown() feature to metadata  (#1052)
diff --git a/frictionless/assets/VERSION b/frictionless/assets/VERSION
index eed4df59..b6a23584 100644
--- a/frictionless/assets/VERSION
+++ b/frictionless/assets/VERSION
@@ -1,1 +1,1 @@
-4.32.0
+4.34.0
diff --git a/frictionless/detector.py b/frictionless/detector.py
index e06d14e7..a16fce90 100644
--- a/frictionless/detector.py
+++ b/frictionless/detector.py
@@ -446,21 +446,26 @@ class Detector:
             # Infer fields
             fields = [None] * len(names)
             max_score = [len(fragment)] * len(names)
-            treshold = len(fragment) * (self.__field_confidence - 1)
+            threshold = len(fragment) * (self.__field_confidence - 1)
             for cells in fragment:
                 for index, name in enumerate(names):
                     if fields[index] is not None:
                         continue
                     source = cells[index] if len(cells) > index else None
-                    if source in self.__field_missing_values:
+                    is_field_missing_value = source in self.__field_missing_values
+                    if is_field_missing_value:
                         max_score[index] -= 1
-                        continue
+
                     for runner in runners[index]:
-                        if runner["score"] < treshold:
+                        if runner["score"] < threshold:
                             continue
-                        target, notes = runner["field"].read_cell(source)
-                        runner["score"] += 1 if not notes else -1
-                        if runner["score"] >= max_score[index] * self.__field_confidence:
+                        if not is_field_missing_value:
+                            target, notes = runner["field"].read_cell(source)
+                            runner["score"] += 1 if not notes else -1
+
+                        if max_score[index] > 0 and runner["score"] >= (
+                            max_score[index] * self.__field_confidence
+                        ):
                             field = runner["field"].to_copy()
                             field.name = name
                             field.schema = schema
diff --git a/frictionless/package/__init__.py b/frictionless/package/__init__.py
new file mode 100644
index 00000000..c756f164
--- /dev/null
+++ b/frictionless/package/__init__.py
@@ -0,0 +1,1 @@
+from .package import Package
diff --git a/frictionless/package/actions/__init__.py b/frictionless/package/actions/__init__.py
new file mode 100644
index 00000000..de36d1a1
--- /dev/null
+++ b/frictionless/package/actions/__init__.py
@@ -0,0 +1,4 @@
+from .describe import describe
+from .extract import extract
+from .transform import transform
+from .validate import validate
diff --git a/frictionless/package/actions/describe.py b/frictionless/package/actions/describe.py
new file mode 100644
index 00000000..9be256ff
--- /dev/null
+++ b/frictionless/package/actions/describe.py
@@ -0,0 +1,23 @@
+from importlib import import_module
+
+
+# TODO: rebase from source to path/glob?
+def describe(source=None, *, expand=False, stats=False, **options):
+    """Describe the given source as a package
+
+    Parameters:
+        source (any): data source
+        expand? (bool): if `True` it will expand the metadata
+        stats? (bool): if `True` infer resource's stats
+        **options (dict): Package constructor options
+
+    Returns:
+        Package: data package
+
+    """
+    frictionless = import_module("frictionless")
+    package = frictionless.Package(source, **options)
+    package.infer(stats=stats)
+    if expand:
+        package.expand()
+    return package
diff --git a/frictionless/package/actions/extract.py b/frictionless/package/actions/extract.py
new file mode 100644
index 00000000..02db19d7
--- /dev/null
+++ b/frictionless/package/actions/extract.py
@@ -0,0 +1,35 @@
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from ..package import Package
+
+
+def extract(package: "Package", *, process=None, stream=False):
+    """Extract package rows
+
+    Parameters:
+        source (dict|str): data resource descriptor
+        process? (func): a row processor function
+        stream? (bool): return a row streams instead of loading into memory
+        **options (dict): Package constructor options
+
+    Returns:
+        {path: Row[]}: a dictionary of arrays/streams of rows
+
+    """
+    result = {}
+    for number, resource in enumerate(package.resources, start=1):
+        key = resource.fullpath if not resource.memory else f"memory{number}"
+        data = read_row_stream(resource)
+        data = (process(row) for row in data) if process else data
+        result[key] = data if stream else list(data)
+    return result
+
+
+# Internal
+
+
+def read_row_stream(resource):
+    with resource:
+        for row in resource.row_stream:
+            yield row
diff --git a/frictionless/package/actions/transform.py b/frictionless/package/actions/transform.py
new file mode 100644
index 00000000..87778e69
--- /dev/null
+++ b/frictionless/package/actions/transform.py
@@ -0,0 +1,52 @@
+import types
+from typing import TYPE_CHECKING
+from ...step import Step
+from ...system import system
+from ...helpers import get_name
+from ...exception import FrictionlessException
+from ... import errors
+
+if TYPE_CHECKING:
+    from ..package import Package
+
+
+def transform(package: "Package", *, steps):
+    """Transform package
+
+    Parameters:
+        source (any): data source
+        steps (Step[]): transform steps
+        **options (dict): Package constructor options
+
+    Returns:
+        Package: the transform result
+    """
+
+    # Prepare package
+    package.infer()
+
+    # Prepare steps
+    for index, step in enumerate(steps):
+        if not isinstance(step, Step):
+            steps[index] = (
+                Step(function=step)
+                if isinstance(step, types.FunctionType)
+                else system.create_step(step)
+            )
+
+    # Validate steps
+    for step in steps:
+        if step.metadata_errors:
+            raise FrictionlessException(step.metadata_errors[0])
+
+    # Run transforms
+    for step in steps:
+
+        # Transform
+        try:
+            step.transform_package(package)
+        except Exception as exception:
+            error = errors.StepError(note=f'"{get_name(step)}" raises "{exception}"')
+            raise FrictionlessException(error) from exception
+
+    return package
diff --git a/frictionless/package/actions/validate.py b/frictionless/package/actions/validate.py
new file mode 100644
index 00000000..d6a2a54f
--- /dev/null
+++ b/frictionless/package/actions/validate.py
@@ -0,0 +1,78 @@
+import warnings
+from typing import TYPE_CHECKING
+from ...report import Report
+from ...inquiry import Inquiry, InquiryTask
+from ...exception import FrictionlessException
+from ... import helpers
+
+if TYPE_CHECKING:
+    from ..package import Package
+
+
+# TODO: move exception catching to high-level validate?
+@Report.from_validate
+def validate(package: "Package", original=False, parallel=False, **options):
+    """Validate package
+
+    Parameters:
+        source (dict|str): a package descriptor
+        basepath? (str): package basepath
+        trusted? (bool): don't raise an exception on unsafe paths
+        original? (bool): validate metadata as it is (without inferring)
+        parallel? (bool): enable multiprocessing
+        **options (dict): resource validateion options
+
+    Returns:
+        Report: validation report
+
+    """
+
+    # Create state
+    timer = helpers.Timer()
+
+    # Prepare package
+    try:
+        package_stats = []
+        for resource in package.resources:
+            package_stats.append({key: val for key, val in resource.stats.items() if val})
+    except FrictionlessException as exception:
+        return Report(time=timer.time, errors=[exception.error], tasks=[])
+
+    # Validate metadata
+    metadata_errors = []
+    for error in package.metadata_errors:
+        if error.code == "package-error":
+            metadata_errors.append(error)
+        if metadata_errors:
+            return Report(time=timer.time, errors=metadata_errors, tasks=[])
+
+    # Validate sequentially
+    if not parallel:
+        tasks = []
+        errors = []
+        for resource, stats in zip(package.resources, package_stats):
+            resource.stats = stats
+            report = resource.validate(original=original, **options)
+            tasks.extend(report.tasks)
+            errors.extend(report.errors)
+        return Report(time=timer.time, errors=errors, tasks=tasks)
+
+    # Validate in-parallel
+    else:
+        inquiry = Inquiry(tasks=[])
+        for resource, stats in zip(package.resources, package_stats):
+            for fk in resource.schema.foreign_keys:
+                if fk["reference"]["resource"]:
+                    message = "Foreign keys validation is ignored in the parallel mode"
+                    warnings.warn(message, UserWarning)
+                    break
+            resource.stats = stats
+            inquiry.tasks.append(
+                InquiryTask(
+                    source=resource,
+                    basepath=resource.basepath,
+                    original=original,
+                    **options,
+                )
+            )
+        return inquiry.run(parallel=parallel)
diff --git a/frictionless/package.py b/frictionless/package/package.py
similarity index 98%
rename from frictionless/package.py
rename to frictionless/package/package.py
index 2aed4006..e99b6300 100644
--- a/frictionless/package.py
+++ b/frictionless/package/package.py
@@ -4,15 +4,16 @@ import zipfile
 import tempfile
 from pathlib import Path
 from copy import deepcopy
-from .exception import FrictionlessException
-from .metadata import Metadata
-from .detector import Detector
-from .resource import Resource
-from .field import Field
-from .system import system
-from . import settings
-from . import helpers
-from . import errors
+from ..exception import FrictionlessException
+from ..metadata import Metadata
+from ..detector import Detector
+from ..resource import Resource
+from ..field import Field
+from ..system import system
+from .. import settings
+from .. import helpers
+from .. import errors
+from . import actions
 
 
 class Package(Metadata):
@@ -445,6 +446,13 @@ class Package(Metadata):
         self.resources.remove(resource)
         return resource
 
+    # Actions
+
+    describe = staticmethod(actions.describe)
+    extract = actions.extract
+    transform = actions.transform
+    validate = actions.validate
+
     # Expand
 
     def expand(self):
diff --git a/frictionless/report.py b/frictionless/report.py
index 861f20df..a43a392c 100644
--- a/frictionless/report.py
+++ b/frictionless/report.py
@@ -1,6 +1,6 @@
 import functools
 from copy import deepcopy
-from .resource import Resource
+from importlib import import_module
 from .metadata import Metadata
 from .errors import Error, TaskError, ReportError
 from .exception import FrictionlessException
@@ -334,6 +334,7 @@ class ReportTask(Metadata):
     metadata_profile = settings.REPORT_PROFILE["properties"]["tasks"]["items"]
 
     def metadata_process(self):
+        Resource = import_module("frictionless.resource").Resource
 
         # Resource
         resource = self.get("resource")
diff --git a/frictionless/resource/__init__.py b/frictionless/resource/__init__.py
new file mode 100644
index 00000000..c37a2ae9
--- /dev/null
+++ b/frictionless/resource/__init__.py
@@ -0,0 +1,1 @@
+from .resource import Resource
diff --git a/frictionless/resource/actions/__init__.py b/frictionless/resource/actions/__init__.py
new file mode 100644
index 00000000..de36d1a1
--- /dev/null
+++ b/frictionless/resource/actions/__init__.py
@@ -0,0 +1,4 @@
+from .describe import describe
+from .extract import extract
+from .transform import transform
+from .validate import validate
diff --git a/frictionless/resource/actions/describe.py b/frictionless/resource/actions/describe.py
new file mode 100644
index 00000000..b8356718
--- /dev/null
+++ b/frictionless/resource/actions/describe.py
@@ -0,0 +1,23 @@
+from importlib import import_module
+
+
+# TODO: rebase from source to path/data
+def describe(source=None, *, expand=False, stats=False, **options):
+    """Describe the given source as a resource
+
+    Parameters:
+        source (any): data source
+        expand? (bool): if `True` it will expand the metadata
+        stats? (bool): if `True` infer resource's stats
+        **options (dict): Resource constructor options
+
+    Returns:
+        Resource: data resource
+
+    """
+    frictionless = import_module("frictionless")
+    resource = frictionless.Resource(source, **options)
+    resource.infer(stats=stats)
+    if expand:
+        resource.expand()
+    return resource
diff --git a/frictionless/resource/actions/extract.py b/frictionless/resource/actions/extract.py
new file mode 100644
index 00000000..df575c44
--- /dev/null
+++ b/frictionless/resource/actions/extract.py
@@ -0,0 +1,29 @@
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from ..resource import Resource
+
+
+def extract(resource: "Resource", *, process=None, stream=False):
+    """Extract resource rows
+
+    Parameters:
+        process? (func): a row processor function
+        stream? (bool): whether to stream data
+
+    Returns:
+        Row[]: an array/stream of rows
+
+    """
+    data = read_row_stream(resource)
+    data = (process(row) for row in data) if process else data
+    return data if stream else list(data)
+
+
+# Internal
+
+
+def read_row_stream(resource):
+    with resource:
+        for row in resource.row_stream:
+            yield row
diff --git a/frictionless/resource/actions/transform.py b/frictionless/resource/actions/transform.py
new file mode 100644
index 00000000..fb5db527
--- /dev/null
+++ b/frictionless/resource/actions/transform.py
@@ -0,0 +1,90 @@
+import types
+from typing import TYPE_CHECKING
+from ...step import Step
+from ...system import system
+from ...helpers import get_name
+from ...exception import FrictionlessException
+from ... import errors
+
+if TYPE_CHECKING:
+    from ..resource import Resource
+
+
+def transform(resource: "Resource", *, steps):
+    """Transform resource
+
+    Parameters:
+        steps (Step[]): transform steps
+
+    Returns:
+        Resource: the transform result
+    """
+
+    # Prepare resource
+    resource.infer()
+
+    # Prepare steps
+    for index, step in enumerate(steps):
+        if not isinstance(step, Step):
+            steps[index] = (
+                Step(function=step)
+                if isinstance(step, types.FunctionType)
+                else system.create_step(step)
+            )
+
+    # Validate steps
+    for step in steps:
+        if step.metadata_errors:
+            raise FrictionlessException(step.metadata_errors[0])
+
+    # Run transforms
+    for step in steps:
+        data = resource.data
+
+        # Transform
+        try:
+            step.transform_resource(resource)
+        except Exception as exception:
+            error = errors.StepError(note=f'"{get_name(step)}" raises "{exception}"')
+            raise FrictionlessException(error) from exception
+
+        # Postprocess
+        if resource.data is not data:
+            resource.data = DataWithErrorHandling(resource.data, step=step)
+            # NOTE:
+            # We need rework resource.data or move to resource.__setattr__
+            # https://github.com/frictionlessdata/frictionless-py/issues/722
+            resource.scheme = ""
+            resource.format = "inline"
+            dict.pop(resource, "path", None)
+            dict.pop(resource, "hashing", None)
+            dict.pop(resource, "encoding", None)
+            dict.pop(resource, "innerpath", None)
+            dict.pop(resource, "compression", None)
+            dict.pop(resource, "control", None)
+            dict.pop(resource, "dialect", None)
+            dict.pop(resource, "layout", None)
+
+    return resource
+
+
+# Internal
+
+
+class DataWithErrorHandling:
+    def __init__(self, data, *, step):
+        self.data = data
+        self.step = step
+
+    def __repr__(self):
+        return "<transformed-data>"
+
+    def __iter__(self):
+        try:
+            yield from self.data() if callable(self.data) else self.data
+        except Exception as exception:
+            if isinstance(exception, FrictionlessException):
+                if exception.error.code == "step-error":
+                    raise
+            error = errors.StepError(note=f'"{get_name(self.step)}" raises "{exception}"')
+            raise FrictionlessException(error) from exception
diff --git a/frictionless/resource/actions/validate.py b/frictionless/resource/actions/validate.py
new file mode 100644
index 00000000..c4b8d61b
--- /dev/null
+++ b/frictionless/resource/actions/validate.py
@@ -0,0 +1,202 @@
+import types
+from typing import TYPE_CHECKING
+from ...check import Check
+from ...system import system
+from ...exception import FrictionlessException
+from ...report import Report, ReportTask
+from ...errors import TaskError
+from ... import helpers
+from ... import settings
+
+if TYPE_CHECKING:
+    from ..resource import Resource
+
+
+# NOTE:
+# Shall metadata validation be a part of BaselineCheck?
+
+
+# TODO: shall we catch exceptions here or in global validate?
+@Report.from_validate
+def validate(
+    resource: "Resource",
+    *,
+    checks=None,
+    original=False,
+    pick_errors=None,
+    skip_errors=None,
+    limit_errors=settings.DEFAULT_LIMIT_ERRORS,
+    limit_memory=settings.DEFAULT_LIMIT_MEMORY,
+):
+    """Validate table
+
+    Parameters:
+        checks? (list): a list of checks
+        pick_errors? ((str|int)[]): pick errors
+        skip_errors? ((str|int)[]): skip errors
+        limit_errors? (int): limit errors
+        limit_memory? (int): limit memory
+        original? (bool): validate metadata as it is (without inferring)
+        **options? (dict): Resource constructor options
+
+    Returns:
+        Report: validation report
+    """
+
+    # Create state
+    partial = False
+    timer = helpers.Timer()
+    errors = ManagedErrors(pick_errors, skip_errors, limit_errors)
+
+    # Create resource
+    try:
+        stats = {key: val for key, val in resource.stats.items() if val}
+        original_resource = resource.to_copy()
+    except FrictionlessException as exception:
+        resource = None
+        errors.append(exception.error)
+
+    # Open resource
+    if not errors:
+        try:
+            resource.open()
+        except FrictionlessException as exception:
+            errors.append(exception.error)
+            resource.close()
+
+    # Prepare checks
+    if not errors:
+        checks = checks or []
+        checks.insert(0, {"code": "baseline", "stats": stats})
+        for index, check in enumerate(checks):
+            if not isinstance(check, Check):
+                func = isinstance(check, types.FunctionType)
+                check = Check(function=check) if func else system.create_check(check)
+                checks[index] = check
+            errors.register(check)
+
+    # Validate checks
+    if not errors:
+        for index, check in enumerate(checks.copy()):
+            if check.metadata_errors:
+                del checks[index]
+                for error in check.metadata_errors:
+                    errors.append(error)
+
+    # Validate metadata
+    if not errors:
+        metadata_resource = original_resource if original else resource
+        for error in metadata_resource.metadata_errors:
+            errors.append(error)
+
+    # Validate data
+    if not errors:
+        with resource:
+
+            # Validate start
+            for index, check in enumerate(checks.copy()):
+                check.connect(resource)
+                for error in check.validate_start():
+                    if error.code == "check-error":
+                        del checks[index]
+                    errors.append(error)
+
+            # Validate rows
+            if resource.tabular:
+                for row in resource.row_stream:
+
+                    # Validate row
+                    for check in checks:
+                        for error in check.validate_row(row):
+                            errors.append(error)
+
+                    # Limit errors
+                    if limit_errors and len(errors) >= limit_errors:
+                        partial = True
+                        break
+
+                    # Limit memory
+                    if limit_memory and not row.row_number % 100000:
+                        memory = helpers.get_current_memory_usage()
+                        if memory and memory > limit_memory:
+                            note = f'exceeded memory limit "{limit_memory}MB"'
+                            errors.append(TaskError(note=note))
+                            partial = True
+                            break
+
+            # Validate end
+            if not partial:
+                if not resource.tabular:
+                    helpers.pass_through(resource.byte_stream)
+                for check in checks:
+                    for error in check.validate_end():
+                        errors.append(error)
+
+    # Return report
+    return Report(
+        time=timer.time,
+        errors=[],
+        tasks=[
+            ReportTask(
+                time=timer.time,
+                scope=errors.scope,
+                partial=partial,
+                errors=errors,
+                resource=resource,
+            )
+        ],
+    )
+
+
+# Internal
+
+
+# NOTE:
+# We might consider merging this code into ReportTask
+# It had been written much earlier that ReportTask was introduces
+# Also, we can use Report/ReportTask API instead of working with lists
+
+
+class ManagedErrors(list):
+    def __init__(self, pick_errors, skip_errors, limit_errors):
+        self.__pick_errors = set(pick_errors or [])
+        self.__skip_errors = set(skip_errors or [])
+        self.__limit_errors = limit_errors
+        self.__scope = []
+
+    @property
+    def scope(self):
+        return self.__scope
+
+    def append(self, error):
+        if "#general" not in error.tags:
+            if self.__limit_errors:
+                if len(self) >= self.__limit_errors:
+                    return
+            if not self.match(error):
+                return
+        super().append(error)
+
+    def match(self, error):
+        match = True
+        if self.__pick_errors:
+            match = False
+            if error.code in self.__pick_errors:
+                match = True
+            if self.__pick_errors.intersection(error.tags):
+                match = True
+        if self.__skip_errors:
+            match = True
+            if error.code in self.__skip_errors:
+                match = False
+            if self.__skip_errors.intersection(error.tags):
+                match = False
+        return match
+
+    def register(self, check):
+        for Error in check.Errors:
+            if not self.match(Error):
+                continue
+            if Error.code in self.__scope:
+                continue
+            self.__scope.append(Error.code)
diff --git a/frictionless/resource.py b/frictionless/resource/resource.py
similarity index 98%
rename from frictionless/resource.py
rename to frictionless/resource/resource.py
index 22045d76..7edb3728 100644
--- a/frictionless/resource.py
+++ b/frictionless/resource/resource.py
@@ -5,18 +5,19 @@ import warnings
 from pathlib import Path
 from copy import deepcopy
 from itertools import zip_longest, chain
-from .exception import FrictionlessException
-from .detector import Detector
-from .metadata import Metadata
-from .layout import Layout
-from .schema import Schema
-from .header import Header
-from .system import system
-from .field import Field
-from .row import Row
-from . import settings
-from . import helpers
-from . import errors
+from ..exception import FrictionlessException
+from ..detector import Detector
+from ..metadata import Metadata
+from ..layout import Layout
+from ..schema import Schema
+from ..header import Header
+from ..system import system
+from ..field import Field
+from ..row import Row
+from .. import settings
+from .. import helpers
+from .. import errors
+from . import actions
 
 
 # NOTE:
@@ -695,6 +696,13 @@ class Resource(Metadata):
         """
         return self.__row_stream
 
+    # Actions
+
+    describe = staticmethod(actions.describe)
+    extract = actions.extract
+    transform = actions.transform
+    validate = actions.validate
+
     # Expand
 
     def expand(self):
diff --git a/frictionless/steps/__init__.py b/frictionless/steps/__init__.py
index 800be129..00e4689c 100644
--- a/frictionless/steps/__init__.py
+++ b/frictionless/steps/__init__.py
@@ -7,6 +7,8 @@ from .field import (
     field_split,
     field_unpack,
     field_update,
+    field_pack,
+    field_merge,
 )
 from .resource import (
     resource_add,
diff --git a/frictionless/steps/field/__init__.py b/frictionless/steps/field/__init__.py
index d8a2eec8..c1c04eea 100644
--- a/frictionless/steps/field/__init__.py
+++ b/frictionless/steps/field/__init__.py
@@ -5,3 +5,5 @@ from .field_remove import field_remove
 from .field_split import field_split
 from .field_unpack import field_unpack
 from .field_update import field_update
+from .field_pack import field_pack
+from .field_merge import field_merge
diff --git a/frictionless/steps/field/field_merge.py b/frictionless/steps/field/field_merge.py
new file mode 100644
index 00000000..fb6ec3fa
--- /dev/null
+++ b/frictionless/steps/field/field_merge.py
@@ -0,0 +1,104 @@
+from ...step import Step
+from ...field import Field
+from typing import List, Iterator
+from petl.compat import next, text_type
+
+
+def merge(
+    source: any, name: str, from_names: list, sep: str = "-", preserve: bool = True
+) -> Iterator:
+    it = iter(source)
+
+    hdr = next(it)
+    field_indexes = list()
+    flds = list(map(text_type, hdr))
+
+    # determine output fields
+    outhdr = list(flds)
+    for field in from_names:
+        field_index = flds.index(field)
+        if not preserve:
+            outhdr.remove(field)
+        field_indexes.append(field_index)
+    outhdr.extend([name])
+    yield tuple(outhdr)
+
+    # construct the output data
+    for row in it:
+        value = [v for i, v in enumerate(row) if i in field_indexes]
+        if preserve:
+            out_row = list(row)
+        else:
+            out_row = [v for i, v in enumerate(row) if i not in field_indexes]
+        out_row.extend([sep.join(value)])
+        yield tuple(out_row)
+
+
+class field_merge(Step):
+    """Merge fields
+
+    API      | Usage
+    -------- | --------
+    Public   | `from frictionless import steps`
+    Implicit | `validate(checks=([{"code": "field-merge", **descriptor}])`
+
+    This step can be added using the `steps` parameter
+    for the `transform` function.
+
+    Parameters:
+       descriptor (dict): step's descriptor
+       name (str): name of new field
+       from_names (str): field names to merge
+       field_type? (str): type of new field
+       separator? (str): delimeter to use
+       preserve? (bool): preserve source fields
+
+    """
+
+    code = "field-merge"
+
+    def __init__(
+        self,
+        descriptor: any = None,
+        *,
+        name: str = None,
+        from_names: List[str] = None,
+        field_type: str = None,
+        separator: str = "-",
+        preserve: bool = False
+    ):
+        self.setinitial("name", name)
+        self.setinitial("fromNames", from_names)
+        self.setinitial("fieldType", field_type)
+        self.setinitial("separator", separator)
+        self.setinitial("preserve", preserve)
+        super().__init__(descriptor)
+
+    # Transform
+
+    def transform_resource(self, resource: any) -> None:
+        table = resource.to_petl()
+        name = self.get("name")
+        from_names = self.get("fromNames")
+        field_type = self.get("fieldType", "string")
+        separator = self.get("separator")
+        preserve = self.get("preserve")
+        resource.schema.add_field(Field(name=name, type=field_type))
+        if not preserve:
+            for name in from_names:
+                resource.schema.remove_field(name)
+        resource.data = merge(table, name, from_names, separator, preserve)
+
+    # Metadata
+
+    metadata_profile = {
+        "type": "object",
+        "required": ["name", "fromNames"],
+        "properties": {
+            "name": {"type": "string"},
+            "fromNames": {"type": "array"},
+            "fieldType": {"type": "string"},
+            "separator": {"type": "string"},
+            "preserve": {"type": "boolean"},
+        },
+    }
diff --git a/frictionless/steps/field/field_pack.py b/frictionless/steps/field/field_pack.py
new file mode 100644
index 00000000..d5efc6d9
--- /dev/null
+++ b/frictionless/steps/field/field_pack.py
@@ -0,0 +1,140 @@
+from ...step import Step
+from ...field import Field
+from typing import List, Iterator
+from petl.compat import next, text_type
+
+
+def iterpack(
+    source: any, name: str, from_names: list, preserve: bool = False
+) -> Iterator:
+    """Combines multiple columns as array
+    Code partially referenced from https://github.com/petl-developers/petl/blob/master/petl/transform/unpacks.py#L64
+    """
+    it = iter(source)
+
+    hdr = next(it)
+    field_indexes = list()
+    flds = list(map(text_type, hdr))
+
+    # determine output fields
+    outhdr = list(flds)
+    for field in from_names:
+        field_index = flds.index(field)
+        if not preserve:
+            outhdr.remove(field)
+        field_indexes.append(field_index)
+    outhdr.extend([name])
+    yield tuple(outhdr)
+
+    # construct the output data
+    for row in it:
+        value = [v for i, v in enumerate(row) if i in field_indexes]
+        if preserve:
+            out_row = list(row)
+        else:
+            out_row = [v for i, v in enumerate(row) if i not in field_indexes]
+        out_row.extend([value])
+        yield tuple(out_row)
+
+
+def iterpackdict(
+    source: any, name: str, from_names: list, preserve: bool = False
+) -> Iterator:
+    """Combines multiple columns as JSON Object"""
+    it = iter(source)
+
+    hdr = next(it)
+    field_indexes = list()
+    flds = list(map(text_type, hdr))
+
+    # determine output fields
+    outhdr = list(flds)
+    for field in from_names:
+        field_index = flds.index(field)
+        if not preserve:
+            outhdr.remove(field)
+        field_indexes.append(field_index)
+    outhdr.extend([name])
+    yield tuple(outhdr)
+
+    # construct the output data
+    for row in it:
+        value = dict(
+            (from_names[i - 1], v) for i, v in enumerate(row) if i in field_indexes
+        )
+        if preserve:
+            out_row = list(row)
+        else:
+            out_row = [v for i, v in enumerate(row) if i not in field_indexes]
+        out_row.extend([value])
+        yield tuple(out_row)
+
+
+class field_pack(Step):
+    """Pack fields
+
+    API      | Usage
+    -------- | --------
+    Public   | `from frictionless import steps`
+    Implicit | `validate(checks=([{"code": "field-pack", **descriptor}])`
+
+    This step can be added using the `steps` parameter
+    for the `transform` function.
+
+    Parameters:
+       descriptor (dict): step's descriptor
+       name (str): name of new field
+       from_names (str): field names to pack
+       field_type? (str): type of new field
+       preserve? (bool): preserve source fields
+
+    """
+
+    code = "field-pack"
+
+    def __init__(
+        self,
+        descriptor=None,
+        *,
+        name: str = None,
+        from_names: List[str] = None,
+        field_type: str = None,
+        preserve: bool = False
+    ):
+        self.setinitial("name", name)
+        self.setinitial("fromNames", from_names)
+        self.setinitial("fieldType", field_type)
+        self.setinitial("preserve", preserve)
+        super().__init__(descriptor)
+
+    # Transform
+
+    def transform_resource(self, resource: dict) -> None:
+        table = resource.to_petl()
+        name = self.get("name")
+        from_names = self.get("fromNames")
+        field_type = self.get("fieldType", "array")
+        preserve = self.get("preserve")
+        resource.schema.add_field(Field(name=name, type=field_type))
+        if not preserve:
+            for name in from_names:
+                resource.schema.remove_field(name)
+        if field_type == "object":
+            resource.data = iterpackdict(
+                table, "detail", ["name", "population"], preserve
+            )
+        else:
+            resource.data = iterpack(table, "detail", ["name", "population"], preserve)
+
+    # Metadata
+
+    metadata_profile = {
+        "type": "object",
+        "required": ["name", "fromNames"],
+        "properties": {
+            "name": {"type": "string"},
+            "fromNames": {"type": "array"},
+            "fieldType": {"type": "string"},
+            "preserve": {"type": "boolean"},
+        },
+    }


Test patch:
diff --git a/tests/package/actions/__init__.py b/tests/package/actions/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/package/actions/describe/__init__.py b/tests/package/actions/describe/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/package/actions/describe/test_general.py b/tests/package/actions/describe/test_general.py
new file mode 100644
index 00000000..c2602201
--- /dev/null
+++ b/tests/package/actions/describe/test_general.py
@@ -0,0 +1,130 @@
+from frictionless import Package, helpers
+
+
+IS_UNIX = not helpers.is_platform("windows")
+
+
+# General
+
+
+def test_describe_package():
+    package = Package.describe("data/tables/chunk*.csv")
+    assert package.metadata_valid
+    if IS_UNIX:
+        assert package == {
+            "profile": "data-package",
+            "resources": [
+                {
+                    "path": "data/tables/chunk1.csv",
+                    "profile": "tabular-data-resource",
+                    "name": "chunk1",
+                    "scheme": "file",
+                    "format": "csv",
+                    "hashing": "md5",
+                    "encoding": "utf-8",
+                    "schema": {
+                        "fields": [
+                            {"name": "id", "type": "integer"},
+                            {"name": "name", "type": "string"},
+                        ]
+                    },
+                },
+                {
+                    "path": "data/tables/chunk2.csv",
+                    "profile": "tabular-data-resource",
+                    "name": "chunk2",
+                    "scheme": "file",
+                    "format": "csv",
+                    "hashing": "md5",
+                    "encoding": "utf-8",
+                    "schema": {
+                        "fields": [
+                            {"name": "id", "type": "integer"},
+                            {"name": "name", "type": "string"},
+                        ]
+                    },
+                },
+            ],
+        }
+
+
+def test_describe_package_with_stats():
+    package = Package.describe("data/tables/chunk*.csv", stats=True)
+    assert package.metadata_valid
+    if IS_UNIX:
+        assert package == {
+            "profile": "data-package",
+            "resources": [
+                {
+                    "path": "data/tables/chunk1.csv",
+                    "profile": "tabular-data-resource",
+                    "name": "chunk1",
+                    "scheme": "file",
+                    "format": "csv",
+                    "hashing": "md5",
+                    "encoding": "utf-8",
+                    "schema": {
+                        "fields": [
+                            {"name": "id", "type": "integer"},
+                            {"name": "name", "type": "string"},
+                        ]
+                    },
+                    "stats": {
+                        "hash": "8fff9d97e5c0cb77b7c469ec37c8e766",
+                        "bytes": 18,
+                        "fields": 2,
+                        "rows": 1,
+                    },
+                },
+                {
+                    "path": "data/tables/chunk2.csv",
+                    "profile": "tabular-data-resource",
+                    "name": "chunk2",
+                    "scheme": "file",
+                    "format": "csv",
+                    "hashing": "md5",
+                    "encoding": "utf-8",
+                    "schema": {
+                        "fields": [
+                            {"name": "id", "type": "integer"},
+                            {"name": "name", "type": "string"},
+                        ]
+                    },
+                    "stats": {
+                        "hash": "ebfa07d04a148a92a18078f78468694d",
+                        "bytes": 20,
+                        "fields": 2,
+                        "rows": 1,
+                    },
+                },
+            ],
+        }
+
+
+def test_describe_package_basepath():
+    package = Package.describe("chunk*.csv", basepath="data")
+    assert package.get_resource("chunk1").path == "chunk1.csv"
+    assert package.get_resource("chunk2").path == "chunk2.csv"
+    assert package.get_resource("chunk1").basepath == "data"
+    assert package.get_resource("chunk2").basepath == "data"
+
+
+def test_describe_package_hashing():
+    package = Package.describe("data/chunk*.csv", hashing="sha256", stats=True)
+    assert package.get_resource("chunk1").hashing == "sha256"
+    assert package.get_resource("chunk2").hashing == "sha256"
+    if IS_UNIX:
+        assert (
+            package.get_resource("chunk1").stats["hash"]
+            == "3872c98bd72eb4a91ac666f7758cd83da904c61a35178ca1ce9e10d6b009cd21"
+        )
+        assert (
+            package.get_resource("chunk2").stats["hash"]
+            == "556e92cdacfc46c2338ab0b88daf9d560c6760eac2d4cb6f7df589c108fc07ce"
+        )
+
+
+def test_describe_package_expand():
+    package = Package.describe("data/chunk*.csv", expand=True)
+    assert package.get_resource("chunk1").layout.header is True
+    assert package.get_resource("chunk1").schema.missing_values == [""]
diff --git a/tests/package/actions/extract/__init__.py b/tests/package/actions/extract/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/package/actions/extract/test_general.py b/tests/package/actions/extract/test_general.py
new file mode 100644
index 00000000..39d00dc0
--- /dev/null
+++ b/tests/package/actions/extract/test_general.py
@@ -0,0 +1,59 @@
+import types
+from frictionless import Package, helpers
+
+
+IS_UNIX = not helpers.is_platform("windows")
+
+
+# General
+
+
+def test_extract_package():
+    path = "data/table.csv" if IS_UNIX else "data\\table.csv"
+    package = Package(path)
+    assert package.extract() == {
+        path: [{"id": 1, "name": "english"}, {"id": 2, "name": "中国人"}]
+    }
+
+
+def test_extract_package_process():
+    process = lambda row: row.to_list()
+    path = "data/table.csv" if IS_UNIX else "data\\table.csv"
+    package = Package(path)
+    assert package.extract(process=process) == {
+        path: [
+            [1, "english"],
+            [2, "中国人"],
+        ],
+    }
+
+
+def test_extract_package_stream():
+    path = "data/table.csv" if IS_UNIX else "data\\table.csv"
+    package = Package(path)
+    row_streams = package.extract(stream=True)
+    row_stream = row_streams[path]
+    assert isinstance(row_stream, types.GeneratorType)
+    assert list(row_stream) == [
+        {"id": 1, "name": "english"},
+        {"id": 2, "name": "中国人"},
+    ]
+
+
+def test_extract_package_process_and_stream():
+    process = lambda row: row.to_list()
+    path = "data/table.csv" if IS_UNIX else "data\\table.csv"
+    package = Package(path)
+    list_streams = package.extract(process=process, stream=True)
+    list_stream = list_streams[path]
+    assert isinstance(list_stream, types.GeneratorType)
+    assert list(list_stream) == [
+        [1, "english"],
+        [2, "中国人"],
+    ]
+
+
+def test_extract_package_descriptor_type_package():
+    package = Package(descriptor="data/package/datapackage.json")
+    data = package.extract()
+    assert isinstance(data, dict)
diff --git a/tests/package/actions/transform/__init__.py b/tests/package/actions/transform/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/package/actions/transform/test_general.py b/tests/package/actions/transform/test_general.py
new file mode 100644
index 00000000..e6aff2ff
--- /dev/null
+++ b/tests/package/actions/transform/test_general.py
@@ -0,0 +1,24 @@
+from frictionless import Package, steps
+
+
+# General
+
+
+def test_transform_package():
+    source = Package("data/tables/chunk*.csv")
+    target = source.transform(
+        steps=[
+            steps.resource_transform(
+                name="chunk1",
+                steps=[
+                    steps.table_merge(resource="chunk2"),
+                ],
+            ),
+            steps.resource_remove(name="chunk2"),
+        ],
+    )
+    assert target.resource_names == ["chunk1"]
+    assert target.get_resource("chunk1").read_rows() == [
+        {"id": 1, "name": "english"},
+        {"id": 2, "name": "中国人"},
+    ]
diff --git a/tests/package/actions/validate/__init__.py b/tests/package/actions/validate/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/package/actions/validate/test_general.py b/tests/package/actions/validate/test_general.py
new file mode 100644
index 00000000..90d92bfe
--- /dev/null
+++ b/tests/package/actions/validate/test_general.py
@@ -0,0 +1,296 @@
+import json
+import pytest
+import pathlib
+from frictionless import Package, Resource, Schema, Field, Detector, validate, helpers
+
+
+IS_UNIX = not helpers.is_platform("windows")
+
+
+# General
+
+
+def test_validate_package():
+    report = validate({"resources": [{"path": "data/table.csv"}]})
+    assert report.valid
+
+
+def test_validate_package_from_dict():
+    with open("data/package/datapackage.json") as file:
+        report = validate(json.load(file), basepath="data/package")
+        assert report.valid
+
+
+def test_validate_package_from_dict_invalid():
+    with open("data/invalid/datapackage.json") as file:
+        report = validate(json.load(file), basepath="data/invalid")
+        assert report.flatten(
+            ["taskPosition", "rowPosition", "fieldPosition", "code"]
+        ) == [
+            [1, 3, None, "blank-row"],
+            [1, 3, None, "primary-key-error"],
+            [2, 4, None, "blank-row"],
+        ]
+
+
+def test_validate_package_from_path():
+    report = validate("data/package/datapackage.json")
+    assert report.valid
+
+
+def test_validate_package_from_path_invalid():
+    report = validate("data/invalid/datapackage.json")
+    assert report.flatten(["taskPosition", "rowPosition", "fieldPosition", "code"]) == [
+        [1, 3, None, "blank-row"],
+        [1, 3, None, "primary-key-error"],
+        [2, 4, None, "blank-row"],
+    ]
+
+
+def test_validate_package_from_zip():
+    report = validate("data/package.zip", type="package")
+    assert report.valid
+
+
+def test_validate_package_from_zip_invalid():
+    report = validate("data/package-invalid.zip", type="package")
+    assert report.flatten(["taskPosition", "rowPosition", "fieldPosition", "code"]) == [
+        [1, 3, None, "blank-row"],
+        [1, 3, None, "primary-key-error"],
+        [2, 4, None, "blank-row"],
+    ]
+
+
+def test_validate_package_with_non_tabular():
+    report = validate(
+        {
+            "resources": [
+                {"path": "data/table.csv"},
+                {"path": "data/file.txt"},
+            ]
+        },
+    )
+    assert report.valid
+
+
+def test_validate_package_invalid_descriptor_path():
+    report = validate("bad/datapackage.json")
+    assert report["stats"]["errors"] == 1
+    error = report["errors"][0]
+    assert error["code"] == "package-error"
+    assert error["note"].count("[Errno 2]") and error["note"].count(
+        "bad/datapackage.json"
+    )
+
+
+def test_validate_package_invalid_package():
+    report = validate({"resources": [{"path": "data/table.csv", "schema": "bad"}]})
+    assert report["stats"]["errors"] == 1
+    error = report["errors"][0]
+    assert error["code"] == "schema-error"
+    assert error["note"].count("[Errno 2]") and error["note"].count("'bad'")
+
+
+def test_validate_package_invalid_package_original():
+    report = validate({"resources": [{"path": "data/table.csv"}]}, original=True)
+    assert report.flatten(["code", "note"]) == [
+        [
+            "resource-error",
+            "\"{'path': 'data/table.csv', 'stats': {}} is not valid under any of the given schemas\" at \"\" in metadata and at \"oneOf\" in profile",
+        ]
+    ]
+
+
+def test_validate_package_invalid_table():
+    report = validate({"resources": [{"path": "data/invalid.csv"}]})
+    assert report.flatten(["rowPosition", "fieldPosition", "code"]) == [
+        [None, 3, "blank-label"],
+        [None, 4, "duplicate-label"],
+        [2, 3, "missing-cell"],
+        [2, 4, "missing-cell"],
+        [3, 3, "missing-cell"],
+        [3, 4, "missing-cell"],
+        [4, None, "blank-row"],
+        [5, 5, "extra-cell"],
+    ]
+
+
+def test_validate_package_pathlib_source():
+    report = validate(pathlib.Path("data/package/datapackage.json"))
+    assert report.valid
+
+
+def test_validate_package_infer():
+    report = validate("data/infer/datapackage.json")
+    assert report.valid
+
+
+def test_validate_package_dialect_header_false():
+    descriptor = {
+        "resources": [
+            {
+                "name": "name",
+                "data": [["John", "22"], ["Alex", "33"], ["Paul", "44"]],
+                "schema": {
+                    "fields": [{"name": "name"}, {"name": "age", "type": "integer"}]
+                },
+                "layout": {"header": False},
+            }
+        ]
+    }
+    report = validate(descriptor)
+    assert report.valid
+
+
+def test_validate_package_with_schema_as_string():
+    report = validate(
+        {"resources": [{"path": "data/table.csv", "schema": "data/schema.json"}]}
+    )
+    assert report.valid
+
+
+# Issues
+
+
+def test_validate_package_mixed_issue_170():
+    report = validate("data/infer/datapackage.json")
+    assert report.valid
+
+
+def test_validate_package_invalid_json_issue_192():
+    report = validate("data/invalid.json", type="package")
+    assert report.flatten(["code", "note"]) == [
+        [
+            "package-error",
+            'cannot extract metadata "data/invalid.json" because "Expecting property name enclosed in double quotes: line 2 column 5 (char 6)"',
+        ]
+    ]
+
+
+def test_validate_package_composite_primary_key_unique_issue_215():
+    source = {
+        "resources": [
+            {
+                "name": "name",
+                "data": [["id1", "id2"], ["a", "1"], ["a", "2"]],
+                "schema": {
+                    "fields": [{"name": "id1"}, {"name": "id2"}],
+                    "primaryKey": ["id1", "id2"],
+                },
+            }
+        ],
+    }
+    report = validate(source)
+    assert report.valid
+
+
+def test_validate_package_composite_primary_key_not_unique_issue_215():
+    descriptor = {
+        "resources": [
+            {
+                "name": "name",
+                "data": [["id1", "id2"], ["a", "1"], ["a", "1"]],
+                "schema": {
+                    "fields": [{"name": "id1"}, {"name": "id2"}],
+                    "primaryKey": ["id1", "id2"],
+                },
+            }
+        ],
+    }
+    report = validate(descriptor, skip_errors=["duplicate-row"])
+    assert report.flatten(["rowPosition", "fieldPosition", "code"]) == [
+        [3, None, "primary-key-error"],
+    ]
+
+
+def test_validate_package_geopoint_required_constraint_issue_231():
+    # We check here that it doesn't raise exceptions
+    report = validate("data/geopoint/datapackage.json")
+    assert not report.valid
+
+
+def test_validate_package_number_test_issue_232():
+    # We check here that it doesn't raise exceptions
+    report = validate("data/number/datapackage.json")
+    assert not report.valid
+
+
+def test_validate_package_with_schema_issue_348():
+    descriptor = {
+        "resources": [
+            {
+                "name": "people",
+                "data": [
+                    ["id", "name", "surname"],
+                    ["p1", "Tom", "Hanks"],
+                    ["p2", "Meryl", "Streep"],
+                ],
+                "schema": {
+                    "fields": [
+                        {"name": "id", "type": "string"},
+                        {"name": "name", "type": "string"},
+                        {"name": "surname", "type": "string"},
+                        {"name": "dob", "type": "date"},
+                    ]
+                },
+            }
+        ]
+    }
+    report = validate(descriptor)
+    assert report.flatten(["rowPosition", "fieldPosition", "code"]) == [
+        [None, 4, "missing-label"],
+        [2, 4, "missing-cell"],
+        [3, 4, "missing-cell"],
+    ]
+
+
+@pytest.mark.ci
+@pytest.mark.vcr
+def test_validate_package_uppercase_format_issue_494():
+    with pytest.warns(UserWarning):
+        report = validate("data/issue-494.package.json")
+        assert report.valid
+        assert report.stats["tasks"] == 1
+
+
+# See also: https://github.com/frictionlessdata/project/discussions/678
+def test_validate_package_using_detector_schema_sync_issue_847():
+    package = Package(
+        resources=[
+            Resource(
+                data=[["f1"], ["v1"], ["v2"], ["v3"]],
+                schema=Schema(fields=[Field(name="f1"), Field(name="f2")]),
+            ),
+        ]
+    )
+    for resource in package.resources:
+        resource.detector = Detector(schema_sync=True)
+    report = validate(package)
+    assert report.valid
+
+
+def test_validate_package_descriptor_type_package():
+    report = validate(descriptor="data/package/datapackage.json")
+    assert report.valid
+
+
+def test_validate_package_descriptor_type_package_invalid():
+    report = validate(descriptor="data/invalid/datapackage.json")
+    assert report.flatten() == [
+        [1, 3, None, "blank-row"],
+        [1, 3, None, "primary-key-error"],
+        [2, 4, None, "blank-row"],
+    ]
+
+
+def test_validate_package_with_diacritic_symbol_issue_905():
+    report = validate(descriptor="data/issue-905/datapackage.json")
+    assert report.stats["tasks"] == 3
+
+
+def test_validate_package_with_resource_data_is_a_string_issue_977():
+    report = validate(descriptor="data/issue-977.json", type="package")
+    print(report)
+    assert report.flatten() == [
+        [None, None, None, "package-error"],
+    ]
diff --git a/tests/package/actions/validate/test_parallel.py b/tests/package/actions/validate/test_parallel.py
new file mode 100644
index 00000000..c5ee452c
--- /dev/null
+++ b/tests/package/actions/validate/test_parallel.py
@@ -0,0 +1,40 @@
+import json
+import pytest
+from frictionless import Package, helpers
+
+
+IS_UNIX = not helpers.is_platform("windows")
+
+
+@pytest.mark.ci
+def test_validate_package_parallel_from_dict():
+    with open("data/package/datapackage.json") as file:
+        with pytest.warns(UserWarning):
+            package = Package(json.load(file), basepath="data/package")
+            report = package.validate(parallel=True)
+            assert report.valid
+
+
+@pytest.mark.ci
+def test_validate_package_parallel_from_dict_invalid():
+    with open("data/invalid/datapackage.json") as file:
+        package = Package(json.load(file), basepath="data/invalid")
+        report = package.validate(parallel=True)
+        assert report.flatten(
+            ["taskPosition", "rowPosition", "fieldPosition", "code"]
+        ) == [
+            [1, 3, None, "blank-row"],
+            [1, 3, None, "primary-key-error"],
+            [2, 4, None, "blank-row"],
+        ]
+
+
+@pytest.mark.ci
+def test_validate_package_with_parallel():
+    package = Package("data/invalid/datapackage.json")
+    report = package.validate(parallel=True)
+    assert report.flatten(["taskPosition", "rowPosition", "fieldPosition", "code"]) == [
+        [1, 3, None, "blank-row"],
+        [1, 3, None, "primary-key-error"],
+        [2, 4, None, "blank-row"],
+    ]
diff --git a/tests/package/actions/validate/test_schema.py b/tests/package/actions/validate/test_schema.py
new file mode 100644
index 00000000..b0bb39fb
--- /dev/null
+++ b/tests/package/actions/validate/test_schema.py
@@ -0,0 +1,131 @@
+from copy import deepcopy
+from frictionless import Package, helpers
+
+
+IS_UNIX = not helpers.is_platform("windows")
+
+
+DESCRIPTOR_FK = {
+    "resources": [
+        {
+            "name": "cities",
+            "data": [
+                ["id", "name", "next_id"],
+                [1, "london", 2],
+                [2, "paris", 3],
+                [3, "rome", 4],
+                [4, "rio", None],
+            ],
+            "schema": {
+                "fields": [
+                    {"name": "id", "type": "integer"},
+                    {"name": "name", "type": "string"},
+                    {"name": "next_id", "type": "integer"},
+                ],
+                "foreignKeys": [
+                    {"fields": "next_id", "reference": {"resource": "", "fields": "id"}},
+                    {
+                        "fields": "id",
+                        "reference": {"resource": "people", "fields": "label"},
+                    },
+                ],
+            },
+        },
+        {
+            "name": "people",
+            "data": [["label", "population"], [1, 8], [2, 2], [3, 3], [4, 6]],
+        },
+    ],
+}
+
+MULTI_FK_RESSOURCE = {
+    "name": "travel_time",
+    "data": [["from", "to", "hours"], [1, 2, 1.5], [2, 3, 8], [3, 4, 18]],
+    "schema": {
+        "fields": [
+            {"name": "from", "type": "integer"},
+            {"name": "to", "type": "integer"},
+            {"name": "hours", "type": "number"},
+        ],
+        "foreignKeys": [
+            {
+                "fields": ["from", "to"],
+                "reference": {"resource": "cities", "fields": ["id", "next_id"]},
+            }
+        ],
+    },
+}
+
+
+def test_validate_package_schema_foreign_key_error():
+    descriptor = deepcopy(DESCRIPTOR_FK)
+    package = Package(descriptor)
+    report = package.validate()
+    assert report.valid
+
+
+def test_validate_package_schema_foreign_key_not_defined():
+    descriptor = deepcopy(DESCRIPTOR_FK)
+    del descriptor["resources"][0]["schema"]["foreignKeys"]
+    package = Package(descriptor)
+    report = package.validate()
+    assert report.valid
+
+
+def test_validate_package_schema_foreign_key_self_referenced_resource_violation():
+    descriptor = deepcopy(DESCRIPTOR_FK)
+    del descriptor["resources"][0]["data"][4]
+    package = Package(descriptor)
+    report = package.validate()
+    assert report.flatten(["rowPosition", "fieldPosition", "code", "cells"]) == [
+        [4, None, "foreign-key-error", ["3", "rome", "4"]],
+    ]
+
+
+def test_validate_package_schema_foreign_key_internal_resource_violation():
+    descriptor = deepcopy(DESCRIPTOR_FK)
+    del descriptor["resources"][1]["data"][4]
+    package = Package(descriptor)
+    report = package.validate()
+    assert report.flatten(["rowPosition", "fieldPosition", "code", "cells"]) == [
+        [5, None, "foreign-key-error", ["4", "rio", ""]],
+    ]
+
+
+def test_validate_package_schema_foreign_key_internal_resource_violation_non_existent():
+    descriptor = deepcopy(DESCRIPTOR_FK)
+    descriptor["resources"][1]["data"] = [["label", "population"], [10, 10]]
+    package = Package(descriptor)
+    report = package.validate()
+    assert report.flatten(["rowPosition", "fieldPosition", "code", "cells"]) == [
+        [2, None, "foreign-key-error", ["1", "london", "2"]],
+        [3, None, "foreign-key-error", ["2", "paris", "3"]],
+        [4, None, "foreign-key-error", ["3", "rome", "4"]],
+        [5, None, "foreign-key-error", ["4", "rio", ""]],
+    ]
+
+
+def test_validate_package_schema_multiple_foreign_key():
+    descriptor = deepcopy(DESCRIPTOR_FK)
+    descriptor["resources"].append(MULTI_FK_RESSOURCE)
+    package = Package(descriptor)
+    report = package.validate()
+    assert report.valid
+
+
+def test_validate_package_schema_multiple_foreign_key_resource_violation_non_existent():
+    descriptor = deepcopy(DESCRIPTOR_FK)
+    # remove London
+    del descriptor["resources"][0]["data"][1]
+    descriptor["resources"].append(MULTI_FK_RESSOURCE)
+    package = Package(descriptor)
+    report = package.validate()
+    assert report.flatten(["rowPosition", "fieldPosition", "code", "cells", "note"]) == [
+        [
+            2,
+            None,
+            "foreign-key-error",
+            ["1", "2", "1.5"],
+            'for "from, to": values "1, 2" not found in the lookup table "cities" as "id, next_id"',
+        ],
+    ]
diff --git a/tests/package/actions/validate/test_stats.py b/tests/package/actions/validate/test_stats.py
new file mode 100644
index 00000000..ccb29aeb
--- /dev/null
+++ b/tests/package/actions/validate/test_stats.py
@@ -0,0 +1,90 @@
+from copy import deepcopy
+from frictionless import Package, helpers
+
+
+IS_UNIX = not helpers.is_platform("windows")
+
+DESCRIPTOR_SH = {
+    "resources": [
+        {
+            "name": "resource1",
+            "path": "data/table.csv",
+            "hashing": "sha256",
+            "stats": {
+                "hash": "a1fd6c5ff3494f697874deeb07f69f8667e903dd94a7bc062dd57550cea26da8",
+                "bytes": 30,
+            },
+        }
+    ]
+}
+
+
+def test_validate_package_stats():
+    source = deepcopy(DESCRIPTOR_SH)
+    package = Package(source)
+    report = package.validate()
+    if IS_UNIX:
+        assert report.valid
+
+
+def test_validate_package_stats_invalid():
+    source = deepcopy(DESCRIPTOR_SH)
+    source["resources"][0]["stats"]["hash"] += "a"
+    source["resources"][0]["stats"]["bytes"] += 1
+    package = Package(source)
+    report = package.validate()
+    assert report.flatten(["rowPosition", "fieldPosition", "code"]) == [
+        [None, None, "hash-count-error"],
+        [None, None, "byte-count-error"],
+    ]
+
+
+def test_validate_package_stats_size():
+    source = deepcopy(DESCRIPTOR_SH)
+    source["resources"][0]["stats"].pop("hash")
+    package = Package(source)
+    report = package.validate()
+    if IS_UNIX:
+        assert report.valid
+
+
+def test_validate_package_stats_size_invalid():
+    source = deepcopy(DESCRIPTOR_SH)
+    source["resources"][0]["stats"]["bytes"] += 1
+    source["resources"][0]["stats"].pop("hash")
+    package = Package(source)
+    report = package.validate()
+    assert report.flatten(["rowPosition", "fieldPosition", "code"]) == [
+        [None, None, "byte-count-error"],
+    ]
+
+
+def test_validate_package_stats_hash():
+    source = deepcopy(DESCRIPTOR_SH)
+    source["resources"][0]["stats"].pop("bytes")
+    package = Package(source)
+    report = package.validate()
+    if IS_UNIX:
+        assert report.valid
+
+
+def test_check_file_package_stats_hash_invalid():
+    source = deepcopy(DESCRIPTOR_SH)
+    source["resources"][0]["stats"].pop("bytes")
+    source["resources"][0]["stats"]["hash"] += "a"
+    package = Package(source)
+    report = package.validate()
+    assert report.flatten(["rowPosition", "fieldPosition", "code"]) == [
+        [None, None, "hash-count-error"],
+    ]
+
+
+def test_check_file_package_stats_hash_not_supported_algorithm():
+    source = deepcopy(DESCRIPTOR_SH)
+    source["resources"][0]["hashing"] = "bad"
+    source["resources"][0]["stats"].pop("bytes")
+    package = Package(source)
+    report = package.validate()
+    assert report.flatten(["rowPosition", "fieldPosition", "code"]) == [
+        [None, None, "hashing-error"],
+    ]
diff --git a/tests/resource/actions/__init__.py b/tests/resource/actions/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/resource/actions/describe/__init__.py b/tests/resource/actions/describe/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/resource/actions/describe/test_general.py b/tests/resource/actions/describe/test_general.py
new file mode 100644
index 00000000..e95e9e17
--- /dev/null
+++ b/tests/resource/actions/describe/test_general.py
@@ -0,0 +1,188 @@
+from frictionless import Resource, Detector, Layout, helpers
+
+
+IS_UNIX = not helpers.is_platform("windows")
+
+
+# General
+
+
+def test_describe_resource():
+    resource = Resource.describe("data/table.csv")
+    assert resource.metadata_valid
+    assert resource == {
+        "profile": "tabular-data-resource",
+        "name": "table",
+        "path": "data/table.csv",
+        "scheme": "file",
+        "format": "csv",
+        "hashing": "md5",
+        "encoding": "utf-8",
+        "schema": {
+            "fields": [
+                {"name": "id", "type": "integer"},
+                {"name": "name", "type": "string"},
+            ]
+        },
+    }
+
+
+def test_describe_resource_with_stats():
+    resource = Resource.describe("data/table.csv", stats=True)
+    assert resource.metadata_valid
+    if IS_UNIX:
+        assert resource == {
+            "profile": "tabular-data-resource",
+            "name": "table",
+            "path": "data/table.csv",
+            "scheme": "file",
+            "format": "csv",
+            "hashing": "md5",
+            "encoding": "utf-8",
+            "schema": {
+                "fields": [
+                    {"name": "id", "type": "integer"},
+                    {"name": "name", "type": "string"},
+                ]
+            },
+            "stats": {
+                "hash": "6c2c61dd9b0e9c6876139a449ed87933",
+                "bytes": 30,
+                "fields": 2,
+                "rows": 2,
+            },
+        }
+
+
+def test_describe_resource_schema():
+    resource = Resource.describe("data/table-infer.csv")
+    assert resource.schema == {
+        "fields": [
+            {"name": "id", "type": "integer"},
+            {"name": "age", "type": "integer"},
+            {"name": "name", "type": "string"},
+        ],
+    }
+
+
+def test_describe_resource_schema_utf8():
+    resource = Resource.describe("data/table-infer-utf8.csv")
+    assert resource.schema == {
+        "fields": [
+            {"name": "id", "type": "integer"},
+            {"name": "age", "type": "integer"},
+            {"name": "name", "type": "string"},
+        ],
+    }
+
+
+def test_describe_resource_schema_expand():
+    resource = Resource.describe("data/table-infer.csv", expand=True)
+    assert resource.schema == {
+        "fields": [
+            {"name": "id", "type": "integer", "format": "default", "bareNumber": True},
+            {"name": "age", "type": "integer", "format": "default", "bareNumber": True},
+            {"name": "name", "type": "string", "format": "default"},
+        ],
+        "missingValues": [""],
+    }
+
+
+def test_describe_resource_schema_infer_volume():
+    detector = Detector(sample_size=4)
+    resource = Resource.describe("data/table-infer-row-limit.csv", detector=detector)
+    assert resource.schema == {
+        "fields": [
+            {"name": "id", "type": "integer"},
+            {"name": "age", "type": "integer"},
+            {"name": "name", "type": "string"},
+        ],
+    }
+
+
+def test_describe_resource_schema_with_missing_values_default():
+    resource = Resource.describe("data/table-infer-missing-values.csv")
+    assert resource.schema == {
+        "fields": [
+            {"name": "id", "type": "string"},
+            {"name": "age", "type": "integer"},
+            {"name": "name", "type": "string"},
+        ],
+    }
+
+
+def test_describe_resource_schema_with_missing_values_using_the_argument():
+    detector = Detector(field_missing_values=["-"])
+    resource = Resource.describe("data/table-infer-missing-values.csv", detector=detector)
+    assert resource.schema == {
+        "fields": [
+            {"name": "id", "type": "integer"},
+            {"name": "age", "type": "integer"},
+            {"name": "name", "type": "string"},
+        ],
+        "missingValues": ["-"],
+    }
+
+
+def test_describe_resource_schema_check_type_boolean_string_tie():
+    layout = Layout(header=False)
+    detector = Detector(field_names=["field"])
+    resource = Resource.describe([["f"], ["stringish"]], layout=layout, detector=detector)
+    assert resource.schema.get_field("field").type == "string"
+
+
+# Issues
+
+
+def test_describe_resource_schema_xlsx_file_with_boolean_column_issue_203():
+    resource = Resource.describe("data/table-infer-boolean.xlsx")
+    assert resource.schema == {
+        "fields": [
+            {"name": "number", "type": "integer"},
+            {"name": "string", "type": "string"},
+            {"name": "boolean", "type": "boolean"},
+        ],
+    }
+
+
+def test_describe_resource_schema_increase_limit_issue_212():
+    detector = Detector(sample_size=200)
+    resource = Resource.describe("data/table-infer-increase-limit.csv", detector=detector)
+    assert resource.schema == {
+        "fields": [{"name": "a", "type": "integer"}, {"name": "b", "type": "number"}],
+    }
+
+
+def test_describe_resource_values_with_leading_zeros_issue_492():
+    resource = Resource.describe("data/leading-zeros.csv")
+    assert resource.schema == {"fields": [{"name": "value", "type": "integer"}]}
+    assert resource.read_rows() == [{"value": 1}, {"value": 2}, {"value": 3}]
+
+
+def test_describe_schema_proper_quote_issue_493():
+    resource = Resource.describe("data/issue-493.csv")
+    assert resource.dialect.quote_char == '"'
+    assert len(resource.schema.fields) == 126
+
+
+def test_describe_file_with_different_characters_name_issue_600():
+    assert Resource.describe("data/table_with_data.csv").name == "table_with_data"
+    assert Resource.describe("data/Table With Data.csv").name == "table-with-data"
+    assert Resource.describe("data/Таблица.csv").name == "tablitsa"
+
+
+def test_describe_resource_compression_gzip_issue_606():
+    resource = Resource.describe("data/table.csv.gz", stats=True)
+    assert resource.name == "table"
+    assert resource.stats["hash"] == "edf56ce48e402d83eb08d5dac6aa2ad9"
+    assert resource.stats["bytes"] == 61
+
+
+def test_describe_resource_with_json_format_issue_827():
+    resource = Resource.describe(path="data/table.json")
+    assert resource.name == "table"
+
+
+def test_describe_resource_with_years_in_the_header_issue_825():
+    resource = Resource.describe("data/issue-825.csv")
+    assert resource.schema.field_names == ["Musei", "2011", "2010"]
diff --git a/tests/resource/actions/extract/__init__.py b/tests/resource/actions/extract/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/resource/actions/extract/test_general.py b/tests/resource/actions/extract/test_general.py
new file mode 100644
index 00000000..d082465a
--- /dev/null
+++ b/tests/resource/actions/extract/test_general.py
@@ -0,0 +1,110 @@
+import os
+import types
+from pathlib import Path
+from frictionless import Resource
+
+
+# General
+
+
+def test_extract_resource():
+    resource = Resource("data/resource.json")
+    assert resource.extract() == [
+        {"id": 1, "name": "english"},
+        {"id": 2, "name": "中国人"},
+    ]
+
+
+def test_extract_resource_process():
+    resource = Resource("data/resource.json")
+    process = lambda row: row.to_list()
+    assert resource.extract(process=process) == [
+        [1, "english"],
+        [2, "中国人"],
+    ]
+
+
+def test_extract_resource_stream():
+    resource = Resource("data/resource.json")
+    row_stream = resource.extract(stream=True)
+    assert isinstance(row_stream, types.GeneratorType)
+    assert list(row_stream) == [
+        {"id": 1, "name": "english"},
+        {"id": 2, "name": "中国人"},
+    ]
+
+
+def test_extract_resource_process_and_stream():
+    resource = Resource("data/resource.json")
+    process = lambda row: row.to_list()
+    list_stream = resource.extract(process=process, stream=True)
+    assert isinstance(list_stream, types.GeneratorType)
+    assert list(list_stream) == [
+        [1, "english"],
+        [2, "中国人"],
+    ]
+
+
+def test_extract_resource_from_file():
+    resource = Resource("data/table.csv")
+    assert resource.extract() == [
+        {"id": 1, "name": "english"},
+        {"id": 2, "name": "中国人"},
+    ]
+
+
+def test_extract_resource_from_file_process():
+    resource = Resource("data/table.csv")
+    process = lambda row: row.to_list()
+    assert resource.extract(process=process) == [
+        [1, "english"],
+        [2, "中国人"],
+    ]
+
+
+def test_extract_resource_from_file_stream():
+    resource = Resource("data/table.csv")
+    row_stream = resource.extract(stream=True)
+    assert isinstance(row_stream, types.GeneratorType)
+    assert list(row_stream) == [
+        {"id": 1, "name": "english"},
+        {"id": 2, "name": "中国人"},
+    ]
+
+
+def test_extract_resource_from_file_pathlib():
+    resource = Resource(Path("data/table.csv"))
+    assert resource.extract() == [
+        {"id": 1, "name": "english"},
+        {"id": 2, "name": "中国人"},
+    ]
+
+
+def test_extract_resource_from_file_process_and_stream():
+    resource = Resource("data/table.csv")
+    process = lambda row: row.to_list()
+    list_stream = resource.extract(process=process, stream=True)
+    assert isinstance(list_stream, types.GeneratorType)
+    assert list(list_stream) == [
+        [1, "english"],
+        [2, "中国人"],
+    ]
+
+
+def test_extract_resource_from_json_format_issue_827():
+    resource = Resource(path="data/table.json")
+    rows = resource.extract()
+    assert rows == [
+        {"id": 1, "name": "english"},
+        {"id": 2, "name": "中国人"},
+    ]
+
+
+def test_extract_resource_basepath_and_abspath_issue_856():
+    descriptor = {"path": os.path.abspath("data/table.csv")}
+    resource = Resource(descriptor, basepath="data", trusted=True)
+    rows = resource.extract()
+    assert rows == [
+        {"id": 1, "name": "english"},
+        {"id": 2, "name": "中国人"},
+    ]
diff --git a/tests/resource/actions/transform/__init__.py b/tests/resource/actions/transform/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/resource/actions/transform/test_general.py b/tests/resource/actions/transform/test_general.py
new file mode 100644
index 00000000..4c4f389c
--- /dev/null
+++ b/tests/resource/actions/transform/test_general.py
@@ -0,0 +1,30 @@
+from frictionless import Resource, steps
+
+
+# General
+
+
+def test_transform_resource():
+    source = Resource(path="data/transform.csv")
+    source.infer()
+    target = source.transform(
+        steps=[
+            steps.table_normalize(),
+            steps.table_melt(field_name="id"),
+        ],
+    )
+    assert target.schema == {
+        "fields": [
+            {"name": "id", "type": "integer"},
+            {"name": "variable"},
+            {"name": "value"},
+        ]
+    }
+    assert target.read_rows() == [
+        {"id": 1, "variable": "name", "value": "germany"},
+        {"id": 1, "variable": "population", "value": 83},
+        {"id": 2, "variable": "name", "value": "france"},
+        {"id": 2, "variable": "population", "value": 66},
+        {"id": 3, "variable": "name", "value": "spain"},
+        {"id": 3, "variable": "population", "value": 47},
+    ]
diff --git a/tests/resource/actions/validate/__init__.py b/tests/resource/actions/validate/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/resource/actions/validate/test_compression.py b/tests/resource/actions/validate/test_compression.py
new file mode 100644
index 00000000..d10df356
--- /dev/null
+++ b/tests/resource/actions/validate/test_compression.py
@@ -0,0 +1,24 @@
+from frictionless import Resource, helpers
+
+
+IS_UNIX = not helpers.is_platform("windows")
+
+
+def test_validate_compression():
+    resource = Resource("data/table.csv.zip")
+    report = resource.validate()
+    assert report.valid
+
+
+def test_validate_compression_explicit():
+    resource = Resource("data/table.csv.zip", compression="zip")
+    report = resource.validate()
+    assert report.valid
+
+
+def test_validate_compression_invalid():
+    resource = Resource("data/table.csv.zip", compression="bad")
+    report = resource.validate()
+    assert report.flatten(["code", "note"]) == [
+        ["compression-error", 'compression "bad" is not supported'],
+    ]
diff --git a/tests/resource/actions/validate/test_detector.py b/tests/resource/actions/validate/test_detector.py
new file mode 100644
index 00000000..301f04da
--- /dev/null
+++ b/tests/resource/actions/validate/test_detector.py
@@ -0,0 +1,117 @@
+from frictionless import Detector, Resource, helpers
+
+
+IS_UNIX = not helpers.is_platform("windows")
+
+
+def test_validate_detector_sync_schema():
+    schema = {
+        "fields": [
+            {"name": "id", "type": "integer"},
+            {"name": "name", "type": "string"},
+        ],
+    }
+    detector = Detector(schema_sync=True)
+    resource = Resource("data/sync-schema.csv", schema=schema, detector=detector)
+    report = resource.validate()
+    assert report.valid
+    assert report.task.resource.schema == {
+        "fields": [
+            {"name": "name", "type": "string"},
+            {"name": "id", "type": "integer"},
+        ],
+    }
+
+
+def test_validate_detector_sync_schema_invalid():
+    source = [["LastName", "FirstName", "Address"], ["Test", "Tester", "23 Avenue"]]
+    schema = {"fields": [{"name": "id"}, {"name": "FirstName"}, {"name": "LastName"}]}
+    detector = Detector(schema_sync=True)
+    resource = Resource(source, schema=schema, detector=detector)
+    report = resource.validate()
+    assert report.valid
+
+
+def test_validate_detector_headers_errors():
+    source = [
+        ["id", "last_name", "first_name", "language"],
+        [1, "Alex", "John", "English"],
+        [2, "Peters", "John", "Afrikaans"],
+        [3, "Smith", "Paul", None],
+    ]
+    schema = {
+        "fields": [
+            {"name": "id", "type": "number"},
+            {"name": "language", "constraints": {"required": True}},
+            {"name": "country"},
+        ]
+    }
+    detector = Detector(schema_sync=True)
+    resource = Resource(source, schema=schema, detector=detector)
+    report = resource.validate()
+    assert report.flatten(["rowPosition", "fieldPosition", "code", "cells"]) == [
+        [4, 4, "constraint-error", ["3", "Smith", "Paul", ""]],
+    ]
+
+
+def test_validate_detector_patch_schema():
+    detector = Detector(schema_patch={"missingValues": ["-"]})
+    resource = Resource("data/table.csv", detector=detector)
+    report = resource.validate()
+    assert report.valid
+    assert report.task.resource.schema == {
+        "fields": [
+            {"name": "id", "type": "integer"},
+            {"name": "name", "type": "string"},
+        ],
+        "missingValues": ["-"],
+    }
+
+
+def test_validate_detector_patch_schema_fields():
+    detector = Detector(
+        schema_patch={"fields": {"id": {"type": "string"}}, "missingValues": ["-"]}
+    )
+    resource = Resource("data/table.csv", detector=detector)
+    report = resource.validate()
+    assert report.valid
+    assert report.task.resource.schema == {
+        "fields": [{"name": "id", "type": "string"}, {"name": "name", "type": "string"}],
+        "missingValues": ["-"],
+    }
+
+
+def test_validate_detector_infer_type_string():
+    detector = Detector(field_type="string")
+    resource = Resource("data/table.csv", detector=detector)
+    report = resource.validate()
+    assert report.valid
+    assert report.task.resource.schema == {
+        "fields": [{"name": "id", "type": "string"}, {"name": "name", "type": "string"}],
+    }
+
+
+def test_validate_detector_infer_type_any():
+    detector = Detector(field_type="any")
+    resource = Resource("data/table.csv", detector=detector)
+    report = resource.validate()
+    assert report.valid
+    assert report.task.resource.schema == {
+        "fields": [{"name": "id", "type": "any"}, {"name": "name", "type": "any"}],
+    }
+
+
+def test_validate_detector_infer_names():
+    detector = Detector(field_names=["id", "name"])
+    resource = Resource(
+        "data/without-headers.csv",
+        layout={"header": False},
+        detector=detector,
+    )
+    report = resource.validate()
+    assert report.task.resource.schema["fields"][0]["name"] == "id"
+    assert report.task.resource.schema["fields"][1]["name"] == "name"
+    assert report.task.resource.stats["rows"] == 3
+    assert report.task.resource.labels == []
+    assert report.task.resource.header == ["id", "name"]
+    assert report.valid
diff --git a/tests/resource/actions/validate/test_dialect.py b/tests/resource/actions/validate/test_dialect.py
new file mode 100644
index 00000000..3186d233
--- /dev/null
+++ b/tests/resource/actions/validate/test_dialect.py
@@ -0,0 +1,11 @@
+from frictionless import Resource, helpers
+
+
+IS_UNIX = not helpers.is_platform("windows")
+
+
+def test_validate_dialect_delimiter():
+    resource = Resource("data/delimiter.csv", dialect={"delimiter": ";"})
+    report = resource.validate()
+    assert report.valid
+    assert report.task.resource.stats["rows"] == 2
diff --git a/tests/resource/actions/validate/test_encoding.py b/tests/resource/actions/validate/test_encoding.py
new file mode 100644
index 00000000..096e8c8f
--- /dev/null
+++ b/tests/resource/actions/validate/test_encoding.py
@@ -0,0 +1,23 @@
+from frictionless import Resource, helpers
+
+
+IS_UNIX = not helpers.is_platform("windows")
+
+
+def test_validate_encoding():
+    resource = Resource("data/table.csv", encoding="utf-8")
+    report = resource.validate()
+    assert report.valid
+
+
+def test_validate_encoding_invalid():
+    resource = Resource("data/latin1.csv", encoding="utf-8")
+    report = resource.validate()
+    assert not report.valid
+    if IS_UNIX:
+        assert report.flatten(["code", "note"]) == [
+            [
+                "encoding-error",
+                "'utf-8' codec can't decode byte 0xa9 in position 20: invalid start byte",
+            ],
+        ]
diff --git a/tests/resource/actions/validate/test_format.py b/tests/resource/actions/validate/test_format.py
new file mode 100644
index 00000000..996ddfeb
--- /dev/null
+++ b/tests/resource/actions/validate/test_format.py
@@ -0,0 +1,16 @@
+from frictionless import Resource, helpers
+
+
+IS_UNIX = not helpers.is_platform("windows")
+
+
+def test_validate_format():
+    resource = Resource("data/table.csv", format="csv")
+    report = resource.validate()
+    assert report.valid
+
+
+def test_validate_format_non_tabular():
+    resource = Resource("data/table.bad")
+    report = resource.validate()
+    assert report.valid
diff --git a/tests/resource/actions/validate/test_general.py b/tests/resource/actions/validate/test_general.py
new file mode 100644
index 00000000..7d5e025e
--- /dev/null
+++ b/tests/resource/actions/validate/test_general.py
@@ -0,0 +1,521 @@
+import pytest
+import pathlib
+from frictionless import Resource, Detector, Layout, Check, errors, helpers
+
+
+IS_UNIX = not helpers.is_platform("windows")
+
+
+# General
+
+
+def test_validate():
+    resource = Resource({"path": "data/table.csv"})
+    report = resource.validate()
+    assert report.valid
+
+
+# TODO: move to general validate
+@pytest.mark.skip
+def test_validate_invalid_source():
+    resource = Resource("bad.json")
+    report = resource.validate()
+    assert report["stats"]["errors"] == 1
+    [[code, note]] = report.flatten(["code", "note"])
+    assert code == "resource-error"
+    assert note.count("[Errno 2]") and note.count("bad.json")
+
+
+def test_validate_invalid_resource():
+    resource = Resource({"path": "data/table.csv", "schema": "bad"})
+    report = resource.validate()
+    assert report["stats"]["errors"] == 1
+    [[code, note]] = report.flatten(["code", "note"])
+    assert code == "schema-error"
+    assert note.count("[Errno 2]") and note.count("bad")
+
+
+def test_validate_invalid_resource_original():
+    resource = Resource({"path": "data/table.csv"})
+    report = resource.validate(original=True)
+    assert report.flatten(["code", "note"]) == [
+        [
+            "resource-error",
+            '"{\'path\': \'data/table.csv\'} is not valid under any of the given schemas" at "" in metadata and at "oneOf" in profile',
+        ]
+    ]
+
+
+def test_validate_invalid_table():
+    resource = Resource({"path": "data/invalid.csv"})
+    report = resource.validate()
+    assert report.flatten(["rowPosition", "fieldPosition", "code"]) == [
+        [None, 3, "blank-label"],
+        [None, 4, "duplicate-label"],
+        [2, 3, "missing-cell"],
+        [2, 4, "missing-cell"],
+        [3, 3, "missing-cell"],
+        [3, 4, "missing-cell"],
+        [4, None, "blank-row"],
+        [5, 5, "extra-cell"],
+    ]
+
+
+def test_validate_resource_with_schema_as_string():
+    resource = Resource({"path": "data/table.csv", "schema": "data/schema.json"})
+    report = resource.validate()
+    assert report.valid
+
+
+def test_validate_from_path():
+    resource = Resource("data/table.csv")
+    report = resource.validate()
+    assert report.valid
+
+
+def test_validate_invalid():
+    resource = Resource("data/invalid.csv")
+    report = resource.validate()
+    assert report.flatten(["rowPosition", "fieldPosition", "code"]) == [
+        [None, 3, "blank-label"],
+        [None, 4, "duplicate-label"],
+        [2, 3, "missing-cell"],
+        [2, 4, "missing-cell"],
+        [3, 3, "missing-cell"],
+        [3, 4, "missing-cell"],
+        [4, None, "blank-row"],
+        [5, 5, "extra-cell"],
+    ]
+
+
+def test_validate_blank_headers():
+    resource = Resource("data/blank-headers.csv")
+    report = resource.validate()
+    assert report.flatten(["rowPosition", "fieldPosition", "code"]) == [
+        [None, 2, "blank-label"],
+    ]
+
+
+def test_validate_duplicate_headers():
+    resource = Resource("data/duplicate-headers.csv")
+    report = resource.validate()
+    assert report.flatten(["rowPosition", "fieldPosition", "code"]) == [
+        [None, 3, "duplicate-label"],
+        [None, 5, "duplicate-label"],
+    ]
+
+
+def test_validate_defective_rows():
+    resource = Resource("data/defective-rows.csv")
+    report = resource.validate()
+    assert report.flatten(["rowPosition", "fieldPosition", "code"]) == [
+        [2, 3, "missing-cell"],
+        [3, 4, "extra-cell"],
+    ]
+
+
+def test_validate_blank_rows():
+    resource = Resource("data/blank-rows.csv")
+    report = resource.validate()
+    assert report.flatten(["rowPosition", "fieldPosition", "code"]) == [
+        [4, None, "blank-row"],
+    ]
+
+
+def test_validate_blank_rows_multiple():
+    resource = Resource("data/blank-rows-multiple.csv")
+    report = resource.validate()
+    assert report.flatten(["rowPosition", "fieldPosition", "code"]) == [
+        [4, None, "blank-row"],
+        [5, None, "blank-row"],
+        [6, None, "blank-row"],
+        [7, None, "blank-row"],
+        [8, None, "blank-row"],
+        [9, None, "blank-row"],
+        [10, None, "blank-row"],
+        [11, None, "blank-row"],
+        [12, None, "blank-row"],
+        [13, None, "blank-row"],
+        [14, None, "blank-row"],
+    ]
+
+
+def test_validate_blank_cell_not_required():
+    resource = Resource("data/blank-cells.csv")
+    report = resource.validate()
+    assert report.valid
+
+
+def test_validate_no_data():
+    resource = Resource("data/empty.csv")
+    report = resource.validate()
+    assert report.flatten(["code", "note"]) == [
+        ["source-error", "the source is empty"],
+    ]
+
+
+def test_validate_no_rows():
+    resource = Resource("data/without-rows.csv")
+    report = resource.validate()
+    assert report.valid
+
+
+def test_validate_no_rows_with_compression():
+    resource = Resource("data/without-rows.csv.zip")
+    report = resource.validate()
+    assert report.valid
+
+
+def test_validate_task_error():
+    resource = Resource("data/table.csv")
+    report = resource.validate(limit_errors="bad")
+    assert report.flatten(["code"]) == [
+        ["task-error"],
+    ]
+
+
+def test_validate_source_invalid():
+    # Reducing sample size to get raise on iter, not on open
+    detector = Detector(sample_size=1)
+    resource = Resource([["h"], [1], "bad"], detector=detector)
+    report = resource.validate()
+    assert report.flatten(["rowPosition", "fieldPosition", "code"]) == [
+        [None, None, "source-error"],
+    ]
+
+
+def test_validate_source_pathlib_path_table():
+    resource = Resource(pathlib.Path("data/table.csv"))
+    report = resource.validate()
+    assert report.valid
+
+
+def test_validate_pick_errors():
+    resource = Resource("data/invalid.csv")
+    report = resource.validate(pick_errors=["blank-label", "blank-row"])
+    assert report.task.scope == ["blank-label", "blank-row"]
+    assert report.flatten(["rowPosition", "fieldPosition", "code"]) == [
+        [None, 3, "blank-label"],
+        [4, None, "blank-row"],
+    ]
+
+
+def test_validate_pick_errors_tags():
+    resource = Resource("data/invalid.csv")
+    report = resource.validate(pick_errors=["#header"])
+    assert report.task.scope == [
+        "blank-header",
+        "extra-label",
+        "missing-label",
+        "blank-label",
+        "duplicate-label",
+        "incorrect-label",
+    ]
+    assert report.flatten(["rowPosition", "fieldPosition", "code"]) == [
+        [None, 3, "blank-label"],
+        [None, 4, "duplicate-label"],
+    ]
+
+
+def test_validate_skip_errors():
+    resource = Resource("data/invalid.csv")
+    report = resource.validate(skip_errors=["blank-label", "blank-row"])
+    assert report.flatten(["rowPosition", "fieldPosition", "code"]) == [
+        [None, 4, "duplicate-label"],
+        [2, 3, "missing-cell"],
+        [2, 4, "missing-cell"],
+        [3, 3, "missing-cell"],
+        [3, 4, "missing-cell"],
+        [5, 5, "extra-cell"],
+    ]
+
+
+def test_validate_skip_errors_tags():
+    resource = Resource("data/invalid.csv")
+    report = resource.validate(skip_errors=["#header"])
+    assert report.flatten(["rowPosition", "fieldPosition", "code"]) == [
+        [2, 3, "missing-cell"],
+        [2, 4, "missing-cell"],
+        [3, 3, "missing-cell"],
+        [3, 4, "missing-cell"],
+        [4, None, "blank-row"],
+        [5, 5, "extra-cell"],
+    ]
+
+
+def test_validate_invalid_limit_errors():
+    resource = Resource("data/invalid.csv")
+    report = resource.validate(limit_errors=3)
+    assert report.task.partial
+    assert report.flatten(["rowPosition", "fieldPosition", "code"]) == [
+        [None, 3, "blank-label"],
+        [None, 4, "duplicate-label"],
+        [2, 3, "missing-cell"],
+    ]
+
+
+def test_validate_structure_errors_with_limit_errors():
+    resource = Resource("data/structure-errors.csv")
+    report = resource.validate(limit_errors=3)
+    assert report.task.partial
+    assert report.flatten(["rowPosition", "fieldPosition", "code"]) == [
+        [4, None, "blank-row"],
+        [5, 4, "extra-cell"],
+        [5, 5, "extra-cell"],
+    ]
+
+
+@pytest.mark.ci
+def test_validate_limit_memory():
+    source = lambda: ([integer] for integer in range(1, 100000000))
+    schema = {"fields": [{"name": "integer", "type": "integer"}], "primaryKey": "integer"}
+    layout = Layout(header=False)
+    resource = Resource(source, schema=schema, layout=layout)
+    report = resource.validate(limit_memory=50)
+    assert report.flatten(["code", "note"]) == [
+        ["task-error", 'exceeded memory limit "50MB"']
+    ]
+
+
+@pytest.mark.ci
+def test_validate_limit_memory_small():
+    source = lambda: ([integer] for integer in range(1, 100000000))
+    schema = {"fields": [{"name": "integer", "type": "integer"}], "primaryKey": "integer"}
+    layout = Layout(header=False)
+    resource = Resource(source, schema=schema, layout=layout)
+    report = resource.validate(limit_memory=1)
+    assert report.flatten(["code", "note"]) == [
+        ["task-error", 'exceeded memory limit "1MB"']
+    ]
+
+
+def test_validate_custom_check():
+
+    # Create check
+    class custom(Check):
+        def validate_row(self, row):
+            yield errors.BlankRowError(
+                note="",
+                cells=list(map(str, row.values())),
+                row_number=row.row_number,
+                row_position=row.row_position,
+            )
+
+    # Validate resource
+    resource = Resource("data/table.csv")
+    report = resource.validate(checks=[custom()])
+    assert report.flatten(["rowPosition", "fieldPosition", "code"]) == [
+        [2, None, "blank-row"],
+        [3, None, "blank-row"],
+    ]
+
+
+def test_validate_custom_check_with_arguments():
+
+    # Create check
+    class custom(Check):
+        def __init__(self, descriptor=None, *, row_position=None):
+            self.setinitial("rowPosition", row_position)
+            super().__init__(descriptor)
+
+        def validate_row(self, row):
+            yield errors.BlankRowError(
+                note="",
+                cells=list(map(str, row.values())),
+                row_number=row.row_number,
+                row_position=self.get("rowPosition") or row.row_position,
+            )
+
+    # Validate resource
+    resource = Resource("data/table.csv")
+    report = resource.validate(checks=[custom(row_position=1)])
+    assert report.flatten(["rowPosition", "fieldPosition", "code"]) == [
+        [1, None, "blank-row"],
+        [1, None, "blank-row"],
+    ]
+
+
+def test_validate_custom_check_function_based():
+
+    # Create check
+    def custom(row):
+        yield errors.BlankRowError(
+            note="",
+            cells=list(map(str, row.values())),
+            row_number=row.row_number,
+            row_position=row.row_position,
+        )
+
+    # Validate resource
+    resource = Resource("data/table.csv")
+    report = resource.validate(checks=[custom])
+    assert report.flatten(["rowPosition", "fieldPosition", "code"]) == [
+        [2, None, "blank-row"],
+        [3, None, "blank-row"],
+    ]
+
+
+def test_validate_custom_check_bad_name():
+    resource = Resource("data/table.csv")
+    report = resource.validate(checks=[{"code": "bad"}])
+    assert report.flatten(["code", "note"]) == [
+        ["check-error", 'cannot create check "bad". Try installing "frictionless-bad"'],
+    ]
+
+
+# TODO: move to general validate
+@pytest.mark.skip
+def test_validate_resource_descriptor_type_invalid():
+    resource = Resource(descriptor="data/table.csv")
+    report = resource.validate()
+    assert report.flatten() == [[1, None, None, "resource-error"]]
+
+
+# Issues
+
+
+def test_validate_infer_fields_issue_223():
+    source = [["name1", "name2"], ["123", "abc"], ["456", "def"], ["789", "ghi"]]
+    detector = Detector(schema_patch={"fields": {"name": {"type": "string"}}})
+    resource = Resource(source, detector=detector)
+    report = resource.validate()
+    assert report.valid
+
+
+def test_validate_infer_fields_issue_225():
+    source = [["name1", "name2"], ["123", None], ["456", None], ["789"]]
+    detector = Detector(schema_patch={"fields": {"name": {"type": "string"}}})
+    resource = Resource(source, detector=detector)
+    report = resource.validate()
+    assert report.flatten(["rowPosition", "fieldPosition", "code"]) == [
+        [4, 2, "missing-cell"],
+    ]
+
+
+def test_validate_fails_with_wrong_encoding_issue_274():
+    # For now, by default encoding is detected incorectly by chardet
+    resource = Resource("data/encoding-issue-274.csv", encoding="utf-8")
+    report = resource.validate()
+    assert report.valid
+
+
+def test_validate_wide_table_with_order_fields_issue_277():
+    source = "data/issue-277.csv"
+    schema = "data/issue-277.json"
+    detector = Detector(schema_sync=True)
+    resource = Resource(source, schema=schema, detector=detector)
+    report = resource.validate()
+    assert report.flatten(["rowPosition", "fieldPosition", "code"]) == [
+        [49, 50, "constraint-error"],
+        [68, 50, "constraint-error"],
+        [69, 50, "constraint-error"],
+    ]
+
+
+def test_validate_invalid_table_schema_issue_304():
+    source = [["name", "age"], ["Alex", "33"]]
+    schema = {"fields": [{"name": "name"}, {"name": "age", "type": "bad"}]}
+    resource = Resource(source, schema=schema)
+    report = resource.validate()
+    assert report.flatten(["code", "note"]) == [
+        [
+            "field-error",
+            "\"{'name': 'age', 'type': 'bad'} is not valid under any of the given schemas\" at \"\" in metadata and at \"anyOf\" in profile",
+        ],
+    ]
+
+
+def test_validate_table_is_invalid_issue_312():
+    resource = Resource("data/issue-312.xlsx")
+    report = resource.validate()
+    assert report.flatten(["rowPosition", "fieldPosition", "code"]) == [
+        [None, 3, "blank-label"],
+        [None, 4, "duplicate-label"],
+        [None, 5, "blank-label"],
+        [5, None, "blank-row"],
+    ]
+
+
+def test_validate_order_fields_issue_313():
+    source = "data/issue-313.xlsx"
+    layout = Layout(pick_fields=[1, 2, 3, 4, 5])
+    schema = {
+        "fields": [
+            {"name": "Column_1", "type": "string"},
+            {"name": "Column_2", "type": "string", "constraints": {"required": True}},
+            {"name": "Column_3", "type": "string"},
+            {"name": "Column_4", "type": "string"},
+            {"name": "Column_5", "type": "string"},
+        ]
+    }
+    detector = Detector(schema_sync=True)
+    resource = Resource(source, layout=layout, schema=schema, detector=detector)
+    report = resource.validate()
+    assert report.valid
+
+
+def test_validate_missing_local_file_raises_scheme_error_issue_315():
+    resource = Resource("bad-path.csv")
+    report = resource.validate()
+    assert report["stats"]["errors"] == 1
+    [[code, note]] = report.flatten(["code", "note"])
+    assert code == "scheme-error"
+    assert note.count("[Errno 2]") and note.count("bad-path.csv")
+
+
+def test_validate_inline_not_a_binary_issue_349():
+    with open("data/table.csv") as source:
+        resource = Resource(source)
+        report = resource.validate()
+        assert report.valid
+
+
+def test_validate_newline_inside_label_issue_811():
+    resource = Resource("data/issue-811.csv")
+    report = resource.validate()
+    assert report.valid
+
+
+def test_validate_resource_from_json_format_issue_827():
+    resource = Resource(path="data/table.json")
+    report = resource.validate()
+    assert report.valid
+
+
+def test_validate_resource_none_is_not_iterable_enum_constraint_issue_833():
+    resource = Resource("data/issue-833.csv", schema="data/issue-833.json")
+    report = resource.validate()
+    assert report.valid
+
+
+def test_validate_resource_header_row_has_first_number_issue_870():
+    resource = Resource("data/issue-870.xlsx", layout={"limitRows": 5})
+    report = resource.validate()
+    assert report.valid
+
+
+def test_validate_resource_array_path_issue_991():
+    resource = Resource("data/issue-991.resource.json")
+    report = resource.validate()
+    assert report.flatten(["code", "note"]) == [
+        [
+            "scheme-error",
+            'Multipart resource requires "multipart" scheme but "file" is set',
+        ],
+    ]
+
+
+def test_validate_resource_duplicate_labels_with_sync_schema_issue_910():
+    detector = Detector(schema_sync=True)
+    resource = Resource(
+        "data/duplicate-column.csv",
+        schema="data/duplicate-column-schema.json",
+        detector=detector,
+    )
+    report = resource.validate()
+    assert report.flatten(["code", "note"]) == [
+        [
+            "general-error",
+            'Duplicate labels in header is not supported with "schema_sync"',
+        ],
+    ]
diff --git a/tests/resource/actions/validate/test_layout.py b/tests/resource/actions/validate/test_layout.py
new file mode 100644
index 00000000..8c4e8218
--- /dev/null
+++ b/tests/resource/actions/validate/test_layout.py
@@ -0,0 +1,228 @@
+from frictionless import Layout, Resource, helpers
+
+
+IS_UNIX = not helpers.is_platform("windows")
+
+
+def test_validate_layout_none():
+    layout = Layout(header=False)
+    resource = Resource("data/without-headers.csv", layout=layout)
+    report = resource.validate()
+    assert report.valid
+    assert report.task.resource.stats["rows"] == 3
+    assert report.task.resource.layout.header is False
+    assert report.task.resource.labels == []
+    assert report.task.resource.header == ["field1", "field2"]
+
+
+def test_validate_layout_none_extra_cell():
+    layout = Layout(header=False)
+    resource = Resource("data/without-headers-extra.csv", layout=layout)
+    report = resource.validate()
+    assert report.task.resource.stats["rows"] == 3
+    assert report.task.resource.layout.header is False
+    assert report.task.resource.labels == []
+    assert report.task.resource.header == ["field1", "field2"]
+    assert report.flatten(["rowPosition", "fieldPosition", "code"]) == [
+        [3, 3, "extra-cell"],
+    ]
+
+
+def test_validate_layout_number():
+    layout = Layout(header_rows=[2])
+    resource = Resource("data/matrix.csv", layout=layout)
+    report = resource.validate()
+    assert report.task.resource.header == ["11", "12", "13", "14"]
+    assert report.valid
+
+
+def test_validate_layout_list_of_numbers():
+    layout = Layout(header_rows=[2, 3, 4])
+    resource = Resource("data/matrix.csv", layout=layout)
+    report = resource.validate()
+    assert report.task.resource.header == ["11 21 31", "12 22 32", "13 23 33", "14 24 34"]
+    assert report.valid
+
+
+def test_validate_layout_list_of_numbers_and_headers_join():
+    layout = Layout(header_rows=[2, 3, 4], header_join=".")
+    resource = Resource("data/matrix.csv", layout=layout)
+    report = resource.validate()
+    assert report.task.resource.header == ["11.21.31", "12.22.32", "13.23.33", "14.24.34"]
+    assert report.valid
+
+
+def test_validate_layout_pick_fields():
+    layout = Layout(pick_fields=[2, "f3"])
+    resource = Resource("data/matrix.csv", layout=layout)
+    report = resource.validate()
+    assert report.task.resource.header == ["f2", "f3"]
+    assert report.task.resource.stats["rows"] == 4
+    assert report.task.valid
+
+
+def test_validate_layout_pick_fields_regex():
+    layout = Layout(pick_fields=["<regex>f[23]"])
+    resource = Resource("data/matrix.csv", layout=layout)
+    report = resource.validate()
+    assert report.task.resource.header == ["f2", "f3"]
+    assert report.task.resource.stats["rows"] == 4
+    assert report.task.valid
+
+
+def test_validate_layout_skip_fields():
+    layout = Layout(skip_fields=[1, "f4"])
+    resource = Resource("data/matrix.csv", layout=layout)
+    report = resource.validate()
+    assert report.task.resource.header == ["f2", "f3"]
+    assert report.task.resource.stats["rows"] == 4
+    assert report.task.valid
+
+
+def test_validate_layout_skip_fields_regex():
+    layout = Layout(skip_fields=["<regex>f[14]"])
+    resource = Resource("data/matrix.csv", layout=layout)
+    report = resource.validate()
+    assert report.task.resource.header == ["f2", "f3"]
+    assert report.task.resource.stats["rows"] == 4
+    assert report.task.valid
+
+
+def test_validate_layout_limit_fields():
+    layout = Layout(limit_fields=1)
+    resource = Resource("data/matrix.csv", layout=layout)
+    report = resource.validate()
+    assert report.task.resource.header == ["f1"]
+    assert report.task.resource.stats["rows"] == 4
+    assert report.task.valid
+
+
+def test_validate_layout_offset_fields():
+    layout = Layout(offset_fields=3)
+    resource = Resource("data/matrix.csv", layout=layout)
+    report = resource.validate()
+    assert report.task.resource.header == ["f4"]
+    assert report.task.resource.stats["rows"] == 4
+    assert report.task.valid
+
+
+def test_validate_layout_limit_and_offset_fields():
+    layout = Layout(limit_fields=2, offset_fields=1)
+    resource = Resource("data/matrix.csv", layout=layout)
+    report = resource.validate()
+    assert report.task.resource.header == ["f2", "f3"]
+    assert report.task.resource.stats["rows"] == 4
+    assert report.task.valid
+
+
+def test_validate_layout_pick_rows():
+    layout = Layout(pick_rows=[1, 3, "31"])
+    resource = Resource("data/matrix.csv", layout=layout)
+    report = resource.validate()
+    assert report.task.resource.header == ["f1", "f2", "f3", "f4"]
+    assert report.task.resource.stats["rows"] == 2
+    assert report.task.valid
+
+
+def test_validate_layout_pick_rows_regex():
+    layout = Layout(pick_rows=["<regex>[f23]1"])
+    resource = Resource("data/matrix.csv", layout=layout)
+    report = resource.validate()
+    assert report.task.resource.header == ["f1", "f2", "f3", "f4"]
+    assert report.task.resource.stats["rows"] == 2
+    assert report.task.valid
+
+
+def test_validate_layout_skip_rows():
+    layout = Layout(skip_rows=[2, "41"])
+    resource = Resource("data/matrix.csv", layout=layout)
+    report = resource.validate()
+    assert report.task.resource.header == ["f1", "f2", "f3", "f4"]
+    assert report.task.resource.stats["rows"] == 2
+    assert report.task.valid
+
+
+def test_validate_layout_skip_rows_regex():
+    layout = Layout(skip_rows=["<regex>[14]1"])
+    resource = Resource("data/matrix.csv", layout=layout)
+    report = resource.validate()
+    assert report.task.resource.header == ["f1", "f2", "f3", "f4"]
+    assert report.task.resource.stats["rows"] == 2
+    assert report.task.valid
+
+
+def test_validate_layout_skip_rows_blank():
+    layout = Layout(skip_rows=["<blank>"])
+    resource = Resource("data/blank-rows.csv", layout=layout)
+    report = resource.validate()
+    assert report.task.resource.header == ["id", "name", "age"]
+    assert report.task.resource.stats["rows"] == 2
+    assert report.task.valid
+
+
+def test_validate_layout_pick_rows_and_fields():
+    layout = Layout(pick_rows=[1, 3, "31"], pick_fields=[2, "f3"])
+    resource = Resource("data/matrix.csv", layout=layout)
+    report = resource.validate()
+    assert report.task.resource.header == ["f2", "f3"]
+    assert report.task.resource.stats["rows"] == 2
+    assert report.task.valid
+
+
+def test_validate_layout_skip_rows_and_fields():
+    layout = Layout(skip_rows=[2, "41"], skip_fields=[1, "f4"])
+    resource = Resource("data/matrix.csv", layout=layout)
+    report = resource.validate()
+    assert report.task.resource.header == ["f2", "f3"]
+    assert report.task.resource.stats["rows"] == 2
+    assert report.task.valid
+
+
+def test_validate_layout_limit_rows():
+    layout = Layout(limit_rows=1)
+    resource = Resource("data/matrix.csv", layout=layout)
+    report = resource.validate()
+    assert report.task.resource.header == ["f1", "f2", "f3", "f4"]
+    assert report.task.resource.stats["rows"] == 1
+    assert report.task.valid
+
+
+def test_validate_layout_offset_rows():
+    layout = Layout(offset_rows=3)
+    resource = Resource("data/matrix.csv", layout=layout)
+    report = resource.validate()
+    assert report.task.resource.header == ["f1", "f2", "f3", "f4"]
+    assert report.task.resource.stats["rows"] == 1
+    assert report.task.valid
+
+
+def test_validate_layout_limit_and_offset_rows():
+    layout = Layout(limit_rows=2, offset_rows=1)
+    resource = Resource("data/matrix.csv", layout=layout)
+    report = resource.validate()
+    assert report.task.resource.header == ["f1", "f2", "f3", "f4"]
+    assert report.task.resource.stats["rows"] == 2
+    assert report.task.valid
+
+
+def test_validate_layout_invalid_limit_rows():
+    layout = Layout(limit_rows=2)
+    resource = Resource("data/invalid.csv", layout=layout)
+    report = resource.validate()
+    assert report.flatten(["rowPosition", "fieldPosition", "code"]) == [
+        [None, 3, "blank-label"],
+        [None, 4, "duplicate-label"],
+        [2, 3, "missing-cell"],
+        [2, 4, "missing-cell"],
+        [3, 3, "missing-cell"],
+        [3, 4, "missing-cell"],
+    ]
+
+
+def test_validate_layout_structure_errors_with_limit_rows():
+    layout = Layout(limit_rows=3)
+    resource = Resource("data/structure-errors.csv", layout=layout)
+    report = resource.validate()
+    assert report.flatten(["rowPosition", "fieldPosition", "code"]) == [
+        [4, None, "blank-row"],
+    ]
diff --git a/tests/resource/actions/validate/test_schema.py b/tests/resource/actions/validate/test_schema.py
new file mode 100644
index 00000000..c09e271a
--- /dev/null
+++ b/tests/resource/actions/validate/test_schema.py
@@ -0,0 +1,228 @@
+from frictionless import Resource, helpers
+
+
+IS_UNIX = not helpers.is_platform("windows")
+
+
+def test_validate_schema_invalid():
+    source = [["name", "age"], ["Alex", "33"]]
+    schema = {"fields": [{"name": "name"}, {"name": "age", "type": "bad"}]}
+    resource = Resource(source, schema=schema)
+    report = resource.validate()
+    assert report.flatten(["code", "note"]) == [
+        [
+            "field-error",
+            "\"{'name': 'age', 'type': 'bad'} is not valid under any of the given schemas\" at \"\" in metadata and at \"anyOf\" in profile",
+        ],
+    ]
+
+
+def test_validate_schema_invalid_json():
+    resource = Resource("data/table.csv", schema="data/invalid.json")
+    report = resource.validate()
+    assert report.flatten(["rowPosition", "fieldPosition", "code"]) == [
+        [None, None, "schema-error"],
+    ]
+
+
+def test_validate_schema_extra_headers_and_cells():
+    schema = {"fields": [{"name": "id", "type": "integer"}]}
+    resource = Resource("data/table.csv", schema=schema)
+    report = resource.validate()
+    assert report.flatten(["rowPosition", "fieldPosition", "code"]) == [
+        [None, 2, "extra-label"],
+        [2, 2, "extra-cell"],
+        [3, 2, "extra-cell"],
+    ]
+
+
+def test_validate_schema_multiple_errors():
+    source = "data/schema-errors.csv"
+    schema = "data/schema-valid.json"
+    resource = Resource(source, schema=schema)
+    report = resource.validate(pick_errors=["#row"], limit_errors=3)
+    assert report.task.partial
+    assert report.task.flatten(["rowPosition", "fieldPosition", "code"]) == [
+        [4, 1, "type-error"],
+        [4, 2, "constraint-error"],
+        [4, 3, "constraint-error"],
+    ]
+
+
+def test_validate_schema_min_length_constraint():
+    source = [["row", "word"], [2, "a"], [3, "ab"], [4, "abc"], [5, "abcd"], [6]]
+    schema = {
+        "fields": [
+            {"name": "row", "type": "integer"},
+            {"name": "word", "type": "string", "constraints": {"minLength": 2}},
+        ]
+    }
+    resource = Resource(source, schema=schema)
+    report = resource.validate(pick_errors=["constraint-error"])
+    assert report.flatten(["rowPosition", "fieldPosition", "code"]) == [
+        [2, 2, "constraint-error"],
+    ]
+
+
+def test_validate_schema_max_length_constraint():
+    source = [["row", "word"], [2, "a"], [3, "ab"], [4, "abc"], [5, "abcd"], [6]]
+    schema = {
+        "fields": [
+            {"name": "row", "type": "integer"},
+            {"name": "word", "type": "string", "constraints": {"maxLength": 2}},
+        ]
+    }
+    resource = Resource(source, schema=schema)
+    report = resource.validate(pick_errors=["constraint-error"])
+    assert report.flatten(["rowPosition", "fieldPosition", "code"]) == [
+        [4, 2, "constraint-error"],
+        [5, 2, "constraint-error"],
+    ]
+
+
+def test_validate_schema_minimum_constraint():
+    source = [["row", "score"], [2, 1], [3, 2], [4, 3], [5, 4], [6]]
+    schema = {
+        "fields": [
+            {"name": "row", "type": "integer"},
+            {"name": "score", "type": "integer", "constraints": {"minimum": 2}},
+        ]
+    }
+    resource = Resource(source, schema=schema)
+    report = resource.validate(pick_errors=["constraint-error"])
+    assert report.flatten(["rowPosition", "fieldPosition", "code"]) == [
+        [2, 2, "constraint-error"],
+    ]
+
+
+def test_validate_schema_maximum_constraint():
+    source = [["row", "score"], [2, 1], [3, 2], [4, 3], [5, 4], [6]]
+    schema = {
+        "fields": [
+            {"name": "row", "type": "integer"},
+            {"name": "score", "type": "integer", "constraints": {"maximum": 2}},
+        ]
+    }
+    resource = Resource(source, schema=schema)
+    report = resource.validate(pick_errors=["constraint-error"])
+    assert report.flatten(["rowPosition", "fieldPosition", "code"]) == [
+        [4, 2, "constraint-error"],
+        [5, 2, "constraint-error"],
+    ]
+
+
+def test_validate_schema_foreign_key_error_self_referencing():
+    source = {
+        "path": "data/nested.csv",
+        "schema": {
+            "fields": [
+                {"name": "id", "type": "integer"},
+                {"name": "cat", "type": "integer"},
+                {"name": "name", "type": "string"},
+            ],
+            "foreignKeys": [
+                {"fields": "cat", "reference": {"resource": "", "fields": "id"}}
+            ],
+        },
+    }
+    resource = Resource(source)
+    report = resource.validate()
+    assert report.valid
+
+
+def test_validate_schema_foreign_key_error_self_referencing_invalid():
+    source = {
+        "path": "data/nested-invalid.csv",
+        "schema": {
+            "fields": [
+                {"name": "id", "type": "integer"},
+                {"name": "cat", "type": "integer"},
+                {"name": "name", "type": "string"},
+            ],
+            "foreignKeys": [
+                {"fields": "cat", "reference": {"resource": "", "fields": "id"}}
+            ],
+        },
+    }
+    resource = Resource(source)
+    report = resource.validate()
+    assert report.flatten(["rowPosition", "fieldPosition", "code", "cells"]) == [
+        [6, None, "foreign-key-error", ["5", "6", "Rome"]],
+    ]
+
+
+def test_validate_schema_unique_error():
+    resource = Resource("data/unique-field.csv", schema="data/unique-field.json")
+    report = resource.validate(pick_errors=["unique-error"])
+    assert report.flatten(["rowPosition", "fieldPosition", "code"]) == [
+        [10, 1, "unique-error"],
+    ]
+
+
+def test_validate_schema_unique_error_and_type_error():
+    source = [
+        ["id", "unique_number"],
+        ["a1", 100],
+        ["a2", "bad"],
+        ["a3", 100],
+        ["a4", 0],
+        ["a5", 0],
+    ]
+    schema = {
+        "fields": [
+            {"name": "id"},
+            {"name": "unique_number", "type": "number", "constraints": {"unique": True}},
+        ]
+    }
+    resource = Resource(source, schema=schema)
+    report = resource.validate()
+    assert report.flatten(["rowPosition", "fieldPosition", "code", "cells"]) == [
+        [3, 2, "type-error", ["a2", "bad"]],
+        [4, 2, "unique-error", ["a3", "100"]],
+        [6, 2, "unique-error", ["a5", "0"]],
+    ]
+
+
+def test_validate_schema_primary_key_error():
+    resource = Resource("data/unique-field.csv", schema="data/unique-field.json")
+    report = resource.validate(pick_errors=["primary-key-error"])
+    assert report.flatten(["rowPosition", "fieldPosition", "code"]) == [
+        [10, None, "primary-key-error"],
+    ]
+
+
+def test_validate_schema_primary_key_and_unique_error():
+    resource = Resource(
+        "data/unique-field.csv",
+        schema="data/unique-field.json",
+    )
+    report = resource.validate()
+    assert report.flatten(["rowPosition", "fieldPosition", "code"]) == [
+        [10, 1, "unique-error"],
+        [10, None, "primary-key-error"],
+    ]
+
+
+def test_validate_schema_primary_key_error_composite():
+    source = [
+        ["id", "name"],
+        [1, "Alex"],
+        [1, "John"],
+        ["", "Paul"],
+        [1, "John"],
+        ["", None],
+    ]
+    schema = {
+        "fields": [
+            {"name": "id", "type": "integer"},
+            {"name": "name", "type": "string"},
+        ],
+        "primaryKey": ["id", "name"],
+    }
+    resource = Resource(source, schema=schema)
+    report = resource.validate()
+    assert report.flatten(["rowPosition", "fieldPosition", "code"]) == [
+        [5, None, "primary-key-error"],
+        [6, None, "blank-row"],
+        [6, None, "primary-key-error"],
+    ]
diff --git a/tests/resource/actions/validate/test_scheme.py b/tests/resource/actions/validate/test_scheme.py
new file mode 100644
index 00000000..f03c6346
--- /dev/null
+++ b/tests/resource/actions/validate/test_scheme.py
@@ -0,0 +1,18 @@
+from frictionless import Resource, helpers
+
+
+IS_UNIX = not helpers.is_platform("windows")
+
+
+def test_validate_scheme():
+    resource = Resource("data/table.csv", scheme="file")
+    report = resource.validate()
+    assert report.valid
+
+
+def test_validate_scheme_invalid():
+    resource = Resource("bad://data/table.csv")
+    report = resource.validate()
+    assert report.flatten(["code", "note"]) == [
+        ["scheme-error", 'cannot create loader "bad". Try installing "frictionless-bad"'],
+    ]
diff --git a/tests/resource/actions/validate/test_stats.py b/tests/resource/actions/validate/test_stats.py
new file mode 100644
index 00000000..4f4c3dfb
--- /dev/null
+++ b/tests/resource/actions/validate/test_stats.py
@@ -0,0 +1,136 @@
+from frictionless import Resource, helpers
+
+
+IS_UNIX = not helpers.is_platform("windows")
+
+
+def test_validate_stats_hash():
+    hash = "6c2c61dd9b0e9c6876139a449ed87933"
+    resource = Resource("data/table.csv", stats={"hash": hash})
+    report = resource.validate()
+    if IS_UNIX:
+        assert report.task.valid
+
+
+def test_validate_stats_hash_invalid():
+    hash = "6c2c61dd9b0e9c6876139a449ed87933"
+    resource = Resource("data/table.csv", stats={"hash": "bad"})
+    report = resource.validate()
+    if IS_UNIX:
+        assert report.flatten(["code", "note"]) == [
+            ["hash-count-error", 'expected md5 is "bad" and actual is "%s"' % hash],
+        ]
+
+
+def test_validate_stats_hash_md5():
+    hash = "6c2c61dd9b0e9c6876139a449ed87933"
+    resource = Resource("data/table.csv", stats={"hash": hash})
+    report = resource.validate()
+    if IS_UNIX:
+        assert report.task.valid
+
+
+def test_validate_stats_hash_md5_invalid():
+    hash = "6c2c61dd9b0e9c6876139a449ed87933"
+    resource = Resource("data/table.csv", stats={"hash": "bad"})
+    report = resource.validate()
+    if IS_UNIX:
+        assert report.flatten(["code", "note"]) == [
+            ["hash-count-error", 'expected md5 is "bad" and actual is "%s"' % hash],
+        ]
+
+
+def test_validate_stats_hash_sha1():
+    hash = "db6ea2f8ff72a9e13e1d70c28ed1c6b42af3bb0e"
+    resource = Resource("data/table.csv", hashing="sha1", stats={"hash": hash})
+    report = resource.validate()
+    if IS_UNIX:
+        assert report.task.valid
+
+
+def test_validate_stats_hash_sha1_invalid():
+    hash = "db6ea2f8ff72a9e13e1d70c28ed1c6b42af3bb0e"
+    resource = Resource("data/table.csv", hashing="sha1", stats={"hash": "bad"})
+    report = resource.validate()
+    if IS_UNIX:
+        assert report.flatten(["code", "note"]) == [
+            ["hash-count-error", 'expected sha1 is "bad" and actual is "%s"' % hash],
+        ]
+
+
+def test_validate_stats_hash_sha256():
+    hash = "a1fd6c5ff3494f697874deeb07f69f8667e903dd94a7bc062dd57550cea26da8"
+    resource = Resource("data/table.csv", hashing="sha256", stats={"hash": hash})
+    report = resource.validate()
+    if IS_UNIX:
+        assert report.task.valid
+
+
+def test_validate_stats_hash_sha256_invalid():
+    hash = "a1fd6c5ff3494f697874deeb07f69f8667e903dd94a7bc062dd57550cea26da8"
+    resource = Resource("data/table.csv", hashing="sha256", stats={"hash": "bad"})
+    report = resource.validate()
+    if IS_UNIX:
+        assert report.flatten(["code", "note"]) == [
+            [
+                "hash-count-error",
+                'expected sha256 is "bad" and actual is "%s"' % hash,
+            ],
+        ]
+
+
+def test_validate_stats_hash_sha512():
+    hash = "d52e3f5f5693894282f023b9985967007d7984292e9abd29dca64454500f27fa45b980132d7b496bc84d336af33aeba6caf7730ec1075d6418d74fb8260de4fd"
+    resource = Resource("data/table.csv", hashing="sha512", stats={"hash": hash})
+    report = resource.validate()
+    if IS_UNIX:
+        assert report.task.valid
+
+
+def test_validate_stats_hash_sha512_invalid():
+    hash = "d52e3f5f5693894282f023b9985967007d7984292e9abd29dca64454500f27fa45b980132d7b496bc84d336af33aeba6caf7730ec1075d6418d74fb8260de4fd"
+    resource = Resource("data/table.csv", hashing="sha512", stats={"hash": "bad"})
+    report = resource.validate()
+    if IS_UNIX:
+        assert report.flatten(["code", "note"]) == [
+            [
+                "hash-count-error",
+                'expected sha512 is "bad" and actual is "%s"' % hash,
+            ],
+        ]
+
+
+def test_validate_stats_bytes():
+    resource = Resource("data/table.csv", stats={"bytes": 30})
+    report = resource.validate()
+    if IS_UNIX:
+        assert report.task.valid
+
+
+def test_validate_stats_bytes_invalid():
+    resource = Resource("data/table.csv", stats={"bytes": 40})
+    report = resource.validate()
+    assert report.task.error.get("rowPosition") is None
+    assert report.task.error.get("fieldPosition") is None
+    if IS_UNIX:
+        assert report.flatten(["code", "note"]) == [
+            ["byte-count-error", 'expected is "40" and actual is "30"'],
+        ]
+
+
+def test_validate_stats_rows():
+    resource = Resource("data/table.csv", stats={"rows": 2})
+    report = resource.validate()
+    if IS_UNIX:
+        assert report.task.valid
+
+
+def test_validate_stats_rows_invalid():
+    resource = Resource("data/table.csv", stats={"rows": 3})
+    report = resource.validate()
+    assert report.task.error.get("rowPosition") is None
+    assert report.task.error.get("fieldPosition") is None
+    if IS_UNIX:
+        assert report.flatten(["code", "note"]) == [
+            ["row-count-error", 'expected is "3" and actual is "2"'],
+        ]
diff --git a/tests/steps/field/test_field_merge.py b/tests/steps/field/test_field_merge.py
new file mode 100644
index 00000000..43d55e7b
--- /dev/null
+++ b/tests/steps/field/test_field_merge.py
@@ -0,0 +1,45 @@
+from frictionless import Resource, transform, steps
+
+
+def test_step_field_merge_907():
+    source = Resource("data/transform.csv")
+    target = transform(
+        source,
+        steps=[steps.field_merge(name="details", from_names=["name", "population"])],
+    )
+    assert target.schema == {
+        "fields": [
+            {"name": "id", "type": "integer"},
+            {"name": "details", "type": "string"},
+        ]
+    }
+    assert target.read_rows()[0] == {
+        "id": 1,
+        "details": "germany-83",
+    }
+
+
+def test_step_field_merge_preserve_907():
+    source = Resource("data/transform.csv")
+    target = transform(
+        source,
+        steps=[
+            steps.field_merge(
+                name="details", from_names=["name", "population"], preserve=True
+            )
+        ],
+    )
+    assert target.schema == {
+        "fields": [
+            {"name": "id", "type": "integer"},
+            {"name": "name", "type": "string"},
+            {"name": "population", "type": "integer"},
+            {"name": "details", "type": "string"},
+        ]
+    }
+    assert target.read_rows()[0] == {
+        "id": 1,
+        "name": "germany",
+        "population": 83,
+        "details": "germany-83",
+    }
diff --git a/tests/steps/field/test_field_pack.py b/tests/steps/field/test_field_pack.py
new file mode 100644
index 00000000..9bc1d525
--- /dev/null
+++ b/tests/steps/field/test_field_pack.py
@@ -0,0 +1,74 @@
+from frictionless import Resource, transform, steps
+
+
+def test_step_field_pack_907():
+    source = Resource("data/transform.csv")
+    target = transform(
+        source,
+        steps=[steps.field_pack(name="details", from_names=["name", "population"])],
+    )
+    assert target.schema == {
+        "fields": [
+            {"name": "id", "type": "integer"},
+            {"name": "details", "type": "array"},
+        ]
+    }
+    assert target.read_rows()[0] == {
+        "id": 1,
+        "details": ["germany", "83"],
+    }
+
+
+def test_step_field_pack_header_preserve_907():
+    source = Resource("data/transform.csv")
+    target = transform(
+        source,
+        steps=[
+            steps.field_pack(
+                name="details", from_names=["name", "population"], preserve=True
+            )
+        ],
+    )
+    assert target.schema == {
+        "fields": [
+            {"name": "id", "type": "integer"},
+            {"name": "name", "type": "string"},
+            {"name": "population", "type": "integer"},
+            {"name": "details", "type": "array"},
+        ]
+    }
+    assert target.read_rows()[0] == {
+        "id": 1,
+        "name": "germany",
+        "population": 83,
+        "details": ["germany", "83"],
+    }
+
+
+def test_step_field_pack_object_907():
+    source = Resource("data/transform.csv")
+    target = transform(
+        source,
+        steps=[
+            steps.field_pack(
+                name="details",
+                from_names=["name", "population"],
+                field_type="object",
+                preserve=True,
+            )
+        ],
+    )
+    assert target.schema == {
+        "fields": [
+            {"name": "id", "type": "integer"},
+            {"name": "name", "type": "string"},
+            {"name": "population", "type": "integer"},
+            {"name": "details", "type": "object"},
+        ]
+    }
+    assert target.read_rows()[0] == {
+        "id": 1,
+        "name": "germany",
+        "population": 83,
+        "details": {"name": "germany", "population": "83"},
+    }
diff --git a/tests/test_detector.py b/tests/test_detector.py
index 391b11e3..b5de3db5 100644
--- a/tests/test_detector.py
+++ b/tests/test_detector.py
@@ -61,6 +61,25 @@ def test_schema_from_sample_confidence_full():
     }
 
 
+def test_schema_from_sparse_sample():
+    labels = ["id", "age", "name"]
+    sample = [
+        ["1", "39", "Paul"],
+        ["2", "23", "Jimmy"],
+        ["3", "", "Jane"],
+        ["4", "", "Judy"],
+    ]
+    detector = Detector(field_confidence=1)
+    schema = detector.detect_schema(sample, labels=labels)
+    assert schema == {
+        "fields": [
+            {"name": "id", "type": "integer"},
+            {"name": "age", "type": "integer"},
+            {"name": "name", "type": "string"},
+        ],
+    }
+
+
 def test_schema_infer_no_names():
     sample = [[1], [2], [3]]
     detector = Detector()