cja 0.2.2__tar.gz → 0.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: cja
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: CMake-compatible build system that generates Ninja build files
5
5
  Author: Jan Niklas Hasse
6
6
  Author-email: Jan Niklas Hasse <jhasse@bixense.com>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cja"
3
- version = "0.2.2"
3
+ version = "0.2.3"
4
4
  description = "CMake-compatible build system that generates Ninja build files"
5
5
  readme = "README.md"
6
6
  license = { text = "GPL-3.0-or-later" }
@@ -47,6 +47,21 @@ class CustomCommand:
47
47
  defined_line: int = 0
48
48
 
49
49
 
50
+ @dataclass
51
+ class CustomTarget:
52
+ """A custom target (phony build target)."""
53
+
54
+ name: str
55
+ commands: list[list[str]]
56
+ depends: list[str]
57
+ all: bool = False
58
+ working_directory: str | None = None
59
+ verbatim: bool = False
60
+ comment: str = ""
61
+ defined_file: Path | None = None
62
+ defined_line: int = 0
63
+
64
+
50
65
  @dataclass
51
66
  class BuildContext:
52
67
  """Context for processing CMake commands."""
@@ -58,7 +73,9 @@ class BuildContext:
58
73
  project_name: str = ""
59
74
  variables: dict[str, str] = field(default_factory=dict)
60
75
  cache_variables: set[str] = field(default_factory=set) # Variables from -D flags
61
- cli_variables: dict[str, str] = field(default_factory=dict) # Original -D flag values
76
+ cli_variables: dict[str, str] = field(
77
+ default_factory=dict
78
+ ) # Original -D flag values
62
79
  libraries: list[Library] = field(default_factory=list)
63
80
  executables: list[Executable] = field(default_factory=list)
64
81
  imported_targets: dict[str, ImportedTarget] = field(default_factory=dict)
@@ -69,6 +86,9 @@ class BuildContext:
69
86
  custom_commands: list[CustomCommand] = field(
70
87
  default_factory=list
71
88
  ) # Custom build commands
89
+ custom_targets: list[CustomTarget] = field(
90
+ default_factory=list
91
+ ) # Custom targets (phony)
72
92
  functions: dict[str, FunctionDef] = field(
73
93
  default_factory=dict
74
94
  ) # User-defined functions
@@ -179,7 +199,9 @@ class BuildContext:
179
199
  if allow_undefined_empty:
180
200
  return ""
181
201
  if allow_undefined_warning:
182
- self.print_warning(f"undefined variable referenced: {var_name}", line)
202
+ self.print_warning(
203
+ f"undefined variable referenced: {var_name}", line
204
+ )
183
205
  return ""
184
206
  level = self.print_error if strict else self.print_warning
185
207
  level(f"undefined variable referenced: {var_name}", line)
