• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# Copyright 2021 The Pigweed Authors
2#
3# Licensed under the Apache License, Version 2.0 (the "License"); you may not
4# use this file except in compliance with the License. You may obtain a copy of
5# the License at
6#
7#     https://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12# License for the specific language governing permissions and limitations under
13# the License.
14
15import("//build_overrides/pigweed.gni")
16
17import("$dir_pw_build/input_group.gni")
18import("$dir_pw_build/mirror_tree.gni")
19import("$dir_pw_build/python_action.gni")
20import("$dir_pw_protobuf_compiler/toolchain.gni")
21
22declare_args() {
23  # Python tasks, such as running tests and Pylint, are done in a single GN
24  # toolchain to avoid unnecessary duplication in the build.
25  pw_build_PYTHON_TOOLCHAIN = "$dir_pw_build/python_toolchain:python"
26
27  # Constraints file selection (arguments to pip install --constraint).
28  # See pip help install.
29  pw_build_PIP_CONSTRAINTS =
30      [ "$dir_pw_env_setup/py/pw_env_setup/virtualenv_setup/constraint.list" ]
31
32  # If true, GN will run each Python test using the coverage command. A separate
33  # coverage data file for each test will be saved. To generate reports from
34  # this information run: pw presubmit --step gn_python_test_coverage
35  pw_build_PYTHON_TEST_COVERAGE = false
36}
37
38# Python packages provide the following targets as $target_name.$subtarget.
39pw_python_package_subtargets = [
40  "tests",
41  "lint",
42  "lint.mypy",
43  "lint.pylint",
44  "install",
45  "wheel",
46
47  # Internal targets that directly depend on one another.
48  "_run_pip_install",
49  "_build_wheel",
50]
51
52# Create aliases for subsargets when the target name matches the directory name.
53# This allows //foo:foo.tests to be accessed as //foo:tests, for example.
54template("_pw_create_aliases_if_name_matches_directory") {
55  not_needed([ "invoker" ])
56
57  if (get_label_info(":$target_name", "name") ==
58      get_path_info(get_label_info(":$target_name", "dir"), "name")) {
59    foreach(subtarget, pw_python_package_subtargets) {
60      group(subtarget) {
61        public_deps = [ ":${invoker.target_name}.$subtarget" ]
62      }
63    }
64  }
65}
66
67# Internal template that runs Mypy.
68template("_pw_python_static_analysis_mypy") {
69  pw_python_action(target_name) {
70    module = "mypy"
71    args = [
72      "--pretty",
73      "--show-error-codes",
74    ]
75
76    if (defined(invoker.mypy_ini)) {
77      args +=
78          [ "--config-file=" + rebase_path(invoker.mypy_ini, root_build_dir) ]
79      inputs = [ invoker.mypy_ini ]
80    }
81
82    args += rebase_path(invoker.sources, root_build_dir)
83
84    # Use this environment variable to force mypy to colorize output.
85    # See https://github.com/python/mypy/issues/7771
86    environment = [ "MYPY_FORCE_COLOR=1" ]
87
88    stamp = true
89
90    deps = invoker.deps
91
92    foreach(dep, invoker.python_deps) {
93      deps += [ string_replace(dep, "(", ".lint.mypy(") ]
94    }
95  }
96}
97
98# Internal template that runs Pylint.
99template("_pw_python_static_analysis_pylint") {
100  # Create a target to run pylint on each of the Python files in this
101  # package and its dependencies.
102  pw_python_action_foreach(target_name) {
103    module = "pylint"
104    args = [
105      rebase_path(".", root_build_dir) + "/{{source_target_relative}}",
106      "--jobs=1",
107      "--output-format=colorized",
108    ]
109
110    if (defined(invoker.pylintrc)) {
111      args += [ "--rcfile=" + rebase_path(invoker.pylintrc, root_build_dir) ]
112      inputs = [ invoker.pylintrc ]
113    }
114
115    if (host_os == "win") {
116      # Allow CRLF on Windows, in case Git is set to switch line endings.
117      args += [ "--disable=unexpected-line-ending-format" ]
118    }
119
120    sources = invoker.sources
121
122    stamp = "$target_gen_dir/{{source_target_relative}}.pylint.passed"
123
124    public_deps = invoker.deps
125
126    foreach(dep, invoker.python_deps) {
127      public_deps += [ string_replace(dep, "(", ".lint.pylint(") ]
128    }
129  }
130}
131
132# Defines a Python package. GN Python packages contain several GN targets:
133#
134#   - $name - Provides the Python files in the build, but does not take any
135#         actions. All subtargets depend on this target.
136#   - $name.lint - Runs static analyis tools on the Python code. This is a group
137#     of two subtargets:
138#     - $name.lint.mypy - Runs mypy (if enabled).
139#     - $name.lint.pylint - Runs pylint (if enabled).
140#   - $name.tests - Runs all tests for this package.
141#   - $name.install - Installs the package in a venv.
142#   - $name.wheel - Builds a Python wheel for the package.
143#
144# All Python packages are instantiated with in pw_build_PYTHON_TOOLCHAIN,
145# regardless of the current toolchain. This prevents Python-specific work, like
146# running Pylint, from occurring multiple times in a build.
147#
148# Args:
149#   setup: List of setup file paths (setup.py or pyproject.toml & setup.cfg),
150#       which must all be in the same directory.
151#   generate_setup: As an alternative to 'setup', generate setup files with the
152#       keywords in this scope. 'name' is required.
153#   sources: Python sources files in the package.
154#   tests: Test files for this Python package.
155#   python_deps: Dependencies on other pw_python_packages in the GN build.
156#   python_test_deps: Test-only pw_python_package dependencies.
157#   other_deps: Dependencies on GN targets that are not pw_python_packages.
158#   inputs: Other files to track, such as package_data.
159#   proto_library: A pw_proto_library target to embed in this Python package.
160#       generate_setup is required in place of setup if proto_library is used.
161#   static_analysis: List of static analysis tools to run; "*" (default) runs
162#       all tools. The supported tools are "mypy" and "pylint".
163#   pylintrc: Path to a pylintrc configuration file to use. If not
164#       provided, Pylint's default rcfile search is used. As this may
165#       use the the local user's configuration file, it is highly
166#       recommended to pass this option to specify the rcfile explicitly.
167#   mypy_ini: Optional path to a mypy configuration file to use. If not
168#       provided, mypy's default configuration file search is used. mypy is
169#       executed from the package's setup directory, so mypy.ini files in that
170#       directory will take precedence over others.
171#
172template("pw_python_package") {
173  # The Python targets are always instantiated in pw_build_PYTHON_TOOLCHAIN. Use
174  # fully qualified labels so that the toolchain is not lost.
175  _other_deps = []
176  if (defined(invoker.other_deps)) {
177    foreach(dep, invoker.other_deps) {
178      _other_deps += [ get_label_info(dep, "label_with_toolchain") ]
179    }
180  }
181
182  _python_deps = []
183  if (defined(invoker.python_deps)) {
184    foreach(dep, invoker.python_deps) {
185      _python_deps += [ get_label_info(dep, "label_with_toolchain") ]
186    }
187  }
188
189  # pw_python_script uses pw_python_package, but with a limited set of features.
190  # _pw_standalone signals that this target is actually a pw_python_script.
191  _is_package = !(defined(invoker._pw_standalone) && invoker._pw_standalone)
192
193  _generate_package = false
194
195  # Check the generate_setup and import_protos args to determine if this package
196  # is generated.
197  if (_is_package) {
198    assert(defined(invoker.generate_setup) != defined(invoker.setup),
199           "Either 'setup' or 'generate_setup' (but not both) must provided")
200
201    if (defined(invoker.proto_library)) {
202      assert(invoker.proto_library != "", "'proto_library' cannot be empty")
203      assert(defined(invoker.generate_setup),
204             "Python packages that import protos with 'proto_library' must " +
205                 "use 'generate_setup' instead of 'setup'")
206
207      _import_protos = [ invoker.proto_library ]
208
209      # Depend on the dependencies of the proto library.
210      _proto = get_label_info(invoker.proto_library, "label_no_toolchain")
211      _toolchain = get_label_info(invoker.proto_library, "toolchain")
212      _python_deps += [ "$_proto.python._deps($_toolchain)" ]
213    } else if (defined(invoker.generate_setup)) {
214      _import_protos = []
215    }
216
217    if (defined(invoker.generate_setup)) {
218      _generate_package = true
219      _setup_dir = "$target_gen_dir/$target_name.generated_python_package"
220
221      if (defined(invoker.strip_prefix)) {
222        _source_root = invoker.strip_prefix
223      } else {
224        _source_root = "."
225      }
226    } else {
227      # Non-generated packages with sources provided need an __init__.py.
228      assert(!defined(invoker.sources) || invoker.sources == [] ||
229                 filter_include(invoker.sources, [ "*\b__init__.py" ]) != [],
230             "Python packages must have at least one __init__.py file")
231
232      # Get the directories of the setup files. All must be in the same dir.
233      _setup_dirs = get_path_info(invoker.setup, "dir")
234      _setup_dir = _setup_dirs[0]
235
236      foreach(dir, _setup_dirs) {
237        assert(dir == _setup_dir,
238               "All files in 'setup' must be in the same directory")
239      }
240
241      assert(!defined(invoker.strip_prefix),
242             "'strip_prefix' may only be given if 'generate_setup' is provided")
243    }
244  }
245
246  # Process arguments defaults and set defaults.
247
248  _supported_static_analysis_tools = [
249    "mypy",
250    "pylint",
251  ]
252  not_needed([ "_supported_static_analysis_tools" ])
253
254  # Argument: static_analysis (list of tool names or "*"); default = "*" (all)
255  if (!defined(invoker.static_analysis) || invoker.static_analysis == "*") {
256    _static_analysis = _supported_static_analysis_tools
257  } else {
258    _static_analysis = invoker.static_analysis
259  }
260
261  foreach(_tool, _static_analysis) {
262    assert(_supported_static_analysis_tools + [ _tool ] - [ _tool ] !=
263               _supported_static_analysis_tools,
264           "'$_tool' is not a supported static analysis tool")
265  }
266
267  # Argument: sources (list)
268  _sources = []
269  if (defined(invoker.sources)) {
270    if (_generate_package) {
271      foreach(source, rebase_path(invoker.sources, _source_root)) {
272        _sources += [ "$_setup_dir/$source" ]
273      }
274    } else {
275      _sources += invoker.sources
276    }
277  }
278
279  # Argument: tests (list)
280  _test_sources = []
281  if (defined(invoker.tests)) {
282    if (_generate_package) {
283      foreach(source, rebase_path(invoker.tests, _source_root)) {
284        _test_sources += [ "$_setup_dir/$source" ]
285      }
286    } else {
287      _test_sources += invoker.tests
288    }
289  }
290
291  # Argument: setup (list)
292  _setup_sources = []
293  if (defined(invoker.setup)) {
294    _setup_sources = invoker.setup
295  } else if (_generate_package) {
296    _setup_sources = [
297      "$_setup_dir/pyproject.toml",
298      "$_setup_dir/setup.cfg",
299    ]
300  }
301
302  # Argument: python_test_deps (list)
303  _python_test_deps = _python_deps  # include all deps in test deps
304  if (defined(invoker.python_test_deps)) {
305    foreach(dep, invoker.python_test_deps) {
306      _python_test_deps += [ get_label_info(dep, "label_with_toolchain") ]
307    }
308  }
309
310  if (_test_sources == []) {
311    assert(!defined(invoker.python_test_deps),
312           "python_test_deps was provided, but there are no tests in " +
313               get_label_info(":$target_name", "label_no_toolchain"))
314    not_needed([ "_python_test_deps" ])
315  }
316
317  _all_py_files =
318      _sources + _test_sources + filter_include(_setup_sources, [ "*.py" ])
319
320  # The pw_python_package subtargets are only instantiated in
321  # pw_build_PYTHON_TOOLCHAIN. Targets in other toolchains just refer to the
322  # targets in this toolchain.
323  if (current_toolchain == pw_build_PYTHON_TOOLCHAIN) {
324    # Create the package_metadata.json file. This is used by the
325    # pw_create_python_source_tree template.
326    _package_metadata_json_file =
327        "$target_gen_dir/$target_name/package_metadata.json"
328
329    # Get Python package metadata and write to disk as JSON.
330    _package_metadata = {
331      gn_target_name = get_label_info(invoker.target_name, "label_no_toolchain")
332
333      # Get package source files
334      sources = rebase_path(_sources, root_build_dir)
335
336      # Get setup.cfg, pyproject.toml, or setup.py file
337      setup_sources = rebase_path(_setup_sources, root_build_dir)
338
339      # Get test source files
340      tests = rebase_path(_test_sources, root_build_dir)
341
342      # Get package input files (package data)
343      inputs = []
344      if (defined(invoker.inputs)) {
345        inputs = rebase_path(invoker.inputs, root_build_dir)
346      }
347
348      # Get generate_setup
349      if (defined(invoker.generate_setup)) {
350        generate_setup = invoker.generate_setup
351      }
352    }
353
354    # Finally, write out the json
355    write_file(_package_metadata_json_file, _package_metadata, "json")
356
357    # Declare the main Python package group. This represents the Python files,
358    # but does not take any actions. GN targets can depend on the package name
359    # to run when any files in the package change.
360    if (_generate_package) {
361      # If this package is generated, mirror the sources to the final directory.
362      pw_mirror_tree("$target_name._mirror_sources_to_out_dir") {
363        directory = _setup_dir
364
365        sources = []
366        if (defined(invoker.sources)) {
367          sources += invoker.sources
368        }
369        if (defined(invoker.tests)) {
370          sources += invoker.tests
371        }
372        if (defined(invoker.inputs)) {
373          sources += invoker.inputs
374        }
375
376        source_root = _source_root
377        public_deps = _python_deps + _other_deps
378      }
379
380      # Get generated_setup scope and write it to disk as JSON.
381
382      # Expected setup.cfg structure:
383      # https://setuptools.readthedocs.io/en/latest/userguide/declarative_config.html
384      _gen_setup = invoker.generate_setup
385      assert(defined(_gen_setup.metadata),
386             "'metadata = {}' is required in generate_package")
387
388      # Get metadata which should contain at least name.
389      _gen_metadata = {
390      }
391      _gen_metadata = _gen_setup.metadata
392      assert(
393          defined(_gen_metadata.name),
394          "metadata = { name = 'package_name' } is required in generate_package")
395
396      # Get options which should not have packages or package_data.
397      if (defined(_gen_setup.options)) {
398        _gen_options = {
399        }
400        _gen_options = _gen_setup.options
401        assert(!defined(_gen_options.packages) &&
402                   !defined(_gen_options.package_data),
403               "'packages' and 'package_data' may not be provided " +
404                   "in 'generate_package' options.")
405      }
406
407      write_file("$_setup_dir/setup.json", _gen_setup, "json")
408
409      # Generate the setup.py, py.typed, and __init__.py files as needed.
410      action(target_name) {
411        metadata = {
412          pw_python_package_metadata_json = [ _package_metadata_json_file ]
413        }
414
415        script = "$dir_pw_build/py/pw_build/generate_python_package.py"
416        args = [
417                 "--label",
418                 get_label_info(":$target_name", "label_no_toolchain"),
419                 "--generated-root",
420                 rebase_path(_setup_dir, root_build_dir),
421                 "--setup-json",
422                 rebase_path("$_setup_dir/setup.json", root_build_dir),
423               ] + rebase_path(_sources, root_build_dir)
424
425        # Pass in the .json information files for the imported proto libraries.
426        foreach(proto, _import_protos) {
427          _label = get_label_info(proto, "label_no_toolchain") +
428                   ".python($pw_protobuf_compiler_TOOLCHAIN)"
429          _file = get_label_info(_label, "target_gen_dir") + "/" +
430                  get_label_info(_label, "name") + ".json"
431          args += [
432            "--proto-library",
433            rebase_path(_file, root_build_dir),
434          ]
435        }
436
437        if (defined(invoker._pw_module_as_package) &&
438            invoker._pw_module_as_package) {
439          args += [ "--module-as-package" ]
440        }
441
442        inputs = [ "$_setup_dir/setup.json" ]
443
444        public_deps = [ ":$target_name._mirror_sources_to_out_dir" ]
445
446        outputs = _setup_sources
447      }
448    } else {
449      # If the package is not generated, use an input group for the sources.
450      pw_input_group(target_name) {
451        metadata = {
452          pw_python_package_metadata_json = [ _package_metadata_json_file ]
453        }
454        inputs = _all_py_files
455        if (defined(invoker.inputs)) {
456          inputs += invoker.inputs
457        }
458
459        deps = _python_deps + _other_deps
460      }
461    }
462
463    if (_is_package) {
464      # Install this Python package and its dependencies in the current Python
465      # environment using pip.
466      pw_python_action("$target_name._run_pip_install") {
467        module = "pip"
468        public_deps = []
469        if (defined(invoker.public_deps)) {
470          public_deps += invoker.public_deps
471        }
472
473        args = [
474          "install",
475
476          # This speeds up pip installs. At this point in the gn build the
477          # virtualenv is already activated so build isolation isn't required.
478          # This requires that pip, setuptools, and wheel packages are
479          # installed.
480          "--no-build-isolation",
481        ]
482
483        inputs = pw_build_PIP_CONSTRAINTS
484        foreach(_constraints_file, pw_build_PIP_CONSTRAINTS) {
485          args += [
486            "--constraint",
487            rebase_path(_constraints_file, root_build_dir),
488          ]
489        }
490
491        # For generated packages, reinstall when any files change. For regular
492        # packages, only reinstall when setup.py changes.
493        if (_generate_package) {
494          public_deps += [ ":${invoker.target_name}" ]
495        } else {
496          inputs += invoker.setup
497
498          # Install with --editable since the complete package is in source.
499          args += [ "--editable" ]
500        }
501
502        args += [ rebase_path(_setup_dir, root_build_dir) ]
503
504        stamp = true
505
506        # Parallel pip installations don't work, so serialize pip invocations.
507        pool = "$dir_pw_build/pool:pip($default_toolchain)"
508
509        foreach(dep, _python_deps) {
510          # We need to add a suffix to the target name, but the label is
511          # formatted as "//path/to:target(toolchain)", so we can't just append
512          # ".subtarget". Instead, we replace the opening parenthesis of the
513          # toolchain with ".suffix(".
514          public_deps += [ string_replace(dep, "(", "._run_pip_install(") ]
515        }
516      }
517
518      # Builds a Python wheel for this package. Records the output directory
519      # in the pw_python_package_wheels metadata key.
520      pw_python_action("$target_name._build_wheel") {
521        metadata = {
522          pw_python_package_wheels = [ "$target_out_dir/$target_name" ]
523        }
524
525        module = "build"
526
527        args =
528            [
529              rebase_path(_setup_dir, root_build_dir),
530              "--wheel",
531              "--no-isolation",
532              "--outdir",
533            ] + rebase_path(metadata.pw_python_package_wheels, root_build_dir)
534
535        deps = [ ":${invoker.target_name}" ]
536        foreach(dep, _python_deps) {
537          deps += [ string_replace(dep, "(", ".wheel(") ]
538        }
539
540        stamp = true
541      }
542    } else {
543      # Stubs for non-package targets.
544      group("$target_name._run_pip_install") {
545      }
546      group("$target_name._build_wheel") {
547      }
548    }
549
550    # Create the .install and .wheel targets. To limit unnecessary pip
551    # executions, non-generated packages are only reinstalled when their
552    # setup.py changes. However, targets that depend on the .install subtarget
553    # re-run whenever any source files change.
554    #
555    # These targets just represent the source files if this isn't a package.
556    group("$target_name.install") {
557      public_deps = [ ":${invoker.target_name}" ]
558
559      if (_is_package) {
560        public_deps += [ ":${invoker.target_name}._run_pip_install" ]
561      }
562
563      foreach(dep, _python_deps) {
564        public_deps += [ string_replace(dep, "(", ".install(") ]
565      }
566    }
567
568    group("$target_name.wheel") {
569      public_deps = [ ":${invoker.target_name}.install" ]
570
571      if (_is_package) {
572        public_deps += [ ":${invoker.target_name}._build_wheel" ]
573      }
574
575      foreach(dep, _python_deps) {
576        public_deps += [ string_replace(dep, "(", ".wheel(") ]
577      }
578    }
579
580    # Define the static analysis targets for this package.
581    group("$target_name.lint") {
582      deps = []
583      foreach(_tool, _supported_static_analysis_tools) {
584        deps += [ ":${invoker.target_name}.lint.$_tool" ]
585      }
586    }
587
588    if (_static_analysis != [] || _test_sources != []) {
589      # All packages to install for either general use or test running.
590      _test_install_deps = [ ":$target_name.install" ]
591      foreach(dep, _python_test_deps + [ "$dir_pw_build:python_lint" ]) {
592        _test_install_deps += [ string_replace(dep, "(", ".install(") ]
593      }
594    }
595
596    # For packages that are not generated, create targets to run mypy and pylint.
597    foreach(_tool, _static_analysis) {
598      # Run lint tools from the setup or target directory so that the tools detect
599      # config files (e.g. pylintrc or mypy.ini) in that directory. Config files
600      # may be explicitly specified with the pylintrc or mypy_ini arguments.
601      target("_pw_python_static_analysis_$_tool", "$target_name.lint.$_tool") {
602        sources = _all_py_files
603        deps = _test_install_deps
604        python_deps = _python_deps
605
606        _optional_variables = [
607          "mypy_ini",
608          "pylintrc",
609        ]
610        forward_variables_from(invoker, _optional_variables)
611        not_needed(_optional_variables)
612      }
613    }
614
615    foreach(_unused_tool, _supported_static_analysis_tools - _static_analysis) {
616      pw_input_group("$target_name.lint.$_unused_tool") {
617        inputs = []
618        if (defined(invoker.pylintrc)) {
619          inputs += [ invoker.pylintrc ]
620        }
621        if (defined(invoker.mypy_ini)) {
622          inputs += [ invoker.mypy_ini ]
623        }
624      }
625
626      # Generated packages with linting disabled never need the whole file list.
627      not_needed([ "_all_py_files" ])
628    }
629  } else {
630    # Create groups with the public target names ($target_name, $target_name.lint,
631    # $target_name.install, etc.). These are actually wrappers around internal
632    # Python actions instantiated with the default toolchain. This ensures there
633    # is only a single copy of each Python action in the build.
634    #
635    # The $target_name.tests group is created separately below.
636    group("$target_name") {
637      deps = [ ":$target_name($pw_build_PYTHON_TOOLCHAIN)" ]
638    }
639
640    foreach(subtarget, pw_python_package_subtargets - [ "tests" ]) {
641      group("$target_name.$subtarget") {
642        deps =
643            [ ":${invoker.target_name}.$subtarget($pw_build_PYTHON_TOOLCHAIN)" ]
644      }
645    }
646
647    # Everything Python-related is only instantiated in the default toolchain.
648    # Silence not-needed warnings except for in the default toolchain.
649    not_needed("*")
650    not_needed(invoker, "*")
651  }
652
653  # Create a target for each test file.
654  _test_targets = []
655
656  foreach(test, _test_sources) {
657    if (_is_package) {
658      _name = rebase_path(test, _setup_dir)
659    } else {
660      _name = test
661    }
662
663    _test_target = "$target_name.tests." + string_replace(_name, "/", "_")
664
665    if (current_toolchain == pw_build_PYTHON_TOOLCHAIN) {
666      pw_python_action(_test_target) {
667        if (pw_build_PYTHON_TEST_COVERAGE) {
668          module = "coverage"
669          working_directory =
670              rebase_path(get_path_info(test, "dir"), root_build_dir)
671          args = [
672            "run",
673            "--branch",
674
675            # Include all source files in the working_directory when calculating coverage.
676            "--source=.",
677
678            # Test file to run.
679            get_path_info(test, "file"),
680          ]
681
682          # Set the coverage file to a location in out/python/gen/
683          _coverage_data_file = "$target_gen_dir/$target_name.coverage"
684          outputs = [ _coverage_data_file ]
685
686          # The coverage tool only allows setting the output with an environment variable.
687          environment =
688              [ "COVERAGE_FILE=" +
689                rebase_path(_coverage_data_file, get_path_info(test, "dir")) ]
690        } else {
691          script = test
692        }
693
694        stamp = true
695
696        deps = _test_install_deps
697
698        foreach(dep, _python_test_deps) {
699          deps += [ string_replace(dep, "(", ".tests(") ]
700        }
701      }
702    } else {
703      # Create a public version of each test target, so tests can be executed as
704      # //path/to:package.tests.foo.py.
705      group(_test_target) {
706        deps = [ ":$_test_target($pw_build_PYTHON_TOOLCHAIN)" ]
707      }
708    }
709
710    _test_targets += [ ":$_test_target" ]
711  }
712
713  group("$target_name.tests") {
714    deps = _test_targets
715  }
716
717  _pw_create_aliases_if_name_matches_directory(target_name) {
718  }
719}
720
721# Declares a group of Python packages or other Python groups. pw_python_groups
722# expose the same set of subtargets as pw_python_package (e.g.
723# "$group_name.lint" and "$group_name.tests"), but these apply to all packages
724# in deps and their dependencies.
725template("pw_python_group") {
726  if (defined(invoker.python_deps)) {
727    _python_deps = invoker.python_deps
728  } else {
729    _python_deps = []
730    not_needed([ "invoker" ])  # Allow empty groups.
731  }
732
733  group(target_name) {
734    deps = _python_deps
735
736    if (defined(invoker.other_deps)) {
737      deps += invoker.other_deps
738    }
739  }
740
741  foreach(subtarget, pw_python_package_subtargets) {
742    group("$target_name.$subtarget") {
743      public_deps = []
744      foreach(dep, _python_deps) {
745        # Split out the toolchain to support deps with a toolchain specified.
746        _target = get_label_info(dep, "label_no_toolchain")
747        _toolchain = get_label_info(dep, "toolchain")
748        public_deps += [ "$_target.$subtarget($_toolchain)" ]
749      }
750    }
751  }
752
753  _pw_create_aliases_if_name_matches_directory(target_name) {
754  }
755}
756
757# Declares Python scripts or tests that are not part of a Python package.
758# Similar to pw_python_package, but only supports a subset of its features.
759#
760# pw_python_script accepts the same arguments as pw_python_package, except
761# `setup` cannot be provided.
762#
763# pw_python_script provides the same subtargets as pw_python_package, but
764# $target_name.install and $target_name.wheel only affect the python_deps of
765# this GN target, not the target itself.
766#
767# pw_python_script allows creating a pw_python_action associated with the
768# script. This is provided by passing an 'action' scope to pw_python_script.
769# This functions like a normal action, with a few additions: the action uses the
770# pw_python_script's python_deps and defaults to using the source file as its
771# 'script' argument, if there is only a single source file.
772template("pw_python_script") {
773  _package_variables = [
774    "sources",
775    "tests",
776    "python_deps",
777    "other_deps",
778    "inputs",
779    "pylintrc",
780    "mypy_ini",
781    "static_analysis",
782  ]
783
784  pw_python_package(target_name) {
785    _pw_standalone = true
786    forward_variables_from(invoker, _package_variables)
787  }
788
789  _pw_create_aliases_if_name_matches_directory(target_name) {
790  }
791
792  if (defined(invoker.action)) {
793    pw_python_action("$target_name.action") {
794      forward_variables_from(invoker.action, "*", [ "python_deps" ])
795      python_deps = [ ":${invoker.target_name}" ]
796
797      if (!defined(script) && !defined(module) && defined(invoker.sources)) {
798        _sources = invoker.sources
799        assert(_sources != [] && _sources == [ _sources[0] ],
800               "'script' must be specified unless there is only one source " +
801                   "in 'sources'")
802        script = _sources[0]
803      }
804    }
805  }
806}
807
808# Represents a list of Python requirements, as in a requirements.txt.
809#
810# Args:
811#  files: One or more requirements.txt files.
812#  requirements: A list of requirements.txt-style requirements.
813template("pw_python_requirements") {
814  assert(defined(invoker.files) || defined(invoker.requirements),
815         "pw_python_requirements requires a list of requirements.txt files " +
816             "in the 'files' arg or requirements in 'requirements'")
817
818  _requirements_files = []
819
820  if (defined(invoker.files)) {
821    _requirements_files += invoker.files
822  }
823
824  if (defined(invoker.requirements)) {
825    _requirements_file = "$target_gen_dir/$target_name.requirements.txt"
826    write_file(_requirements_file, invoker.requirements)
827    _requirements_files += [ _requirements_file ]
828  }
829
830  # The default target represents the requirements themselves.
831  pw_input_group(target_name) {
832    inputs = _requirements_files
833  }
834
835  # Use the same subtargets as pw_python_package so these targets can be listed
836  # as python_deps of pw_python_packages.
837  pw_python_action("$target_name.install") {
838    inputs = _requirements_files
839
840    module = "pip"
841    args = [ "install" ]
842
843    foreach(_requirements_file, inputs) {
844      args += [
845        "--requirement",
846        rebase_path(_requirements_file, root_build_dir),
847      ]
848    }
849
850    inputs += pw_build_PIP_CONSTRAINTS
851    foreach(_constraints_file, pw_build_PIP_CONSTRAINTS) {
852      args += [
853        "--constraint",
854        rebase_path(_constraints_file, root_build_dir),
855      ]
856    }
857
858    pool = "$dir_pw_build/pool:pip($default_toolchain)"
859    stamp = true
860  }
861
862  # Create stubs for the unused subtargets so that pw_python_requirements can be
863  # used as python_deps.
864  foreach(subtarget, pw_python_package_subtargets - [ "install" ]) {
865    group("$target_name.$subtarget") {
866    }
867  }
868
869  _pw_create_aliases_if_name_matches_directory(target_name) {
870  }
871}
872