Repository: plotly/dash

Issue:
R package generator should allow exclusions for user-written anonymous and nested functions
The Dash R package generator code now allows users to bundle in their own functions, in addition to those generated within Python at R package assembly time (https://github.com/plotly/dash/pull/725). If these functions are saved in scripts with names other than `internal.R` or `utils.R`, they will automatically be exported individually via the package `NAMESPACE` file.

The currently proposed implementation does not account for anonymous functions within functions, e.g. such as may be passed via the argument  `FUN = function(` or others which should remain internal to the functions from which they are called.

Simple edits to remedy this would include
- not exporting `FUN` (see [here](https://github.com/plotly/dash-table/blob/ae03f11a6946bdd680259ea31ae8a3f1a8db4a19/R/df_to_list.R#L5))
- allowing a file with function names to ignore in the package root (i.e. `.ignorefns`)
- adding the `.ignorefns` file to `.Rbuildignore`


Discussion:

Resolving patch:
diff --git a/dash/development/_r_components_generation.py b/dash/development/_r_components_generation.py
--- a/dash/development/_r_components_generation.py
+++ b/dash/development/_r_components_generation.py
@@ -622,6 +622,31 @@ def generate_exports(
         package_suggests,
         **kwargs
 ):
+    export_string = make_namespace_exports(components, prefix)
+
+    # Look for wildcards in the metadata
+    has_wildcards = False
+    for component_data in metadata.values():
+        if any(key.endswith('-*') for key in component_data['props']):
+            has_wildcards = True
+            break
+
+    # now, bundle up the package information and create all the requisite
+    # elements of an R package, so that the end result is installable either
+    # locally or directly from GitHub
+    generate_rpkg(
+        pkg_data,
+        rpkg_data,
+        project_shortname,
+        export_string,
+        package_depends,
+        package_imports,
+        package_suggests,
+        has_wildcards,
+    )
+
+
+def make_namespace_exports(components, prefix):
     export_string = ""
     for component in components:
         if (
@@ -639,46 +664,49 @@ def generate_exports(
     omitlist = ["utils.R", "internal.R"] + [
         "{}{}.R".format(prefix, component) for component in components
     ]
-    stripped_line = ""
     fnlist = []
 
     for script in os.listdir("R"):
         if script.endswith(".R") and script not in omitlist:
             rfilelist += [os.path.join("R", script)]
 
-    # in R, either = or <- may be used to create and assign objects
-    definitions = ["<-function", "=function"]
-
     for rfile in rfilelist:
         with open(rfile, "r") as script:
-            for line in script:
-                stripped_line = line.replace(" ", "").replace("\n", "")
-                if any(fndef in stripped_line for fndef in definitions):
-                    fnlist += set([re.split("<-|=", stripped_line)[0]])
+            s = script.read()
+
+            # remove comments
+            s = re.sub('#.*$', '', s, flags=re.M)
+
+            # put the whole file on one line
+            s = s.replace("\n", " ").replace("\r", " ")
+
+            # empty out strings, in case of unmatched block terminators
+            s = re.sub(r"'([^'\\]|\\'|\\[^'])*'", "''", s)
+            s = re.sub(r'"([^"\\]|\\"|\\[^"])*"', '""', s)
+
+            # empty out block terminators () and {}
+            # so we don't catch nested functions, or functions as arguments
+            # repeat until it stops changing, in case of multiply nested blocks
+            prev_len = len(s) + 1
+            while len(s) < prev_len:
+                prev_len = len(s)
+                s = re.sub(r"\(([^()]|\(\))*\)", "()", s)
+                s = re.sub(r"\{([^{}]|\{\})*\}", "{}", s)
+
+            # now, in whatever is left, look for functions
+            matches = re.findall(
+                # in R, either = or <- may be used to create and assign objects
+                r"([^A-Za-z0-9._]|^)([A-Za-z0-9._]+)\s*(=|<-)\s*function", s
+            )
+            for match in matches:
+                fn = match[1]
+                # Allow users to mark functions as private by prefixing with .
+                if fn[0] != "." and fn not in fnlist:
+                    fnlist.append(fn)
 
     export_string += "\n".join("export({})".format(function)
                                for function in fnlist)
-
-    # Look for wildcards in the metadata
-    has_wildcards = False
-    for component_data in metadata.values():
-        if any(key.endswith('-*') for key in component_data['props']):
-            has_wildcards = True
-            break
-
-    # now, bundle up the package information and create all the requisite
-    # elements of an R package, so that the end result is installable either
-    # locally or directly from GitHub
-    generate_rpkg(
-        pkg_data,
-        rpkg_data,
-        project_shortname,
-        export_string,
-        package_depends,
-        package_imports,
-        package_suggests,
-        has_wildcards,
-    )
+    return export_string
 
 
 def get_r_prop_types(type_object):
diff --git a/dash/development/component_generator.py b/dash/development/component_generator.py
--- a/dash/development/component_generator.py
+++ b/dash/development/component_generator.py
@@ -89,7 +89,7 @@ def generate_components(
         )
         sys.exit(1)
 
-    metadata = safe_json_loads(out.decode())
+    metadata = safe_json_loads(out.decode("utf-8"))
 
     generator_methods = [generate_class_file]
 


Test patch:
diff --git a/tests/unit/development/test_r_component_gen.py b/tests/unit/development/test_r_component_gen.py
new file mode 100644
--- /dev/null
+++ b/tests/unit/development/test_r_component_gen.py
@@ -0,0 +1,85 @@
+import os
+import shutil
+import re
+from textwrap import dedent
+
+import pytest
+
+from dash.development._r_components_generation import (
+    make_namespace_exports
+)
+
+
+@pytest.fixture
+def make_r_dir():
+    os.makedirs("R")
+
+    yield
+
+    shutil.rmtree("R")
+
+
+def test_r_exports(make_r_dir):
+    extra_file = dedent("""
+        # normal function syntax
+        my_func <- function(a, b) {
+            c <- a + b
+            nested_func <- function() { stop("no!") }
+            another_to_exclude = function(d) { d * d }
+            another_to_exclude(c)
+        }
+
+        # indented (no reason but we should allow) and using = instead of <-
+        # also braces in comments enclosing it {
+            my_func2 = function() {
+                s <- "unmatched closing brace }"
+                ignore_please <- function() { 1 }
+            }
+        # }
+
+        # real example from dash-table that should exclude FUN
+        df_to_list <- function(df) {
+          if(!(is.data.frame(df)))
+            stop("!")
+          setNames(lapply(split(df, seq(nrow(df))),
+                          FUN = function (x) {
+                            as.list(x)
+                          }), NULL)
+        }
+
+        # single-line compressed
+        util<-function(x){x+1}
+
+        # prefix with . to tell us to ignore
+        .secret <- function() { stop("You can't see me") }
+
+        # . in the middle is OK though
+        not.secret <- function() { 42 }
+    """)
+
+    components = ["Component1", "Component2"]
+    prefix = 'pre'
+
+    expected_exports = [prefix + c for c in components] + [
+        "my_func",
+        "my_func2",
+        "df_to_list",
+        "util",
+        "not.secret"
+    ]
+
+    mock_component_file = dedent("""
+        nope <- function() { stop("we don't look in component files") }
+    """)
+
+    with open(os.path.join("R", "preComponent1.R"), "w") as f:
+        f.write(mock_component_file)
+
+    with open(os.path.join("R", "extras.R"), "w") as f:
+        f.write(extra_file)
+
+    exports = make_namespace_exports(components, prefix)
+    print(exports)
+    matches = re.findall(r"export\(([^()]+)\)", exports.replace('\n', ' '))
+
+    assert matches == expected_exports