@@ -519,4 +519,104 @@ def handle_builtin_find_package(
519
519
  print(f"{colored('✗', 'red')} {package_name}")
520
520
  return True
521
521
 
522
+ if package_name == "Qt5":
523
+ keywords = {
524
+ "REQUIRED",
525
+ "QUIET",
526
+ "COMPONENTS",
527
+ "OPTIONAL_COMPONENTS",
528
+ "EXACT",
529
+ "MODULE",
530
+ "CONFIG",
531
+ "NO_MODULE",
532
+ }
533
+ required_components: list[str] = []
534
+ optional_components: list[str] = []
535
+ i = 1
536
+ while i < len(args):
537
+ token = args[i]
538
+ if token == "COMPONENTS":
539
+ i += 1
540
+ while i < len(args) and args[i] not in keywords:
541
+ required_components.append(args[i])
542
+ i += 1
543
+ continue
544
+ if token == "OPTIONAL_COMPONENTS":
545
+ i += 1
546
+ while i < len(args) and args[i] not in keywords:
547
+ optional_components.append(args[i])
548
+ i += 1
549
+ continue
550
+ i += 1
551
+
552
+ all_components = required_components + optional_components
553
+ if not all_components:
554
+ all_components = ["Core"]
555
+
556
+ found = False
557
+ missing_required: list[str] = []
558
+
559
+ for component in all_components:
560
+ pkg_name = f"Qt5{component}"
561
+ component_found = False
562
+ try:
563
+ result = subprocess.run(
564
+ ["pkg-config", "--exists", pkg_name],
565
+ capture_output=True,
566
+ )
567
+ component_found = result.returncode == 0
568
+ except FileNotFoundError:
569
+ component_found = False
570
+
571
+ if component_found:
572
+ found = True
573
+ cflags_result = subprocess.run(
574
+ ["pkg-config", "--cflags", pkg_name],
575
+ capture_output=True,
576
+ text=True,
577
+ )
578
+ libs_result = subprocess.run(
579
+ ["pkg-config", "--libs", pkg_name],
580
+ capture_output=True,
581
+ text=True,
582
+ )
583
+ version_result = subprocess.run(
584
+ ["pkg-config", "--modversion", pkg_name],
585
+ capture_output=True,
586
+ text=True,
587
+ )
588
+
589
+ comp_cflags = cflags_result.stdout.strip()
590
+ comp_libs = libs_result.stdout.strip()
591
+ comp_version = version_result.stdout.strip()
592
+
593
+ ctx.variables[f"Qt5{component}_FOUND"] = "TRUE"
594
+ if comp_version:
595
+ ctx.variables[f"Qt5{component}_VERSION"] = comp_version
596
+ ctx.variables["Qt5_VERSION"] = comp_version
597
+
598
+ ctx.imported_targets[f"Qt5::{component}"] = ImportedTarget(
599
+ cflags=comp_cflags,
600
+ libs=comp_libs,
601
+ )
602
+ else:
603
+ ctx.variables[f"Qt5{component}_FOUND"] = "FALSE"
604
+ if component in required_components:
605
+ missing_required.append(component)
606
+
607
+ found = found and not missing_required
608
+ ctx.variables["Qt5_FOUND"] = "TRUE" if found else "FALSE"
609
+
610
+ if found:
611
+ if not quiet:
612
+ print(f"{colored('✓', 'green')} {package_name}")
613
+ else:
614
+ ctx.variables["Qt5_FOUND"] = "FALSE"
615
+ if required:
616
+ ctx.print_error("could not find package: Qt5", cmd.line)
617
+ raise SystemExit(1)
618
+ if not quiet:
619
+ print(f"{colored('✗', 'red')} {package_name}")
620
+ return True
621
+
522
622
  return False
@@ -27,6 +27,7 @@ from .syntax import (
27
27
  from .build_context import (
28
28
  BuildContext,
29
29
  CustomCommand,
30
+ CustomTarget,
30
31
  find_matching_endforeach,
31
32
  find_matching_endif,
32
33
  )
@@ -808,6 +809,8 @@ def process_commands(
808
809
  add_dir(root)
809
810
  else:
810
811
  unix_defaults = ["/usr/local", "/usr"]
812
+ if platform.system() == "Darwin" and Path("/opt/homebrew").is_dir():
813
+ unix_defaults.insert(0, "/opt/homebrew")
811
814
  for root in unix_defaults:
812
815
  if kind == "path":
813
816
  add_dir(str(Path(root) / "include"))
@@ -996,7 +999,6 @@ def process_commands(
996
999
  if sub_cmakelists.exists():
997
1000
  from .parser import parse_file
998
1001
 
999
- ctx.record_cmake_file(sub_cmakelists)
1000
1002
  ctx.record_cmake_file(sub_cmakelists)
1001
1003
  sub_commands = parse_file(sub_cmakelists)
1002
1004
 
@@ -1327,6 +1329,7 @@ def process_commands(
1327
1329
  if sub_cmakelists.exists():
1328
1330
  from .parser import parse_file
1329
1331
 
1332
+ ctx.record_cmake_file(sub_cmakelists)
1330
1333
  sub_commands = parse_file(sub_cmakelists)
1331
1334
 
1332
1335
  saved_current_source_dir = ctx.current_source_dir
@@ -2235,6 +2238,64 @@ int main() {{
2235
2238
  )
2236
2239
  )
2237
2240
 
2241
+ case "add_custom_target":
2242
+ # Support: add_custom_target(<name> [ALL] [COMMAND cmd ...] [DEPENDS ...] [WORKING_DIRECTORY ...] [VERBATIM] [COMMENT ...])
2243
+ if not args:
2244
+ break
2245
+ target_name = ctx.expand_variables(args[0], strict, cmd.line)
2246
+ ct_commands: list[list[str]] = []
2247
+ ct_depends: list[str] = []
2248
+ ct_all = False
2249
+ ct_working_directory: str | None = None
2250
+ ct_verbatim = False
2251
+ ct_comment = ""
2252
+ ct_section: str | None = None
2253
+ ct_idx = 1
2254
+ while ct_idx < len(args):
2255
+ arg = args[ct_idx]
2256
+ if arg == "ALL":
2257
+ ct_all = True
2258
+ elif arg in ("COMMAND", "DEPENDS", "WORKING_DIRECTORY", "COMMENT"):
2259
+ ct_section = arg
2260
+ if arg == "COMMAND":
2261
+ ct_commands.append([])
2262
+ elif arg in ("VERBATIM", "USES_TERMINAL", "COMMAND_EXPAND_LISTS"):
2263
+ if arg == "VERBATIM":
2264
+ ct_verbatim = True
2265
+ ct_section = None
2266
+ elif arg == "SOURCES":
2267
+ ct_section = "SOURCES"
2268
+ else:
2269
+ arg = ctx.expand_variables(arg, strict, cmd.line)
2270
+ if ct_section == "COMMAND":
2271
+ ct_commands[-1].append(arg)
2272
+ elif ct_section == "DEPENDS":
2273
+ rel = make_relative(arg, ctx.build_dir)
2274
+ if rel == arg:
2275
+ rel = ctx.resolve_path(arg)
2276
+ ct_depends.append(rel)
2277
+ elif ct_section == "WORKING_DIRECTORY":
2278
+ ct_working_directory = arg
2279
+ elif ct_section == "COMMENT":
2280
+ ct_comment = arg
2281
+ elif ct_section == "SOURCES":
2282
+ pass # Ignored, only for IDE integration
2283
+ ct_idx += 1
2284
+
2285
+ ctx.custom_targets.append(
2286
+ CustomTarget(
2287
+ name=target_name,
2288
+ commands=ct_commands,
2289
+ depends=ct_depends,
2290
+ all=ct_all,
2291
+ working_directory=ct_working_directory,
2292
+ verbatim=ct_verbatim,
2293
+ comment=ct_comment,
2294
+ defined_file=ctx.current_list_file,
2295
+ defined_line=cmd.line,
2296
+ )
2297
+ )
2298
+
2238
2299
  case "add_test":
2239
2300
  # Support: add_test(NAME <name> COMMAND <command> ...)
2240
2301
  # Or: add_test(<name> <command> ...)
@@ -2370,6 +2431,12 @@ int main() {{
2370
2431
  case "find_path":
2371
2432
  if len(args) >= 2:
2372
2433
  var_name = args[0]
2434
+ existing = ctx.variables.get(var_name, "")
2435
+ if var_name in ctx.cache_variables or (
2436
+ existing and not existing.endswith("-NOTFOUND")
2437
+ ):
2438
+ frame.pc += 1
2439
+ continue
2373
2440
  ctx.cache_variables.add(var_name)
2374
2441
  names: list[str] = []
2375
2442
  paths: list[str] = []
@@ -2478,6 +2545,12 @@ int main() {{
2478
2545
  case "find_library":
2479
2546
  if len(args) >= 2:
2480
2547
  var_name = args[0]
2548
+ existing = ctx.variables.get(var_name, "")
2549
+ if var_name in ctx.cache_variables or (
2550
+ existing and not existing.endswith("-NOTFOUND")
2551
+ ):
2552
+ frame.pc += 1
2553
+ continue
2481
2554
  ctx.cache_variables.add(var_name)
2482
2555
  names: list[str] = []
2483
2556
  paths: list[str] = []
@@ -3519,7 +3592,17 @@ def generate_ninja(
3519
3592
  return f"-D{name}="
3520
3593
  return f"-D{name}={value}"
3521
3594
 
3522
- reconfigure_cmd_parts = ["cja", "--regenerate-during-build"]
3595
+ cja_cmd = ["cja"]
3596
+ absolute_cja_cmd = shutil.which(cja_cmd[0])
3597
+ if absolute_cja_cmd is None:
3598
+ # If cja is not in PATH, use the current Python executable to run it as a module
3599
+ cja_cmd = [sys.executable, "-m", "cja"]
3600
+ elif os.getenv("VIRTUAL_ENV") is not None:
3601
+ # The venv might not be active when the user runs ninja (e.g. the IDE runs it)
3602
+ cja_cmd = [absolute_cja_cmd]
3603
+ if platform.system() == "Windows":
3604
+ cja_cmd[0] = to_posix_path(cja_cmd[0])
3605
+ reconfigure_cmd_parts = cja_cmd + ["--regenerate-during-build"]
3523
3606
  if builddir != "build":
3524
3607
  reconfigure_cmd_parts += ["-B", "$builddir"]
3525
3608
  for var_name in sorted(ctx.cli_variables):
@@ -3583,9 +3666,24 @@ def generate_ninja(
3583
3666
  n.newline()
3584
3667
 
3585
3668
  # Archive rule for static libraries
3669
+ # We must delete the old archive before creating a new one because
3670
+ # ar rcs only adds/replaces members but never removes them.
3671
+ if platform.system() == "Windows":
3672
+ # On Windows, shell metacharacters (&, |, <, >, ^) cause ninja to
3673
+ # wrap the command with cmd.exe, which corrupts pipe handles
3674
+ # ("ReadFile: The handle is invalid"). Use a Python one-liner to
3675
+ # avoid all shell metacharacters so ninja uses CreateProcess directly.
3676
+ ar_command = (
3677
+ 'python -c "import os,subprocess,sys;'
3678
+ "o=sys.argv[1];os.path.exists(o) and os.remove(o);"
3679
+ 'sys.exit(subprocess.call(sys.argv[2:]))"'
3680
+ " $out $ar rcs $out $in"
3681
+ )
3682
+ else:
3683
+ ar_command = "rm -f $out && $ar rcs $out $in"
3586
3684
  n.rule(
3587
3685
  "ar",
3588
- command="$ar rcs $out $in",
3686
+ command=ar_command,
3589
3687
  description="\x1b[32;1mArchiving $out\x1b[0m",
3590
3688
  # TODO: CMake shows "Linking C static library" or "Linking C++ static library" instead.
3591
3689
  # "static" is redundant info here, but we should also distinguish C vs C++.
@@ -3660,6 +3758,19 @@ def generate_ninja(
3660
3758
  )
3661
3759
  n.newline()
3662
3760
 
3761
+ shell_operators = (
3762
+ ">",
3763
+ ">>",
3764
+ "2>",
3765
+ "2>&1",
3766
+ "<",
3767
+ "|",
3768
+ "&",
3769
+ "&&",
3770
+ "||",
3771
+ ";",
3772
+ )
3773
+
3663
3774
  # Generate custom commands
3664
3775
  for custom_cmd in ctx.custom_commands:
3665
3776
  outputs = []
@@ -3673,18 +3784,6 @@ def generate_ninja(
3673
3784
 
3674
3785
  # Process multiple commands
3675
3786
  cmd_parts: list[str] = []
3676
- shell_operators = (
3677
- ">",
3678
- ">>",
3679
- "2>",
3680
- "2>&1",
3681
- "<",
3682
- "|",
3683
- "&",
3684
- "&&",
3685
- "||",
3686
- ";",
3687
- )
3688
3787
  for command in custom_cmd.commands:
3689
3788
  if custom_cmd.verbatim:
3690
3789
  parts = []
@@ -3728,6 +3827,52 @@ def generate_ninja(
3728
3827
  register_output(out, custom_cmd.defined_file, custom_cmd.defined_line)
3729
3828
  n.newline()
3730
3829
 
3830
+ # Generate custom targets (phony targets)
3831
+ custom_target_all: list[str] = []
3832
+ for ct in ctx.custom_targets:
3833
+ ct_depends = [
3834
+ f"$builddir/{d}"
3835
+ if d in custom_command_outputs and not Path(d).is_absolute()
3836
+ else d
3837
+ for d in ct.depends
3838
+ ]
3839
+
3840
+ if ct.commands:
3841
+ # Custom target with commands: use custom_command rule
3842
+ ct_cmd_parts: list[str] = []
3843
+ for command in ct.commands:
3844
+ if ct.verbatim:
3845
+ parts = []
3846
+ for arg in command:
3847
+ if arg in shell_operators:
3848
+ parts.append(str(arg))
3849
+ else:
3850
+ parts.append(shlex.quote(str(arg)))
3851
+ ct_cmd_parts.append(" ".join(parts))
3852
+ else:
3853
+ ct_cmd_parts.append(" ".join(str(c) for c in command))
3854
+
3855
+ ct_cmd_str = " && ".join(ct_cmd_parts)
3856
+ if ct.working_directory:
3857
+ ct_cmd_str = f"cd {ct.working_directory} && {ct_cmd_str}"
3858
+
3859
+ # Use a stamp file so ninja can track when the target last ran
3860
+ stamp = f"$builddir/{ct.name}.stamp"
3861
+ n.build(
3862
+ [stamp],
3863
+ "custom_command",
3864
+ ct_depends,
3865
+ variables={"cmd": f"{ct_cmd_str} && touch {stamp}"},
3866
+ )
3867
+ n.build([ct.name], "phony", [stamp])
3868
+ else:
3869
+ # No commands: purely a phony dependency aggregator
3870
+ n.build([ct.name], "phony", ct_depends)
3871
+
3872
+ if ct.all:
3873
+ custom_target_all.append(ct.name)
3874
+ n.newline()
3875
+
3731
3876
  # Helper to expand link libraries recursively
3732
3877
  def expand_link_libraries(
3733
3878
  initial: list[str], follow_private_of_static: bool
@@ -4008,6 +4153,7 @@ def generate_ninja(
4008
4153
 
4009
4154
  # Collect default targets (libraries that produce real output files)
4010
4155
  default_targets: list[str] = list(lib_outputs.values())
4156
+ default_targets.extend(custom_target_all)
4011
4157
 
4012
4158
  # Generate build statements for executables
4013
4159
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes